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

View File

@@ -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 — `"<view>.<widget_purpose>"`, 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.

379
docs/huong-dan-su-dung.md Normal file
View File

@@ -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ủ.*

View File

@@ -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"),

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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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")

View File

@@ -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 = (

View File

@@ -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

View File

@@ -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: