diff --git a/AppImage/lib/api-errors.ts b/AppImage/lib/api-errors.ts index 7e995dee..a80c9add 100644 --- a/AppImage/lib/api-errors.ts +++ b/AppImage/lib/api-errors.ts @@ -1,17 +1,19 @@ -import enMessages from "../messages/en/common.json" -import skMessages from "../messages/sk/common.json" -import { detectBrowserLanguage, LANGUAGE_STORAGE_KEY } from "./i18n/languages" +import { MESSAGE_CATALOG } from "./i18n/catalogs" +import { detectBrowserLanguage, isSupportedLanguage, LANGUAGE_STORAGE_KEY } from "./i18n/languages" -type Failure = keyof typeof enMessages.apiErrors +type Failure = keyof (typeof import("../messages/en/common.json"))["apiErrors"] // fetchApi also runs outside React hooks. Read the provider's current document // language, with its stored/browser preference as a fallback. export function apiFailure(key: Failure, params: Record = {}): string { - let language = typeof document === "undefined" ? "" : document.documentElement?.lang - if (!language && typeof window !== "undefined") { - try { language = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || "" } catch { /* private browsing */ } + let preference = typeof document === "undefined" ? "" : document.documentElement?.lang + if (!isSupportedLanguage(preference) && typeof window !== "undefined") { + try { preference = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || "" } catch { /* private browsing */ } } - language ||= detectBrowserLanguage() - const messages = language === "sk" ? skMessages.apiErrors : enMessages.apiErrors - return messages[key].replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name])) + const language = isSupportedLanguage(preference) ? preference : detectBrowserLanguage() + const messages = MESSAGE_CATALOG[language].apiErrors + const localized = messages && typeof messages === "object" ? (messages as Record)[key] : undefined + const fallback = (MESSAGE_CATALOG.en.apiErrors as Record)[key] + const template = typeof localized === "string" ? localized : fallback + return template.replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name])) } diff --git a/AppImage/lib/i18n/catalogs.ts b/AppImage/lib/i18n/catalogs.ts new file mode 100644 index 00000000..3547eb10 --- /dev/null +++ b/AppImage/lib/i18n/catalogs.ts @@ -0,0 +1,23 @@ +import enMessages from "../../messages/en/common.json" +import deMessages from "../../messages/de/common.json" +import esMessages from "../../messages/es/common.json" +import frMessages from "../../messages/fr/common.json" +import itMessages from "../../messages/it/common.json" +import ptMessages from "../../messages/pt/common.json" +import skMessages from "../../messages/sk/common.json" +import svMessages from "../../messages/sv/common.json" +import type { LanguageCode } from "./languages" + +type MessageTree = Record + +// Shared by React translations and error handling outside React hooks. +export const MESSAGE_CATALOG: Record = { + en: enMessages as MessageTree, + de: deMessages as MessageTree, + es: esMessages as MessageTree, + fr: frMessages as MessageTree, + it: itMessages as MessageTree, + pt: ptMessages as MessageTree, + sk: skMessages as MessageTree, + sv: svMessages as MessageTree, +} diff --git a/AppImage/lib/i18n/provider.tsx b/AppImage/lib/i18n/provider.tsx index 04d5b638..ebdc6097 100644 --- a/AppImage/lib/i18n/provider.tsx +++ b/AppImage/lib/i18n/provider.tsx @@ -1,14 +1,7 @@ "use client" import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react" -import enMessages from "../../messages/en/common.json" -import deMessages from "../../messages/de/common.json" -import esMessages from "../../messages/es/common.json" -import frMessages from "../../messages/fr/common.json" -import itMessages from "../../messages/it/common.json" -import ptMessages from "../../messages/pt/common.json" -import skMessages from "../../messages/sk/common.json" -import svMessages from "../../messages/sv/common.json" +import { MESSAGE_CATALOG } from "./catalogs" import { DEFAULT_LANGUAGE, LANGUAGE_STORAGE_KEY, @@ -21,17 +14,6 @@ import { type MessageTree = Record type TranslationParams = Record -const MESSAGE_CATALOG: Record = { - en: enMessages as MessageTree, - de: deMessages as MessageTree, - es: esMessages as MessageTree, - fr: frMessages as MessageTree, - it: itMessages as MessageTree, - pt: ptMessages as MessageTree, - sk: skMessages as MessageTree, - sv: svMessages as MessageTree, -} - interface I18nContextValue { language: LanguageCode setLanguage: (language: LanguageCode) => void diff --git a/scripts/vm/synology.sh b/scripts/vm/synology.sh index 66668b95..b75ff7cc 100644 --- a/scripts/vm/synology.sh +++ b/scripts/vm/synology.sh @@ -1431,9 +1431,9 @@ HTML_DESC="

-$(translate "Docs") -$(translate "Code") -$(translate "Loader") +Docs +Code +Loader Ko-fi

diff --git a/tests/backup_i18n_seam.cjs b/tests/backup_i18n_seam.cjs index 3404efe2..db8ce033 100644 --- a/tests/backup_i18n_seam.cjs +++ b/tests/backup_i18n_seam.cjs @@ -29,10 +29,11 @@ const lookupCode = pure.map(node => printer.printNode(ts.EmitHint.Unspecified, n const catalogs = Object.fromEntries(fs.readdirSync(path.join(app, 'messages')).filter(locale => fs.existsSync(path.join(app, 'messages', locale, 'common.json'))).map(locale => [locale, JSON.parse(read(`messages/${locale}/common.json`))])); -const registered = provider.statements.flatMap(statement => ts.isVariableStatement(statement) ? - statement.declarationList.declarations.filter(declaration => declaration.name.getText(provider) === 'MESSAGE_CATALOG') : []); -assert.equal(registered.length, 1, 'real provider catalog declaration'); -assert.deepEqual(registered[0].initializer.properties.map(property => property.name.getText(provider)).sort(), +const catalogSource = ts.createSourceFile('catalogs.ts', read('lib/i18n/catalogs.ts'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +const registered = catalogSource.statements.flatMap(statement => ts.isVariableStatement(statement) ? + statement.declarationList.declarations.filter(declaration => declaration.name.getText(catalogSource) === 'MESSAGE_CATALOG') : []); +assert.equal(registered.length, 1, 'real shared catalog declaration'); +assert.deepEqual(registered[0].initializer.properties.map(property => property.name.getText(catalogSource)).sort(), Object.keys(catalogs).sort(), 'test each runtime-registered shipped locale'); assert.ok(catalogs.en, 'English fallback catalog exists'); const translator = (language, messages = catalogs) => diff --git a/tests/fixtures/cli-before-i18n/synology-display.sh b/tests/fixtures/cli-before-i18n/synology-display.sh deleted file mode 100644 index 8418de52..00000000 --- a/tests/fixtures/cli-before-i18n/synology-display.sh +++ /dev/null @@ -1,141 +0,0 @@ - msg_title "Synology VM Creator" - msg_warn "$(translate "No compatible GPU detected for VM passthrough. Skipping GPU wizard option.")" - msg_warn "$gpu_notice" - msg_info2 "$(translate "Launching GPU passthrough assistant for VM") ${VMID}..." - echo -e "${DEF}Using Default Settings${CL}" - echo -e "${CUS}Using Advanced Settings${CL}" - echo -e " ${TAB}${DGN}Using Virtual Machine ID: ${BGN}${VMID}${CL}" - echo -e " ${TAB}${DGN}Using Machine Type: ${BGN}q35${CL}" - echo -e " ${TAB}${DGN}Using BIOS Type: ${BGN}OVMF (UEFI)${CL}" - echo -e " ${TAB}${DGN}Using Hostname: ${BGN}${HN}${CL}" - echo -e " ${TAB}${DGN}Using CPU Model: ${BGN}Host${CL}" - echo -e " ${TAB}${DGN}Allocated Cores: ${BGN}${CORE_COUNT}${CL}" - echo -e " ${TAB}${DGN}Allocated RAM: ${BGN}${RAM_SIZE}${CL}" - echo -e " ${TAB}${DGN}Using Bridge: ${BGN}${BRG}${CL}" - echo -e " ${TAB}${DGN}Using MAC Address: ${BGN}${MAC}${CL}" - echo -e " ${TAB}${DGN}Using VLAN: ${BGN}Default${CL}" - echo -e " ${TAB}${DGN}Using Interface MTU Size: ${BGN}Default${CL}" - echo -e " ${TAB}${DGN}Configuring Serial Port: ${BGN}${SERIAL_PORT}${CL}" - echo -e " ${TAB}${DGN}Start VM when completed: ${BGN}${START_VM}${CL}" - echo -e "${DEF}Creating a $NAME using the above default settings${CL}" - echo -e "${CROSS}${RD} ID $VMID is already in use${CL}" - echo -e "${DGN}Virtual Machine ID: ${BGN}$VMID${CL}" - echo -e "${DGN}Using Machine Type: ${BGN}$MACH${CL}" - echo -e "${DGN}Using Machine Type: ${BGN}$MACH${CL}" - echo -e "${DGN}Using BIOS Type: ${BGN}SeaBIOS${CL}" - echo -e "${DGN}Using BIOS Type: ${BGN}OVMF (UEFI)${CL}" - echo -e "${DGN}Using Hostname: ${BGN}$HN${CL}" - echo -e "${DGN}Using Hostname: ${BGN}$HN${CL}" - echo -e "${DGN}Using CPU Model: ${BGN}Host${CL}" - echo -e "${DGN}Using CPU Model: ${BGN}KVM64${CL}" - echo -e "${DGN}Allocated Cores: ${BGN}$CORE_COUNT${CL}" - echo -e "${DGN}Allocated Cores: ${BGN}$CORE_COUNT${CL}" - echo -e "${DGN}Allocated RAM: ${BGN}$RAM_SIZE${CL}" - echo -e "${DGN}Allocated RAM: ${BGN}$RAM_SIZE${CL}" - echo -e "${DGN}Using Bridge: ${BGN}$BRG${CL}" - echo -e "${DGN}Using Bridge: ${BGN}$BRG${CL}" - echo -e "${DGN}Using MAC Address: ${BGN}$MAC${CL}" - echo -e "${DGN}Using MAC Address: ${BGN}$MAC1${CL}" - echo -e "${DGN}Using Vlan: ${BGN}$VLAN1${CL}" - echo -e "${DGN}Using Vlan: ${BGN}$VLAN1${CL}" - echo -e "${DGN}Using Interface MTU Size: ${BGN}$MTU1${CL}" - echo -e "${DGN}Using Interface MTU Size: ${BGN}$MTU1${CL}" - echo -e "${CUS}Creating a $NAME using the above advanced settings${CL}" - echo -e "${CUS}Using Advanced Settings${CL}" - echo -e "$summary" - msg_warn "$(translate "Storage plan selection cancelled.")" - msg_ok "$(translate "Virtual Disks Created:")" - msg_ok "$(translate "Import Disks Selected:")" - msg_ok "$(translate "Controllers + NVMe Selected:")" - msg_info "$(translate "Detecting storage for disk images...")" - msg_error "Unable to detect a valid storage location." - msg_info "$(translate "Detecting PCI storage controllers and NVMe devices...")" - msg_info "Creating images directory" - msg_ok "Images directory created: $IMAGES_DIR" - echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}" - echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}" - echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}" - echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}" - msg_error "No custom image selected" - echo -e "${DGN}${TAB}Using Custom Image: ${BGN}$(basename "$LOADER_FILE")${CL}" - echo -e "${DGN}${TAB}Retrieving the URL for the ${BGN}$LOADER_NAME loader${CL}" - msg_info "Installing unzip..." - msg_error "Failed to install unzip" - msg_ok "Installed unzip successfully." - msg_error "$(translate "Failed to retrieve loader release metadata.")" - msg_error "$(translate "No unambiguous loader download found in release metadata.")" - msg_error "$(translate "Failed to prepare loader download directory.")" - msg_error "$(translate "Failed to download loader image.")" - msg_error "$(translate "Failed to extract a nonempty loader image.")" - msg_error "$(translate "Failed to save loader image.")" - msg_ok "Downloaded ${CL}${BL}${FILE}${CL} to ${IMAGES_DIR}" - msg_error "Unable to detect a valid storage location for EFI disk." >&2 - msg_warn "$(translate "EFI storage selection cancelled.")" >&2 - msg_error "Unable to detect a valid storage location." >&2 - msg_warn "$(translate "Storage selection cancelled for $purpose.")" >&2 - msg_error "Failed to create base VM. Check VM ID and host configuration." - msg_ok "Create a $NAME" - msg_info "Configuring EFI disk" - msg_error "EFI storage selection failed or was cancelled." - msg_ok "EFI disk created (raw) and configured on ${CL}${BL}$EFI_STORAGE${GN}${CL}" - msg_error "Failed to configure EFI disk" - msg_ok "EFI disk created and configured on ${CL}${BL}$EFI_STORAGE${GN}${CL}" - msg_error "Failed to configure EFI disk" - msg_error "Failed to create EFI disk" - msg_error "Loader storage selection failed or was cancelled." - echo -n "Importing loader disk: " - echo -n "." - msg_error "Loader import failed. No disk detected." - msg_ok "Loader imported successfully to ${CL}${BL}$LOADER_STORAGE${GN}${CL}" - msg_ok "Configured loader disk as ide0" - msg_error "Failed to assign loader disk to ide0" - msg_error "Loader import failed. No disk detected in config." - msg_ok "Configured loader disk as ide0" - msg_error "Failed to assign loader disk" - msg_ok "Loader configured as boot device." - msg_info "Creating virtual disk (format=raw) for $STORAGE_TYPE..." - msg_error "Failed to assign disk $DISK_NUM ($SATA_ID) on $STORAGE" - msg_info "Allocating virtual disk for $STORAGE_TYPE..." - msg_error "Failed to allocate virtual disk $DISK_NUM" - msg_error "Failed to configure virtual disk as $SATA_ID" - msg_ok "Configured virtual disk as $SATA_ID, ${SIZE}GB on ${CL}${BL}$STORAGE${CL} ${GN}" - msg_ok "Configured import disk ${CL}${BL}($DISK_MODEL $DISK_SIZE)${CL}${GN} as $DISK_ID" - msg_error "Failed to configure import disk $DISK as $DISK_ID" - msg_warn "$(translate "Skipping SR-IOV device"): ${SRIOV_SKIPPED}" - msg_warn "$(translate "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.")" - msg_warn "$(translate "IOMMU was configured during this wizard and a reboot is pending.")" - msg_warn "$(translate "Controller + NVMe assignment will be written now and become active after host reboot.")" - msg_error "$(translate "IOMMU is not active. Skipping Controller + NVMe assignment.")" - msg_error "$(translate "Controller + NVMe passthrough requires machine type q35. Skipping controller assignment.")" - msg_warn "Controller/NVMe already present in VM config (${PCI_DEV})" - msg_warn "Controller/NVMe already present in VM config (${PCI_DEV})" - msg_warn "$(translate "Controller/NVMe is in use by running VM") ${SRC_VMID} ($(translate "stop source VM first"))" - msg_warn "$(translate "Start on boot disabled for VM") ${SRC_VMID}" - msg_ok "$(translate "Controller/NVMe removed from source VM") ${SRC_VMID} (${PCI_DEV})" - msg_info2 "$(translate "Skipped device"): ${PCI_DEV}" - msg_ok "Configured controller/NVMe as hostpci${HOSTPCI_INDEX}: ${PCI_DEV}" - msg_error "Failed to configure controller/NVMe: ${PCI_DEV}" - msg_error "No disks/controllers configured." -msg_info "Setting VM description" - msg_error "Failed to set VM description" -msg_ok "Configured VM description" - msg_error "VM created with errors. Check configuration." - msg_success "$(translate "Completed Successfully with GPU passthrough configured!")" - msg_warn "$(translate "Host VFIO configuration changed (initramfs updated). Reboot required before starting the VM.")" - msg_success "$(translate "Completed Successfully!")" - msg_warn "$(translate "GPU passthrough was skipped (no compatible GPU detected).")" - msg_warn "$(translate "GPU passthrough was not applied.")" -echo -e "${TAB}${GN}$(translate "Next Steps:")${CL}" -echo -e "${TAB}1. $(translate "Start the VM")" -echo -e "${TAB}2. $(translate "Open the VM console and wait for the loader to boot")" -echo -e "${TAB}3. $(translate "In the loader interface, follow the instructions to select your Synology model")" -echo -e "${TAB}4. $(translate "Complete the DSM installation wizard")" -echo -e "${TAB}5. $(translate "Find your device using https://finds.synology.com")" - echo -e "${TAB}- $(translate "If you want to use a physical monitor on the passthrough GPU:")" - echo -e "${TAB}• $(translate "First complete DSM setup and verify Web UI/SSH access.")" - echo -e "${TAB}• $(translate "Then change the VM display to none (vga: none) when the system is stable.")" - msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")" - msg_warn "$(translate "Rebooting the system...")" - msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")" - msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")" - msg_warn "$(translate "VM creation cancelled before disk planning.")" diff --git a/tests/fixtures/cli-before-i18n/update_post_install_function.sh b/tests/fixtures/cli-before-i18n/update_post_install_function.sh deleted file mode 100644 index 17690202..00000000 --- a/tests/fixtures/cli-before-i18n/update_post_install_function.sh +++ /dev/null @@ -1,200 +0,0 @@ -#!/bin/bash -# ========================================================== -# ProxMenux - Sprint 12B: re-run a single post-install function -# ========================================================== -# Invoked by the Monitor's "Update" buttons (Settings → ProxMenux -# Optimizations) and by the bash menu's update flow. Sources the -# appropriate post_install script and invokes one specific function so -# the user can update a single tool without re-running the whole flow. -# -# Two invocation modes: -# -# SINGLE — set SOURCE_TYPE + FUNCTION_NAME (+ optionally TOOL_KEY): -# SOURCE_TYPE=auto FUNCTION_NAME=install_log2ram_auto -# -# BATCH — set FUNCTIONS_BATCH to a newline-separated list of -# "source:function:tool_key" triples (tool_key optional): -# FUNCTIONS_BATCH="auto:install_log2ram_auto:log2ram -# custom:install_ceph:ceph" -# -# In batch mode the wrapper iterates the list, sourcing each flow once -# and re-using its function definitions. Tools coming from the same -# flow share a single source step so the terminal output stays clean. -# ========================================================== - -set -e - -LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" -BASE_DIR="/usr/local/share/proxmenux" -UTILS_FILE="$BASE_DIR/utils.sh" - -if [[ -f "$UTILS_FILE" ]]; then - # shellcheck disable=SC1090 - source "$UTILS_FILE" -fi - -if command -v load_language >/dev/null 2>&1; then - load_language -fi -if command -v initialize_cache >/dev/null 2>&1; then - initialize_cache -fi - -if command -v show_proxmenux_logo >/dev/null 2>&1; then - show_proxmenux_logo -fi - -SOURCE_TYPE="${SOURCE_TYPE:-}" -FUNCTION_NAME="${FUNCTION_NAME:-}" -TOOL_KEY="${TOOL_KEY:-${FUNCTION_NAME}}" -FUNCTIONS_BATCH="${FUNCTIONS_BATCH:-}" - -# Build the list of (source, function, tool_key) tuples from either the -# single-mode env vars or the batch payload. -declare -a BATCH=() -if [[ -n "$FUNCTIONS_BATCH" ]]; then - while IFS= read -r line; do - [[ -z "$line" ]] && continue - BATCH+=("$line") - done <<< "$FUNCTIONS_BATCH" -elif [[ -n "$SOURCE_TYPE" && -n "$FUNCTION_NAME" ]]; then - BATCH+=("${SOURCE_TYPE}:${FUNCTION_NAME}:${TOOL_KEY}") -else - echo "ERROR: provide either SOURCE_TYPE+FUNCTION_NAME (single mode) or FUNCTIONS_BATCH (batch mode)." - exit 2 -fi - -# ---------------------------------------------------------------------- -# Source the flow scripts on first use. Both auto and customizable -# guard their entry point with `if [[ "${BASH_SOURCE[0]}" == "${0}" ]]`, -# so sourcing loads function definitions without triggering the -# interactive menu or the extremeshok dialog. -# ---------------------------------------------------------------------- -SOURCED_AUTO=0 -SOURCED_CUSTOM=0 -ensure_flow_loaded() { - local source_type="$1" - case "$source_type" in - # `detected` is the synthetic source the registry assigns to a - # tool when a legacy detector finds it on the host but the - # entry was never written to installed_tools.json (e.g. - # proxmox_repos on a pre-1.2.2 install). Treat it as `auto` - # for the source-loading step — the legacy-tool's wrapper - # function lives in auto_post_install.sh, and once the update - # runs successfully `register_tool` overwrites the synthetic - # entry with source="auto" so subsequent runs don't hit this - # branch again. - auto|detected) - if [[ $SOURCED_AUTO -eq 0 ]]; then - # shellcheck disable=SC1091 - source "$LOCAL_SCRIPTS/post_install/auto_post_install.sh" - SOURCED_AUTO=1 - fi - ;; - custom) - if [[ $SOURCED_CUSTOM -eq 0 ]]; then - # shellcheck disable=SC1091 - source "$LOCAL_SCRIPTS/post_install/customizable_post_install.sh" - SOURCED_CUSTOM=1 - fi - ;; - *) - echo "ERROR: invalid source '$source_type' (must be 'auto', 'custom', or 'detected')" - return 2 - ;; - esac -} - -# Suppress the Monitor's `service_fail` notifications while this -# wrapper runs. Some post-install functions legitimately restart their -# services, and systemd can expose a transient "failed" state before the -# fresh unit starts — same class of noise as PVE services restarting -# during an apt full-upgrade. Shared markers with -# scripts/utilities/proxmox_update.sh; consumed by -# AppImage/scripts/notification_events.py::is_apt_active_on_host. -_PROXMENUX_UPDATE_MARKER="/var/run/proxmenux-update-in-progress" -_PROXMENUX_UPDATE_FINISHED_MARKER="/var/run/proxmenux-update-just-finished" -_proxmenux_postinstall_cleanup() { - rm -f "$_PROXMENUX_UPDATE_MARKER" - touch "$_PROXMENUX_UPDATE_FINISHED_MARKER" -} -trap _proxmenux_postinstall_cleanup EXIT -touch "$_PROXMENUX_UPDATE_MARKER" - -# ---------------------------------------------------------------------- -# Run each tool. We don't bail on the first failure — the user marked a -# multi-select, they expect every chosen tool to be attempted. RCs are -# collected and the wrapper exits non-zero if any failed. -# ---------------------------------------------------------------------- -TOTAL=${#BATCH[@]} -FAILED=0 -INDEX=0 -for entry in "${BATCH[@]}"; do - INDEX=$((INDEX + 1)) - IFS=':' read -r src fn tkey <<< "$entry" - [[ -z "$tkey" ]] && tkey="$fn" - - if command -v msg_info2 >/dev/null 2>&1; then - msg_info2 "[$INDEX/$TOTAL] Updating ${tkey} (running ${fn} from ${src} flow)..." - else - echo "[ProxMenux] [$INDEX/$TOTAL] Updating ${tkey} (running ${fn} from ${src} flow)..." - fi - - if ! ensure_flow_loaded "$src"; then - FAILED=$((FAILED + 1)) - continue - fi - - if ! declare -F "$fn" >/dev/null 2>&1; then - if command -v msg_error >/dev/null 2>&1; then - msg_error "Function '$fn' is not defined in the ${src} flow." - else - echo "ERROR: function '$fn' is not defined in the ${src} flow." - fi - FAILED=$((FAILED + 1)) - continue - fi - - set +e - "$fn" - RC=$? - set -e - - if [[ $RC -eq 0 ]]; then - if command -v msg_ok >/dev/null 2>&1; then - msg_ok "Update for ${tkey} completed." - else - echo "[ProxMenux] Update for ${tkey} completed." - fi - else - if command -v msg_error >/dev/null 2>&1; then - msg_error "Update for ${tkey} exited with status $RC." - else - echo "ERROR: update for ${tkey} exited with status $RC." - fi - FAILED=$((FAILED + 1)) - fi -done - -if [[ $FAILED -eq 0 ]]; then - if command -v msg_success >/dev/null 2>&1; then - msg_success "All $TOTAL update(s) completed successfully." - else - echo "[ProxMenux] All $TOTAL update(s) completed successfully." - fi -else - if command -v msg_warn >/dev/null 2>&1; then - msg_warn "$FAILED of $TOTAL updates failed — see the messages above." - else - echo "WARNING: $FAILED of $TOTAL updates failed." - fi -fi - -if command -v msg_success >/dev/null 2>&1; then - msg_success "Press Enter to close this terminal..." -else - echo "Press Enter to close this terminal..." -fi -read -r 2>/dev/null || true - -[[ $FAILED -eq 0 ]] && exit 0 || exit 1 diff --git a/tests/fixtures/cli-before-i18n/uup_dump_iso_creator-display.sh b/tests/fixtures/cli-before-i18n/uup_dump_iso_creator-display.sh deleted file mode 100644 index 81daf239..00000000 --- a/tests/fixtures/cli-before-i18n/uup_dump_iso_creator-display.sh +++ /dev/null @@ -1,23 +0,0 @@ - msg_info "$(translate "Installing dependencies: ${MISSING[*]}")" - msg_ok "$(translate "All dependencies installed and verified.")" - msg_error "$(translate "Failed to install: ${MISSING[*]}")" - msg_error "$(translate "Failed to install: ${MISSING[*]}")" - msg_error "$(translate "Could not determine a valid ISO storage directory.")" - msg_error "$(translate "Failed to create temporary directory:") $TMP_DIR" - msg_warn "$(translate "Cancelled by user or empty URL.")" - msg_error "$(translate "The URL does not contain the required parameters (id, pack, edition).")" - echo -e "\n${BGN}=============== UUP Dump Creator ===============${CL}" - echo -e " ${BGN}🆔 ID:${CL} ${DGN}$BUILD_ID${CL}" - echo -e " ${BGN}🌐 Language:${CL} ${DGN}$LANG${CL}" - echo -e " ${BGN}💿 Edition:${CL} ${DGN}$EDITION${CL}" - echo -e " ${BGN}🖥️ Architecture:${CL} ${DGN}$ARCH${CL}" - echo -e " ${BGN}📁 Destination:${CL} ${DGN}$OUT_DIR${CL}" - echo -e "${BGN}===============================================${CL}\n" - echo -e "\n${GN}=======================================${CL}" - echo -e " 💿 ${GN}Starting ISO conversion...${CL}" - echo -e "${GN}=======================================${CL}\n" - msg_ok "$(translate "ISO created successfully:") $OUT_DIR/$(basename "$ISO_FILE")" - msg_ok "$(translate "Cleaning temporary files...")" - msg_success "$(translate "Press Enter to return to menu...")" - msg_warn "$(translate "No ISO was generated.")" - msg_success "$(translate "Press Enter to return to menu...")" diff --git a/tests/test_api_errors.cjs b/tests/test_api_errors.cjs index 0a4ef081..4a2c76f7 100644 --- a/tests/test_api_errors.cjs +++ b/tests/test_api_errors.cjs @@ -1,5 +1,17 @@ const assert=require('node:assert/strict') +const fs=require('node:fs'),path=require('node:path') const {loadTypeScript}=require('./ts-fixture.cjs') +async function withApiCatalog(language,values,run){ + const original=fs.readFileSync,target=path.resolve(__dirname,'../AppImage/messages',language,'common.json') + fs.readFileSync=function(file,...args){ + const content=original.call(this,file,...args) + if(typeof file==='string'&&path.resolve(file)===target){ + const catalog=JSON.parse(content);catalog.apiErrors=values;return JSON.stringify(catalog) + } + return content + } + try{return await run()}finally{fs.readFileSync=original} +} function fixture(language='sk',token='fixture-token'){ const local=new Map(token?[['proxmenux-auth-token',token]]:[]),session=new Map(),calls=[];let reloads=0,reply=()=>new Response('{}',{headers:{'Content-Type':'application/json'}}) const storage=map=>({getItem:key=>map.get(key)||null,setItem:(key,v)=>map.set(key,v),removeItem:key=>map.delete(key)}) @@ -9,6 +21,35 @@ function fixture(language='sk',token='fixture-token'){ return{fetchApi,calls,reply:value=>reply=value,globals,local,reloads:()=>reloads} } async function main(){ + // Future translations must work without another language-specific code branch. + for(const language of ['de','es','fr','it','pt','sv']){ + await withApiCatalog(language,{http:language+' HTTP {status} {statusText}',unknownContentType:language+' TYPE'},async()=>{ + const f=fixture(language) + f.reply(()=>new Response('html',{status:502,statusText:'Bad Gateway'})) + await assert.rejects(f.fetchApi('/api/demo'),e=>e.message===language+' HTTP 502 Bad Gateway') + // A partial catalog falls back per key, preserving the original endpoint. + f.reply(()=>new Response('{',{headers:{'Content-Type':'application/json'}})) + await assert.rejects(f.fetchApi('/api/demo'),e=>e.message==='Invalid JSON response from /api/demo') + assert.equal(f.calls.length,2,'Localization never retries or makes its own request') + }) + } + await withApiCatalog('fr',{http:{invalid:'not a message'},invalidJson:'FR {endpoint}'},async()=>{ + const f=fixture('fr');f.reply(()=>new Response('',{status:503,statusText:'Unavailable'})) + await assert.rejects(f.fetchApi('/api/demo'),e=>e.message==='API request failed: 503 Unavailable') + const browser=loadTypeScript('lib/api-errors.ts',{navigator:{language:'fr-FR',languages:['sk']}}) + assert.equal(browser.apiFailure('invalidJson',{endpoint:'/literal?x=&y=1'}),'FR /literal?x=&y=1') + const document={documentElement:{lang:'sk'}};let storedReads=0 + const current=loadTypeScript('lib/api-errors.ts',{document,window:{localStorage:{getItem:()=>{storedReads++;return 'sk'}}}}) + assert.equal(current.apiFailure('invalidJson',{endpoint:'/api/demo'}),'Odpoveď z /api/demo obsahuje neplatný JSON') + document.documentElement.lang='fr' + assert.equal(current.apiFailure('invalidJson',{endpoint:'/api/demo'}),'FR /api/demo','The next error follows a language switch without reloading') + assert.equal(storedReads,0,'The provider document language takes precedence') + document.documentElement.lang='unsupported' + assert.equal(current.apiFailure('unknownContentType'),'neznámy typ obsahu') + assert.equal(storedReads,1,'An invalid document language uses a supported stored preference') + const denied=loadTypeScript('lib/api-errors.ts',{window:{get localStorage(){throw new Error('denied')}},navigator:{language:'fr-FR',languages:[]}}) + assert.equal(denied.apiFailure('invalidJson',{endpoint:'/api/demo'}),'FR /api/demo') + }) for(const lang of ['sk','en','de']){ const f=fixture(lang),sk=lang==='sk' f.reply(()=>new Response('html',{status:502,statusText:'Bad Gateway',headers:{'Content-Type':'text/html'}})) diff --git a/tests/test_post_install_i18n.py b/tests/test_post_install_i18n.py index 7878c240..b0d08b1e 100644 --- a/tests/test_post_install_i18n.py +++ b/tests/test_post_install_i18n.py @@ -26,12 +26,21 @@ def run(source,params,language='en',utilities=True): return result.returncode,result.stdout+result.stderr,trace.read_text() class PostInstallI18n(unittest.TestCase): - def test_existing_single_batch_failures_and_fallback_contract(self): - before=(ROOT/'tests/fixtures/cli-before-i18n/update_post_install_function.sh').read_text();after=(ROOT/FILE).read_text() - cases=[{}, {'SOURCE_TYPE':'auto','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'detected','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'custom','FUNCTION_NAME':'missing_fn','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'wrong','FUNCTION_NAME':'fixture_ok'}, {'FUNCTIONS_BATCH':'auto:fixture_ok:one\ncustom:fixture_fail:two\nauto:missing_fn:three\ndetected:fixture_ok:four'}] + def test_single_batch_exit_codes_and_fallback_without_host_calls(self): + source=(ROOT/FILE).read_text() + cases=[({},2,''), + ({'SOURCE_TYPE':'auto','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'},0,'ok\n'), + ({'SOURCE_TYPE':'detected','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'},0,'ok\n'), + ({'SOURCE_TYPE':'custom','FUNCTION_NAME':'missing_fn','TOOL_KEY':'fixture'},1,''), + ({'SOURCE_TYPE':'wrong','FUNCTION_NAME':'fixture_ok'},1,''), + ({'FUNCTIONS_BATCH':'auto:fixture_ok:one\ncustom:fixture_fail:two\nauto:missing_fn:three\ndetected:fixture_ok:four'},1,'ok\nfail\nok\n')] for utilities in (False,True): - for params in cases: - with self.subTest(utilities=utilities,params=params):self.assertEqual(run(before,params,utilities=utilities),run(after,params,utilities=utilities)) + for params,expected_code,expected_trace in cases: + with self.subTest(utilities=utilities,params=params): + code,text,trace=run(source,params,utilities=utilities) + self.assertEqual(code,expected_code) + self.assertEqual(trace,expected_trace) + self.assertNotIn('command not found',text) def test_slovak_missing_failure_summary_and_no_real_host_calls(self): source=(ROOT/FILE).read_text() code,text,trace=run(source,{'FUNCTIONS_BATCH':'auto:fixture_ok:one\ncustom:fixture_fail:two\nauto:missing_fn:three'},language='sk') diff --git a/tests/test_remaining_cli_i18n.py b/tests/test_remaining_cli_i18n.py index 36a46b65..fea9f5ae 100644 --- a/tests/test_remaining_cli_i18n.py +++ b/tests/test_remaining_cli_i18n.py @@ -1,6 +1,7 @@ """Real translate and display-only shell calls; never source a VM creator.""" from pathlib import Path import importlib.util,json,os,re,shlex,subprocess,tempfile,unittest +from html.parser import HTMLParser ROOT=Path(__file__).resolve().parents[1] SPEC=importlib.util.spec_from_file_location('official_i18n',ROOT/'.github/scripts/build_translation_cache.py') @@ -27,6 +28,25 @@ def render(calls,language): return result.stdout+result.stderr class RemainingCliI18n(unittest.TestCase): + def test_badges_remain_valid_with_hostile_translations_without_host_calls(self): + source=(ROOT/FILES[0]).read_text() + html=re.search(r'(?ms)^HTML_DESC=".*?^
"',source).group() + class Images(HTMLParser): + def __init__(self):super().__init__();self.images=[] + def handle_starttag(self,tag,attrs): + if tag=='img':self.images.append(dict(attrs)) + with tempfile.TemporaryDirectory() as tmp: + blocked=Path(tmp)/'blocked';blocked.write_text('') + forbidden='\n'.join(name+'(){ echo '+shlex.quote(name)+' >> '+shlex.quote(str(blocked))+'; return 97; }' for name in ('qm','pct','pvesh','curl','wget','mount','reboot','systemctl')) + script=forbidden+'\ntranslate(){ printf "%s" "O\'Brien &