diff --git a/CLAUDE.md b/CLAUDE.md index 8f0e71f..5cf56ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,33 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s **`run_tool` subprocess streaming:** Tool scripts are launched via `subprocess.Popen` (not `subprocess.run`) with `stdout=PIPE, stderr=STDOUT`, read line-by-line via `iter(proc.stdout.readline, "")`, and posted to the log queue immediately. Python's own output buffering is disabled with the `-u` flag and `PYTHONUNBUFFERED=1` in the environment — without these, output would batch inside the pipe and only appear when the script exits. +### GUI localization (`gui/locales.py`) + +All user-facing strings are centralised in `gui/locales.py`. Two languages are supported: English (`"en"`) and Vietnamese (`"vi"`). + +**API:** +```python +from gui.locales import t, set_language, get_language + +t("nav.dashboard") # → "Dashboard" or "Tổng quan" +t("dashboard.stats", total=42, shared=10) # → "42 mods · 10 shared" +set_language("vi") # switch active language +get_language() # → "vi" +``` + +**Key naming:** flat dot-notation — `"."`, e.g. `"dashboard.run_btn"`, `"wizard.step1_title"`, `"tools.cn_warn"`. + +**Dynamic strings** use `str.format_map` with keyword args. The dict value contains `{placeholder}` and the caller passes `t("key", placeholder=value)`. + +**Hot-swap:** `app.switch_language(lang)` calls `set_language()`, saves the preference to `config.json` under `"ui": {"language": "..."}`, retranslates sidebar nav buttons, then calls `view.refresh()` on every cached view. Views that build all content in `refresh()` (Settings, Mods) update automatically. Views with static `build()`-time widgets (Dashboard, Logs, Tools) store widget references and retranslate them at the top of `refresh()`. + +**Constraints:** +- `CTkTabview` tab names in `tools.py` are kept in English — they double as frame lookup keys (`tv.tab("Check Names")`) and cannot be renamed after creation. +- Segmented button values in `tools.py` (`"Status"`, `"Link"`, `"Unlink"`) are kept in English — they drive the logic in `_lm_on_change()`. +- `_VIEW_NAMES` routing keys (`"Dashboard"`, `"Mods"`, etc.) are kept in English — they are `_view_cache` dict keys. + +**Adding a new string:** Add the key to both `_EN` and `_VI` dicts in `locales.py`. The `assert set(_EN.keys()) == set(_VI.keys())` guard at module load will catch any mismatch. + ## Python Version Compatibility Minimum is Python **3.9**. All files that use `X | Y` union type annotations **must** have `from __future__ import annotations` as the first import. Without it, the `|` syntax raises `TypeError` at runtime on Python < 3.10. Every module in `arma_modlist_tools/` already has it; any new CLI script you add must include it too. diff --git a/docs/huong-dan-su-dung.md b/docs/huong-dan-su-dung.md new file mode 100644 index 0000000..5cd6cf1 --- /dev/null +++ b/docs/huong-dan-su-dung.md @@ -0,0 +1,379 @@ +# Hướng dẫn sử dụng Arma Mod Manager + +Tài liệu này dành cho người dùng **chưa biết gì** về dự án. Bạn không cần kiến thức lập trình để sử dụng ứng dụng này. + +--- + +## Mục lục + +1. [Arma Mod Manager là gì?](#1-arma-mod-manager-là-gì) +2. [Yêu cầu hệ thống](#2-yêu-cầu-hệ-thống) +3. [Khởi động lần đầu — Trình thiết lập](#3-khởi-động-lần-đầu--trình-thiết-lập) +4. [Giao diện chính](#4-giao-diện-chính) +5. [Tổng quan (Dashboard) — Quy trình cơ bản](#5-tổng-quan-dashboard--quy-trình-cơ-bản) +6. [Danh sách Mod](#6-danh-sách-mod) +7. [Công cụ nâng cao](#7-công-cụ-nâng-cao) +8. [Nhật ký (Logs)](#8-nhật-ký-logs) +9. [Cài đặt](#9-cài-đặt) +10. [Đổi sang giao diện tiếng Việt](#10-đổi-sang-giao-diện-tiếng-việt) +11. [Xử lý sự cố thường gặp](#11-xử-lý-sự-cố-thường-gặp) +12. [Bảng thuật ngữ](#12-bảng-thuật-ngữ) + +--- + +## 1. Arma Mod Manager là gì? + +**Arma Mod Manager** là công cụ giúp bạn tải về và quản lý các mod cho **Arma 3 Server** từ một máy chủ lưu trữ riêng (Caddy server). Thay vì tải từng mod thủ công, bạn chỉ cần: + +1. Xuất danh sách mod từ Arma 3 Launcher dưới dạng tệp HTML (gọi là **preset**) +2. Chọn preset trong ứng dụng +3. Nhấn nút **Chạy toàn bộ quy trình** + +Ứng dụng sẽ tự động: +- Đọc danh sách mod từ các preset +- So sánh, tìm mod dùng chung và mod riêng giữa các preset +- Tải mod từ máy chủ về máy tính của bạn +- Tạo liên kết (junction/symlink) để Arma 3 Server nhận ra các mod + +--- + +## 2. Yêu cầu hệ thống + +| Yêu cầu | Thông tin | +|---------|-----------| +| Hệ điều hành | Windows 10/11 (64-bit) | +| Python | 3.9 trở lên | +| Arma 3 Server | Đã cài đặt trên máy | +| Kết nối mạng | Cần thiết để tải mod từ máy chủ | + +**Cài đặt thư viện cần thiết** (chỉ cần làm một lần): + +Mở Command Prompt, điều hướng tới thư mục ứng dụng, chạy: + +``` +pip install -r requirements.txt +``` + +--- + +## 3. Khởi động lần đầu — Trình thiết lập + +Khi khởi động ứng dụng lần đầu (chưa có tệp `config.json`), cửa sổ **Thiết lập** sẽ tự động hiện ra gồm 3 bước: + +### Bước 1 / 3 — Kết nối máy chủ + +![Bước 1](placeholder) + +| Trường | Mô tả | +|--------|-------| +| **URL máy chủ** | Địa chỉ đầy đủ của máy chủ Caddy lưu mod, ví dụ: `https://mods.example.com/` | +| **Tên đăng nhập** | Tài khoản được cấp bởi người quản trị máy chủ | +| **Mật khẩu** | Mật khẩu tương ứng | + +- Nhấn **Kiểm tra kết nối** để xác nhận thông tin trước khi tiếp tục. + - `✓ Đã kết nối` — thành công, nhấn **Tiếp theo →** + - `✗ HTTP 401` — sai tên đăng nhập hoặc mật khẩu + - `✗ ...` — lỗi mạng hoặc URL sai + +### Bước 2 / 3 — Thư mục Arma 3 Server + +Nhấn **Duyệt** để chọn thư mục gốc của Arma 3 Server trên máy tính (thư mục chứa `arma3server.exe`). + +> Ví dụ: `C:\servers\arma3` + +Các thư mục khác (downloads, presets) sẽ tự động được tạo bên cạnh ứng dụng. + +### Bước 3 / 3 — Xem lại & Lưu + +Kiểm tra thông tin đã nhập rồi nhấn **Lưu & Mở**. Ứng dụng sẽ lưu cấu hình và mở giao diện chính. + +> **Mẹo:** Để mở lại trình thiết lập bất cứ lúc nào, vào **Cài đặt → Mở trình thiết lập**. + +--- + +## 4. Giao diện chính + +Giao diện gồm thanh điều hướng bên trái và khu vực nội dung bên phải. + +``` +┌─────────────────┬──────────────────────────────────┐ +│ Arma Mod │ │ +│ Manager │ Khu vực nội dung │ +│─────────────────│ (thay đổi theo mục chọn) │ +│ Tổng quan │ │ +│ Danh sách Mod │ │ +│ Công cụ │ │ +│ Nhật ký │ │ +│ Cài đặt │ │ +└─────────────────┴──────────────────────────────────┘ +``` + +| Mục | Chức năng | +|-----|-----------| +| **Tổng quan** | Chọn preset, xem trạng thái pipeline, chạy quy trình tải mod | +| **Danh sách Mod** | Xem tất cả mod theo nhóm, trạng thái tải/liên kết/máy chủ | +| **Công cụ** | Các tác vụ bảo trì nâng cao | +| **Nhật ký** | Xem toàn bộ output của pipeline và công cụ | +| **Cài đặt** | Cấu hình giao diện, ngôn ngữ, mở lại trình thiết lập | + +--- + +## 5. Tổng quan (Dashboard) — Quy trình cơ bản + +Đây là trang chính bạn sẽ dùng thường xuyên nhất. + +### 5.1 Thêm tệp preset + +**Preset** là tệp HTML xuất từ Arma 3 Launcher chứa danh sách mod bạn đang dùng. + +**Cách xuất preset từ Arma 3 Launcher:** +1. Mở Arma 3 Launcher +2. Vào tab **Mods** +3. Nhấn **Preset** → **Export to HTML** +4. Lưu tệp vào máy tính + +**Thêm preset vào ứng dụng:** +1. Ở mục **Tệp Preset**, nhấn **+ Thêm tệp Preset** +2. Chọn tệp `.html` vừa xuất +3. Tệp sẽ xuất hiện trong danh sách với ô tick + +### 5.2 Chọn preset để xử lý + +- **Tick** vào các preset bạn muốn so sánh (cần ít nhất **2 preset**) +- Nhấn **Tất cả** để chọn tất cả, hoặc **Bỏ chọn** để bỏ hết +- Nhãn `Đã chọn X / Y` cho biết số lượng đã chọn: + - Màu xanh: đã chọn đủ (≥ 2) + - Màu vàng: mới chọn 1 + - Màu đỏ: chưa chọn + +### 5.3 Trạng thái Pipeline + +Cột bên phải hiển thị 4 bước của quy trình: + +| Bước | Mô tả | Dấu hiệu hoàn thành | +|------|-------|-------------------| +| Phân tích preset | Đọc danh sách mod từ tệp HTML | Có ≥ 2 preset được chọn | +| So sánh preset | Tìm mod chung và riêng | Tệp `comparison.json` tồn tại | +| Tải mod | Tải tệp mod từ máy chủ | Có thư mục mod trong `downloads/` | +| Liên kết với Arma | Tạo junction tới thư mục Arma 3 | Thư mục Arma 3 Server tồn tại | + +Biểu tượng `✓` (xanh) = đã xong, `○` (xám) = chưa xong. + +### 5.4 Chạy toàn bộ quy trình + +1. Đảm bảo đã chọn ít nhất 2 preset +2. Nhấn **▶ Chạy toàn bộ quy trình** +3. Ứng dụng tự động chuyển sang tab **Nhật ký** và hiển thị tiến trình +4. Chờ cho đến khi thấy dòng `✓ Pipeline complete.` (hoặc tiếng Việt: `✓ Hoàn thành`) + +> **Lưu ý:** Quá trình này có thể mất vài phút đến hàng giờ tùy số lượng và kích thước mod. + +--- + +## 6. Danh sách Mod + +Trang này hiển thị tất cả mod được nhóm theo preset (tab). + +### Các tab nhóm + +- **shared (X)** — Mod có mặt trong **tất cả** preset đã chọn +- **[tên preset] (X)** — Mod chỉ có trong preset đó + +Nhấn vào tên tab để chuyển nhóm. + +### Các cột thông tin + +| Cột | Ý nghĩa | +|-----|---------| +| **Tên Mod** | Tên mod theo danh sách preset | +| **Đã tải** | `✓` = đã có trong thư mục downloads · `✗` = chưa tải | +| **Đã liên kết** | `✓` = đã tạo junction tới Arma 3 · `✗` = chưa · `—` = chưa tải nên không liên kết được | +| **Trạng thái máy chủ** | Xem bên dưới | + +### Trạng thái máy chủ + +Nhấn nút **☁ Kiểm tra cập nhật** để kiểm tra từng mod với máy chủ: + +| Trạng thái | Ý nghĩa | +|-----------|---------| +| `✓ Đã cập nhật` | Tệp local khớp với máy chủ | +| `⚠ X tệp cũ` | Có X tệp cần cập nhật, nhấn **Cập nhật** ở cuối hàng | +| `Không có trên máy chủ` | Mod này không tồn tại trên máy chủ | +| `—` | Mod chưa được tải về | +| `✗ Lỗi` | Không thể kiểm tra (lỗi mạng) | + +### Tìm kiếm mod + +Gõ vào ô **Tìm kiếm:** để lọc mod theo tên trong tab đang xem. + +--- + +## 7. Công cụ nâng cao + +Trang **Công cụ** có 5 tab phụ cho các tác vụ bảo trì. Mỗi tab đều có nút chạy ở góc phải phía dưới, output hiển thị trong **Nhật ký**. + +### Tab "Check Names" — Kiểm tra tên thư mục + +Quét thư mục mod trên máy tính và so sánh với máy chủ. Báo cáo các vấn đề: + +| Vấn đề | Ý nghĩa | +|--------|---------| +| `MISMATCH` | Tên thư mục local khác với tên trên máy chủ | +| `NOT_ON_SERVER` | Thư mục local không tìm thấy trên máy chủ | +| `ID_COLLISION` | Tệp `meta.cpp` chứa Steam ID sai | + +**Tùy chọn:** +- `--fix`: Tự động đổi tên thư mục sai → Dùng cẩn thận, sẽ di chuyển tệp +- `--fix-ids`: Tự động sửa Steam ID trong `meta.cpp` → Dùng cẩn thận, sẽ ghi đè tệp + +### Tab "Update Mods" — Cập nhật mod + +Tải lại các tệp mod có kích thước khác với bản trên máy chủ. + +- **Nhóm**: Chọn `Tất cả nhóm` hoặc một nhóm cụ thể +- **Thư mục mod**: Nhập tên thư mục cụ thể (ví dụ `@ace`) nếu muốn cập nhật một mod +- **--force**: Tải lại **tất cả** tệp bất kể kích thước — cẩn thận với mod nặng + +### Tab "Link Mods" — Quản lý liên kết + +Tạo hoặc xóa junction giữa thư mục `downloads/` và thư mục Arma 3. + +| Lệnh | Chức năng | +|------|-----------| +| **Status** | Hiển thị trạng thái liên kết hiện tại | +| **Link** | Tạo junction còn thiếu | +| **Unlink** | Xóa junction (tệp mod **KHÔNG** bị xóa) | + +> **Lưu ý:** Phải chọn một nhóm cụ thể trước khi chạy Unlink. + +### Tab "Sync Missing" — Đồng bộ mod thiếu + +Thử tải lại các mod bị thiếu từ lần chạy pipeline trước. Hữu ích khi máy chủ vừa bổ sung mod mới sau khi bạn đã chạy pipeline. + +### Tab "Report Missing" — Báo cáo mod thiếu + +Kiểm tra mod nào trong `comparison.json` chưa có trên máy chủ và lưu báo cáo vào `missing_report.json`. Dùng để theo dõi mod cần yêu cầu admin bổ sung. + +--- + +## 8. Nhật ký (Logs) + +Trang này hiển thị toàn bộ output khi chạy pipeline hoặc công cụ. + +- **Sao chép**: Copy toàn bộ nội dung nhật ký vào clipboard +- **Xóa**: Xóa sạch nội dung nhật ký + +Output được giữ nguyên khi bạn chuyển sang trang khác và quay lại. + +**Ký hiệu trong log:** +- `✓` — bước hoàn thành thành công +- `✗` — có lỗi xảy ra +- `SKIP` — bỏ qua (ví dụ preset không được chọn) + +--- + +## 9. Cài đặt + +### Cấu hình máy chủ & đường dẫn + +Nhấn **Mở trình thiết lập** để thay đổi URL máy chủ, tài khoản, hoặc thư mục Arma 3. + +### Giao diện + +Chọn chế độ hiển thị: **Dark** (tối), **Light** (sáng), hoặc **System** (theo hệ thống). + +### Ngôn ngữ + +Xem phần [10. Đổi sang giao diện tiếng Việt](#10-đổi-sang-giao-diện-tiếng-việt). + +### Cấu hình hiện tại + +Hiển thị các đường dẫn đang dùng: URL máy chủ, thư mục Arma, thư mục downloads, thư mục presets. + +--- + +## 10. Đổi sang giao diện tiếng Việt + +1. Nhấn **Cài đặt** ở thanh bên trái +2. Cuộn xuống đến mục **Ngôn ngữ** +3. Chọn **Tiếng Việt** từ danh sách thả xuống +4. Giao diện sẽ chuyển sang tiếng Việt ngay lập tức — không cần khởi động lại + +Để chuyển lại tiếng Anh, chọn **English** trong cùng mục đó. + +> Lựa chọn ngôn ngữ được lưu vào `config.json` và sẽ được ghi nhớ cho lần khởi động tiếp theo. + +--- + +## 11. Xử lý sự cố thường gặp + +### Không kết nối được máy chủ + +**Triệu chứng:** Wizard hiển thị `✗ HTTP 401` hoặc `✗ [lỗi kết nối]` + +**Kiểm tra:** +- URL có bắt đầu bằng `https://` không? +- URL có dấu `/` ở cuối không? (ví dụ `https://mods.example.com/`) +- Tên đăng nhập và mật khẩu có đúng không? (kiểm tra phân biệt hoa thường) +- Máy tính có kết nối internet không? +- Máy chủ có đang hoạt động không? (hỏi admin) + +### Pipeline chạy xong nhưng không tải được mod + +**Triệu chứng:** Log hiển thị `missing from server` hoặc `NOT_ON_SERVER` + +**Giải thích:** Mod tồn tại trong preset nhưng chưa có trên máy chủ. + +**Xử lý:** +- Vào **Công cụ → Report Missing** để tạo báo cáo +- Gửi báo cáo cho admin để bổ sung mod +- Sau khi mod được thêm, vào **Công cụ → Sync Missing** để tải về + +### Mod tải về rồi nhưng không liên kết được + +**Triệu chứng:** Cột "Đã tải" là `✓` nhưng "Đã liên kết" là `✗` + +**Xử lý:** +- Vào **Công cụ → Link Mods**, chọn lệnh **Link**, chọn nhóm tương ứng, nhấn **Tạo liên kết** +- Nếu vẫn lỗi, kiểm tra quyền ghi vào thư mục Arma 3 (có thể cần chạy ứng dụng với quyền Administrator) + +### Tên thư mục mod bị sai + +**Triệu chứng:** Arma 3 Server không nhận ra mod dù đã tạo junction + +**Xử lý:** +- Vào **Công cụ → Check Names**, nhấn **Chạy kiểm tra tên** +- Xem log để tìm dòng `MISMATCH` +- Nếu muốn tự động sửa, tick vào **Tự động sửa tên thư mục** rồi chạy lại + +### Ứng dụng không khởi động / lỗi Python + +**Triệu chứng:** Màn hình đen nháy tắt ngay + +**Xử lý:** +- Mở Command Prompt, chạy `python gui.py` để xem thông báo lỗi +- Đảm bảo đã cài đủ thư viện: `pip install -r requirements.txt` +- Kiểm tra phiên bản Python: `python --version` (cần ≥ 3.9) + +--- + +## 12. Bảng thuật ngữ + +| Thuật ngữ | Giải thích | +|-----------|------------| +| **Preset** | Tệp HTML xuất từ Arma 3 Launcher chứa danh sách mod | +| **Pipeline** | Chuỗi 4 bước tự động: phân tích → so sánh → tải → liên kết | +| **Junction / Symlink** | Liên kết thư mục ảo — Arma 3 thấy mod trong thư mục của mình nhưng tệp thực sự nằm ở `downloads/` | +| **Shared mods** | Mod xuất hiện trong tất cả preset đã chọn | +| **Unique mods** | Mod chỉ có trong một preset cụ thể | +| **Caddy server** | Phần mềm máy chủ lưu trữ tệp mod (HTTP file server) | +| **meta.cpp** | Tệp metadata của mỗi mod, chứa `publishedid` (Steam Workshop ID) | +| **Steam ID / publishedid** | Mã định danh mod trên Steam Workshop | +| **comparison.json** | Tệp kết quả so sánh preset, lưu danh sách mod theo nhóm | +| **missing_report.json** | Báo cáo mod có trong preset nhưng chưa có trên máy chủ | +| **downloads/** | Thư mục chứa tệp mod đã tải về | +| **config.json** | Tệp cấu hình lưu thông tin máy chủ và đường dẫn | + +--- + +*Phiên bản tài liệu: 2026-04. Nếu có vấn đề, liên hệ người quản trị máy chủ.* diff --git a/gui/app.py b/gui/app.py index c868ac4..0bffaa5 100644 --- a/gui/app.py +++ b/gui/app.py @@ -15,6 +15,7 @@ from gui._constants import ( SIDEBAR_W, APP_TITLE, PROJECT_ROOT, SELECTION_FILE, ) from gui._io import _QueueWriter +from gui.locales import t from gui.wizard import SetupWizard from gui.views.base import BaseView @@ -62,6 +63,7 @@ class ArmaModManagerApp(ctk.CTk): else: self._load_config() + self._apply_startup_language() self._build_layout() self._poll_log() @@ -106,21 +108,31 @@ class ArmaModManagerApp(ctk.CTk): """Thread-safe: enqueue text for the Logs panel.""" self._log_q.put(text) + def switch_language(self, lang: str) -> None: + """Switch the UI language and refresh all cached views.""" + from gui import locales + locales.set_language(lang) + self._save_language_pref(lang) + self._rebuild_nav_labels() + for view in self._view_cache.values(): + view.refresh() + if self._active_name: + self.navigate_to(self._active_name) + def run_pipeline(self, selected_names: set[str]) -> None: """Start the background pipeline for the given preset filenames.""" if self._pipeline_running: return if len(selected_names) < 2: messagebox.showwarning( - "Not enough presets selected", - "Please select at least 2 preset files to compare.\n\n" - "Use the checkboxes on the Dashboard to choose which presets to use.", + t("app.dlg_presets_title"), + t("app.dlg_presets_body"), ) return cfg = self._cfg if not cfg: - messagebox.showwarning("Setup required", "Please complete Setup first.") + messagebox.showwarning(t("app.dlg_setup_title"), t("app.dlg_setup_body")) return self._pipeline_running = True @@ -137,7 +149,7 @@ class ArmaModManagerApp(ctk.CTk): from arma_modlist_tools.compare import compare_presets # Step 1 — Parse selected presets - _hdr("Step 1 / 4", "Parse presets") + _hdr("Step 1 / 4", t("pipeline.step1_name")) cfg.modlist_json.mkdir(exist_ok=True) presets = [] for fp in sorted(cfg.modlist_html.glob("*.html")): @@ -154,7 +166,7 @@ class ArmaModManagerApp(ctk.CTk): presets.append(preset) # Step 2 — Compare - _hdr("Step 2 / 4", "Compare presets") + _hdr("Step 2 / 4", t("pipeline.step2_name")) result = compare_presets(*presets) cfg.comparison.write_text( json.dumps(result, indent=2, ensure_ascii=False), @@ -166,11 +178,11 @@ class ArmaModManagerApp(ctk.CTk): f"Unique: {total_unique}") # Step 3 — Fetch - _hdr("Step 3 / 4", "Download mods") + _hdr("Step 3 / 4", t("pipeline.step3_name")) step_fetch(cfg) # Step 4 — Link - _hdr("Step 4 / 4", "Link mods") + _hdr("Step 4 / 4", t("pipeline.step4_name")) groups = ( sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir()) if cfg.downloads.is_dir() else [] @@ -212,11 +224,13 @@ class ArmaModManagerApp(ctk.CTk): self.post_log(line) proc.wait() ok = proc.returncode == 0 - self.post_log( - f"\n{'✓ Done' if ok else f'✗ Exited with code {proc.returncode}'}.\n" + done_msg = ( + t("app.tool_done") if ok + else t("app.tool_exit_code", code=proc.returncode) ) + self.post_log(f"\n{done_msg}.\n") except Exception as e: - self.post_log(f"\n✗ Failed to start {script}: {e}\n") + self.post_log(f"\n{t('app.tool_failed', script=script, e=e)}\n") threading.Thread(target=worker, daemon=True).start() @@ -241,6 +255,38 @@ class ArmaModManagerApp(ctk.CTk): def open_wizard(self) -> None: SetupWizard(self, on_complete=self._after_wizard) + # ========================================================================= + # Private — language + # ========================================================================= + + def _apply_startup_language(self) -> None: + """Read language preference from config.json and activate it.""" + from gui import locales + lang = "en" + cfg_path = PROJECT_ROOT / "config.json" + if cfg_path.exists(): + try: + raw = json.loads(cfg_path.read_text(encoding="utf-8")) + lang = raw.get("ui", {}).get("language", "en") + except Exception: + pass + locales.set_language(lang) + + def _save_language_pref(self, lang: str) -> None: + """Persist language preference into the 'ui' key of config.json.""" + cfg_path = PROJECT_ROOT / "config.json" + try: + raw = json.loads(cfg_path.read_text(encoding="utf-8")) + raw.setdefault("ui", {})["language"] = lang + cfg_path.write_text(json.dumps(raw, indent=2), encoding="utf-8") + except Exception: + pass # non-fatal — language preference simply resets next run + + def _rebuild_nav_labels(self) -> None: + """Retranslate the sidebar navigation button labels.""" + for name, btn in self._nav_btns.items(): + btn.configure(text=t(f"nav.{name.lower()}")) + # ========================================================================= # Private — layout # ========================================================================= @@ -264,7 +310,7 @@ class ArmaModManagerApp(ctk.CTk): self._nav_btns: dict[str, ctk.CTkButton] = {} for i, name in enumerate(_VIEW_NAMES, start=1): b = ctk.CTkButton( - sb, text=name, width=SIDEBAR_W - 24, + sb, text=t(f"nav.{name.lower()}"), width=SIDEBAR_W - 24, anchor="w", command=lambda n=name: self.navigate_to(n), fg_color="transparent", hover_color=("gray80", "gray30"), diff --git a/gui/locales.py b/gui/locales.py new file mode 100644 index 0000000..fb083ce --- /dev/null +++ b/gui/locales.py @@ -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 diff --git a/gui/views/dashboard.py b/gui/views/dashboard.py index a94c260..0d10424 100644 --- a/gui/views/dashboard.py +++ b/gui/views/dashboard.py @@ -11,6 +11,7 @@ import customtkinter as ctk from gui._constants import ( COLOR_OK, COLOR_PENDING, COLOR_ERROR, COLOR_WARN, ) +from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: @@ -38,10 +39,12 @@ class DashboardView(BaseView): # ── Header ──────────────────────────────────────────────────────────── hdr = ctk.CTkFrame(self, fg_color="transparent") hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 10)) - ctk.CTkLabel(hdr, text="Dashboard", - font=ctk.CTkFont(size=22, weight="bold")).pack(side="left") - ctk.CTkButton(hdr, text="⟳ Refresh", width=100, - command=self.refresh).pack(side="right") + self._title_lbl = ctk.CTkLabel(hdr, text=t("dashboard.title"), + font=ctk.CTkFont(size=22, weight="bold")) + self._title_lbl.pack(side="left") + self._refresh_btn = ctk.CTkButton(hdr, text=t("dashboard.refresh_btn"), + width=100, command=self.refresh) + self._refresh_btn.pack(side="right") # ── Cards ───────────────────────────────────────────────────────────── cards = ctk.CTkFrame(self, fg_color="transparent") @@ -58,7 +61,7 @@ class DashboardView(BaseView): self._run_btn = ctk.CTkButton( run_area, - text="▶ Run Full Pipeline", + text=t("dashboard.run_btn"), font=ctk.CTkFont(size=15, weight="bold"), height=46, command=self._on_run, @@ -72,11 +75,11 @@ class DashboardView(BaseView): pc = ctk.CTkFrame(parent) pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4) - ctk.CTkLabel(pc, text="Preset Files", + ctk.CTkLabel(pc, text=t("dashboard.preset_card_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=14, pady=(14, 2)) ctk.CTkLabel(pc, - text="HTML exports from Arma 3 Launcher → Mods → Export to HTML", + text=t("dashboard.preset_card_desc"), text_color="gray", font=ctk.CTkFont(size=11)).pack( anchor="w", padx=14) @@ -89,16 +92,16 @@ class DashboardView(BaseView): self._sel_count_lbl = ctk.CTkLabel(sel_row, text="", text_color="gray", font=ctk.CTkFont(size=11)) self._sel_count_lbl.pack(side="left") - ctk.CTkButton(sel_row, text="None", width=54, + ctk.CTkButton(sel_row, text=t("dashboard.btn_none"), width=54, fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11), command=self._select_none).pack(side="right") - ctk.CTkButton(sel_row, text="All", width=54, + ctk.CTkButton(sel_row, text=t("dashboard.btn_all"), width=54, fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), font=ctk.CTkFont(size=11), command=self._select_all).pack(side="right", padx=(0, 6)) - ctk.CTkButton(pc, text="+ Add Preset Files", + ctk.CTkButton(pc, text=t("dashboard.btn_add"), fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), command=self._add_presets).pack(pady=(0, 14)) @@ -109,16 +112,17 @@ class DashboardView(BaseView): pipe = ctk.CTkFrame(parent) pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4) - ctk.CTkLabel(pipe, text="Pipeline Status", + ctk.CTkLabel(pipe, text=t("dashboard.pipeline_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=14, pady=(14, 8)) self._step_icons: dict[str, ctk.CTkLabel] = {} - for key, label in [ - ("parse", "Parse presets"), - ("compare", "Compare presets"), - ("download", "Download mods"), - ("link", "Link to Arma"), + self._step_labels: dict[str, ctk.CTkLabel] = {} + for key, lbl_key in [ + ("parse", "dashboard.step_parse"), + ("compare", "dashboard.step_compare"), + ("download", "dashboard.step_download"), + ("link", "dashboard.step_link"), ]: row = ctk.CTkFrame(pipe, fg_color="transparent") row.pack(fill="x", padx=14, pady=3) @@ -126,8 +130,10 @@ class DashboardView(BaseView): text_color=COLOR_PENDING, font=ctk.CTkFont(size=15)) icon.pack(side="left") - ctk.CTkLabel(row, text=label, anchor="w").pack(side="left", padx=6) - self._step_icons[key] = icon + lbl = ctk.CTkLabel(row, text=t(lbl_key), anchor="w") + lbl.pack(side="left", padx=6) + self._step_icons[key] = icon + self._step_labels[key] = lbl self._stats_lbl = ctk.CTkLabel(pipe, text="", text_color="gray", font=ctk.CTkFont(size=11), @@ -137,6 +143,22 @@ class DashboardView(BaseView): # ── refresh ─────────────────────────────────────────────────────────────── def refresh(self) -> None: + # Retranslate static widgets that were built once + self._title_lbl.configure(text=t("dashboard.title")) + self._refresh_btn.configure(text=t("dashboard.refresh_btn")) + # Only update run_btn text when not currently running + if self._run_btn.cget("state") != "disabled": + self._run_btn.configure(text=t("dashboard.run_btn")) + # Retranslate step labels + for key, lbl_key in [ + ("parse", "dashboard.step_parse"), + ("compare", "dashboard.step_compare"), + ("download", "dashboard.step_download"), + ("link", "dashboard.step_link"), + ]: + if key in self._step_labels: + self._step_labels[key].configure(text=t(lbl_key)) + self._rebuild_preset_list() self._update_pipeline_status() @@ -148,21 +170,21 @@ class DashboardView(BaseView): cfg = self.app.cfg if not cfg: ctk.CTkLabel(self._preset_scroll, - text="No config found. Complete Setup first.", + text=t("dashboard.no_config"), text_color=COLOR_WARN).pack(anchor="w") return html_dir = cfg.modlist_html if not html_dir.is_dir(): ctk.CTkLabel(self._preset_scroll, - text=f"Folder missing:\n{html_dir}", + text=t("dashboard.folder_missing", path=html_dir), text_color=COLOR_WARN, justify="left").pack(padx=4, pady=8) return files = sorted(html_dir.glob("*.html")) if not files: ctk.CTkLabel(self._preset_scroll, - text="No preset files yet.\nUse the button below to add them.", + text=t("dashboard.no_presets"), text_color="gray", justify="left").pack(padx=4, pady=8) return @@ -207,9 +229,9 @@ class DashboardView(BaseView): if cfg.missing_report.exists(): rep = json.loads(cfg.missing_report.read_text(encoding="utf-8")) missing = rep.get("missing", 0) - stat = f"{total} mods · {shared} shared" + stat = t("dashboard.stats", total=total, shared=shared) if missing: - stat += f"\n{missing} missing from server" + stat += t("dashboard.stats_missing", missing=missing) self._stats_lbl.configure(text=stat) except Exception: pass @@ -236,7 +258,9 @@ class DashboardView(BaseView): COLOR_WARN if n_sel == 1 else COLOR_ERROR) self._sel_count_lbl.configure( - text=f"{n_sel} of {n_total} selected", text_color=color) + text=t("dashboard.sel_count", n_sel=n_sel, n_total=n_total), + text_color=color, + ) def _select_all(self) -> None: for var in self._preset_checks.values(): @@ -253,11 +277,11 @@ class DashboardView(BaseView): def _add_presets(self) -> None: cfg = self.app.cfg if not cfg: - messagebox.showwarning("Setup required", - "Please complete Setup before adding presets.") + messagebox.showwarning(t("dashboard.dlg_setup_title"), + t("dashboard.dlg_setup_body")) return files = filedialog.askopenfilenames( - title="Select Arma 3 Launcher preset files", + title=t("dashboard.file_dialog_title"), filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")], ) if not files: @@ -280,10 +304,10 @@ class DashboardView(BaseView): def set_pipeline_ui(self, running: bool) -> None: """Called by the app to reflect pipeline start/end in the UI.""" if running: - self._run_btn.configure(state="disabled", text="Running…") + self._run_btn.configure(state="disabled", text=t("dashboard.running")) self._prog.pack(fill="x", pady=(6, 0)) self._prog.start() else: - self._run_btn.configure(state="normal", text="▶ Run Full Pipeline") + self._run_btn.configure(state="normal", text=t("dashboard.run_btn")) self._prog.stop() self._prog.pack_forget() diff --git a/gui/views/logs.py b/gui/views/logs.py index c886243..6a3d44c 100644 --- a/gui/views/logs.py +++ b/gui/views/logs.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING import customtkinter as ctk from gui._constants import COLOR_ERROR +from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: @@ -27,16 +28,19 @@ class LogsView(BaseView): # ── Header ──────────────────────────────────────────────────────────── hdr = ctk.CTkFrame(self, fg_color="transparent") hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 8)) - ctk.CTkLabel(hdr, text="Logs", - font=ctk.CTkFont(size=22, weight="bold")).pack(side="left") + self._title_lbl = ctk.CTkLabel(hdr, text=t("logs.title"), + font=ctk.CTkFont(size=22, weight="bold")) + self._title_lbl.pack(side="left") btn_row = ctk.CTkFrame(hdr, fg_color="transparent") btn_row.pack(side="right") - ctk.CTkButton(btn_row, text="Copy", width=72, - command=self._copy).pack(side="left", padx=4) - ctk.CTkButton(btn_row, text="Clear", width=72, - fg_color=COLOR_ERROR, hover_color="#c62828", - command=self._clear).pack(side="left") + self._copy_btn = ctk.CTkButton(btn_row, text=t("logs.copy_btn"), width=72, + command=self._copy) + self._copy_btn.pack(side="left", padx=4) + self._clear_btn = ctk.CTkButton(btn_row, text=t("logs.clear_btn"), width=72, + fg_color=COLOR_ERROR, hover_color="#c62828", + command=self._clear) + self._clear_btn.pack(side="left") # ── Log textbox (persistent) ────────────────────────────────────────── self._log_box = ctk.CTkTextbox( @@ -44,6 +48,12 @@ class LogsView(BaseView): font=ctk.CTkFont(family="Consolas", size=12)) self._log_box.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12)) + def refresh(self) -> None: + # Retranslate header widgets (log content intentionally preserved) + self._title_lbl.configure(text=t("logs.title")) + self._copy_btn.configure(text=t("logs.copy_btn")) + self._clear_btn.configure(text=t("logs.clear_btn")) + def append(self, text: str) -> None: """Thread-safe-ish: called from the app's after() poll loop (main thread).""" try: diff --git a/gui/views/mods.py b/gui/views/mods.py index 5db993d..f9bbb81 100644 --- a/gui/views/mods.py +++ b/gui/views/mods.py @@ -9,6 +9,7 @@ import customtkinter as ctk from arma_modlist_tools.fetcher import _normalize_name from gui._constants import COLOR_OK, COLOR_ERROR, COLOR_WARN, COLOR_RUNNING +from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: @@ -61,18 +62,19 @@ class ModsView(BaseView): hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6)) hdr.columnconfigure(1, weight=1) - ctk.CTkLabel(hdr, text="Mods", - font=ctk.CTkFont(size=22, weight="bold")).grid( - row=0, column=0, sticky="w") + self._title_lbl = ctk.CTkLabel(hdr, text=t("mods.title"), + font=ctk.CTkFont(size=22, weight="bold")) + self._title_lbl.grid(row=0, column=0, sticky="w") btn_frame = ctk.CTkFrame(hdr, fg_color="transparent") btn_frame.grid(row=0, column=2, sticky="e") - ctk.CTkButton(btn_frame, text="⟳ Refresh", width=100, - command=self.refresh).pack(side="left", padx=(0, 6)) + self._refresh_btn = ctk.CTkButton(btn_frame, text=t("mods.refresh_btn"), + width=100, command=self.refresh) + self._refresh_btn.pack(side="left", padx=(0, 6)) self._check_btn = ctk.CTkButton( - btn_frame, text="☁ Check Updates", width=130, + btn_frame, text=t("mods.check_btn"), width=140, command=self._check_updates, ) self._check_btn.pack(side="left") @@ -80,9 +82,10 @@ class ModsView(BaseView): # ── Search ──────────────────────────────────────────────────────────── bar = ctk.CTkFrame(self, fg_color="transparent") bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8)) - ctk.CTkLabel(bar, text="Search:").pack(side="left", padx=(0, 6)) + self._search_lbl = ctk.CTkLabel(bar, text=t("mods.search_label")) + self._search_lbl.pack(side="left", padx=(0, 6)) ctk.CTkEntry(bar, textvariable=self._search_var, - placeholder_text="Filter mods in active tab…", + placeholder_text=t("mods.search_placeholder"), width=220).pack(side="left") self._search_var.trace_add("write", lambda *_: self._apply_search()) @@ -99,6 +102,13 @@ class ModsView(BaseView): # ========================================================================= def refresh(self) -> None: + # Retranslate static header widgets + self._title_lbl.configure(text=t("mods.title")) + self._refresh_btn.configure(text=t("mods.refresh_btn")) + if not self._checking: + self._check_btn.configure(text=t("mods.check_btn")) + self._search_lbl.configure(text=t("mods.search_label")) + self._mod_rows.clear() # Destroy previous tab_view / message @@ -111,19 +121,16 @@ class ModsView(BaseView): cfg = self.app.cfg if not cfg: - self._show_msg("No config found. Complete Setup first.") + self._show_msg(t("mods.no_config")) return if not cfg.comparison.exists(): - self._show_msg( - "No mod data yet.\n" - "Go to Dashboard, select your presets, then click Run Full Pipeline." - ) + self._show_msg(t("mods.no_data")) return try: comp = json.loads(cfg.comparison.read_text(encoding="utf-8")) except Exception as e: - self._show_msg(f"Error reading comparison.json: {e}", error=True) + self._show_msg(t("mods.read_error", e=e), error=True) return # Build ordered group list: shared first, then unique groups @@ -164,14 +171,14 @@ class ModsView(BaseView): fg_color=("gray82", "gray22"), corner_radius=6) col_hdr.grid(row=0, column=0, sticky="ew", padx=4, pady=(6, 2)) col_hdr.columnconfigure(0, weight=1) - for col, (w, lbl) in enumerate([ - (0, "Mod Name"), - (80, "Downloaded"), - (80, "Linked"), - (160, "Server Status"), + for col, (w, lbl_key) in enumerate([ + (0, "mods.col_name"), + (80, "mods.col_downloaded"), + (80, "mods.col_linked"), + (160, "mods.col_server"), (80, ""), ]): - ctk.CTkLabel(col_hdr, text=lbl, + ctk.CTkLabel(col_hdr, text=t(lbl_key) if lbl_key else "", font=ctk.CTkFont(weight="bold"), anchor="w", width=w or 1).grid( row=0, column=col, @@ -247,7 +254,7 @@ class ModsView(BaseView): # Update button (hidden until stale detected) folder_name = folder_path.name if folder_path else None update_btn = ctk.CTkButton( - row, text="Update", width=70, + row, text=t("mods.update_btn"), width=70, command=(lambda g=group, fn=folder_name: self._update_mod(g, fn)) if folder_name else None, state="normal" if folder_name else "disabled", @@ -282,13 +289,13 @@ class ModsView(BaseView): return self._checking = True - self._check_btn.configure(text="Checking…", state="disabled") + self._check_btn.configure(text=t("mods.check_btn_checking"), state="disabled") # Reset downloaded rows to "Checking…" for row in self._mod_rows.values(): if row["folder_path"]: row["status_label"].configure( - text="Checking…", text_color=COLOR_RUNNING) + text=t("mods.status_checking"), text_color=COLOR_RUNNING) else: row["status_label"].configure(text="—", text_color="gray") @@ -325,27 +332,29 @@ class ModsView(BaseView): threading.Thread(target=worker, daemon=True).start() def _apply_check_results(self, results: dict[str, tuple[str, int]]) -> None: + # Build status map from current translations _STATUS: dict[str, tuple[str, str]] = { - "ok": ("✓ Up to date", COLOR_OK), - "stale": ("⚠ {n} outdated", COLOR_WARN), - "not_downloaded": ("—", "gray"), - "not_on_server": ("Not on server", "gray"), - "error": ("✗ Error", COLOR_ERROR), + "ok": (t("mods.status_ok"), COLOR_OK), + "stale": (t("mods.status_stale"), COLOR_WARN), + "not_downloaded": (t("mods.status_not_downloaded"), "gray"), + "not_on_server": (t("mods.status_not_on_server"), "gray"), + "error": (t("mods.status_error"), COLOR_ERROR), } for key, (status, n) in results.items(): row = self._mod_rows.get(key) if not row: continue tmpl, color = _STATUS.get(status, ("—", "gray")) - row["status_label"].configure( - text=tmpl.replace("{n}", str(n)), text_color=color) + # For "stale", the template contains {n} placeholder + text = tmpl.format_map({"n": n}) if "{n}" in tmpl else tmpl + row["status_label"].configure(text=text, text_color=color) if status == "stale" and row["folder_path"]: row["update_btn"].grid() else: row["update_btn"].grid_remove() self._checking = False - self._check_btn.configure(text="☁ Check Updates", state="normal") + self._check_btn.configure(text=t("mods.check_btn"), state="normal") def _update_mod(self, group: str, folder_name: str) -> None: self.app.navigate_to("Logs") diff --git a/gui/views/settings.py b/gui/views/settings.py index 15bd1cd..7b3b335 100644 --- a/gui/views/settings.py +++ b/gui/views/settings.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import customtkinter as ctk +from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: @@ -11,15 +12,15 @@ if TYPE_CHECKING: class SettingsView(BaseView): - """Appearance switcher, wizard re-opener, and current config display.""" + """Appearance switcher, language selector, wizard re-opener, and current config display.""" def build(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) - ctk.CTkLabel(self, text="Settings", - font=ctk.CTkFont(size=22, weight="bold")).grid( - row=0, column=0, sticky="w", padx=24, pady=(20, 8)) + self._title_lbl = ctk.CTkLabel(self, text=t("settings.title"), + font=ctk.CTkFont(size=22, weight="bold")) + self._title_lbl.grid(row=0, column=0, sticky="w", padx=24, pady=(20, 8)) self._scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") self._scroll.grid(row=1, column=0, sticky="nsew", padx=24, pady=(0, 12)) @@ -27,7 +28,8 @@ class SettingsView(BaseView): self._build_cards() def refresh(self) -> None: - # Config info may have changed (e.g. after wizard); rebuild cards. + # Config info and language may have changed; rebuild everything. + self._title_lbl.configure(text=t("settings.title")) for w in self._scroll.winfo_children(): w.destroy() self._build_cards() @@ -36,22 +38,21 @@ class SettingsView(BaseView): # ── Server & Paths ──────────────────────────────────────────────────── c1 = ctk.CTkFrame(self._scroll) c1.pack(fill="x", pady=6) - ctk.CTkLabel(c1, text="Server & Path Configuration", + ctk.CTkLabel(c1, text=t("settings.server_card_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=16, pady=(14, 3)) ctk.CTkLabel(c1, - text="Re-run the setup wizard to change your server URL, " - "credentials, or Arma folder.", + text=t("settings.server_card_desc"), text_color="gray", wraplength=600, justify="left").pack( anchor="w", padx=16, pady=(0, 8)) - ctk.CTkButton(c1, text="Open Setup Wizard", width=160, + ctk.CTkButton(c1, text=t("settings.wizard_btn"), width=160, command=self.app.open_wizard).pack( anchor="e", padx=16, pady=(0, 14)) # ── Appearance ──────────────────────────────────────────────────────── c2 = ctk.CTkFrame(self._scroll) c2.pack(fill="x", pady=6) - ctk.CTkLabel(c2, text="Appearance", + ctk.CTkLabel(c2, text=t("settings.appearance_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=16, pady=(14, 3)) mode_var = ctk.StringVar(value=ctk.get_appearance_mode()) @@ -60,12 +61,28 @@ class SettingsView(BaseView): command=ctk.set_appearance_mode, width=140).pack(anchor="w", padx=16, pady=(0, 14)) + # ── Language ────────────────────────────────────────────────────────── + c_lang = ctk.CTkFrame(self._scroll) + c_lang.pack(fill="x", pady=6) + ctk.CTkLabel(c_lang, text=t("settings.language_title"), + font=ctk.CTkFont(size=14, weight="bold")).pack( + anchor="w", padx=16, pady=(14, 3)) + from gui.locales import get_language + current_display = "Tiếng Việt" if get_language() == "vi" else "English" + ctk.CTkOptionMenu( + c_lang, + values=["English", "Tiếng Việt"], + variable=ctk.StringVar(value=current_display), + command=lambda v: self.app.switch_language("vi" if v == "Tiếng Việt" else "en"), + width=160, + ).pack(anchor="w", padx=16, pady=(0, 14)) + # ── Current config info ─────────────────────────────────────────────── cfg = self.app.cfg if cfg: c3 = ctk.CTkFrame(self._scroll) c3.pack(fill="x", pady=6) - ctk.CTkLabel(c3, text="Current Configuration", + ctk.CTkLabel(c3, text=t("settings.config_title"), font=ctk.CTkFont(size=14, weight="bold")).pack( anchor="w", padx=16, pady=(14, 3)) info = ( diff --git a/gui/views/tools.py b/gui/views/tools.py index 77e7cf8..9446751 100644 --- a/gui/views/tools.py +++ b/gui/views/tools.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Optional import customtkinter as ctk from gui._constants import COLOR_WARN, PROJECT_ROOT +from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: @@ -22,9 +23,9 @@ class ToolsView(BaseView): self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) - ctk.CTkLabel(self, text="Tools", - font=ctk.CTkFont(size=22, weight="bold")).grid( - row=0, column=0, sticky="w", padx=24, pady=(20, 8)) + self._title_lbl = ctk.CTkLabel(self, text=t("tools.title"), + font=ctk.CTkFont(size=22, weight="bold")) + self._title_lbl.grid(row=0, column=0, sticky="w", padx=24, pady=(20, 8)) self._tab_view = ctk.CTkTabview(self) self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) @@ -32,6 +33,9 @@ class ToolsView(BaseView): # Per-tab group menu references so refresh() can repopulate them all self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = [] + # Tab-internal translatable labels (for hot-swap on refresh) + self._translatable: list[tuple[ctk.CTkLabel | ctk.CTkButton | ctk.CTkCheckBox, str]] = [] + self._build_check_names_tab() self._build_update_mods_tab() self._build_link_mods_tab() @@ -43,23 +47,32 @@ class ToolsView(BaseView): # ========================================================================= def refresh(self) -> None: + self._title_lbl.configure(text=t("tools.title")) + + # Retranslate registered widgets + for widget, key in self._translatable: + widget.configure(text=t(key)) + + # Refresh link-mods button label (depends on current command selection) + self._lm_on_change() + groups = self._get_groups() - all_groups = ["All groups"] + groups + all_groups = [t("tools.all_groups")] + groups # Repopulate generic group menus for menu, var in self._group_menus: prev = var.get() menu.configure(values=all_groups) - if prev not in all_groups: - var.set("All groups") + # Keep selection if still valid, else reset to "All groups" + if prev not in all_groups and prev not in groups: + var.set(t("tools.all_groups")) # Link Mods group menu (no "All groups") lm_prev = self._lm_group_var.get() - lm_vals = groups if groups else ["(no groups found)"] + lm_vals = groups if groups else [t("tools.no_groups")] self._lm_group_menu.configure(values=lm_vals) if lm_prev not in lm_vals: self._lm_group_var.set(lm_vals[0]) - self._lm_on_change() # re-evaluate button state # Info labels self._update_sm_label() @@ -70,47 +83,49 @@ class ToolsView(BaseView): # ========================================================================= def _build_check_names_tab(self) -> None: + # Tab name kept in English — used as CTkTabview lookup key self._tab_view.add("Check Names") tab = self._tab_view.tab("Check Names") tab.grid_columnconfigure(0, weight=1) - _desc(tab, row=0, - text="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).") + desc_lbl = _desc(tab, row=0, text=t("tools.cn_desc")) + self._translatable.append((desc_lbl, "tools.cn_desc")) # Group - gf = _row(tab, row=1, label="Group:") - self._cn_group_var = ctk.StringVar(value="All groups") + gf, group_lbl = _row(tab, row=1, label=t("tools.label_group")) + self._translatable.append((group_lbl, "tools.label_group")) + self._cn_group_var = ctk.StringVar(value=t("tools.all_groups")) menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var, - values=["All groups"], width=200) + values=[t("tools.all_groups")], width=200) menu.pack(side="left") self._group_menus.append((menu, self._cn_group_var)) # Checkboxes - cf = _row(tab, row=2, label="Options:") + cf, opts_lbl = _row(tab, row=2, label=t("tools.label_options")) + self._translatable.append((opts_lbl, "tools.label_options")) self._cn_fix_var = ctk.BooleanVar(value=False) self._cn_fix_ids_var = ctk.BooleanVar(value=False) - ctk.CTkCheckBox(cf, text="Auto-fix folder name mismatches (--fix)", - variable=self._cn_fix_var, - command=self._cn_on_toggle).pack(side="left", padx=(0, 16)) - ctk.CTkCheckBox(cf, text="Auto-fix wrong Steam IDs in meta.cpp (--fix-ids)", - variable=self._cn_fix_ids_var, - command=self._cn_on_toggle).pack(side="left") + cn_fix_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_chk"), + variable=self._cn_fix_var, + command=self._cn_on_toggle) + cn_fix_chk.pack(side="left", padx=(0, 16)) + self._translatable.append((cn_fix_chk, "tools.cn_fix_chk")) + cn_ids_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_ids_chk"), + variable=self._cn_fix_ids_var, + command=self._cn_on_toggle) + cn_ids_chk.pack(side="left") + self._translatable.append((cn_ids_chk, "tools.cn_fix_ids_chk")) # Warning (hidden until checkbox ticked) - self._cn_warn = ctk.CTkLabel( - tab, - text="⚠ --fix renames folders and updates junctions. " - "--fix-ids rewrites meta.cpp files.", - text_color=_WARN_COLOR, anchor="w", - ) - # not gridded yet — shown on demand + self._cn_warn = ctk.CTkLabel(tab, text=t("tools.cn_warn"), + text_color=_WARN_COLOR, anchor="w") + self._translatable.append((self._cn_warn, "tools.cn_warn")) # Run button - ctk.CTkButton(tab, text="Run Check Names", width=180, - command=self._cn_run).grid( - row=10, column=0, padx=24, pady=(16, 24), sticky="e") + cn_btn = ctk.CTkButton(tab, text=t("tools.cn_btn"), width=180, + command=self._cn_run) + cn_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") + self._translatable.append((cn_btn, "tools.cn_btn")) def _cn_on_toggle(self) -> None: if self._cn_fix_var.get() or self._cn_fix_ids_var.get(): @@ -121,7 +136,7 @@ class ToolsView(BaseView): def _cn_run(self) -> None: args = ["check_names.py"] g = self._cn_group_var.get() - if g != "All groups": + if g != t("tools.all_groups"): args += ["--group", g] if self._cn_fix_var.get(): args.append("--fix") @@ -136,54 +151,56 @@ class ToolsView(BaseView): tab = self._tab_view.tab("Update Mods") tab.grid_columnconfigure(0, weight=1) - _desc(tab, row=0, - text="Re-download mod files whose size on the server differs from " - "your local copy. Use --force to re-download everything " - "regardless of size.") + desc_lbl = _desc(tab, row=0, text=t("tools.um_desc")) + self._translatable.append((desc_lbl, "tools.um_desc")) # Group - gf = _row(tab, row=1, label="Group:") - self._um_group_var = ctk.StringVar(value="All groups") + gf, group_lbl = _row(tab, row=1, label=t("tools.label_group")) + self._translatable.append((group_lbl, "tools.label_group")) + self._um_group_var = ctk.StringVar(value=t("tools.all_groups")) um_menu = ctk.CTkOptionMenu( - gf, variable=self._um_group_var, values=["All groups"], width=200, + gf, variable=self._um_group_var, values=[t("tools.all_groups")], width=200, command=self._um_on_group_change, ) um_menu.pack(side="left") self._group_menus.append((um_menu, self._um_group_var)) # Mod name (enabled only when a specific group is selected) - mf = _row(tab, row=2, label="Mod folder:") + mf, mod_lbl = _row(tab, row=2, label=t("tools.um_mod_label")) + self._translatable.append((mod_lbl, "tools.um_mod_label")) self._um_mod_entry = ctk.CTkEntry( - mf, placeholder_text="Optional — e.g. @ace", width=220, + mf, placeholder_text=t("tools.um_mod_placeholder"), width=220, state="disabled", ) self._um_mod_entry.pack(side="left") - ctk.CTkLabel(mf, text="(only when a specific group is selected)", - text_color="gray").pack(side="left", padx=8) + um_hint = ctk.CTkLabel(mf, text=t("tools.um_mod_hint"), text_color="gray") + um_hint.pack(side="left", padx=8) + self._translatable.append((um_hint, "tools.um_mod_hint")) # Force checkbox - ff = _row(tab, row=3, label="Options:") + ff, opts_lbl = _row(tab, row=3, label=t("tools.label_options")) + self._translatable.append((opts_lbl, "tools.label_options")) self._um_force_var = ctk.BooleanVar(value=False) - ctk.CTkCheckBox( - ff, text="Force re-download all files (--force)", + um_force_chk = ctk.CTkCheckBox( + ff, text=t("tools.um_force_chk"), variable=self._um_force_var, command=self._um_on_toggle, - ).pack(side="left") + ) + um_force_chk.pack(side="left") + self._translatable.append((um_force_chk, "tools.um_force_chk")) # Warning - self._um_warn = ctk.CTkLabel( - tab, - text="⚠ --force re-downloads every file regardless of size. " - "This may transfer a large amount of data.", - text_color=_WARN_COLOR, anchor="w", - ) + self._um_warn = ctk.CTkLabel(tab, text=t("tools.um_warn"), + text_color=_WARN_COLOR, anchor="w") + self._translatable.append((self._um_warn, "tools.um_warn")) - ctk.CTkButton(tab, text="Run Update", width=180, - command=self._um_run).grid( - row=10, column=0, padx=24, pady=(16, 24), sticky="e") + um_btn = ctk.CTkButton(tab, text=t("tools.um_btn"), width=180, + command=self._um_run) + um_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") + self._translatable.append((um_btn, "tools.um_btn")) def _um_on_group_change(self, _: str) -> None: - is_specific = self._um_group_var.get() != "All groups" + is_specific = self._um_group_var.get() != t("tools.all_groups") self._um_mod_entry.configure(state="normal" if is_specific else "disabled") if not is_specific: self._um_mod_entry.delete(0, "end") @@ -197,7 +214,7 @@ class ToolsView(BaseView): def _um_run(self) -> None: args = ["update_mods.py"] g = self._um_group_var.get() - if g != "All groups": + if g != t("tools.all_groups"): args += ["--group", g] mod = self._um_mod_entry.get().strip() if mod: @@ -213,15 +230,12 @@ class ToolsView(BaseView): tab = self._tab_view.tab("Link Mods") tab.grid_columnconfigure(0, weight=1) - _desc(tab, row=0, - text="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).") + desc_lbl = _desc(tab, row=0, text=t("tools.lm_desc")) + self._translatable.append((desc_lbl, "tools.lm_desc")) - # Command selector - cf = _row(tab, row=1, label="Command:") + # Command selector — values kept in English (drive internal logic) + cf, cmd_lbl = _row(tab, row=1, label=t("tools.label_command")) + self._translatable.append((cmd_lbl, "tools.label_command")) self._lm_cmd_var = ctk.StringVar(value="Status") ctk.CTkSegmentedButton( cf, @@ -231,33 +245,36 @@ class ToolsView(BaseView): ).pack(side="left") # Group (required — no "All groups") - gf = _row(tab, row=2, label="Group:") + gf, group_lbl = _row(tab, row=2, label=t("tools.label_group")) + self._translatable.append((group_lbl, "tools.label_group")) self._lm_group_var = ctk.StringVar(value="") self._lm_group_menu = ctk.CTkOptionMenu( gf, variable=self._lm_group_var, - values=["(no groups found)"], width=200, + values=[t("tools.no_groups")], width=200, command=lambda _: self._lm_on_change(), ) self._lm_group_menu.pack(side="left") # Warning (shown for Unlink) - self._lm_warn = ctk.CTkLabel( - tab, - text="⚠ Unlink removes junction links from the Arma 3 directory. " - "Mod files in downloads/ are NOT deleted.", - text_color=_WARN_COLOR, anchor="w", - ) + self._lm_warn = ctk.CTkLabel(tab, text=t("tools.lm_warn"), + text_color=_WARN_COLOR, anchor="w") + self._translatable.append((self._lm_warn, "tools.lm_warn")) # Run button (label changes with command) self._lm_run_btn = ctk.CTkButton( - tab, text="Show Status", width=180, + tab, text=t("tools.lm_show_status"), width=180, command=self._lm_run, ) self._lm_run_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") def _lm_on_change(self, _: str = "") -> None: cmd = self._lm_cmd_var.get() - labels = {"Status": "Show Status", "Link": "Create Links", "Unlink": "Remove Links"} + # Keys are English segmented-button values; values are translated labels + labels = { + "Status": t("tools.lm_show_status"), + "Link": t("tools.lm_create_links"), + "Unlink": t("tools.lm_remove_links"), + } self._lm_run_btn.configure(text=labels.get(cmd, cmd)) if cmd == "Unlink": @@ -269,19 +286,17 @@ class ToolsView(BaseView): cmd = self._lm_cmd_var.get().lower() group = self._lm_group_var.get() - if not group or group == "(no groups found)": - messagebox.showwarning("No group selected", - "Please select a group from the dropdown.") + if not group or group == t("tools.no_groups"): + messagebox.showwarning(t("tools.lm_no_group_title"), + t("tools.lm_no_group_body")) return args = ["link_mods.py", cmd, "--group", group] if cmd == "unlink": confirmed = messagebox.askyesno( - "Confirm Unlink", - f"Remove junction links for group '{group}'?\n\n" - "This removes links from the Arma 3 directory but does NOT delete " - "mod files in downloads/.", + t("tools.lm_confirm_title"), + t("tools.lm_confirm_body", group=group), ) if not confirmed: return @@ -296,17 +311,16 @@ class ToolsView(BaseView): tab = self._tab_view.tab("Sync Missing") tab.grid_columnconfigure(0, weight=1) - _desc(tab, row=0, - text="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.") + desc_lbl = _desc(tab, row=0, text=t("tools.sm_desc")) + self._translatable.append((desc_lbl, "tools.sm_desc")) self._sm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w") self._sm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w") - ctk.CTkButton(tab, text="Run Sync Missing", width=180, - command=lambda: self._launch(["sync_missing.py"])).grid( - row=10, column=0, padx=24, pady=(16, 24), sticky="e") + sm_btn = ctk.CTkButton(tab, text=t("tools.sm_btn"), width=180, + command=lambda: self._launch(["sync_missing.py"])) + sm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") + self._translatable.append((sm_btn, "tools.sm_btn")) def _update_sm_label(self) -> None: cfg = self.app.cfg @@ -321,13 +335,11 @@ class ToolsView(BaseView): try: data = json.loads(report_path.read_text(encoding="utf-8")) count = data.get("missing", len(data.get("missing_mods", []))) - self._sm_info.configure( - text=f"{count} mod(s) currently listed as missing.") + self._sm_info.configure(text=t("tools.sm_count", count=count)) return except Exception: pass - self._sm_info.configure( - text="No missing_report.json found — run the pipeline first.") + self._sm_info.configure(text=t("tools.sm_no_report")) # ------------------------------------------------------------------------- @@ -336,17 +348,16 @@ class ToolsView(BaseView): tab = self._tab_view.tab("Report Missing") tab.grid_columnconfigure(0, weight=1) - _desc(tab, row=0, - text="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.") + desc_lbl = _desc(tab, row=0, text=t("tools.rm_desc")) + self._translatable.append((desc_lbl, "tools.rm_desc")) self._rm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w") self._rm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w") - ctk.CTkButton(tab, text="Generate Report", width=180, - command=lambda: self._launch(["report_missing.py"])).grid( - row=10, column=0, padx=24, pady=(16, 24), sticky="e") + rm_btn = ctk.CTkButton(tab, text=t("tools.rm_btn"), width=180, + command=lambda: self._launch(["report_missing.py"])) + rm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") + self._translatable.append((rm_btn, "tools.rm_btn")) def _update_rm_label(self) -> None: cfg = self.app.cfg @@ -361,11 +372,11 @@ class ToolsView(BaseView): try: data = json.loads(report_path.read_text(encoding="utf-8")) ts = data.get("generated_at", "unknown") - self._rm_info.configure(text=f"Last generated: {ts}") + self._rm_info.configure(text=t("tools.rm_last", ts=ts)) return except Exception: pass - self._rm_info.configure(text="No report yet.") + self._rm_info.configure(text=t("tools.rm_none")) # ========================================================================= # Private — helpers @@ -400,10 +411,14 @@ def _desc(parent, row: int, text: str) -> ctk.CTkLabel: return lbl -def _row(parent, row: int, label: str) -> ctk.CTkFrame: - """A label + horizontal frame for a settings row.""" - ctk.CTkLabel(parent, text=label, anchor="w", width=110).grid( - row=row, column=0, padx=(24, 0), pady=6, sticky="w") +def _row(parent, row: int, label: str) -> tuple[ctk.CTkFrame, ctk.CTkLabel]: + """A label + horizontal frame for a settings row. + + Returns (content_frame, label_widget) so callers can register the label + for later retranslation. + """ + lbl = ctk.CTkLabel(parent, text=label, anchor="w", width=110) + lbl.grid(row=row, column=0, padx=(24, 0), pady=6, sticky="w") f = ctk.CTkFrame(parent, fg_color="transparent") f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w") - return f + return f, lbl diff --git a/gui/wizard.py b/gui/wizard.py index 9d20453..d15f475 100644 --- a/gui/wizard.py +++ b/gui/wizard.py @@ -7,6 +7,7 @@ import customtkinter as ctk from tkinter import filedialog from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT +from gui.locales import t class SetupWizard(ctk.CTkToplevel): @@ -18,7 +19,7 @@ class SetupWizard(ctk.CTkToplevel): on_complete: Callable[[], None], ) -> None: super().__init__(parent) - self.title("Setup — Arma Mod Manager") + self.title(t("wizard.title")) self.geometry("500x420") self.resizable(False, False) self.grab_set() @@ -48,20 +49,20 @@ class SetupWizard(ctk.CTkToplevel): def _page_server(self) -> None: ctk.CTkLabel( - self._body, text="Step 1 of 3 — Server Connection", + self._body, text=t("wizard.step1_title"), font=ctk.CTkFont(size=16, weight="bold"), ).pack(anchor="w") ctk.CTkLabel( - self._body, text="Enter the details for your Caddy mod server.", + self._body, text=t("wizard.step1_desc"), text_color="gray", ).pack(anchor="w", pady=(4, 18)) - for lbl, var, show in [ - ("Server URL", self._url, ""), - ("Username", self._user, ""), - ("Password", self._pw, "•"), + for lbl_key, var, show in [ + ("wizard.label_url", self._url, ""), + ("wizard.label_user", self._user, ""), + ("wizard.label_pw", self._pw, "•"), ]: - ctk.CTkLabel(self._body, text=lbl).pack(anchor="w") + ctk.CTkLabel(self._body, text=t(lbl_key)).pack(anchor="w") ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack( anchor="w", pady=(2, 10)) @@ -69,15 +70,15 @@ class SetupWizard(ctk.CTkToplevel): foot.pack(fill="x", pady=(8, 0)) self._conn_lbl = ctk.CTkLabel(foot, text="", text_color="gray") self._conn_lbl.pack(side="left") - ctk.CTkButton(foot, text="Next →", width=90, + ctk.CTkButton(foot, text=t("wizard.btn_next"), width=90, command=lambda: self._show(1)).pack(side="right") - ctk.CTkButton(foot, text="Test Connection", width=140, + ctk.CTkButton(foot, text=t("wizard.btn_test"), width=140, fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), command=self._test).pack(side="right", padx=(0, 8)) def _test(self) -> None: - self._conn_lbl.configure(text="Testing…", text_color="gray") + self._conn_lbl.configure(text=t("wizard.testing"), text_color="gray") self.update() try: import requests @@ -85,53 +86,51 @@ class SetupWizard(ctk.CTkToplevel): auth=(self._user.get(), self._pw.get()), timeout=8) if r.ok: - self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK) + self._conn_lbl.configure(text=t("wizard.connected"), + text_color=COLOR_OK) else: - self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}", + self._conn_lbl.configure(text=t("wizard.http_error", code=r.status_code), text_color=COLOR_ERROR) except Exception as e: - self._conn_lbl.configure(text=f"✗ {e}", text_color=COLOR_ERROR) + self._conn_lbl.configure(text=t("wizard.conn_error", e=e), + text_color=COLOR_ERROR) # ── Page 2: paths ──────────────────────────────────────────────────────── def _page_paths(self) -> None: ctk.CTkLabel( - self._body, text="Step 2 of 3 — Arma 3 Server Folder", + self._body, text=t("wizard.step2_title"), font=ctk.CTkFont(size=16, weight="bold"), ).pack(anchor="w") ctk.CTkLabel( - self._body, - text="Point to your Arma 3 Server installation. " - "Links (junctions) will be created here.", + self._body, text=t("wizard.step2_desc"), text_color="gray", wraplength=440, justify="left", ).pack(anchor="w", pady=(4, 18)) - ctk.CTkLabel(self._body, text="Arma 3 Server folder").pack(anchor="w") + ctk.CTkLabel(self._body, text=t("wizard.label_arma")).pack(anchor="w") row = ctk.CTkFrame(self._body, fg_color="transparent") row.pack(fill="x", pady=(2, 8)) ctk.CTkEntry(row, textvariable=self._arma, width=350).pack(side="left") - ctk.CTkButton(row, text="Browse", width=80, + ctk.CTkButton(row, text=t("wizard.btn_browse"), width=80, command=self._browse_arma).pack(side="left", padx=8) ctk.CTkLabel( - self._body, - text="All other folders (downloads, presets) will be created " - "automatically next to this tool.", + self._body, text=t("wizard.step2_hint"), text_color="gray", font=ctk.CTkFont(size=11), wraplength=440, justify="left", ).pack(anchor="w", pady=(8, 0)) foot = ctk.CTkFrame(self._body, fg_color="transparent") foot.pack(fill="x", pady=(20, 0)) - ctk.CTkButton(foot, text="← Back", width=80, + ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80, fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), command=lambda: self._show(0)).pack(side="left") - ctk.CTkButton(foot, text="Next →", width=80, + ctk.CTkButton(foot, text=t("wizard.btn_next"), width=80, command=lambda: self._show(2)).pack(side="right") def _browse_arma(self) -> None: - d = filedialog.askdirectory(title="Select Arma 3 Server folder") + d = filedialog.askdirectory(title=t("wizard.browse_title")) if d: self._arma.set(d) @@ -139,18 +138,18 @@ class SetupWizard(ctk.CTkToplevel): def _page_review(self) -> None: ctk.CTkLabel( - self._body, text="Step 3 of 3 — Review & Save", + self._body, text=t("wizard.step3_title"), font=ctk.CTkFont(size=16, weight="bold"), ).pack(anchor="w") ctk.CTkLabel( - self._body, text="Check your settings, then click Save.", + self._body, text=t("wizard.step3_desc"), text_color="gray", ).pack(anchor="w", pady=(4, 14)) summary = ( f"Server URL: {self._url.get()}\n" f"Username: {self._user.get()}\n" - f"Arma folder: {self._arma.get() or '(not set)'}\n" + f"Arma folder: {self._arma.get() or t('wizard.not_set')}\n" ) box = ctk.CTkTextbox(self._body, height=90, font=ctk.CTkFont(family="Consolas", size=12)) @@ -160,11 +159,11 @@ class SetupWizard(ctk.CTkToplevel): foot = ctk.CTkFrame(self._body, fg_color="transparent") foot.pack(fill="x") - ctk.CTkButton(foot, text="← Back", width=80, + ctk.CTkButton(foot, text=t("wizard.btn_back"), width=80, fg_color="transparent", border_width=1, text_color=("gray10", "gray90"), command=lambda: self._show(1)).pack(side="left") - ctk.CTkButton(foot, text="Save & Open", width=120, + ctk.CTkButton(foot, text=t("wizard.btn_save"), width=120, command=self._save).pack(side="right") def _save(self) -> None: