feat: add Vietnamese localization to GUI

Introduces a two-language (EN/VI) i18n system with hot-swap support.
All ~160 user-facing strings are centralised in gui/locales.py; views
retranslate in-place on language switch without restarting the app.

- gui/locales.py: new file — _EN/_VI dicts, t() lookup, set_language(),
  get_language(); assert guard ensures EN/VI key parity
- gui/app.py: switch_language(), _apply_startup_language(),
  _save_language_pref(), _rebuild_nav_labels(); language stored in
  config.json under ui.language; pipeline step headers and run_tool
  status lines translated
- gui/views/settings.py: Language dropdown card (English / Tiếng Việt)
- gui/views/dashboard.py: all strings via t(); static header widgets
  stored and retranslated in refresh()
- gui/views/mods.py: all strings via t(); _STATUS dict built at call
  time so server status labels update on language switch
- gui/views/tools.py: all strings via _translatable registry; tab names
  and segmented-button values kept in English (CTkTabview constraint)
- gui/views/logs.py: title + Copy/Clear buttons stored, retranslated
- gui/wizard.py: all 3 pages fully translated
- docs/huong-dan-su-dung.md: full Vietnamese user guide
- CLAUDE.md: documents localization architecture and constraints
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-08 16:58:41 +07:00
parent 4478ec3cab
commit 903cd366e2
10 changed files with 1226 additions and 230 deletions

470
gui/locales.py Normal file
View File

@@ -0,0 +1,470 @@
from __future__ import annotations
# ---------------------------------------------------------------------------
# Localization module — English + Vietnamese
#
# Usage:
# from gui.locales import t
# label_text = t("dashboard.title")
# label_text = t("dashboard.sel_count", n_sel=2, n_total=5)
#
# Tab names in ToolsView are NOT translated — they double as CTkTabview
# lookup keys and cannot be renamed after creation.
# Segmented-button values in ToolsView ("Status", "Link", "Unlink") are also
# kept in English because they drive internal logic in _lm_on_change().
# ---------------------------------------------------------------------------
_LANG: str = "en"
_EN: dict[str, str] = {
# ── App / sidebar ────────────────────────────────────────────────────────
"app.title": "Arma Mod Manager",
"nav.dashboard": "Dashboard",
"nav.mods": "Mods",
"nav.tools": "Tools",
"nav.logs": "Logs",
"nav.settings": "Settings",
# ── Pipeline step headers (printed to log) ───────────────────────────────
"pipeline.step1_name": "Parse presets",
"pipeline.step2_name": "Compare presets",
"pipeline.step3_name": "Download mods",
"pipeline.step4_name": "Link mods",
# ── app.py dialogs ────────────────────────────────────────────────────────
"app.dlg_presets_title": "Not enough presets selected",
"app.dlg_presets_body": (
"Please select at least 2 preset files to compare.\n\n"
"Use the checkboxes on the Dashboard to choose which presets to use."
),
"app.dlg_setup_title": "Setup required",
"app.dlg_setup_body": "Please complete Setup first.",
# ── run_tool status lines (go to log) ────────────────────────────────────
"app.tool_done": "✓ Done",
"app.tool_exit_code": "✗ Exited with code {code}",
"app.tool_failed": "✗ Failed to start {script}: {e}",
# ── Dashboard ────────────────────────────────────────────────────────────
"dashboard.title": "Dashboard",
"dashboard.refresh_btn": "⟳ Refresh",
"dashboard.preset_card_title": "Preset Files",
"dashboard.preset_card_desc": "HTML exports from Arma 3 Launcher → Mods → Export to HTML",
"dashboard.sel_count": "{n_sel} of {n_total} selected",
"dashboard.btn_none": "None",
"dashboard.btn_all": "All",
"dashboard.btn_add": "+ Add Preset Files",
"dashboard.no_config": "No config found. Complete Setup first.",
"dashboard.folder_missing": "Folder missing:\n{path}",
"dashboard.no_presets": "No preset files yet.\nUse the button below to add them.",
"dashboard.file_dialog_title": "Select Arma 3 Launcher preset files",
"dashboard.dlg_setup_title": "Setup required",
"dashboard.dlg_setup_body": "Please complete Setup before adding presets.",
"dashboard.pipeline_title": "Pipeline Status",
"dashboard.step_parse": "Parse presets",
"dashboard.step_compare": "Compare presets",
"dashboard.step_download": "Download mods",
"dashboard.step_link": "Link to Arma",
"dashboard.stats": "{total} mods · {shared} shared",
"dashboard.stats_missing": "\n{missing} missing from server",
"dashboard.run_btn": "▶ Run Full Pipeline",
"dashboard.running": "Running…",
# ── Mods ─────────────────────────────────────────────────────────────────
"mods.title": "Mods",
"mods.refresh_btn": "⟳ Refresh",
"mods.check_btn": "☁ Check Updates",
"mods.check_btn_checking": "Checking…",
"mods.search_label": "Search:",
"mods.search_placeholder": "Filter mods in active tab…",
"mods.no_config": "No config found. Complete Setup first.",
"mods.no_data": (
"No mod data yet.\n"
"Go to Dashboard, select your presets, then click Run Full Pipeline."
),
"mods.read_error": "Error reading comparison.json: {e}",
"mods.col_name": "Mod Name",
"mods.col_downloaded": "Downloaded",
"mods.col_linked": "Linked",
"mods.col_server": "Server Status",
"mods.status_ok": "✓ Up to date",
"mods.status_stale": "{n} outdated",
"mods.status_not_downloaded": "",
"mods.status_not_on_server": "Not on server",
"mods.status_error": "✗ Error",
"mods.status_checking": "Checking…",
"mods.update_btn": "Update",
# ── Logs ─────────────────────────────────────────────────────────────────
"logs.title": "Logs",
"logs.copy_btn": "Copy",
"logs.clear_btn": "Clear",
# ── Settings ─────────────────────────────────────────────────────────────
"settings.title": "Settings",
"settings.server_card_title": "Server & Path Configuration",
"settings.server_card_desc": (
"Re-run the setup wizard to change your server URL, "
"credentials, or Arma folder."
),
"settings.wizard_btn": "Open Setup Wizard",
"settings.appearance_title": "Appearance",
"settings.language_title": "Language",
"settings.config_title": "Current Configuration",
# ── Wizard ───────────────────────────────────────────────────────────────
"wizard.title": "Setup — Arma Mod Manager",
"wizard.step1_title": "Step 1 of 3 — Server Connection",
"wizard.step1_desc": "Enter the details for your Caddy mod server.",
"wizard.label_url": "Server URL",
"wizard.label_user": "Username",
"wizard.label_pw": "Password",
"wizard.btn_next": "Next →",
"wizard.btn_test": "Test Connection",
"wizard.testing": "Testing…",
"wizard.connected": "✓ Connected",
"wizard.http_error": "✗ HTTP {code}",
"wizard.conn_error": "{e}",
"wizard.step2_title": "Step 2 of 3 — Arma 3 Server Folder",
"wizard.step2_desc": (
"Point to your Arma 3 Server installation. "
"Links (junctions) will be created here."
),
"wizard.label_arma": "Arma 3 Server folder",
"wizard.btn_browse": "Browse",
"wizard.step2_hint": (
"All other folders (downloads, presets) will be created "
"automatically next to this tool."
),
"wizard.btn_back": "← Back",
"wizard.step3_title": "Step 3 of 3 — Review & Save",
"wizard.step3_desc": "Check your settings, then click Save.",
"wizard.not_set": "(not set)",
"wizard.btn_save": "Save & Open",
"wizard.browse_title": "Select Arma 3 Server folder",
# ── Tools — shared ────────────────────────────────────────────────────────
"tools.title": "Tools",
"tools.label_group": "Group:",
"tools.label_options": "Options:",
"tools.label_command": "Command:",
"tools.all_groups": "All groups",
"tools.no_groups": "(no groups found)",
# ── Tools — Check Names ──────────────────────────────────────────────────
"tools.cn_desc": (
"Scan mod folders and compare against the server. "
"Reports naming mismatches (MISMATCH), unrecognised folders "
"(NOT_ON_SERVER), and wrong Steam IDs in meta.cpp (ID_COLLISION)."
),
"tools.cn_fix_chk": "Auto-fix folder name mismatches (--fix)",
"tools.cn_fix_ids_chk": "Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)",
"tools.cn_warn": (
"⚠ --fix renames folders and updates junctions. "
"--fix-ids rewrites meta.cpp files."
),
"tools.cn_btn": "Run Check Names",
# ── Tools — Update Mods ──────────────────────────────────────────────────
"tools.um_desc": (
"Re-download mod files whose size on the server differs from "
"your local copy. Use --force to re-download everything "
"regardless of size."
),
"tools.um_mod_label": "Mod folder:",
"tools.um_mod_placeholder": "Optional — e.g. @ace",
"tools.um_mod_hint": "(only when a specific group is selected)",
"tools.um_force_chk": "Force re-download all files (--force)",
"tools.um_warn": (
"⚠ --force re-downloads every file regardless of size. "
"This may transfer a large amount of data."
),
"tools.um_btn": "Run Update",
# ── Tools — Link Mods ────────────────────────────────────────────────────
"tools.lm_desc": (
"Manage junction/symlink links between your downloads folder "
"and the Arma 3 directory.\n"
"Status — show what's linked. "
"Link — create missing junctions. "
"Unlink — remove junctions (mod files are NOT deleted)."
),
"tools.lm_warn": (
"⚠ Unlink removes junction links from the Arma 3 directory. "
"Mod files in downloads/ are NOT deleted."
),
"tools.lm_show_status": "Show Status",
"tools.lm_create_links": "Create Links",
"tools.lm_remove_links": "Remove Links",
"tools.lm_no_group_title": "No group selected",
"tools.lm_no_group_body": "Please select a group from the dropdown.",
"tools.lm_confirm_title": "Confirm Unlink",
"tools.lm_confirm_body": (
"Remove junction links for group '{group}'?\n\n"
"This removes links from the Arma 3 directory but does NOT delete "
"mod files in downloads/."
),
# ── Tools — Sync Missing ─────────────────────────────────────────────────
"tools.sm_desc": (
"Retry downloading mods that were missing from the server "
"when you last ran the pipeline. "
"Checks the server again and downloads any that have since appeared."
),
"tools.sm_btn": "Run Sync Missing",
"tools.sm_count": "{count} mod(s) currently listed as missing.",
"tools.sm_no_report": "No missing_report.json found — run the pipeline first.",
# ── Tools — Report Missing ───────────────────────────────────────────────
"tools.rm_desc": (
"Check which mods from comparison.json are absent from the "
"file server. Saves missing_report.json so you can track what "
"still needs to be added to the server."
),
"tools.rm_btn": "Generate Report",
"tools.rm_last": "Last generated: {ts}",
"tools.rm_none": "No report yet.",
}
_VI: dict[str, str] = {
# ── App / sidebar ────────────────────────────────────────────────────────
"app.title": "Arma Mod Manager",
"nav.dashboard": "Tổng quan",
"nav.mods": "Danh sách Mod",
"nav.tools": "Công cụ",
"nav.logs": "Nhật ký",
"nav.settings": "Cài đặt",
# ── Pipeline step headers ────────────────────────────────────────────────
"pipeline.step1_name": "Phân tích preset",
"pipeline.step2_name": "So sánh preset",
"pipeline.step3_name": "Tải mod",
"pipeline.step4_name": "Liên kết mod",
# ── app.py dialogs ────────────────────────────────────────────────────────
"app.dlg_presets_title": "Chưa chọn đủ preset",
"app.dlg_presets_body": (
"Vui lòng chọn ít nhất 2 tệp preset để so sánh.\n\n"
"Sử dụng các ô tick ở Tổng quan để chọn preset."
),
"app.dlg_setup_title": "Cần thiết lập",
"app.dlg_setup_body": "Vui lòng hoàn thành thiết lập trước.",
# ── run_tool status lines ────────────────────────────────────────────────
"app.tool_done": "✓ Hoàn thành",
"app.tool_exit_code": "✗ Thoát với mã lỗi {code}",
"app.tool_failed": "✗ Không thể khởi động {script}: {e}",
# ── Dashboard ────────────────────────────────────────────────────────────
"dashboard.title": "Tổng quan",
"dashboard.refresh_btn": "⟳ Làm mới",
"dashboard.preset_card_title": "Tệp Preset",
"dashboard.preset_card_desc": "Xuất từ Arma 3 Launcher → Mods → Export to HTML",
"dashboard.sel_count": "Đã chọn {n_sel} / {n_total}",
"dashboard.btn_none": "Bỏ chọn",
"dashboard.btn_all": "Tất cả",
"dashboard.btn_add": "+ Thêm tệp Preset",
"dashboard.no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
"dashboard.folder_missing": "Thư mục không tồn tại:\n{path}",
"dashboard.no_presets": "Chưa có tệp preset.\nDùng nút bên dưới để thêm.",
"dashboard.file_dialog_title": "Chọn tệp preset Arma 3 Launcher",
"dashboard.dlg_setup_title": "Cần thiết lập",
"dashboard.dlg_setup_body": "Vui lòng hoàn thành thiết lập trước khi thêm preset.",
"dashboard.pipeline_title": "Trạng thái Pipeline",
"dashboard.step_parse": "Phân tích preset",
"dashboard.step_compare": "So sánh preset",
"dashboard.step_download": "Tải mod",
"dashboard.step_link": "Liên kết với Arma",
"dashboard.stats": "{total} mod · {shared} dùng chung",
"dashboard.stats_missing": "\n{missing} mod thiếu trên máy chủ",
"dashboard.run_btn": "▶ Chạy toàn bộ quy trình",
"dashboard.running": "Đang chạy…",
# ── Mods ─────────────────────────────────────────────────────────────────
"mods.title": "Danh sách Mod",
"mods.refresh_btn": "⟳ Làm mới",
"mods.check_btn": "☁ Kiểm tra cập nhật",
"mods.check_btn_checking": "Đang kiểm tra…",
"mods.search_label": "Tìm kiếm:",
"mods.search_placeholder": "Lọc mod trong tab hiện tại…",
"mods.no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
"mods.no_data": (
"Chưa có dữ liệu mod.\n"
"Vào Tổng quan, chọn preset rồi nhấn Chạy toàn bộ quy trình."
),
"mods.read_error": "Lỗi đọc comparison.json: {e}",
"mods.col_name": "Tên Mod",
"mods.col_downloaded": "Đã tải",
"mods.col_linked": "Đã liên kết",
"mods.col_server": "Trạng thái máy chủ",
"mods.status_ok": "✓ Đã cập nhật",
"mods.status_stale": "{n} tệp cũ",
"mods.status_not_downloaded": "",
"mods.status_not_on_server": "Không có trên máy chủ",
"mods.status_error": "✗ Lỗi",
"mods.status_checking": "Đang kiểm tra…",
"mods.update_btn": "Cập nhật",
# ── Logs ─────────────────────────────────────────────────────────────────
"logs.title": "Nhật ký",
"logs.copy_btn": "Sao chép",
"logs.clear_btn": "Xóa",
# ── Settings ─────────────────────────────────────────────────────────────
"settings.title": "Cài đặt",
"settings.server_card_title": "Cấu hình máy chủ & đường dẫn",
"settings.server_card_desc": (
"Mở lại trình hướng dẫn thiết lập để thay đổi URL máy chủ, "
"thông tin đăng nhập hoặc thư mục Arma."
),
"settings.wizard_btn": "Mở trình thiết lập",
"settings.appearance_title": "Giao diện",
"settings.language_title": "Ngôn ngữ",
"settings.config_title": "Cấu hình hiện tại",
# ── Wizard ───────────────────────────────────────────────────────────────
"wizard.title": "Thiết lập — Arma Mod Manager",
"wizard.step1_title": "Bước 1 / 3 — Kết nối máy chủ",
"wizard.step1_desc": "Nhập thông tin máy chủ Caddy của bạn.",
"wizard.label_url": "URL máy chủ",
"wizard.label_user": "Tên đăng nhập",
"wizard.label_pw": "Mật khẩu",
"wizard.btn_next": "Tiếp theo →",
"wizard.btn_test": "Kiểm tra kết nối",
"wizard.testing": "Đang kiểm tra…",
"wizard.connected": "✓ Đã kết nối",
"wizard.http_error": "✗ HTTP {code}",
"wizard.conn_error": "{e}",
"wizard.step2_title": "Bước 2 / 3 — Thư mục Arma 3 Server",
"wizard.step2_desc": (
"Trỏ tới thư mục cài đặt Arma 3 Server của bạn. "
"Các liên kết (junction) sẽ được tạo tại đây."
),
"wizard.label_arma": "Thư mục Arma 3 Server",
"wizard.btn_browse": "Duyệt",
"wizard.step2_hint": (
"Các thư mục khác (downloads, presets) sẽ được tạo tự động "
"bên cạnh công cụ này."
),
"wizard.btn_back": "← Quay lại",
"wizard.step3_title": "Bước 3 / 3 — Xem lại & Lưu",
"wizard.step3_desc": "Kiểm tra cài đặt rồi nhấn Lưu.",
"wizard.not_set": "(chưa đặt)",
"wizard.btn_save": "Lưu & Mở",
"wizard.browse_title": "Chọn thư mục Arma 3 Server",
# ── Tools — shared ────────────────────────────────────────────────────────
"tools.title": "Công cụ",
"tools.label_group": "Nhóm:",
"tools.label_options": "Tùy chọn:",
"tools.label_command": "Lệnh:",
"tools.all_groups": "Tất cả nhóm",
"tools.no_groups": "(không tìm thấy nhóm)",
# ── Tools — Check Names ──────────────────────────────────────────────────
"tools.cn_desc": (
"Quét thư mục mod và so sánh với máy chủ. "
"Báo cáo tên không khớp (MISMATCH), thư mục không nhận ra "
"(NOT_ON_SERVER) và Steam ID sai trong meta.cpp (ID_COLLISION)."
),
"tools.cn_fix_chk": "Tự động sửa tên thư mục không khớp (--fix)",
"tools.cn_fix_ids_chk": "Tự động sửa Steam ID sai trong meta.cpp (--fix-ids)",
"tools.cn_warn": (
"⚠ --fix đổi tên thư mục và cập nhật junction. "
"--fix-ids ghi đè tệp meta.cpp."
),
"tools.cn_btn": "Chạy kiểm tra tên",
# ── Tools — Update Mods ──────────────────────────────────────────────────
"tools.um_desc": (
"Tải lại tệp mod có kích thước khác với bản trên máy chủ. "
"Dùng --force để tải lại tất cả bất kể kích thước."
),
"tools.um_mod_label": "Thư mục mod:",
"tools.um_mod_placeholder": "Không bắt buộc — ví dụ @ace",
"tools.um_mod_hint": "(chỉ dùng khi chọn một nhóm cụ thể)",
"tools.um_force_chk": "Buộc tải lại tất cả tệp (--force)",
"tools.um_warn": (
"⚠ --force tải lại mọi tệp bất kể kích thước. "
"Điều này có thể truyền một lượng dữ liệu lớn."
),
"tools.um_btn": "Chạy cập nhật",
# ── Tools — Link Mods ────────────────────────────────────────────────────
"tools.lm_desc": (
"Quản lý liên kết junction/symlink giữa thư mục downloads "
"và thư mục Arma 3.\n"
"Status — xem liên kết hiện có. "
"Link — tạo junction còn thiếu. "
"Unlink — xóa junction (tệp mod KHÔNG bị xóa)."
),
"tools.lm_warn": (
"⚠ Unlink xóa liên kết junction khỏi thư mục Arma 3. "
"Tệp mod trong downloads/ KHÔNG bị xóa."
),
"tools.lm_show_status": "Xem trạng thái",
"tools.lm_create_links": "Tạo liên kết",
"tools.lm_remove_links": "Xóa liên kết",
"tools.lm_no_group_title": "Chưa chọn nhóm",
"tools.lm_no_group_body": "Vui lòng chọn một nhóm từ danh sách.",
"tools.lm_confirm_title": "Xác nhận xóa liên kết",
"tools.lm_confirm_body": (
"Xóa liên kết junction cho nhóm '{group}'?\n\n"
"Thao tác này xóa liên kết khỏi thư mục Arma 3 "
"nhưng KHÔNG xóa tệp mod trong downloads/."
),
# ── Tools — Sync Missing ─────────────────────────────────────────────────
"tools.sm_desc": (
"Thử tải lại các mod bị thiếu trên máy chủ khi chạy pipeline lần trước. "
"Kiểm tra lại máy chủ và tải về nếu mod đã xuất hiện."
),
"tools.sm_btn": "Chạy đồng bộ mod thiếu",
"tools.sm_count": "{count} mod đang được liệt kê là thiếu.",
"tools.sm_no_report": "Chưa có missing_report.json — hãy chạy pipeline trước.",
# ── Tools — Report Missing ───────────────────────────────────────────────
"tools.rm_desc": (
"Kiểm tra mod nào trong comparison.json không có trên máy chủ. "
"Lưu missing_report.json để theo dõi mod cần bổ sung."
),
"tools.rm_btn": "Tạo báo cáo",
"tools.rm_last": "Tạo lần cuối: {ts}",
"tools.rm_none": "Chưa có báo cáo.",
}
# Guard: both dicts must have identical key sets
assert set(_EN.keys()) == set(_VI.keys()), (
"EN/VI key mismatch: "
+ str(set(_EN.keys()) ^ set(_VI.keys()))
)
_STRINGS: dict[str, dict[str, str]] = {"en": _EN, "vi": _VI}
def set_language(lang: str) -> None:
"""Set the active language. Unknown codes fall back to English."""
global _LANG
_LANG = lang if lang in _STRINGS else "en"
def get_language() -> str:
"""Return the currently active language code."""
return _LANG
def t(key: str, **kwargs: object) -> str:
"""Look up *key* in the active language, falling back to English then the key itself.
Dynamic placeholders use str.format_map with keyword arguments::
t("dashboard.sel_count", n_sel=2, n_total=5)
# dict entry: "{n_sel} of {n_total} selected"
"""
text = _STRINGS[_LANG].get(key) or _STRINGS["en"].get(key, key)
if kwargs:
try:
return text.format_map(kwargs)
except (KeyError, IndexError):
return text
return text