Compare commits
15 Commits
4478ec3cab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24828ac68 | ||
|
|
48637ffe90 | ||
|
|
45cb023513 | ||
|
|
ecfa5fa636 | ||
|
|
fd513b3688 | ||
|
|
50990cca4e | ||
|
|
4fde566cf4 | ||
|
|
68fcaaf6d9 | ||
|
|
06f0c6eb92 | ||
|
|
3276f4b63f | ||
|
|
e0c2dfb32a | ||
|
|
5c824280c6 | ||
|
|
90cc6c00ff | ||
|
|
85bc406236 | ||
|
|
903cd366e2 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,6 +16,13 @@ dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
*.cover
|
||||
*.py,cover
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -92,7 +92,7 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s
|
||||
- `gui/app.py` — `ArmaModManagerApp` main window; manages view routing, config loading, thread-safe log queue, and background pipeline execution
|
||||
- `gui/wizard.py` — `SetupWizard` dialog shown on first launch when no `config.json` exists
|
||||
- `gui/_constants.py` — window dimensions, status color constants, file paths
|
||||
- `gui/_io.py` — `_QueueWriter` redirects stdout/stderr to a thread-safe queue so pipeline output streams into the Logs view
|
||||
- `gui/_io.py` — `_QueueWriter` redirects stdout/stderr to a thread-safe queue so pipeline output streams into the Logs view. `write()` strips ANSI/CSI escape codes and converts bare `\r` to `\n` before enqueuing, so `tqdm` progress output is legible in the textbox.
|
||||
|
||||
**Views** (`gui/views/`): each inherits `BaseView`; `build()` runs once on creation, `refresh()` runs on each navigation:
|
||||
- `dashboard.py` — overview, status, quick stats
|
||||
@@ -101,19 +101,75 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s
|
||||
- `logs.py` — real-time log viewer fed from the stdout/stderr queue
|
||||
- `settings.py` — in-app editor for `config.json` (server URL, paths, credentials)
|
||||
|
||||
**`_find_folder` (mods.py) — three-level name matching:** The mods view resolves a mod's local folder by mod name from `comparison.json`, which may differ from the server-canonical folder name used by the fetcher. Lookup order:
|
||||
**`_find_folder` (mods.py) — four-level name matching:** The mods view resolves a mod's local folder by mod name from `comparison.json`, which may differ from the server-canonical folder name used by the fetcher. Lookup order:
|
||||
1. Exact: `@{mod_name}`
|
||||
2. Case-insensitive: `@CBA_A3` matches `CBA_A3`
|
||||
3. Normalized (`_normalize_name`): strips all non-alphanumeric — handles punctuation/spacing differences, e.g. `@US GEAr- Units (IFA3)` matches `US GEAr: Units (IFA3)` (both → `usgearunitsifa3`)
|
||||
4. Steam ID via `meta.cpp`: reads `publishedid` from each folder's `meta.cpp` and matches against `mod["steam_id"]` — handles the case where the folder name bears no resemblance to the modlist name but the mod content is correct
|
||||
|
||||
**`selection.json`** — GUI selection state file, tracked in git. Persists which mods/groups are selected between GUI sessions. Written by the GUI; safe to delete (GUI recreates it on next save).
|
||||
|
||||
**`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.
|
||||
**`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. The `Popen` call uses `encoding="utf-8", errors="replace"` and sets `PYTHONUTF8=1` in the child environment so that tqdm's Unicode block characters (e.g. `▉`) don't crash the pipe reader on Windows, where the default `charmap` codec cannot decode them.
|
||||
|
||||
**GUI threading model:** Every network or long-running operation runs in a `threading.Thread(daemon=True)` so the Tkinter event loop is never blocked. The only safe way to update widgets from a background thread is `self.after(0, callback)` — never touch widgets directly from a worker thread. `_poll_log` drains the entire log queue in one `after(80, ...)` tick and does a single batched `CTkTextbox.insert()` call rather than one per log entry, keeping the UI smooth even when `tqdm` emits many rapid updates during downloads. The wizard's "Test Connection" button follows the same pattern: `requests.get` runs in a daemon thread; the result is posted back via `self.after(0, ...)` with widget references captured *before* the thread starts, so stale references cannot update the wrong widgets if the user navigates away mid-request.
|
||||
|
||||
**`run_pipeline` worker — import guard:** `from run import step_fetch, step_link` is performed inside its own `try/except` *before* stdout is redirected. If this import fails for any reason the exception is posted to the log via `self.after(0, ...)` and `_pipeline_done` is called so the UI resets cleanly. Previously an import failure would silently kill the worker thread and leave the pipeline button disabled forever.
|
||||
|
||||
**`build_server_index` progress callback:** Accepts an optional `progress_fn(current, total, name)` callback. `step_fetch` in `run.py` uses this to print `Indexing N/M: @FolderName` every 25 folders so the log never goes silent during the server scan phase. The library itself never calls `print` — the caller owns the I/O.
|
||||
|
||||
### `migrator.py` — mod group migration
|
||||
|
||||
Before `step_fetch` runs, `step_migrate` moves locally-downloaded mod folders to match the group assignments in the new `comparison.json`. This avoids re-downloading mods that already exist on disk under a different group when presets are switched (e.g. `A` → `A_v1`).
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. `_build_local_index(downloads)` — scans every `downloads/{group}/@Folder`, reads `meta.cpp` to extract `publishedid`, builds `{steam_id → (group, path)}` and `{norm_name → (group, path)}` maps.
|
||||
2. `_build_target_list(comparison)` — flattens `comparison.json` into `[(new_group, steam_id, mod_name)]`.
|
||||
3. For each target: locate mod on disk (steam_id first, normalised name fallback); skip if already in correct group or destination exists; remove stale junction from `arma_dir` if present; move folder with `shutil.move`.
|
||||
|
||||
**Junction removal is critical:** a stale junction (target moved away) still has the reparse point attribute, so `_is_junction()` returns `True` and `link_group` would skip it as `already_linked` without recreating it at the new path. Removing the junction before the move lets `step_link` recreate it correctly.
|
||||
|
||||
**CLI:** `python run.py --skip-migrate` bypasses the step if needed.
|
||||
|
||||
### `update_mods.py` — orphan file removal
|
||||
|
||||
After downloading updated files, `update_mods.py` compares every file in the local mod folder against the server's file list and **deletes any local files that no longer exist on the server**. This prevents stale `.pbo` or `.bisign` files from accumulating when a mod's content changes upstream. Each removed file is logged as `[-] orphan removed: <rel_path>` and the final summary line includes an orphan count. The orphan check runs even when no files need downloading (e.g. timestamps match but the local folder has extras).
|
||||
|
||||
### 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.
|
||||
|
||||
### `fix_console_encoding` — `None` stdout guard
|
||||
|
||||
When the GUI is launched via `pythonw.exe` (no console window), Python sets `sys.stdout` and `sys.stderr` to `None`. `fix_console_encoding()` must check `if sys.stdout is None or sys.stderr is None: return` **before** accessing `.encoding`, otherwise it raises `AttributeError: 'NoneType' object has no attribute 'encoding'`. This error surfaces in the GUI as *"Failed to load pipeline"* because `run.py` calls `fix_console_encoding()` at module level and the exception is caught by the pipeline import guard.
|
||||
|
||||
## Test Suite
|
||||
|
||||
`test_suite.py` uses a custom harness (no pytest/unittest dependency). Structure:
|
||||
|
||||
103
README.md
103
README.md
@@ -4,6 +4,31 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# First time setup
|
||||
cp config.template.json config.json # fill in server URL + credentials + arma_dir
|
||||
python check_deps.py # verify dependencies
|
||||
|
||||
# Day-to-day: full pipeline
|
||||
python run.py # parse → compare → migrate → download → link
|
||||
|
||||
# GUI (recommended)
|
||||
python gui.py
|
||||
|
||||
# Maintenance
|
||||
python clean_orphans.py --dry-run # find stale mod folders from old presets
|
||||
python update_mods.py # re-download changed files (size-check)
|
||||
python sync_missing.py # retry mods that were absent from server
|
||||
python check_names.py --fix # fix folder name mismatches
|
||||
|
||||
# Testing
|
||||
python test_suite.py # 158 tests (network tests auto-skip if offline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
@@ -20,6 +45,7 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
||||
- [sync_missing.py](#sync_missingpy)
|
||||
- [update_mods.py](#update_modspy)
|
||||
- [check_names.py](#check_namespy)
|
||||
- [clean_orphans.py](#clean_orphanspy)
|
||||
- [run.py](#runpy)
|
||||
- [gui.py](#guipy)
|
||||
6. [Migrating Existing Mods](#migrating-existing-mods)
|
||||
@@ -107,29 +133,34 @@ Place your Arma 3 Launcher preset exports (`.html`) into the `modlist_html/` fol
|
||||
python run.py
|
||||
```
|
||||
|
||||
This runs all four steps in sequence:
|
||||
This runs all five steps in sequence:
|
||||
|
||||
```
|
||||
Step 1/4: Parse presets — modlist_html/*.html -> modlist_json/*.json
|
||||
Step 2/4: Compare presets — produces modlist_json/comparison.json
|
||||
Step 3/4: Fetch mods — downloads from server -> downloads/
|
||||
Step 4/4: Link mods — creates junctions/symlinks in Arma 3 Server dir
|
||||
Step 1/5: Parse presets — modlist_html/*.html -> modlist_json/*.json
|
||||
Step 2/5: Compare presets — produces modlist_json/comparison.json
|
||||
Step 3/5: Migrate mod groups — moves existing folders to match new group assignments
|
||||
Step 4/5: Fetch mods — downloads from server -> downloads/
|
||||
Step 5/5: Link mods — creates junctions/symlinks in Arma 3 Server dir
|
||||
```
|
||||
|
||||
The **migrate step** avoids re-downloading mods that already exist on disk when you switch preset versions (e.g. `A` → `A_v1`). It matches mods by steam ID (via `meta.cpp`) and moves the folder to the correct group, removing any stale junction first so the link step can re-create it at the new path.
|
||||
|
||||
### Skip flags
|
||||
|
||||
```bash
|
||||
python run.py --skip-fetch --skip-link # parse + compare only
|
||||
python run.py --skip-fetch --skip-link # parse + compare + migrate only
|
||||
python run.py --skip-parse --skip-compare --skip-fetch # link only
|
||||
python run.py --skip-parse --skip-compare --skip-fetch --group shared
|
||||
python run.py --skip-migrate # skip auto-migration
|
||||
```
|
||||
|
||||
| Flag | Skips |
|
||||
|------|-------|
|
||||
| `--skip-parse` | Step 1 (HTML parsing) |
|
||||
| `--skip-compare` | Step 2 (preset comparison) |
|
||||
| `--skip-fetch` | Step 3 (downloading) |
|
||||
| `--skip-link` | Step 4 (linking) |
|
||||
| `--skip-migrate` | Step 3 (mod group migration) |
|
||||
| `--skip-fetch` | Step 4 (downloading) |
|
||||
| `--skip-link` | Step 5 (linking) |
|
||||
| `--group GROUP` | Link step: only link this one group (e.g. `shared`) |
|
||||
|
||||
> **Safe to re-run.** Every step is idempotent — existing files are skipped, already-linked mods are skipped.
|
||||
@@ -468,9 +499,39 @@ python check_names.py --fix --fix-ids
|
||||
|
||||
---
|
||||
|
||||
### clean_orphans.py
|
||||
|
||||
Find and optionally delete orphaned mod folders — `downloads/{group}/@ModName` folders that are no longer referenced in `comparison.json`. These accumulate when you switch presets and re-run the pipeline without cleaning up old downloads.
|
||||
|
||||
```bash
|
||||
python clean_orphans.py # list orphans, prompt for confirmation
|
||||
python clean_orphans.py --dry-run # list orphans, do not delete
|
||||
python clean_orphans.py --yes # list and delete without prompting
|
||||
```
|
||||
|
||||
```
|
||||
Group Folder Size
|
||||
---------------------------- -------------------------------- ----------
|
||||
shared @OldMod 124.5 MB
|
||||
150th_WW2_2026_V1.0 @SomeMod 88.2 MB
|
||||
|
||||
2 orphan(s) found — 212.7 MB total
|
||||
|
||||
Delete all orphans? [y/N]
|
||||
```
|
||||
|
||||
- Matches by normalized name (same logic as the fetcher), so spacing/capitalization differences are handled correctly
|
||||
- Junction-safe: uses `os.rmdir()` on junctions rather than `shutil.rmtree` to avoid deleting target files
|
||||
|
||||
**Requires:** `modlist_json/comparison.json` (run `run.py --skip-fetch --skip-link` first).
|
||||
|
||||
The same functionality is available as the **Clean Orphans** tab in the GUI.
|
||||
|
||||
---
|
||||
|
||||
### run.py
|
||||
|
||||
Orchestrator that chains all four pipeline steps. Described in [Quick Start](#quick-start--full-pipeline) above.
|
||||
Orchestrator that chains all five pipeline steps. Described in [Quick Start](#quick-start--full-pipeline) above.
|
||||
|
||||
---
|
||||
|
||||
@@ -488,10 +549,12 @@ Opens a CustomTkinter desktop window with a sidebar navigation and the following
|
||||
|------|---------|
|
||||
| Dashboard | Overview: status, quick stats, recent activity |
|
||||
| Mods | Browse and manage downloaded mods by group |
|
||||
| Tools | Link/unlink, rename, sync missing, check server |
|
||||
| Tools | Link/unlink, rename, sync missing, check server, **clean orphans** |
|
||||
| Logs | Real-time log output from pipeline operations |
|
||||
| Settings | Edit `config.json` (server URL, paths, credentials) |
|
||||
|
||||
The **Tools** view has five tabs: Check Names, Update Mods, Link Mods, Sync Missing / Report Missing, and **Clean Orphans** (find and delete stale mod folders from old presets).
|
||||
|
||||
On first launch (no `config.json`), a setup wizard walks you through creating one.
|
||||
|
||||
**Requires:** `customtkinter` (`pip install customtkinter`).
|
||||
@@ -543,6 +606,8 @@ arma-modlist-tools/
|
||||
| |- fetcher.py # Caddy server downloader
|
||||
| |- linker.py # Junction/symlink manager
|
||||
| |- reporter.py # Missing-mod report builder
|
||||
| |- cleaner.py # Orphan folder detection
|
||||
| |- migrator.py # Mod group migration (move folders to match comparison.json)
|
||||
| |- config.py # config.json loader
|
||||
| |- compat.py # OS detection + encoding fix
|
||||
|
|
||||
@@ -591,8 +656,9 @@ arma-modlist-tools/
|
||||
|- sync_missing.py # Sync newly available missing mods
|
||||
|- update_mods.py # Re-download updated mod files
|
||||
|- check_names.py # Diagnose and fix folder name / steam_id issues
|
||||
|- clean_orphans.py # Find and delete orphaned mod folders
|
||||
|- check_deps.py # Dependency checker
|
||||
|- test_suite.py # Test suite
|
||||
|- test_suite.py # Test suite (158 tests)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -631,7 +697,7 @@ arma-modlist-tools/
|
||||
|
||||
## Running Tests
|
||||
|
||||
The test suite covers all modules with 85 tests. No network connection required.
|
||||
The test suite covers all modules with 158 tests. Network tests auto-skip when the server is unreachable.
|
||||
|
||||
```bash
|
||||
python test_suite.py
|
||||
@@ -639,16 +705,23 @@ python test_suite.py
|
||||
|
||||
```
|
||||
------------------------------------------------------------
|
||||
compat 6 tests
|
||||
compat 11 tests
|
||||
config 5 tests
|
||||
parser 9 tests
|
||||
compare 8 tests
|
||||
fetcher 19 tests (pure functions, no network)
|
||||
fetcher 24 tests (pure functions + mock)
|
||||
reporter 8 tests
|
||||
linker 12 tests (uses temp dirs)
|
||||
__init__ 2 tests
|
||||
check_names 16 tests
|
||||
integration 2 tests
|
||||
gui._io 11 tests (QueueWriter, no GUI required)
|
||||
cleaner 8 tests
|
||||
e2e — clean_orphans 6 tests (subprocess CLI)
|
||||
coverage gaps 23 tests (mocked platform branches)
|
||||
gui.views.mods 8 tests (_find_folder matching)
|
||||
migrator 7 tests (group migration logic)
|
||||
live server 9 tests (skipped if server unreachable)
|
||||
------------------------------------------------------------
|
||||
Results: 85 passed, 0 failed, 0 skipped (85 total)
|
||||
Results: 158 passed, 0 failed, 0 skipped (158 total)
|
||||
```
|
||||
|
||||
@@ -11,6 +11,8 @@ from .linker import (
|
||||
from .config import load_config, Config
|
||||
from .compat import is_windows, is_linux, get_os_label, fix_console_encoding
|
||||
from .reporter import build_missing_report, save_missing_report
|
||||
from .cleaner import find_orphan_folders, folder_size
|
||||
from .migrator import migrate_mod_groups
|
||||
|
||||
__all__ = [
|
||||
# parser
|
||||
@@ -29,4 +31,8 @@ __all__ = [
|
||||
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
|
||||
# reporter
|
||||
"build_missing_report", "save_missing_report",
|
||||
# cleaner
|
||||
"find_orphan_folders", "folder_size",
|
||||
# migrator
|
||||
"migrate_mod_groups",
|
||||
]
|
||||
|
||||
80
arma_modlist_tools/cleaner.py
Normal file
80
arma_modlist_tools/cleaner.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
arma_modlist_tools.cleaner
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Identify orphaned mod folders in the downloads directory.
|
||||
|
||||
An *orphan* is a downloaded ``@ModName`` folder that is no longer referenced
|
||||
by any group in ``comparison.json``. This happens when the user swaps out a
|
||||
modlist preset and re-runs the compare step — mods that were removed from the
|
||||
preset remain on disk but are no longer tracked.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.cleaner import find_orphan_folders
|
||||
|
||||
comparison = json.loads(Path("modlist_json/comparison.json").read_text())
|
||||
orphans = find_orphan_folders(Path("downloads"), comparison)
|
||||
for o in orphans:
|
||||
print(o["group"], o["name"], o["size"])
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .fetcher import _normalize_name as _normalize
|
||||
|
||||
|
||||
def folder_size(path: Path) -> int:
|
||||
"""Return the total size in bytes of all files under *path* (recursive)."""
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
def find_orphan_folders(
|
||||
downloads: Path,
|
||||
comparison: dict,
|
||||
) -> list[dict]:
|
||||
"""Return a list of orphan mod folder entries.
|
||||
|
||||
A folder ``downloads/{group}/@ModName`` is considered an orphan when its
|
||||
normalised name does not match any mod in *comparison* under the same
|
||||
group. Groups in ``downloads/`` that do not exist in *comparison* at all
|
||||
are treated as entirely orphaned.
|
||||
|
||||
:param downloads: Path to the ``downloads/`` directory.
|
||||
:param comparison: Parsed ``comparison.json`` dict (output of
|
||||
:func:`~arma_modlist_tools.compare.compare_presets`).
|
||||
:returns: List of dicts, each with:
|
||||
|
||||
- ``path`` — absolute :class:`~pathlib.Path` of the folder
|
||||
- ``group`` — group name (e.g. ``"shared"``)
|
||||
- ``name`` — folder name as it appears on disk (e.g. ``"@ace"``)
|
||||
- ``size`` — total size in bytes (recursive)
|
||||
"""
|
||||
# Build group → set-of-normalised-mod-names from comparison data
|
||||
known: dict[str, set[str]] = {}
|
||||
for mod in comparison.get("shared", {}).get("mods", []):
|
||||
known.setdefault("shared", set()).add(_normalize(mod["name"]))
|
||||
for preset, pdata in comparison.get("unique", {}).items():
|
||||
for mod in pdata.get("mods", []):
|
||||
known.setdefault(preset, set()).add(_normalize(mod["name"]))
|
||||
|
||||
orphans: list[dict] = []
|
||||
if not downloads.is_dir():
|
||||
return orphans
|
||||
|
||||
for group_dir in sorted(downloads.iterdir()):
|
||||
if not group_dir.is_dir():
|
||||
continue
|
||||
group_known = known.get(group_dir.name, set()) # empty → group removed
|
||||
for mod_dir in sorted(group_dir.iterdir()):
|
||||
if not mod_dir.is_dir() or not mod_dir.name.startswith("@"):
|
||||
continue
|
||||
if _normalize(mod_dir.name) not in group_known:
|
||||
orphans.append({
|
||||
"path": mod_dir,
|
||||
"group": group_dir.name,
|
||||
"name": mod_dir.name,
|
||||
"size": folder_size(mod_dir),
|
||||
})
|
||||
|
||||
return orphans
|
||||
@@ -102,6 +102,8 @@ def fix_console_encoding() -> None:
|
||||
"""
|
||||
if not is_windows():
|
||||
return
|
||||
if sys.stdout is None or sys.stderr is None:
|
||||
return
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() == "utf-8":
|
||||
return
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
|
||||
@@ -81,7 +81,11 @@ def make_session(auth: tuple[str, str]) -> requests.Session:
|
||||
return s
|
||||
|
||||
|
||||
def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
|
||||
def build_server_index(
|
||||
base_url: str,
|
||||
auth: tuple[str, str],
|
||||
progress_fn: "Callable[[int, int, str], None] | None" = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Scan the root of the file server and build mod lookup maps.
|
||||
|
||||
@@ -90,6 +94,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
|
||||
|
||||
:param base_url: Root URL of the Caddy file server (trailing slash optional).
|
||||
:param auth: ``(username, password)`` tuple for HTTP Basic Auth.
|
||||
:param progress_fn: Optional callback called as ``progress_fn(current, total, name)``
|
||||
after each folder is processed. Use it to report progress without
|
||||
coupling the library to ``print`` or any specific I/O sink.
|
||||
:returns: Dict with keys:
|
||||
|
||||
- ``by_steam_id`` — ``{steam_id: folder_url}``
|
||||
@@ -100,11 +107,12 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
|
||||
root = base_url.rstrip("/") + "/"
|
||||
items = _list_dir(root, session)
|
||||
folders = [it for it in items if it.get("is_dir")]
|
||||
total = len(folders)
|
||||
|
||||
by_steam_id: dict[str, str] = {}
|
||||
by_name: dict[str, str] = {}
|
||||
|
||||
for folder in folders:
|
||||
for i, folder in enumerate(folders, 1):
|
||||
name = folder["name"].strip("/")
|
||||
url = _folder_url(root, name)
|
||||
by_name[_normalize_name(name)] = url
|
||||
@@ -118,6 +126,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict:
|
||||
except requests.RequestException:
|
||||
pass # meta.cpp missing or unreachable — name-based fallback still works
|
||||
|
||||
if progress_fn is not None:
|
||||
progress_fn(i, total, name)
|
||||
|
||||
return {"by_steam_id": by_steam_id, "by_name": by_name, "folders": folders}
|
||||
|
||||
|
||||
|
||||
186
arma_modlist_tools/migrator.py
Normal file
186
arma_modlist_tools/migrator.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
arma_modlist_tools.migrator
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Move locally-downloaded mod folders to match the group assignments in
|
||||
comparison.json, avoiding redundant re-downloads when presets are renamed
|
||||
or reorganised.
|
||||
|
||||
A mod "needs migration" when it exists on disk under
|
||||
``downloads/{old_group}/@FolderName`` but comparison.json now assigns it
|
||||
to ``downloads/{new_group}/@FolderName``.
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
1. Build a *local index*: scan every ``downloads/{group}/@ModDir`` and read
|
||||
its ``meta.cpp`` to find its steam_id. Also record its normalised folder
|
||||
name. Result: ``{steam_id: (group, path)}`` and
|
||||
``{norm_name: (group, path)}``.
|
||||
|
||||
2. Build a *target list* from comparison.json: for every mod in every group,
|
||||
record which group comparison now assigns it to.
|
||||
|
||||
3. For each target entry:
|
||||
- Find the mod in the local index (steam_id first, normalised name fallback).
|
||||
- If not found on disk: skip (step_fetch will download it).
|
||||
- If already in the correct group: skip.
|
||||
- If the destination already exists: skip (step_fetch handles file-level sync).
|
||||
- Otherwise: remove any stale junction first, then move old → new.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .fetcher import _normalize_name, _parse_meta_cpp
|
||||
from .linker import _is_junction, remove_junction
|
||||
|
||||
|
||||
def _build_local_index(downloads: Path) -> dict:
|
||||
"""Scan ``downloads/`` and return a two-key index of existing mod folders.
|
||||
|
||||
:returns: ``{"by_steam_id": {sid: (group, path)},
|
||||
"by_norm_name": {norm: (group, path)}}``
|
||||
|
||||
First match wins for both keys (sorted directory order is deterministic).
|
||||
"""
|
||||
by_steam_id: dict[str, tuple[str, Path]] = {}
|
||||
by_norm_name: dict[str, tuple[str, Path]] = {}
|
||||
|
||||
if not downloads.is_dir():
|
||||
return {"by_steam_id": by_steam_id, "by_norm_name": by_norm_name}
|
||||
|
||||
for group_dir in sorted(downloads.iterdir()):
|
||||
if not group_dir.is_dir():
|
||||
continue
|
||||
group_name = group_dir.name
|
||||
for mod_dir in sorted(group_dir.iterdir()):
|
||||
if not mod_dir.is_dir() or not mod_dir.name.startswith("@"):
|
||||
continue
|
||||
|
||||
norm = _normalize_name(mod_dir.name)
|
||||
if norm not in by_norm_name:
|
||||
by_norm_name[norm] = (group_name, mod_dir)
|
||||
|
||||
meta = mod_dir / "meta.cpp"
|
||||
if meta.exists():
|
||||
try:
|
||||
sid = _parse_meta_cpp(
|
||||
meta.read_text(encoding="utf-8", errors="replace")
|
||||
)
|
||||
if sid and sid not in by_steam_id:
|
||||
by_steam_id[sid] = (group_name, mod_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {"by_steam_id": by_steam_id, "by_norm_name": by_norm_name}
|
||||
|
||||
|
||||
def _build_target_list(comparison: dict) -> list[tuple[str, str | None, str]]:
|
||||
"""Flatten comparison.json into ``[(new_group, steam_id_or_None, mod_name)]``.
|
||||
|
||||
Shared mods → group ``"shared"``; unique mods → group = preset name.
|
||||
"""
|
||||
entries: list[tuple[str, str | None, str]] = []
|
||||
for mod in comparison.get("shared", {}).get("mods", []):
|
||||
entries.append(("shared", mod.get("steam_id") or None, mod.get("name", "")))
|
||||
for preset, pdata in comparison.get("unique", {}).items():
|
||||
for mod in pdata.get("mods", []):
|
||||
entries.append((preset, mod.get("steam_id") or None, mod.get("name", "")))
|
||||
return entries
|
||||
|
||||
|
||||
def migrate_mod_groups(
|
||||
downloads: Path,
|
||||
arma_dir: Path | None,
|
||||
comparison: dict,
|
||||
) -> dict:
|
||||
"""Move locally-downloaded mod folders to match *comparison* group assignments.
|
||||
|
||||
:param downloads: Path to the ``downloads/`` directory.
|
||||
:param arma_dir: Path to the Arma 3 server directory used for junction
|
||||
cleanup. Pass ``None`` to skip junction removal.
|
||||
:param comparison: Parsed ``comparison.json`` dict.
|
||||
:returns: Result dict::
|
||||
|
||||
{
|
||||
"moved": int,
|
||||
"junction_removed": int,
|
||||
"skipped_correct": int,
|
||||
"skipped_dest_exists": int,
|
||||
"skipped_not_found": int,
|
||||
"errors": {mod_name: error_message},
|
||||
}
|
||||
"""
|
||||
result: dict = {
|
||||
"moved": 0,
|
||||
"junction_removed": 0,
|
||||
"skipped_correct": 0,
|
||||
"skipped_dest_exists": 0,
|
||||
"skipped_not_found": 0,
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
local = _build_local_index(downloads)
|
||||
|
||||
for new_group, steam_id, mod_name in _build_target_list(comparison):
|
||||
# 1. Locate on disk — steam_id first, normalised name fallback
|
||||
entry: tuple[str, Path] | None = None
|
||||
if steam_id:
|
||||
entry = local["by_steam_id"].get(steam_id)
|
||||
if entry is None:
|
||||
entry = local["by_norm_name"].get(_normalize_name(mod_name))
|
||||
|
||||
if entry is None:
|
||||
result["skipped_not_found"] += 1
|
||||
continue
|
||||
|
||||
old_group, old_path = entry
|
||||
|
||||
# 2. Already in the correct group?
|
||||
if old_group == new_group:
|
||||
result["skipped_correct"] += 1
|
||||
continue
|
||||
|
||||
# 3. Destination already present — let step_fetch handle file-level sync
|
||||
new_path = downloads / new_group / old_path.name
|
||||
if new_path.exists():
|
||||
result["skipped_dest_exists"] += 1
|
||||
continue
|
||||
|
||||
# 4. Remove stale junction so step_link can re-create it at the new path
|
||||
if arma_dir is not None and arma_dir.is_dir():
|
||||
link = arma_dir / old_path.name
|
||||
if _is_junction(link):
|
||||
ok, err = remove_junction(link)
|
||||
if ok:
|
||||
result["junction_removed"] += 1
|
||||
else:
|
||||
print(f" MIGRATE WARNING: cannot remove junction "
|
||||
f"{link.name}: {err}")
|
||||
|
||||
# 5. Move folder (shutil.move handles cross-device gracefully)
|
||||
try:
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(old_path), str(new_path))
|
||||
print(f" MIGRATE moved: {old_path.name} {old_group} -> {new_group}")
|
||||
result["moved"] += 1
|
||||
|
||||
# Update index so later targets don't re-match the now-moved path
|
||||
if steam_id:
|
||||
local["by_steam_id"][steam_id] = (new_group, new_path)
|
||||
local["by_norm_name"][_normalize_name(old_path.name)] = (
|
||||
new_group, new_path
|
||||
)
|
||||
|
||||
except OSError as exc:
|
||||
print(f" MIGRATE ERROR: {old_path.name}: {exc}")
|
||||
result["errors"][mod_name] = str(exc)
|
||||
|
||||
print(
|
||||
f" Moved: {result['moved']} "
|
||||
f"Junctions removed: {result['junction_removed']} "
|
||||
f"Already correct: {result['skipped_correct']} "
|
||||
f"Dest exists: {result['skipped_dest_exists']} "
|
||||
f"Not on disk: {result['skipped_not_found']}"
|
||||
)
|
||||
return result
|
||||
127
clean_orphans.py
Normal file
127
clean_orphans.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI entry point: find and remove orphaned mod folders from downloads/.
|
||||
|
||||
An orphan is a downloads/{group}/@ModName folder that is no longer referenced
|
||||
by any group in comparison.json. These accumulate when presets change and
|
||||
the pipeline is re-run without cleaning up old folders.
|
||||
|
||||
Usage:
|
||||
python clean_orphans.py # list orphans, ask for confirmation
|
||||
python clean_orphans.py --dry-run # list orphans, do not delete
|
||||
python clean_orphans.py --yes # list and delete without prompting
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from arma_modlist_tools.cleaner import find_orphan_folders
|
||||
from arma_modlist_tools.compat import fix_console_encoding
|
||||
from arma_modlist_tools.config import load_config
|
||||
from arma_modlist_tools.linker import _is_junction, remove_junction
|
||||
|
||||
fix_console_encoding()
|
||||
|
||||
_UNITS = ("B", "KB", "MB", "GB", "TB")
|
||||
|
||||
|
||||
def _fmt_size(n: int) -> str:
|
||||
for unit in _UNITS:
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} PB"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find and remove orphaned mod folders from downloads/."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="List orphans but do not delete anything.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yes", "-y",
|
||||
action="store_true",
|
||||
help="Delete without prompting for confirmation.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config()
|
||||
|
||||
if not cfg.comparison.exists():
|
||||
print(f"ERROR: {cfg.comparison} not found. Run compare_modlists.py first.")
|
||||
sys.exit(1)
|
||||
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
|
||||
print(f"\nScanning {cfg.downloads} for orphaned mod folders...\n")
|
||||
orphans = find_orphan_folders(cfg.downloads, comparison)
|
||||
|
||||
if not orphans:
|
||||
print(" No orphans found. Your downloads folder is clean.")
|
||||
print()
|
||||
return
|
||||
|
||||
total_size = sum(o["size"] for o in orphans)
|
||||
print(f" {'Group':<28} {'Folder':<32} Size")
|
||||
print(f" {'-'*28} {'-'*32} {'-'*10}")
|
||||
for o in orphans:
|
||||
print(f" {o['group']:<28} {o['name']:<32} {_fmt_size(o['size'])}")
|
||||
|
||||
print()
|
||||
print(f" {len(orphans)} orphan(s) found — {_fmt_size(total_size)} total")
|
||||
print()
|
||||
|
||||
if args.dry_run:
|
||||
print(" --dry-run: nothing deleted.")
|
||||
print()
|
||||
return
|
||||
|
||||
if not args.yes:
|
||||
answer = input(" Delete all orphans? [y/N] ").strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
print(" Aborted.")
|
||||
print()
|
||||
return
|
||||
|
||||
deleted = 0
|
||||
freed = 0
|
||||
errors = 0
|
||||
for o in orphans:
|
||||
p = o["path"]
|
||||
try:
|
||||
if _is_junction(p):
|
||||
# Safety: never rmtree a junction — use remove_junction() which
|
||||
# calls os.rmdir() and removes only the pointer, not the target.
|
||||
ok, err = remove_junction(p)
|
||||
if not ok:
|
||||
print(f" ERROR: could not remove junction {p.name}: {err}")
|
||||
errors += 1
|
||||
continue
|
||||
else:
|
||||
shutil.rmtree(p)
|
||||
deleted += 1
|
||||
freed += o["size"]
|
||||
print(f" Deleted: {o['group']}/{o['name']}")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {p.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f" Done: {deleted} deleted, freed {_fmt_size(freed)}"
|
||||
+ (f", {errors} error(s)" if errors else ""))
|
||||
print()
|
||||
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
412
docs/huong-dan-su-dung.md
Normal file
412
docs/huong-dan-su-dung.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# 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)
|
||||
- [Check Names](#tab-check-names--kiểm-tra-tên-thư-mục)
|
||||
- [Update Mods](#tab-update-mods--cập-nhật-mod)
|
||||
- [Link Mods](#tab-link-mods--quản-lý-liên-kết)
|
||||
- [Sync / Report Missing](#tab-sync-missing--đồng-bộ-mod-thiếu)
|
||||
- [Clean Orphans](#tab-clean-orphans--dọn-dẹp-mod-thừa)
|
||||
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
|
||||
- **Di chuyển** các thư mục mod đã tải về sang đúng nhóm mới (tránh tải lại khi đổi phiên bản preset)
|
||||
- Tải mod từ máy chủ về máy tính của bạn (chỉ những mod thực sự chưa có)
|
||||
- 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ủ
|
||||
|
||||

|
||||
|
||||
| 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ị 5 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 |
|
||||
| Di chuyển nhóm mod | Chuyển thư mục mod sẵn có sang đúng nhóm mới | Tự động (không cần tải lại) |
|
||||
| Tải mod | Tải tệp mod thực sự còn thiếu 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 | Chức năng tóm tắt |
|
||||
|-----|-------------------|
|
||||
| Check Names | Kiểm tra và sửa tên thư mục mod |
|
||||
| Update Mods | Tải lại tệp mod đã thay đổi trên máy chủ |
|
||||
| Link Mods | Tạo / xóa junction tới Arma 3 Server |
|
||||
| Sync / Report Missing | Đồng bộ và báo cáo mod còn thiếu |
|
||||
| **Clean Orphans** | Xóa thư mục mod thừa từ preset cũ |
|
||||
|
||||
### 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.
|
||||
|
||||
### Tab "Clean Orphans" — Dọn dẹp mod thừa
|
||||
|
||||
Khi bạn đổi preset và chạy lại pipeline, các mod của preset cũ vẫn còn trong thư mục `downloads/` nhưng không được dùng nữa — gọi là **mod thừa** (orphan). Tab này giúp tìm và xóa chúng để giải phóng dung lượng ổ đĩa.
|
||||
|
||||
**Cách dùng:**
|
||||
|
||||
1. Nhấn **Quét mod thừa** — ứng dụng sẽ so sánh thư mục `downloads/` với `comparison.json` hiện tại
|
||||
2. Danh sách mod thừa hiện ra kèm tên nhóm và dung lượng
|
||||
3. Dùng **Chọn tất cả** hoặc tick thủ công từng mục
|
||||
4. Nhấn **Xóa đã chọn** — xuất hiện hộp thoại xác nhận
|
||||
5. Nhấn **Xác nhận xóa** để thực hiện; danh sách sẽ tự động quét lại sau khi xóa
|
||||
|
||||
> **Lưu ý an toàn:** Ứng dụng chỉ xóa thư mục `@ModName` trong `downloads/`, không đụng tới thư mục Arma 3 Server. Junction (liên kết) sẽ bị xóa đúng cách mà không làm mất tệp gốc.
|
||||
|
||||
**Yêu cầu:** Cần có `comparison.json` (chạy pipeline ít nhất một lần trước).
|
||||
|
||||
---
|
||||
|
||||
## 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 5 bước tự động: phân tích → so sánh → di chuyển nhóm → 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ề |
|
||||
| **Di chuyển nhóm mod** | Bước tự động chuyển thư mục mod từ nhóm cũ sang nhóm mới theo `comparison.json` — tránh tải lại khi đổi phiên bản preset |
|
||||
| **Mod thừa (Orphan)** | Thư mục mod còn trong `downloads/` nhưng không còn trong preset nào đang dùng |
|
||||
| **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 (cập nhật: thêm bước Di chuyển nhóm mod, pipeline 5 bước). Nếu có vấn đề, liên hệ người quản trị máy chủ.*
|
||||
14
gui/_io.py
14
gui/_io.py
@@ -2,6 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import queue
|
||||
import re
|
||||
|
||||
# Strip ANSI escape sequences and normalise carriage returns so tqdm output
|
||||
# is readable in the log textbox (which has no terminal emulation).
|
||||
_ANSI_RE = re.compile(
|
||||
r"\x1b\[[0-9;]*[A-Za-z]" # CSI sequences e.g. \x1b[32m
|
||||
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences, BEL or ST terminator
|
||||
)
|
||||
|
||||
|
||||
class _QueueWriter(io.TextIOBase):
|
||||
@@ -12,7 +20,11 @@ class _QueueWriter(io.TextIOBase):
|
||||
|
||||
def write(self, text: str) -> int: # type: ignore[override]
|
||||
if text:
|
||||
self._q.put(text)
|
||||
cleaned = _ANSI_RE.sub("", text)
|
||||
cleaned = cleaned.replace("\r\n", "\n") # Windows CRLF → LF
|
||||
cleaned = cleaned.replace("\r", "\n") # bare CR → newline
|
||||
if cleaned:
|
||||
self._q.put(cleaned)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
|
||||
121
gui/app.py
121
gui/app.py
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from arma_modlist_tools.config import Config
|
||||
from gui.views.dashboard import DashboardView
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
@@ -15,6 +20,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
|
||||
|
||||
@@ -26,7 +32,7 @@ from gui.views.base import BaseView
|
||||
_VIEW_NAMES = ("Dashboard", "Mods", "Tools", "Logs", "Settings")
|
||||
|
||||
|
||||
def _get_view_class(name: str):
|
||||
def _get_view_class(name: str) -> type[BaseView]:
|
||||
from gui.views import DashboardView, ModsView, ToolsView, LogsView, SettingsView
|
||||
return {
|
||||
"Dashboard": DashboardView,
|
||||
@@ -62,6 +68,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
else:
|
||||
self._load_config()
|
||||
|
||||
self._apply_startup_language()
|
||||
self._build_layout()
|
||||
self._poll_log()
|
||||
|
||||
@@ -70,7 +77,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def cfg(self):
|
||||
def cfg(self) -> Optional["Config"]:
|
||||
"""Loaded Config object, or None if config.json is missing/invalid."""
|
||||
return self._cfg
|
||||
|
||||
@@ -106,38 +113,59 @@ 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
|
||||
self._get_dashboard().set_pipeline_ui(running=True)
|
||||
self.navigate_to("Logs")
|
||||
# Post an immediate banner so the log is never blank after clicking Start.
|
||||
_sep = "=" * 50
|
||||
self.post_log(f"\n{_sep}\n {t('pipeline.starting')}\n{_sep}\n\n")
|
||||
|
||||
def worker() -> None:
|
||||
# run.py calls fix_console_encoding() at import time, which needs
|
||||
# the real sys.stdout.buffer. Import it before we redirect stdout.
|
||||
from run import step_fetch, step_link
|
||||
try:
|
||||
from run import step_migrate, step_fetch, step_link
|
||||
except Exception as _import_err:
|
||||
self.after(0, lambda: self.post_log(
|
||||
f"\n✗ Failed to load pipeline: {_import_err}\n"
|
||||
))
|
||||
self.after(0, self._pipeline_done)
|
||||
return
|
||||
|
||||
self._redirect_output()
|
||||
try:
|
||||
from arma_modlist_tools.parser import parse_modlist_html
|
||||
from arma_modlist_tools.compare import compare_presets
|
||||
|
||||
# Step 1 — Parse selected presets
|
||||
_hdr("Step 1 / 4", "Parse presets")
|
||||
_hdr("Step 1 / 5", t("pipeline.step1_name"))
|
||||
cfg.modlist_json.mkdir(exist_ok=True)
|
||||
presets = []
|
||||
for fp in sorted(cfg.modlist_html.glob("*.html")):
|
||||
@@ -154,7 +182,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
presets.append(preset)
|
||||
|
||||
# Step 2 — Compare
|
||||
_hdr("Step 2 / 4", "Compare presets")
|
||||
_hdr("Step 2 / 5", t("pipeline.step2_name"))
|
||||
result = compare_presets(*presets)
|
||||
cfg.comparison.write_text(
|
||||
json.dumps(result, indent=2, ensure_ascii=False),
|
||||
@@ -165,12 +193,16 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
print(f" Shared: {result['shared']['mod_count']} | "
|
||||
f"Unique: {total_unique}")
|
||||
|
||||
# Step 3 — Fetch
|
||||
_hdr("Step 3 / 4", "Download mods")
|
||||
# Step 3 — Migrate
|
||||
_hdr("Step 3 / 5", t("pipeline.step3_name"))
|
||||
step_migrate(cfg)
|
||||
|
||||
# Step 4 — Fetch
|
||||
_hdr("Step 4 / 5", t("pipeline.step4_name"))
|
||||
step_fetch(cfg)
|
||||
|
||||
# Step 4 — Link
|
||||
_hdr("Step 4 / 4", "Link mods")
|
||||
# Step 5 — Link
|
||||
_hdr("Step 5 / 5", t("pipeline.step5_name"))
|
||||
groups = (
|
||||
sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||
if cfg.downloads.is_dir() else []
|
||||
@@ -191,7 +223,6 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
|
||||
def run_tool(self, script_args: list[str]) -> None:
|
||||
"""Run a maintenance script via subprocess, streaming output to Logs."""
|
||||
import os
|
||||
script = script_args[0]
|
||||
extra = script_args[1:]
|
||||
|
||||
@@ -200,11 +231,14 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(PROJECT_ROOT / script)] + extra,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=env,
|
||||
)
|
||||
@@ -212,11 +246,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 +277,40 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
def open_wizard(self) -> None:
|
||||
SetupWizard(self, on_complete=self._after_wizard)
|
||||
|
||||
# =========================================================================
|
||||
# Private — language
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _read_raw_config() -> dict:
|
||||
"""Return config.json as a raw dict, or {} on missing / parse error."""
|
||||
try:
|
||||
return json.loads((PROJECT_ROOT / "config.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _apply_startup_language(self) -> None:
|
||||
"""Read language preference from config.json and activate it."""
|
||||
from gui import locales
|
||||
lang = self._read_raw_config().get("ui", {}).get("language", "en")
|
||||
locales.set_language(lang)
|
||||
|
||||
def _save_language_pref(self, lang: str) -> None:
|
||||
"""Persist language preference into the 'ui' key of config.json."""
|
||||
try:
|
||||
raw = self._read_raw_config()
|
||||
raw.setdefault("ui", {})["language"] = lang
|
||||
(PROJECT_ROOT / "config.json").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 +334,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"),
|
||||
@@ -316,15 +386,16 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
# =========================================================================
|
||||
|
||||
def _poll_log(self) -> None:
|
||||
parts: list[str] = []
|
||||
try:
|
||||
from gui.views.logs import LogsView
|
||||
logs_view = self._view_cache.get("Logs")
|
||||
while True:
|
||||
text = self._log_q.get_nowait()
|
||||
if isinstance(logs_view, LogsView):
|
||||
logs_view.append(text)
|
||||
parts.append(self._log_q.get_nowait())
|
||||
except queue.Empty:
|
||||
pass
|
||||
if parts:
|
||||
logs_view = self._view_cache.get("Logs")
|
||||
if logs_view is not None and hasattr(logs_view, "append"):
|
||||
logs_view.append("".join(parts)) # type: ignore[attr-defined]
|
||||
self.after(80, self._poll_log)
|
||||
|
||||
def _redirect_output(self) -> None:
|
||||
@@ -347,7 +418,7 @@ class ArmaModManagerApp(ctk.CTk):
|
||||
for view in self._view_cache.values():
|
||||
view.refresh()
|
||||
|
||||
def _get_dashboard(self):
|
||||
def _get_dashboard(self) -> "DashboardView":
|
||||
from gui.views.dashboard import DashboardView
|
||||
view = self._view_cache.get("Dashboard")
|
||||
if not isinstance(view, DashboardView):
|
||||
|
||||
534
gui/locales.py
Normal file
534
gui/locales.py
Normal file
@@ -0,0 +1,534 @@
|
||||
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.starting": "Pipeline started",
|
||||
"pipeline.step1_name": "Parse presets",
|
||||
"pipeline.step2_name": "Compare presets",
|
||||
"pipeline.step3_name": "Migrate mod groups",
|
||||
"pipeline.step4_name": "Download mods",
|
||||
"pipeline.step5_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.",
|
||||
|
||||
# ── Tools — Clean Orphans ────────────────────────────────────────────────
|
||||
"tools.oc_desc": (
|
||||
"Scan the downloads folder for mod folders that are no longer "
|
||||
"referenced in comparison.json. These orphans accumulate when you "
|
||||
"remove mods from your presets and re-run the pipeline. "
|
||||
"Select the ones you want to remove to free up disk space."
|
||||
),
|
||||
"tools.oc_warn": (
|
||||
"⚠ Deleting orphans permanently removes mod files from disk. "
|
||||
"This cannot be undone."
|
||||
),
|
||||
"tools.oc_scan_btn": "Scan for Orphans",
|
||||
"tools.oc_scanning": "Scanning…",
|
||||
"tools.oc_no_config": "No config found. Complete Setup first.",
|
||||
"tools.oc_no_comparison": "No comparison.json found — run the pipeline first.",
|
||||
"tools.oc_none_found": "No orphans found. Your downloads folder is clean.",
|
||||
"tools.oc_found": "{count} orphan(s) found — {size} total",
|
||||
"tools.oc_sel_all": "Select All",
|
||||
"tools.oc_sel_none": "Deselect All",
|
||||
"tools.oc_delete_btn": "Delete Selected",
|
||||
"tools.oc_confirm_title": "Confirm Delete",
|
||||
"tools.oc_confirm_body": (
|
||||
"Permanently delete {count} orphan folder(s) ({size})?\n\n"
|
||||
"This cannot be undone."
|
||||
),
|
||||
"tools.oc_done": "Deleted {count} folder(s), freed {size}.",
|
||||
"tools.oc_error": "Error deleting {path}: {e}",
|
||||
"tools.oc_error_title": "Delete errors",
|
||||
"tools.oc_scan_error": "Scan error: {e}",
|
||||
}
|
||||
|
||||
_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.starting": "Pipeline đã bắt đầu",
|
||||
"pipeline.step1_name": "Phân tích preset",
|
||||
"pipeline.step2_name": "So sánh preset",
|
||||
"pipeline.step3_name": "Di chuyển nhóm mod",
|
||||
"pipeline.step4_name": "Tải mod",
|
||||
"pipeline.step5_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.",
|
||||
|
||||
# ── Tools — Clean Orphans ────────────────────────────────────────────────
|
||||
"tools.oc_desc": (
|
||||
"Quét thư mục downloads để tìm các thư mục mod không còn được "
|
||||
"tham chiếu trong comparison.json. Các mod mồ côi này tích tụ khi "
|
||||
"bạn xóa mod khỏi preset và chạy lại pipeline. "
|
||||
"Chọn các thư mục muốn xóa để giải phóng dung lượng."
|
||||
),
|
||||
"tools.oc_warn": (
|
||||
"⚠ Xóa mod mồ côi sẽ xóa vĩnh viễn tệp mod khỏi ổ đĩa. "
|
||||
"Thao tác này không thể hoàn tác."
|
||||
),
|
||||
"tools.oc_scan_btn": "Quét mod mồ côi",
|
||||
"tools.oc_scanning": "Đang quét…",
|
||||
"tools.oc_no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
|
||||
"tools.oc_no_comparison": "Chưa có comparison.json — hãy chạy pipeline trước.",
|
||||
"tools.oc_none_found": "Không tìm thấy mod mồ côi. Thư mục downloads sạch.",
|
||||
"tools.oc_found": "Tìm thấy {count} mod mồ côi — tổng {size}",
|
||||
"tools.oc_sel_all": "Chọn tất cả",
|
||||
"tools.oc_sel_none": "Bỏ chọn",
|
||||
"tools.oc_delete_btn": "Xóa đã chọn",
|
||||
"tools.oc_confirm_title": "Xác nhận xóa",
|
||||
"tools.oc_confirm_body": (
|
||||
"Xóa vĩnh viễn {count} thư mục mồ côi ({size})?\n\n"
|
||||
"Thao tác này không thể hoàn tác."
|
||||
),
|
||||
"tools.oc_done": "Đã xóa {count} thư mục, giải phóng {size}.",
|
||||
"tools.oc_error": "Lỗi khi xóa {path}: {e}",
|
||||
"tools.oc_error_title": "Lỗi xóa",
|
||||
"tools.oc_scan_error": "Lỗi quét: {e}",
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -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)
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
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).pack(side="left")
|
||||
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:
|
||||
|
||||
@@ -7,21 +7,23 @@ from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from arma_modlist_tools.fetcher import _normalize_name
|
||||
from arma_modlist_tools.fetcher import _normalize_name, _parse_meta_cpp
|
||||
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:
|
||||
from gui.app import ArmaModManagerApp
|
||||
|
||||
|
||||
def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]:
|
||||
def _find_folder(group_dir: Path, mod_name: str, steam_id: str | None = None) -> Optional[Path]:
|
||||
"""Return the local mod folder path, or None if not downloaded.
|
||||
|
||||
Matches in priority order:
|
||||
1. Exact folder name ``@{mod_name}``
|
||||
2. Case-insensitive name (handles ``@CBA_A3`` vs ``CBA_A3``)
|
||||
3. Normalized name — strips non-alphanumeric (handles ``@cba_a3`` vs ``CBA A3``)
|
||||
4. Steam ID match via ``meta.cpp`` ``publishedid`` field (folder name differs from modlist name)
|
||||
"""
|
||||
if not group_dir.is_dir():
|
||||
return None
|
||||
@@ -37,6 +39,18 @@ def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]:
|
||||
return p
|
||||
if _normalize_name(p.name) == target_norm:
|
||||
return p
|
||||
# Fallback: match by steam_id via meta.cpp when folder name diverges from modlist name
|
||||
if steam_id:
|
||||
for p in group_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
meta = p / "meta.cpp"
|
||||
try:
|
||||
sid = _parse_meta_cpp(meta.read_text(encoding="utf-8", errors="replace"))
|
||||
if sid == steam_id:
|
||||
return p
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -61,18 +75,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 +95,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 +115,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 +134,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,19 +184,22 @@ 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"),
|
||||
(80, ""),
|
||||
for col, (w, lbl_key, anc) in enumerate([
|
||||
(0, "mods.col_name", "w"),
|
||||
(80, "mods.col_downloaded", "center"),
|
||||
(80, "mods.col_linked", "center"),
|
||||
(160, "mods.col_server", "w"),
|
||||
(80, "", "center"),
|
||||
]):
|
||||
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(
|
||||
anchor=anc, width=w or 1).grid(
|
||||
row=0, column=col,
|
||||
padx=(10 if col == 0 else 4, 4), pady=5,
|
||||
padx=(8 if col == 0 else 4, 4), pady=5,
|
||||
sticky="ew" if col == 0 else "")
|
||||
# Spacer compensates for CTkScrollableFrame's internal scrollbar width
|
||||
# so the header columns line up with the data rows below.
|
||||
ctk.CTkLabel(col_hdr, text="", width=16).grid(row=0, column=5, padx=0)
|
||||
|
||||
# Scrollable rows
|
||||
scroll = ctk.CTkScrollableFrame(tab_frame)
|
||||
@@ -208,7 +231,7 @@ class ModsView(BaseView):
|
||||
link_map: dict[str, bool],
|
||||
) -> None:
|
||||
for i, mod in enumerate(sorted(mods, key=lambda m: m["name"].lower())):
|
||||
folder_path = _find_folder(cfg.downloads / group, mod["name"])
|
||||
folder_path = _find_folder(cfg.downloads / group, mod["name"], mod.get("steam_id"))
|
||||
downloaded = folder_path is not None
|
||||
linked = (link_map.get(folder_path.name.lower(), False)
|
||||
if folder_path else False)
|
||||
@@ -219,15 +242,15 @@ class ModsView(BaseView):
|
||||
row.columnconfigure(0, weight=1)
|
||||
|
||||
# Mod name
|
||||
name_lbl = ctk.CTkLabel(row, text=f" {mod['name']}", anchor="w")
|
||||
name_lbl.grid(row=0, column=0, sticky="ew", padx=4, pady=3)
|
||||
name_lbl = ctk.CTkLabel(row, text=mod["name"], anchor="w")
|
||||
name_lbl.grid(row=0, column=0, sticky="ew", padx=(8, 4), pady=3)
|
||||
|
||||
# Downloaded
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text="✓" if downloaded else "✗",
|
||||
text_color=COLOR_OK if downloaded else COLOR_ERROR,
|
||||
width=80, anchor="w",
|
||||
width=80, anchor="center",
|
||||
).grid(row=0, column=1, padx=4)
|
||||
|
||||
# Linked
|
||||
@@ -235,7 +258,7 @@ class ModsView(BaseView):
|
||||
row,
|
||||
text="✓" if linked else ("—" if not downloaded else "✗"),
|
||||
text_color=COLOR_OK if linked else "gray",
|
||||
width=80, anchor="w",
|
||||
width=80, anchor="center",
|
||||
).grid(row=0, column=2, padx=4)
|
||||
|
||||
# Server status
|
||||
@@ -247,7 +270,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=80,
|
||||
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 +305,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 +348,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")
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
from tkinter import messagebox
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from arma_modlist_tools.cleaner import find_orphan_folders
|
||||
from arma_modlist_tools.linker import _is_junction, remove_junction
|
||||
from gui._constants import COLOR_WARN, PROJECT_ROOT
|
||||
from gui.locales import t
|
||||
from gui.views.base import BaseView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,9 +27,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,34 +37,47 @@ 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()
|
||||
self._build_sync_missing_tab()
|
||||
self._build_report_missing_tab()
|
||||
self._build_clean_orphans_tab()
|
||||
|
||||
# =========================================================================
|
||||
# Public
|
||||
# =========================================================================
|
||||
|
||||
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 +88,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)",
|
||||
cn_fix_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_chk"),
|
||||
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)",
|
||||
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).pack(side="left")
|
||||
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 +141,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 +156,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 +219,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 +235,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 +250,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 +291,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 +316,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 +340,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 +353,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 +377,213 @@ 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"))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_clean_orphans_tab(self) -> None:
|
||||
self._tab_view.add("Clean Orphans")
|
||||
tab = self._tab_view.tab("Clean Orphans")
|
||||
tab.grid_columnconfigure(0, weight=1)
|
||||
tab.grid_rowconfigure(3, weight=1)
|
||||
|
||||
desc_lbl = _desc(tab, row=0, text=t("tools.oc_desc"))
|
||||
self._translatable.append((desc_lbl, "tools.oc_desc"))
|
||||
|
||||
oc_warn = ctk.CTkLabel(tab, text=t("tools.oc_warn"),
|
||||
text_color=_WARN_COLOR, anchor="w")
|
||||
oc_warn.grid(row=1, column=0, padx=24, pady=(0, 4), sticky="w")
|
||||
self._translatable.append((oc_warn, "tools.oc_warn"))
|
||||
|
||||
self._oc_status = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
|
||||
self._oc_status.grid(row=2, column=0, padx=24, pady=(0, 2), sticky="w")
|
||||
|
||||
# Scrollable list for results
|
||||
self._oc_scroll = ctk.CTkScrollableFrame(tab)
|
||||
self._oc_scroll.grid(row=3, column=0, sticky="nsew", padx=16, pady=(0, 4))
|
||||
self._oc_scroll.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Bottom action bar
|
||||
bot = ctk.CTkFrame(tab, fg_color="transparent")
|
||||
bot.grid(row=4, column=0, sticky="ew", padx=16, pady=(4, 12))
|
||||
|
||||
self._oc_sel_all_btn = ctk.CTkButton(
|
||||
bot, text=t("tools.oc_sel_all"), width=110,
|
||||
command=self._oc_select_all,
|
||||
)
|
||||
self._oc_sel_all_btn.pack(side="left", padx=(0, 4))
|
||||
self._translatable.append((self._oc_sel_all_btn, "tools.oc_sel_all"))
|
||||
|
||||
self._oc_sel_none_btn = ctk.CTkButton(
|
||||
bot, text=t("tools.oc_sel_none"), width=110,
|
||||
command=self._oc_deselect_all,
|
||||
)
|
||||
self._oc_sel_none_btn.pack(side="left", padx=4)
|
||||
self._translatable.append((self._oc_sel_none_btn, "tools.oc_sel_none"))
|
||||
|
||||
self._oc_scan_btn = ctk.CTkButton(
|
||||
bot, text=t("tools.oc_scan_btn"), width=150,
|
||||
command=self._oc_scan,
|
||||
)
|
||||
self._oc_scan_btn.pack(side="right", padx=(4, 0))
|
||||
self._translatable.append((self._oc_scan_btn, "tools.oc_scan_btn"))
|
||||
|
||||
self._oc_delete_btn = ctk.CTkButton(
|
||||
bot, text=t("tools.oc_delete_btn"), width=150,
|
||||
fg_color="darkred", hover_color="#8b0000",
|
||||
command=self._oc_delete_selected,
|
||||
state="disabled",
|
||||
)
|
||||
self._oc_delete_btn.pack(side="right", padx=4)
|
||||
self._translatable.append((self._oc_delete_btn, "tools.oc_delete_btn"))
|
||||
|
||||
# Internal scan state
|
||||
self._oc_orphans: list[dict] = []
|
||||
self._oc_check_vars: list[ctk.BooleanVar] = []
|
||||
self._oc_pending_done_msg: str | None = None
|
||||
|
||||
def _oc_scan(self) -> None:
|
||||
cfg = self.app.cfg
|
||||
if not cfg:
|
||||
self._oc_status.configure(text=t("tools.oc_no_config"), text_color="gray")
|
||||
return
|
||||
if not cfg.comparison.exists():
|
||||
self._oc_status.configure(text=t("tools.oc_no_comparison"), text_color="gray")
|
||||
return
|
||||
|
||||
self._oc_scan_btn.configure(state="disabled", text=t("tools.oc_scanning"))
|
||||
self._oc_delete_btn.configure(state="disabled")
|
||||
self._oc_status.configure(text=t("tools.oc_scanning"), text_color="gray")
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
orphans = find_orphan_folders(cfg.downloads, comparison)
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._oc_scan_done(None, str(e)))
|
||||
return
|
||||
self.after(0, lambda: self._oc_scan_done(orphans, None))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _oc_scan_done(self, orphans: list[dict] | None, error: str | None) -> None:
|
||||
self._oc_scan_btn.configure(state="normal", text=t("tools.oc_scan_btn"))
|
||||
|
||||
# Consume any pending success message from a previous delete operation
|
||||
done_msg = self._oc_pending_done_msg
|
||||
self._oc_pending_done_msg = None
|
||||
|
||||
# Clear previous results
|
||||
for w in self._oc_scroll.winfo_children():
|
||||
w.destroy()
|
||||
self._oc_orphans = []
|
||||
self._oc_check_vars = []
|
||||
|
||||
if error:
|
||||
self._oc_status.configure(text=t("tools.oc_scan_error", e=error), text_color="red")
|
||||
return
|
||||
|
||||
if not orphans:
|
||||
msg = done_msg or t("tools.oc_none_found")
|
||||
self._oc_status.configure(text=msg, text_color="gray")
|
||||
return
|
||||
|
||||
total_size = sum(o["size"] for o in orphans)
|
||||
self._oc_status.configure(
|
||||
text=t("tools.oc_found", count=len(orphans), size=_fmt_size(total_size)),
|
||||
text_color="gray",
|
||||
)
|
||||
self._oc_orphans = orphans
|
||||
self._oc_delete_btn.configure(state="normal")
|
||||
|
||||
for i, orphan in enumerate(orphans):
|
||||
var = ctk.BooleanVar(value=True)
|
||||
self._oc_check_vars.append(var)
|
||||
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
|
||||
row = ctk.CTkFrame(self._oc_scroll, fg_color=bg, corner_radius=4)
|
||||
row.pack(fill="x", pady=1)
|
||||
row.columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkCheckBox(row, text="", variable=var, width=24).grid(
|
||||
row=0, column=0, padx=(8, 4), pady=4,
|
||||
)
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text=f" {orphan['group']} / {orphan['name']}",
|
||||
anchor="w",
|
||||
).grid(row=0, column=1, sticky="ew", padx=4)
|
||||
ctk.CTkLabel(
|
||||
row,
|
||||
text=_fmt_size(orphan["size"]),
|
||||
text_color="gray",
|
||||
width=80,
|
||||
anchor="e",
|
||||
).grid(row=0, column=2, padx=(4, 12))
|
||||
|
||||
def _oc_select_all(self) -> None:
|
||||
for var in self._oc_check_vars:
|
||||
var.set(True)
|
||||
|
||||
def _oc_deselect_all(self) -> None:
|
||||
for var in self._oc_check_vars:
|
||||
var.set(False)
|
||||
|
||||
def _oc_delete_selected(self) -> None:
|
||||
selected = [
|
||||
self._oc_orphans[i]
|
||||
for i, var in enumerate(self._oc_check_vars)
|
||||
if var.get()
|
||||
]
|
||||
if not selected:
|
||||
return
|
||||
total_size = sum(o["size"] for o in selected)
|
||||
confirmed = messagebox.askyesno(
|
||||
t("tools.oc_confirm_title"),
|
||||
t("tools.oc_confirm_body", count=len(selected), size=_fmt_size(total_size)),
|
||||
)
|
||||
if not confirmed:
|
||||
return
|
||||
|
||||
self._oc_delete_btn.configure(state="disabled")
|
||||
self._oc_scan_btn.configure(state="disabled")
|
||||
|
||||
def _run() -> None:
|
||||
freed = 0
|
||||
errors = []
|
||||
for orphan in selected:
|
||||
try:
|
||||
p = orphan["path"]
|
||||
if _is_junction(p):
|
||||
# Safety: never rmtree a junction — it follows the
|
||||
# reparse point and deletes the target's contents.
|
||||
# Use remove_junction() which calls os.rmdir() instead.
|
||||
ok, err = remove_junction(p)
|
||||
if not ok:
|
||||
errors.append(t("tools.oc_error", path=p.name, e=err))
|
||||
continue
|
||||
else:
|
||||
shutil.rmtree(p)
|
||||
freed += orphan["size"]
|
||||
except Exception as e:
|
||||
errors.append(t("tools.oc_error", path=orphan["path"].name, e=e))
|
||||
self.after(0, lambda: self._oc_delete_done(len(selected), freed, errors))
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def _oc_delete_done(self, count: int, freed: int, errors: list[str]) -> None:
|
||||
# Store success message so _oc_scan_done() can display it after the rescan
|
||||
self._oc_pending_done_msg = (
|
||||
None if errors
|
||||
else t("tools.oc_done", count=count, size=_fmt_size(freed))
|
||||
)
|
||||
self._oc_scan_btn.configure(state="normal")
|
||||
self._oc_scan()
|
||||
if errors:
|
||||
messagebox.showerror(t("tools.oc_error_title"), "\n".join(errors))
|
||||
|
||||
# =========================================================================
|
||||
# Private — helpers
|
||||
@@ -389,6 +607,21 @@ class ToolsView(BaseView):
|
||||
self.app.run_tool(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Size formatting helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fmt_size(n: int) -> str:
|
||||
"""Human-readable file size string."""
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
if n < 1024 ** 2:
|
||||
return f"{n / 1024:.1f} KB"
|
||||
if n < 1024 ** 3:
|
||||
return f"{n / 1024 ** 2:.1f} MB"
|
||||
return f"{n / 1024 ** 3:.2f} GB"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -400,10 +633,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
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from tkinter import TclError, filedialog
|
||||
from typing import Callable
|
||||
|
||||
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 +20,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 +50,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,69 +71,85 @@ 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,
|
||||
self._test_btn = 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))
|
||||
command=self._test)
|
||||
self._test_btn.pack(side="right", padx=(0, 8))
|
||||
|
||||
def _test(self) -> None:
|
||||
self._conn_lbl.configure(text="Testing…", text_color="gray")
|
||||
self.update()
|
||||
# Capture widget refs now — _clear() replaces them if the user
|
||||
# navigates away and back while the request is in-flight.
|
||||
lbl = self._conn_lbl
|
||||
btn = self._test_btn
|
||||
lbl.configure(text=t("wizard.testing"), text_color="gray")
|
||||
btn.configure(state="disabled")
|
||||
|
||||
url = self._url.get()
|
||||
auth = (self._user.get(), self._pw.get())
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(self._url.get(),
|
||||
auth=(self._user.get(), self._pw.get()),
|
||||
timeout=8)
|
||||
r = requests.get(url, auth=auth, timeout=8)
|
||||
if r.ok:
|
||||
self._conn_lbl.configure(text="✓ Connected", text_color=COLOR_OK)
|
||||
result = (t("wizard.connected"), COLOR_OK)
|
||||
else:
|
||||
self._conn_lbl.configure(text=f"✗ HTTP {r.status_code}",
|
||||
text_color=COLOR_ERROR)
|
||||
result = (t("wizard.http_error", code=r.status_code), COLOR_ERROR)
|
||||
except Exception as e:
|
||||
self._conn_lbl.configure(text=f"✗ {e}", text_color=COLOR_ERROR)
|
||||
result = (t("wizard.conn_error", e=e), COLOR_ERROR)
|
||||
self.after(0, lambda: _apply_test_result(lbl, btn, *result))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def _apply_test_result(lbl: ctk.CTkLabel, btn: ctk.CTkButton,
|
||||
text: str, color: str) -> None:
|
||||
"""Update connection test result widgets. Silently ignores destroyed widgets."""
|
||||
try:
|
||||
lbl.configure(text=text, text_color=color)
|
||||
btn.configure(state="normal")
|
||||
except TclError:
|
||||
pass # wizard was closed before the HTTP response arrived
|
||||
|
||||
# ── 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 +157,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 +178,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:
|
||||
|
||||
33
run.py
33
run.py
@@ -78,6 +78,22 @@ def step_compare(cfg) -> None:
|
||||
print(f" -> {cfg.comparison}")
|
||||
|
||||
|
||||
def step_migrate(cfg) -> None:
|
||||
if not cfg.comparison.exists():
|
||||
print(f" NOTE: {cfg.comparison} not found — skipping migration.")
|
||||
return
|
||||
if not cfg.downloads.is_dir():
|
||||
print(f" NOTE: downloads dir missing ({cfg.downloads}) — nothing to migrate.")
|
||||
return
|
||||
from arma_modlist_tools.migrator import migrate_mod_groups
|
||||
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||
arma_dir = cfg.arma_dir if cfg.arma_dir.is_dir() else None
|
||||
result = migrate_mod_groups(cfg.downloads, arma_dir, comparison)
|
||||
if result["errors"]:
|
||||
for name, err in result["errors"].items():
|
||||
print(f" ERROR {name}: {err}")
|
||||
|
||||
|
||||
def step_fetch(cfg) -> None:
|
||||
if not cfg.comparison.exists():
|
||||
print(f" ERROR: {cfg.comparison} not found. Run parse + compare first.")
|
||||
@@ -91,9 +107,14 @@ def step_fetch(cfg) -> None:
|
||||
for mod in data["mods"]:
|
||||
queue.append((mod, preset_name))
|
||||
|
||||
def _index_progress(current: int, total: int, name: str) -> None:
|
||||
if current == 1 or current % 25 == 0 or current == total:
|
||||
print(f" Indexing {current}/{total}: {name}")
|
||||
|
||||
print(f" Building server index...")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
||||
print(f" Indexed {len(index['by_steam_id'])} mods\n")
|
||||
index = build_server_index(cfg.server_url, cfg.server_auth, progress_fn=_index_progress)
|
||||
print(f" Indexed {len(index['by_steam_id'])} mods by steam_id, "
|
||||
f"{len(index['by_name'])} by name\n")
|
||||
|
||||
session = make_session(cfg.server_auth)
|
||||
resolved = []
|
||||
@@ -173,6 +194,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run the full mod management pipeline.")
|
||||
parser.add_argument("--skip-parse", action="store_true")
|
||||
parser.add_argument("--skip-compare", action="store_true")
|
||||
parser.add_argument("--skip-migrate", action="store_true")
|
||||
parser.add_argument("--skip-fetch", action="store_true")
|
||||
parser.add_argument("--skip-link", action="store_true")
|
||||
parser.add_argument("--group", "-g", metavar="GROUP",
|
||||
@@ -182,13 +204,15 @@ def main() -> None:
|
||||
steps = [
|
||||
(not args.skip_parse, "Parse presets"),
|
||||
(not args.skip_compare, "Compare presets"),
|
||||
(not args.skip_migrate, "Migrate mods"),
|
||||
(not args.skip_fetch, "Fetch mods"),
|
||||
(not args.skip_link, "Link mods"),
|
||||
]
|
||||
active_count = sum(1 for run, _ in steps if run)
|
||||
step_num = 0
|
||||
|
||||
if args.skip_parse and args.skip_compare and args.skip_fetch and args.skip_link:
|
||||
if (args.skip_parse and args.skip_compare and args.skip_migrate
|
||||
and args.skip_fetch and args.skip_link):
|
||||
print("All steps skipped — nothing to do.")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -196,6 +220,7 @@ def main() -> None:
|
||||
(cfg,),
|
||||
(cfg,),
|
||||
(cfg,),
|
||||
(cfg,),
|
||||
None, # handled separately
|
||||
]):
|
||||
if not run:
|
||||
@@ -216,6 +241,8 @@ def main() -> None:
|
||||
step_parse(cfg)
|
||||
elif name == "Compare presets":
|
||||
step_compare(cfg)
|
||||
elif name == "Migrate mods":
|
||||
step_migrate(cfg)
|
||||
elif name == "Fetch mods":
|
||||
step_fetch(cfg)
|
||||
|
||||
|
||||
1122
test_suite.py
1122
test_suite.py
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,7 @@ def main() -> None:
|
||||
COL_GROUP = 24
|
||||
|
||||
total_checked = total_updated = total_bytes = 0
|
||||
total_removed = 0
|
||||
not_on_server = []
|
||||
|
||||
for group, folder_name, mod_dir in targets:
|
||||
@@ -123,13 +124,21 @@ def main() -> None:
|
||||
all_files = list_mod_files(folder_url, session) if not args.force else stale
|
||||
checked = len(all_files) if not args.force else len(stale)
|
||||
|
||||
if not stale:
|
||||
# Find local files that no longer exist on the server (orphans)
|
||||
server_rel = {rel for rel, _, _ in all_files}
|
||||
orphans = [
|
||||
f for f in mod_dir.rglob("*") if f.is_file()
|
||||
and str(f.relative_to(mod_dir)).replace("\\", "/") not in server_rel
|
||||
]
|
||||
|
||||
if not stale and not orphans:
|
||||
print(f" [=] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {checked} files up-to-date")
|
||||
total_checked += checked
|
||||
continue
|
||||
|
||||
# Download stale files
|
||||
mod_bytes = 0
|
||||
if stale:
|
||||
with tqdm(
|
||||
total=len(stale), unit="file",
|
||||
desc=f" {folder_name[-COL_MOD:]:<{COL_MOD}}",
|
||||
@@ -148,21 +157,32 @@ def main() -> None:
|
||||
mod_bytes += n
|
||||
file_bar.update(1)
|
||||
|
||||
# Remove orphan files
|
||||
for orphan in orphans:
|
||||
tqdm.write(f" [-] orphan removed: {orphan.relative_to(mod_dir)}")
|
||||
orphan.unlink()
|
||||
|
||||
total_checked += checked
|
||||
total_updated += len(stale)
|
||||
total_bytes += mod_bytes
|
||||
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} "
|
||||
f"{checked} files {len(stale)} updated ({_fmt_bytes(mod_bytes)})")
|
||||
total_removed += len(orphans)
|
||||
parts = [f"{checked} files"]
|
||||
if stale:
|
||||
parts.append(f"{len(stale)} updated ({_fmt_bytes(mod_bytes)})")
|
||||
if orphans:
|
||||
parts.append(f"{len(orphans)} orphan(s) removed")
|
||||
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {' '.join(parts)}")
|
||||
|
||||
print(f"\n{'='*56}")
|
||||
print(f" Total: {total_checked} files checked, "
|
||||
f"{total_updated} updated, "
|
||||
f"{total_removed} orphan(s) removed, "
|
||||
f"{_fmt_bytes(total_bytes)} downloaded")
|
||||
if not_on_server:
|
||||
print(f" Not found on server ({len(not_on_server)}): {', '.join(not_on_server)}")
|
||||
print(f"{'='*56}\n")
|
||||
|
||||
if total_updated == 0 and not not_on_server:
|
||||
if total_updated == 0 and total_removed == 0 and not not_on_server:
|
||||
print(" All mods are up-to-date.\n")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user