Compare commits
21 Commits
5b497cf414
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24828ac68 | ||
|
|
48637ffe90 | ||
|
|
45cb023513 | ||
|
|
ecfa5fa636 | ||
|
|
fd513b3688 | ||
|
|
50990cca4e | ||
|
|
4fde566cf4 | ||
|
|
68fcaaf6d9 | ||
|
|
06f0c6eb92 | ||
|
|
3276f4b63f | ||
|
|
e0c2dfb32a | ||
|
|
5c824280c6 | ||
|
|
90cc6c00ff | ||
|
|
85bc406236 | ||
|
|
903cd366e2 | ||
|
|
4478ec3cab | ||
|
|
b7dbf54512 | ||
|
|
80ecd3a919 | ||
|
|
6197659568 | ||
|
|
85fdfebd74 | ||
|
|
57895a04d3 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,6 +16,13 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
.coverage
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
htmlcov/
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
86
CLAUDE.md
86
CLAUDE.md
@@ -20,6 +20,9 @@ python run.py --skip-fetch --skip-link
|
|||||||
# Diagnose mod folder name / steam_id issues
|
# Diagnose mod folder name / steam_id issues
|
||||||
python check_names.py
|
python check_names.py
|
||||||
python check_names.py --fix --fix-ids
|
python check_names.py --fix --fix-ids
|
||||||
|
|
||||||
|
# Launch the GUI
|
||||||
|
python gui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no build step, linter config, or package install beyond `pip install -r requirements.txt`.
|
There is no build step, linter config, or package install beyond `pip install -r requirements.txt`.
|
||||||
@@ -80,10 +83,93 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s
|
|||||||
|
|
||||||
`--fix-ids` corrects `meta.cpp` using steam IDs from `comparison.json` (sourced from Steam Workshop URLs in the HTML presets) as the authoritative source.
|
`--fix-ids` corrects `meta.cpp` using steam IDs from `comparison.json` (sourced from Steam Workshop URLs in the HTML presets) as the authoritative source.
|
||||||
|
|
||||||
|
### GUI package
|
||||||
|
|
||||||
|
`gui/` is a CustomTkinter desktop application wrapping the CLI toolchain. Entry point is `gui.py` at the project root, which calls `gui.run_app()`.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- `gui/__init__.py` — sets dark theme + blue color scheme; exports `run_app()`
|
||||||
|
- `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. `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
|
||||||
|
- `mods.py` — browse and manage downloaded mods by group
|
||||||
|
- `tools.py` — link/unlink, rename folders, sync missing mods, check server
|
||||||
|
- `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) — 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. 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
|
## 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.
|
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
|
||||||
|
|
||||||
`test_suite.py` uses a custom harness (no pytest/unittest dependency). Structure:
|
`test_suite.py` uses a custom harness (no pytest/unittest dependency). Structure:
|
||||||
|
|||||||
160
README.md
160
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
|
## Table of Contents
|
||||||
|
|
||||||
1. [Prerequisites](#prerequisites)
|
1. [Prerequisites](#prerequisites)
|
||||||
@@ -20,7 +45,9 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
|||||||
- [sync_missing.py](#sync_missingpy)
|
- [sync_missing.py](#sync_missingpy)
|
||||||
- [update_mods.py](#update_modspy)
|
- [update_mods.py](#update_modspy)
|
||||||
- [check_names.py](#check_namespy)
|
- [check_names.py](#check_namespy)
|
||||||
|
- [clean_orphans.py](#clean_orphanspy)
|
||||||
- [run.py](#runpy)
|
- [run.py](#runpy)
|
||||||
|
- [gui.py](#guipy)
|
||||||
6. [Migrating Existing Mods](#migrating-existing-mods)
|
6. [Migrating Existing Mods](#migrating-existing-mods)
|
||||||
7. [Folder Structure](#folder-structure)
|
7. [Folder Structure](#folder-structure)
|
||||||
8. [Moving to a New Device](#moving-to-a-new-device)
|
8. [Moving to a New Device](#moving-to-a-new-device)
|
||||||
@@ -35,6 +62,7 @@ Python toolchain for managing Arma 3 mod presets: parse launcher exports, compar
|
|||||||
| Python | >= 3.9 | `python --version` |
|
| Python | >= 3.9 | `python --version` |
|
||||||
| requests | any | `pip install requests` |
|
| requests | any | `pip install requests` |
|
||||||
| tqdm | any | `pip install tqdm` |
|
| tqdm | any | `pip install tqdm` |
|
||||||
|
| customtkinter | any | `pip install customtkinter` (GUI only) |
|
||||||
| Windows or Linux | — | Windows uses junctions, Linux uses symlinks |
|
| Windows or Linux | — | Windows uses junctions, Linux uses symlinks |
|
||||||
|
|
||||||
Run the dep checker to confirm everything is ready:
|
Run the dep checker to confirm everything is ready:
|
||||||
@@ -105,29 +133,34 @@ Place your Arma 3 Launcher preset exports (`.html`) into the `modlist_html/` fol
|
|||||||
python run.py
|
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 1/5: Parse presets — modlist_html/*.html -> modlist_json/*.json
|
||||||
Step 2/4: Compare presets — produces modlist_json/comparison.json
|
Step 2/5: Compare presets — produces modlist_json/comparison.json
|
||||||
Step 3/4: Fetch mods — downloads from server -> downloads/
|
Step 3/5: Migrate mod groups — moves existing folders to match new group assignments
|
||||||
Step 4/4: Link mods — creates junctions/symlinks in Arma 3 Server dir
|
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
|
### Skip flags
|
||||||
|
|
||||||
```bash
|
```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 # link only
|
||||||
python run.py --skip-parse --skip-compare --skip-fetch --group shared
|
python run.py --skip-parse --skip-compare --skip-fetch --group shared
|
||||||
|
python run.py --skip-migrate # skip auto-migration
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Skips |
|
| Flag | Skips |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `--skip-parse` | Step 1 (HTML parsing) |
|
| `--skip-parse` | Step 1 (HTML parsing) |
|
||||||
| `--skip-compare` | Step 2 (preset comparison) |
|
| `--skip-compare` | Step 2 (preset comparison) |
|
||||||
| `--skip-fetch` | Step 3 (downloading) |
|
| `--skip-migrate` | Step 3 (mod group migration) |
|
||||||
| `--skip-link` | Step 4 (linking) |
|
| `--skip-fetch` | Step 4 (downloading) |
|
||||||
|
| `--skip-link` | Step 5 (linking) |
|
||||||
| `--group GROUP` | Link step: only link this one group (e.g. `shared`) |
|
| `--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.
|
> **Safe to re-run.** Every step is idempotent — existing files are skipped, already-linked mods are skipped.
|
||||||
@@ -466,9 +499,65 @@ 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
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### gui.py
|
||||||
|
|
||||||
|
Launch the graphical interface for the toolchain.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens a CustomTkinter desktop window with a sidebar navigation and the following views:
|
||||||
|
|
||||||
|
| View | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| Dashboard | Overview: status, quick stats, recent activity |
|
||||||
|
| Mods | Browse and manage downloaded mods by group |
|
||||||
|
| 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -517,9 +606,24 @@ arma-modlist-tools/
|
|||||||
| |- fetcher.py # Caddy server downloader
|
| |- fetcher.py # Caddy server downloader
|
||||||
| |- linker.py # Junction/symlink manager
|
| |- linker.py # Junction/symlink manager
|
||||||
| |- reporter.py # Missing-mod report builder
|
| |- 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
|
| |- config.py # config.json loader
|
||||||
| |- compat.py # OS detection + encoding fix
|
| |- compat.py # OS detection + encoding fix
|
||||||
|
|
|
|
||||||
|
|- gui/ # GUI package (CustomTkinter desktop app)
|
||||||
|
| |- __init__.py # Theme setup + run_app() entry point
|
||||||
|
| |- app.py # Main window, view management, pipeline runner
|
||||||
|
| |- wizard.py # First-run config setup wizard
|
||||||
|
| |- _constants.py # Window size, color, path constants
|
||||||
|
| |- _io.py # stdout/stderr → thread-safe queue for live logs
|
||||||
|
| |- views/
|
||||||
|
| |- dashboard.py # Overview view
|
||||||
|
| |- mods.py # Mod browser view
|
||||||
|
| |- tools.py # Tool actions view
|
||||||
|
| |- logs.py # Real-time log view
|
||||||
|
| |- settings.py # Config editor view
|
||||||
|
|
|
||||||
|- modlist_html/ # INPUT: put your .html preset exports here
|
|- modlist_html/ # INPUT: put your .html preset exports here
|
||||||
| |- MyPreset_A.html
|
| |- MyPreset_A.html
|
||||||
| |- MyPreset_B.html
|
| |- MyPreset_B.html
|
||||||
@@ -540,7 +644,9 @@ arma-modlist-tools/
|
|||||||
|- config.json # YOUR config (gitignored — contains credentials)
|
|- config.json # YOUR config (gitignored — contains credentials)
|
||||||
|- config.template.json # Template to copy from
|
|- config.template.json # Template to copy from
|
||||||
|- requirements.txt
|
|- requirements.txt
|
||||||
|
|- selection.json # GUI selection state (persisted between sessions)
|
||||||
|
|
|
|
||||||
|
|- gui.py # GUI entry point
|
||||||
|- run.py # Orchestrator (parse + compare + fetch + link)
|
|- run.py # Orchestrator (parse + compare + fetch + link)
|
||||||
|- parse_modlist.py # Step 1 standalone
|
|- parse_modlist.py # Step 1 standalone
|
||||||
|- compare_modlists.py # Step 2 standalone
|
|- compare_modlists.py # Step 2 standalone
|
||||||
@@ -550,8 +656,9 @@ arma-modlist-tools/
|
|||||||
|- sync_missing.py # Sync newly available missing mods
|
|- sync_missing.py # Sync newly available missing mods
|
||||||
|- update_mods.py # Re-download updated mod files
|
|- update_mods.py # Re-download updated mod files
|
||||||
|- check_names.py # Diagnose and fix folder name / steam_id issues
|
|- 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
|
|- check_deps.py # Dependency checker
|
||||||
|- test_suite.py # Test suite
|
|- test_suite.py # Test suite (158 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -590,7 +697,7 @@ arma-modlist-tools/
|
|||||||
|
|
||||||
## Running Tests
|
## 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
|
```bash
|
||||||
python test_suite.py
|
python test_suite.py
|
||||||
@@ -598,16 +705,23 @@ python test_suite.py
|
|||||||
|
|
||||||
```
|
```
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
compat 6 tests
|
compat 11 tests
|
||||||
config 5 tests
|
config 5 tests
|
||||||
parser 9 tests
|
parser 9 tests
|
||||||
compare 8 tests
|
compare 8 tests
|
||||||
fetcher 19 tests (pure functions, no network)
|
fetcher 24 tests (pure functions + mock)
|
||||||
reporter 8 tests
|
reporter 8 tests
|
||||||
linker 12 tests (uses temp dirs)
|
linker 12 tests (uses temp dirs)
|
||||||
__init__ 2 tests
|
__init__ 2 tests
|
||||||
check_names 16 tests
|
check_names 16 tests
|
||||||
integration 2 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 .config import load_config, Config
|
||||||
from .compat import is_windows, is_linux, get_os_label, fix_console_encoding
|
from .compat import is_windows, is_linux, get_os_label, fix_console_encoding
|
||||||
from .reporter import build_missing_report, save_missing_report
|
from .reporter import build_missing_report, save_missing_report
|
||||||
|
from .cleaner import find_orphan_folders, folder_size
|
||||||
|
from .migrator import migrate_mod_groups
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# parser
|
# parser
|
||||||
@@ -29,4 +31,8 @@ __all__ = [
|
|||||||
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
|
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
|
||||||
# reporter
|
# reporter
|
||||||
"build_missing_report", "save_missing_report",
|
"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():
|
if not is_windows():
|
||||||
return
|
return
|
||||||
|
if sys.stdout is None or sys.stderr is None:
|
||||||
|
return
|
||||||
if sys.stdout.encoding and sys.stdout.encoding.lower() == "utf-8":
|
if sys.stdout.encoding and sys.stdout.encoding.lower() == "utf-8":
|
||||||
return
|
return
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
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
|
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.
|
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 base_url: Root URL of the Caddy file server (trailing slash optional).
|
||||||
:param auth: ``(username, password)`` tuple for HTTP Basic Auth.
|
: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:
|
:returns: Dict with keys:
|
||||||
|
|
||||||
- ``by_steam_id`` — ``{steam_id: folder_url}``
|
- ``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("/") + "/"
|
root = base_url.rstrip("/") + "/"
|
||||||
items = _list_dir(root, session)
|
items = _list_dir(root, session)
|
||||||
folders = [it for it in items if it.get("is_dir")]
|
folders = [it for it in items if it.get("is_dir")]
|
||||||
|
total = len(folders)
|
||||||
|
|
||||||
by_steam_id: dict[str, str] = {}
|
by_steam_id: dict[str, str] = {}
|
||||||
by_name: dict[str, str] = {}
|
by_name: dict[str, str] = {}
|
||||||
|
|
||||||
for folder in folders:
|
for i, folder in enumerate(folders, 1):
|
||||||
name = folder["name"].strip("/")
|
name = folder["name"].strip("/")
|
||||||
url = _folder_url(root, name)
|
url = _folder_url(root, name)
|
||||||
by_name[_normalize_name(name)] = url
|
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:
|
except requests.RequestException:
|
||||||
pass # meta.cpp missing or unreachable — name-based fallback still works
|
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}
|
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ủ.*
|
||||||
9
gui.py
Normal file
9
gui.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
gui.py — Arma Mod Manager launcher.
|
||||||
|
The implementation lives in the gui/ package.
|
||||||
|
"""
|
||||||
|
from gui import run_app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_app()
|
||||||
24
gui/__init__.py
Normal file
24
gui/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
gui — Arma Mod Manager UI package.
|
||||||
|
|
||||||
|
Entry point:
|
||||||
|
from gui import run_app
|
||||||
|
run_app()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
# Apply theme before any widget is created
|
||||||
|
ctk.set_appearance_mode("dark")
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
|
||||||
|
def run_app() -> None:
|
||||||
|
"""Create and start the main window."""
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
app = ArmaModManagerApp()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["run_app"]
|
||||||
28
gui/_constants.py
Normal file
28
gui/_constants.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Window geometry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SIDEBAR_W: int = 190
|
||||||
|
APP_TITLE: str = "Arma Mod Manager"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status colours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COLOR_OK: str = "#4CAF50"
|
||||||
|
COLOR_PENDING: str = "#9E9E9E"
|
||||||
|
COLOR_RUNNING: str = "#2196F3"
|
||||||
|
COLOR_ERROR: str = "#F44336"
|
||||||
|
COLOR_WARN: str = "#FF9800"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# gui/ lives one level below the project root
|
||||||
|
PROJECT_ROOT: Path = Path(__file__).parent.parent
|
||||||
|
SELECTION_FILE: Path = PROJECT_ROOT / "selection.json"
|
||||||
31
gui/_io.py
Normal file
31
gui/_io.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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):
|
||||||
|
"""Redirect sys.stdout / sys.stderr into a Queue for the Logs panel."""
|
||||||
|
|
||||||
|
def __init__(self, q: queue.Queue[str]) -> None:
|
||||||
|
self._q = q
|
||||||
|
|
||||||
|
def write(self, text: str) -> int: # type: ignore[override]
|
||||||
|
if 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:
|
||||||
|
pass
|
||||||
438
gui/app.py
Normal file
438
gui/app.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View name → class mapping (imported lazily to avoid circular imports)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VIEW_NAMES = ("Dashboard", "Mods", "Tools", "Logs", "Settings")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_view_class(name: str) -> type[BaseView]:
|
||||||
|
from gui.views import DashboardView, ModsView, ToolsView, LogsView, SettingsView
|
||||||
|
return {
|
||||||
|
"Dashboard": DashboardView,
|
||||||
|
"Mods": ModsView,
|
||||||
|
"Tools": ToolsView,
|
||||||
|
"Logs": LogsView,
|
||||||
|
"Settings": SettingsView,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ArmaModManagerApp(ctk.CTk):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.title(APP_TITLE)
|
||||||
|
self.geometry("980x640")
|
||||||
|
self.minsize(820, 560)
|
||||||
|
|
||||||
|
self._log_q: queue.Queue[str] = queue.Queue()
|
||||||
|
self._orig_stdout = sys.stdout
|
||||||
|
self._orig_stderr = sys.stderr
|
||||||
|
self._pipeline_running: bool = False
|
||||||
|
self._cfg = None
|
||||||
|
self._view_cache: dict[str, BaseView] = {}
|
||||||
|
self._active_name: str = ""
|
||||||
|
|
||||||
|
if not (PROJECT_ROOT / "config.json").exists():
|
||||||
|
self.after(200, self.open_wizard)
|
||||||
|
else:
|
||||||
|
self._load_config()
|
||||||
|
|
||||||
|
self._apply_startup_language()
|
||||||
|
self._build_layout()
|
||||||
|
self._poll_log()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Public interface (used by views)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cfg(self) -> Optional["Config"]:
|
||||||
|
"""Loaded Config object, or None if config.json is missing/invalid."""
|
||||||
|
return self._cfg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pipeline_running(self) -> bool:
|
||||||
|
return self._pipeline_running
|
||||||
|
|
||||||
|
def navigate_to(self, view_name: str) -> None:
|
||||||
|
"""Switch the content area to the named view, refreshing its data."""
|
||||||
|
assert view_name in _VIEW_NAMES, f"Unknown view: {view_name}"
|
||||||
|
|
||||||
|
# Build view on first visit
|
||||||
|
if view_name not in self._view_cache:
|
||||||
|
cls = _get_view_class(view_name)
|
||||||
|
view = cls(self._content, self)
|
||||||
|
self._view_cache[view_name] = view
|
||||||
|
|
||||||
|
# Hide current
|
||||||
|
if self._active_name and self._active_name in self._view_cache:
|
||||||
|
old = self._view_cache[self._active_name]
|
||||||
|
old.grid_forget()
|
||||||
|
old.lower() # push canvas-based widgets below everything on Windows
|
||||||
|
|
||||||
|
# Show new
|
||||||
|
view = self._view_cache[view_name]
|
||||||
|
view.grid(row=0, column=0, sticky="nsew")
|
||||||
|
view.lift() # ensure it's above any lingering hidden views
|
||||||
|
view.refresh()
|
||||||
|
self._active_name = view_name
|
||||||
|
self._nav_select(view_name)
|
||||||
|
|
||||||
|
def post_log(self, text: str) -> None:
|
||||||
|
"""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(
|
||||||
|
t("app.dlg_presets_title"),
|
||||||
|
t("app.dlg_presets_body"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = self._cfg
|
||||||
|
if not cfg:
|
||||||
|
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.
|
||||||
|
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 / 5", t("pipeline.step1_name"))
|
||||||
|
cfg.modlist_json.mkdir(exist_ok=True)
|
||||||
|
presets = []
|
||||||
|
for fp in sorted(cfg.modlist_html.glob("*.html")):
|
||||||
|
if fp.name not in selected_names:
|
||||||
|
print(f" SKIP {fp.name}")
|
||||||
|
continue
|
||||||
|
preset = parse_modlist_html(fp)
|
||||||
|
out = cfg.modlist_json / (preset["preset_name"] + ".json")
|
||||||
|
out.write_text(
|
||||||
|
json.dumps(preset, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f" {fp.name} → {out.name} ({preset['mod_count']} mods)")
|
||||||
|
presets.append(preset)
|
||||||
|
|
||||||
|
# Step 2 — Compare
|
||||||
|
_hdr("Step 2 / 5", t("pipeline.step2_name"))
|
||||||
|
result = compare_presets(*presets)
|
||||||
|
cfg.comparison.write_text(
|
||||||
|
json.dumps(result, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
total_unique = sum(v["mod_count"] for v in result["unique"].values())
|
||||||
|
print(f" Compared: {', '.join(result['compared_presets'])}")
|
||||||
|
print(f" Shared: {result['shared']['mod_count']} | "
|
||||||
|
f"Unique: {total_unique}")
|
||||||
|
|
||||||
|
# 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 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 []
|
||||||
|
)
|
||||||
|
step_link(cfg, groups)
|
||||||
|
|
||||||
|
print("\n✓ Pipeline complete.\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Error: {e}\n")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
self._restore_output()
|
||||||
|
self.after(0, self._pipeline_done)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def run_tool(self, script_args: list[str]) -> None:
|
||||||
|
"""Run a maintenance script via subprocess, streaming output to Logs."""
|
||||||
|
script = script_args[0]
|
||||||
|
extra = script_args[1:]
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
self.post_log(f"\n{'─'*50}\n {' '.join(script_args)}\n{'─'*50}\n")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for line in iter(proc.stdout.readline, ""):
|
||||||
|
self.post_log(line)
|
||||||
|
proc.wait()
|
||||||
|
ok = proc.returncode == 0
|
||||||
|
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{t('app.tool_failed', script=script, e=e)}\n")
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def load_selection(self) -> set[str]:
|
||||||
|
"""Return selected preset filenames, defaulting to all if no file saved."""
|
||||||
|
if SELECTION_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(SELECTION_FILE.read_text(encoding="utf-8"))
|
||||||
|
return set(data.get("selected", []))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._cfg and self._cfg.modlist_html.is_dir():
|
||||||
|
return {f.name for f in self._cfg.modlist_html.glob("*.html")}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def save_selection(self, selected: set[str]) -> None:
|
||||||
|
SELECTION_FILE.write_text(
|
||||||
|
json.dumps({"selected": sorted(selected)}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _build_layout(self) -> None:
|
||||||
|
self.grid_columnconfigure(1, weight=1)
|
||||||
|
self.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# Sidebar
|
||||||
|
sb = ctk.CTkFrame(self, width=SIDEBAR_W, corner_radius=0)
|
||||||
|
sb.grid(row=0, column=0, sticky="nsew")
|
||||||
|
sb.grid_propagate(False)
|
||||||
|
sb.grid_rowconfigure(10, weight=1)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
sb, text=APP_TITLE,
|
||||||
|
font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
wraplength=SIDEBAR_W - 16, justify="center",
|
||||||
|
).grid(row=0, column=0, padx=12, pady=(22, 14))
|
||||||
|
|
||||||
|
self._nav_btns: dict[str, ctk.CTkButton] = {}
|
||||||
|
for i, name in enumerate(_VIEW_NAMES, start=1):
|
||||||
|
b = ctk.CTkButton(
|
||||||
|
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"),
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
)
|
||||||
|
b.grid(row=i, column=0, padx=12, pady=3)
|
||||||
|
self._nav_btns[name] = b
|
||||||
|
|
||||||
|
# Content area
|
||||||
|
self._content = ctk.CTkFrame(self, fg_color="transparent", corner_radius=0)
|
||||||
|
self._content.grid(row=0, column=1, sticky="nsew")
|
||||||
|
self._content.grid_columnconfigure(0, weight=1)
|
||||||
|
self._content.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.navigate_to("Dashboard")
|
||||||
|
|
||||||
|
def _nav_select(self, name: str) -> None:
|
||||||
|
for lbl, btn in self._nav_btns.items():
|
||||||
|
btn.configure(
|
||||||
|
fg_color=("gray75", "gray25") if lbl == name else "transparent"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — config
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _load_config(self) -> None:
|
||||||
|
try:
|
||||||
|
from arma_modlist_tools.config import load_config
|
||||||
|
self._cfg = load_config(PROJECT_ROOT / "config.json")
|
||||||
|
except Exception as e:
|
||||||
|
self._cfg = None
|
||||||
|
self.post_log(f"[config] {e}\n")
|
||||||
|
|
||||||
|
def _after_wizard(self) -> None:
|
||||||
|
self._load_config()
|
||||||
|
# grid_forget before popping — navigate_to can't hide a view that's
|
||||||
|
# already been removed from _view_cache, so we do it manually first.
|
||||||
|
for name in ("Dashboard", "Settings"):
|
||||||
|
view = self._view_cache.pop(name, None)
|
||||||
|
if view is not None:
|
||||||
|
view.grid_forget()
|
||||||
|
view.lower()
|
||||||
|
self._active_name = ""
|
||||||
|
self.navigate_to("Dashboard")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — logging
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _poll_log(self) -> None:
|
||||||
|
parts: list[str] = []
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
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:
|
||||||
|
writer = _QueueWriter(self._log_q)
|
||||||
|
sys.stdout = writer
|
||||||
|
sys.stderr = writer
|
||||||
|
|
||||||
|
def _restore_output(self) -> None:
|
||||||
|
sys.stdout = self._orig_stdout
|
||||||
|
sys.stderr = self._orig_stderr
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — pipeline lifecycle
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _pipeline_done(self) -> None:
|
||||||
|
self._pipeline_running = False
|
||||||
|
self._get_dashboard().set_pipeline_ui(running=False)
|
||||||
|
# Refresh all cached views so data is current without manual refresh
|
||||||
|
for view in self._view_cache.values():
|
||||||
|
view.refresh()
|
||||||
|
|
||||||
|
def _get_dashboard(self) -> "DashboardView":
|
||||||
|
from gui.views.dashboard import DashboardView
|
||||||
|
view = self._view_cache.get("Dashboard")
|
||||||
|
if not isinstance(view, DashboardView):
|
||||||
|
# Build it if not yet visited (shouldn't normally happen)
|
||||||
|
view = DashboardView(self._content, self)
|
||||||
|
self._view_cache["Dashboard"] = view
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _hdr(step: str, name: str) -> None:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" {step}: {name}")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
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
|
||||||
7
gui/views/__init__.py
Normal file
7
gui/views/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from gui.views.dashboard import DashboardView
|
||||||
|
from gui.views.mods import ModsView
|
||||||
|
from gui.views.tools import ToolsView
|
||||||
|
from gui.views.logs import LogsView
|
||||||
|
from gui.views.settings import SettingsView
|
||||||
|
|
||||||
|
__all__ = ["DashboardView", "ModsView", "ToolsView", "LogsView", "SettingsView"]
|
||||||
32
gui/views/base.py
Normal file
32
gui/views/base.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class BaseView(ctk.CTkFrame):
|
||||||
|
"""
|
||||||
|
Common base for all view panels.
|
||||||
|
|
||||||
|
Each view is a CTkFrame owned by the app's content area. The app creates
|
||||||
|
view instances once and caches them; it calls refresh() on each navigation
|
||||||
|
so views can update their dynamic content without rebuilding the whole frame.
|
||||||
|
|
||||||
|
Subclasses must implement build() and may override refresh().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent: ctk.CTkFrame, app: ArmaModManagerApp) -> None:
|
||||||
|
super().__init__(parent, fg_color="transparent")
|
||||||
|
self.app = app
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
"""Construct all child widgets. Called once from __init__."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Re-query data and update dynamic widgets. Called on every navigation."""
|
||||||
313
gui/views/dashboard.py
Normal file
313
gui/views/dashboard.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView(BaseView):
|
||||||
|
"""
|
||||||
|
Preset file selector + pipeline status + Run button.
|
||||||
|
|
||||||
|
Dynamic regions (rebuilt in refresh):
|
||||||
|
- _preset_scroll — checkbox list of .html files
|
||||||
|
- _step_icons — ✓/○ status for each pipeline step
|
||||||
|
- _stats_lbl — mod counts from comparison.json
|
||||||
|
|
||||||
|
Static regions (built once):
|
||||||
|
- _run_btn / _prog — pipeline controls, state changed by set_pipeline_ui()
|
||||||
|
- _sel_count_lbl — "X of Y selected" label, updated by _on_toggle()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 10))
|
||||||
|
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")
|
||||||
|
cards.grid(row=1, column=0, sticky="nsew", padx=24)
|
||||||
|
cards.columnconfigure(0, weight=3)
|
||||||
|
cards.columnconfigure(1, weight=2)
|
||||||
|
|
||||||
|
self._build_preset_card(cards)
|
||||||
|
self._build_pipeline_card(cards)
|
||||||
|
|
||||||
|
# ── Run button area ───────────────────────────────────────────────────
|
||||||
|
run_area = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
run_area.grid(row=2, column=0, sticky="ew", padx=24, pady=16)
|
||||||
|
|
||||||
|
self._run_btn = ctk.CTkButton(
|
||||||
|
run_area,
|
||||||
|
text=t("dashboard.run_btn"),
|
||||||
|
font=ctk.CTkFont(size=15, weight="bold"),
|
||||||
|
height=46,
|
||||||
|
command=self._on_run,
|
||||||
|
)
|
||||||
|
self._run_btn.pack(fill="x")
|
||||||
|
self._prog = ctk.CTkProgressBar(run_area, mode="indeterminate")
|
||||||
|
|
||||||
|
# ── Preset card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_preset_card(self, parent: ctk.CTkFrame) -> None:
|
||||||
|
pc = ctk.CTkFrame(parent)
|
||||||
|
pc.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=4)
|
||||||
|
|
||||||
|
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=t("dashboard.preset_card_desc"),
|
||||||
|
text_color="gray", font=ctk.CTkFont(size=11)).pack(
|
||||||
|
anchor="w", padx=14)
|
||||||
|
|
||||||
|
self._preset_scroll = ctk.CTkScrollableFrame(pc, height=150)
|
||||||
|
self._preset_scroll.pack(fill="x", padx=14, pady=(10, 4))
|
||||||
|
|
||||||
|
# Selection controls
|
||||||
|
sel_row = ctk.CTkFrame(pc, fg_color="transparent")
|
||||||
|
sel_row.pack(fill="x", padx=14, pady=(0, 8))
|
||||||
|
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=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=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=t("dashboard.btn_add"),
|
||||||
|
fg_color="transparent", border_width=1,
|
||||||
|
text_color=("gray10", "gray90"),
|
||||||
|
command=self._add_presets).pack(pady=(0, 14))
|
||||||
|
|
||||||
|
# ── Pipeline card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_pipeline_card(self, parent: ctk.CTkFrame) -> None:
|
||||||
|
pipe = ctk.CTkFrame(parent)
|
||||||
|
pipe.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=4)
|
||||||
|
|
||||||
|
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] = {}
|
||||||
|
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)
|
||||||
|
icon = ctk.CTkLabel(row, text="○", width=22,
|
||||||
|
text_color=COLOR_PENDING,
|
||||||
|
font=ctk.CTkFont(size=15))
|
||||||
|
icon.pack(side="left")
|
||||||
|
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),
|
||||||
|
wraplength=200, justify="left")
|
||||||
|
self._stats_lbl.pack(anchor="w", padx=14, pady=(10, 14))
|
||||||
|
|
||||||
|
# ── 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()
|
||||||
|
|
||||||
|
def _rebuild_preset_list(self) -> None:
|
||||||
|
for w in self._preset_scroll.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
self._preset_checks: dict[str, ctk.BooleanVar] = {}
|
||||||
|
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
ctk.CTkLabel(self._preset_scroll,
|
||||||
|
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=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=t("dashboard.no_presets"),
|
||||||
|
text_color="gray", justify="left").pack(padx=4, pady=8)
|
||||||
|
return
|
||||||
|
|
||||||
|
saved = self.app.load_selection()
|
||||||
|
for fp in files:
|
||||||
|
var = ctk.BooleanVar(value=fp.name in saved)
|
||||||
|
self._preset_checks[fp.name] = var
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
self._preset_scroll,
|
||||||
|
text=fp.name,
|
||||||
|
variable=var,
|
||||||
|
command=self._on_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=3)
|
||||||
|
|
||||||
|
self._on_toggle() # initialise count label
|
||||||
|
|
||||||
|
def _update_pipeline_status(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
for key in self._step_icons:
|
||||||
|
self._set_step(key, False)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_count = sum(1 for v in self._preset_checks.values() if v.get())
|
||||||
|
downloads_ok = (
|
||||||
|
cfg.downloads.is_dir()
|
||||||
|
and any(True for _ in cfg.downloads.rglob("@*"))
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_step("parse", selected_count >= 2)
|
||||||
|
self._set_step("compare", cfg.comparison.exists())
|
||||||
|
self._set_step("download", downloads_ok)
|
||||||
|
self._set_step("link", cfg.arma_dir.is_dir())
|
||||||
|
|
||||||
|
if cfg.comparison.exists():
|
||||||
|
try:
|
||||||
|
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||||
|
total = (comp["shared"]["mod_count"]
|
||||||
|
+ sum(v["mod_count"] for v in comp["unique"].values()))
|
||||||
|
shared = comp["shared"]["mod_count"]
|
||||||
|
missing = 0
|
||||||
|
if cfg.missing_report.exists():
|
||||||
|
rep = json.loads(cfg.missing_report.read_text(encoding="utf-8"))
|
||||||
|
missing = rep.get("missing", 0)
|
||||||
|
stat = t("dashboard.stats", total=total, shared=shared)
|
||||||
|
if missing:
|
||||||
|
stat += t("dashboard.stats_missing", missing=missing)
|
||||||
|
self._stats_lbl.configure(text=stat)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_step(self, key: str, done: bool) -> None:
|
||||||
|
icon = self._step_icons.get(key)
|
||||||
|
if icon:
|
||||||
|
icon.configure(
|
||||||
|
text="✓" if done else "○",
|
||||||
|
text_color=COLOR_OK if done else COLOR_PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Selection helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_selected_names(self) -> set[str]:
|
||||||
|
return {name for name, var in self._preset_checks.items() if var.get()}
|
||||||
|
|
||||||
|
def _on_toggle(self) -> None:
|
||||||
|
selected = self.get_selected_names()
|
||||||
|
self.app.save_selection(selected)
|
||||||
|
n_sel = len(selected)
|
||||||
|
n_total = len(self._preset_checks)
|
||||||
|
color = (COLOR_OK if n_sel >= 2 else
|
||||||
|
COLOR_WARN if n_sel == 1 else
|
||||||
|
COLOR_ERROR)
|
||||||
|
self._sel_count_lbl.configure(
|
||||||
|
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():
|
||||||
|
var.set(True)
|
||||||
|
self._on_toggle()
|
||||||
|
|
||||||
|
def _select_none(self) -> None:
|
||||||
|
for var in self._preset_checks.values():
|
||||||
|
var.set(False)
|
||||||
|
self._on_toggle()
|
||||||
|
|
||||||
|
# ── Add presets ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _add_presets(self) -> None:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
messagebox.showwarning(t("dashboard.dlg_setup_title"),
|
||||||
|
t("dashboard.dlg_setup_body"))
|
||||||
|
return
|
||||||
|
files = filedialog.askopenfilenames(
|
||||||
|
title=t("dashboard.file_dialog_title"),
|
||||||
|
filetypes=[("HTML Preset", "*.html"), ("All files", "*.*")],
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
dest = cfg.modlist_html
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
current = self.app.load_selection()
|
||||||
|
for fp in files:
|
||||||
|
name = Path(fp).name
|
||||||
|
shutil.copy2(fp, dest / name)
|
||||||
|
current.add(name)
|
||||||
|
self.app.save_selection(current)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ── Run button ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_run(self) -> None:
|
||||||
|
self.app.run_pipeline(self.get_selected_names())
|
||||||
|
|
||||||
|
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=t("dashboard.running"))
|
||||||
|
self._prog.pack(fill="x", pady=(6, 0))
|
||||||
|
self._prog.start()
|
||||||
|
else:
|
||||||
|
self._run_btn.configure(state="normal", text=t("dashboard.run_btn"))
|
||||||
|
self._prog.stop()
|
||||||
|
self._prog.pack_forget()
|
||||||
74
gui/views/logs.py
Normal file
74
gui/views/logs.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class LogsView(BaseView):
|
||||||
|
"""
|
||||||
|
Monospace textbox showing captured stdout/stderr from pipeline and tools.
|
||||||
|
|
||||||
|
The app's poll loop appends text by calling append() directly on this view.
|
||||||
|
Log content persists across navigation — the textbox is built once in build()
|
||||||
|
and never recreated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 8))
|
||||||
|
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")
|
||||||
|
self._copy_btn = ctk.CTkButton(btn_row, text=t("logs.copy_btn"), width=72,
|
||||||
|
command=self._copy)
|
||||||
|
self._copy_btn.pack(side="left", padx=4)
|
||||||
|
self._clear_btn = ctk.CTkButton(btn_row, text=t("logs.clear_btn"), width=72,
|
||||||
|
fg_color=COLOR_ERROR, hover_color="#c62828",
|
||||||
|
command=self._clear)
|
||||||
|
self._clear_btn.pack(side="left")
|
||||||
|
|
||||||
|
# ── Log textbox (persistent) ──────────────────────────────────────────
|
||||||
|
self._log_box = ctk.CTkTextbox(
|
||||||
|
self, state="disabled",
|
||||||
|
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:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.insert("end", text)
|
||||||
|
self._log_box.see("end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _copy(self) -> None:
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(self._log_box.get("1.0", "end"))
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
self._log_box.configure(state="normal")
|
||||||
|
self._log_box.delete("1.0", "end")
|
||||||
|
self._log_box.configure(state="disabled")
|
||||||
390
gui/views/mods.py
Normal file
390
gui/views/mods.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
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, 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
|
||||||
|
candidate = group_dir / f"@{mod_name}"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
target_lower = mod_name.lower()
|
||||||
|
target_norm = _normalize_name(mod_name)
|
||||||
|
for p in group_dir.iterdir():
|
||||||
|
if not p.is_dir():
|
||||||
|
continue
|
||||||
|
if p.name.lstrip("@").lower() == target_lower:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ModsView(BaseView):
|
||||||
|
"""Tabbed mod browser — one tab per comparison group with server status checking."""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(2, weight=1)
|
||||||
|
|
||||||
|
# ── State ─────────────────────────────────────────────────────────────
|
||||||
|
self._search_var = ctk.StringVar()
|
||||||
|
self._checking = False
|
||||||
|
self._check_btn: Optional[ctk.CTkButton] = None
|
||||||
|
self._tab_view: Optional[ctk.CTkTabview] = None
|
||||||
|
# key = "{group}/{folder_name_or_mod_name}"
|
||||||
|
# value = dict(status_label, update_btn, group, folder_path, mod_dict, name_label)
|
||||||
|
self._mod_rows: dict[str, dict] = {}
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────
|
||||||
|
hdr = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6))
|
||||||
|
hdr.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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=t("mods.check_btn"), width=140,
|
||||||
|
command=self._check_updates,
|
||||||
|
)
|
||||||
|
self._check_btn.pack(side="left")
|
||||||
|
|
||||||
|
# ── Search ────────────────────────────────────────────────────────────
|
||||||
|
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8))
|
||||||
|
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=t("mods.search_placeholder"),
|
||||||
|
width=220).pack(side="left")
|
||||||
|
self._search_var.trace_add("write", lambda *_: self._apply_search())
|
||||||
|
|
||||||
|
# ── Tab area placeholder ───────────────────────────────────────────────
|
||||||
|
self._tab_area = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
self._tab_area.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||||
|
self._tab_area.grid_columnconfigure(0, weight=1)
|
||||||
|
self._tab_area.grid_rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self._msg_label: Optional[ctk.CTkLabel] = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Public
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
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
|
||||||
|
if self._tab_view is not None:
|
||||||
|
self._tab_view.destroy()
|
||||||
|
self._tab_view = None
|
||||||
|
if self._msg_label is not None:
|
||||||
|
self._msg_label.destroy()
|
||||||
|
self._msg_label = None
|
||||||
|
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
self._show_msg(t("mods.no_config"))
|
||||||
|
return
|
||||||
|
if not cfg.comparison.exists():
|
||||||
|
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(t("mods.read_error", e=e), error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build ordered group list: shared first, then unique groups
|
||||||
|
groups: list[tuple[str, dict]] = [("shared", comp["shared"])]
|
||||||
|
for preset, data in comp["unique"].items():
|
||||||
|
groups.append((preset, data))
|
||||||
|
|
||||||
|
# Precompute link maps per group (one get_link_status call per group)
|
||||||
|
link_maps: dict[str, dict[str, bool]] = {}
|
||||||
|
try:
|
||||||
|
from arma_modlist_tools.linker import get_link_status
|
||||||
|
for group, _ in groups:
|
||||||
|
gdir = cfg.downloads / group
|
||||||
|
if gdir.is_dir():
|
||||||
|
link_maps[group] = {
|
||||||
|
e["name"].lower(): e["is_linked"]
|
||||||
|
for e in get_link_status(gdir, cfg.arma_dir)
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build CTkTabview
|
||||||
|
tv = ctk.CTkTabview(self._tab_area)
|
||||||
|
tv.grid(row=0, column=0, sticky="nsew")
|
||||||
|
self._tab_view = tv
|
||||||
|
|
||||||
|
for group, data in groups:
|
||||||
|
mods = data.get("mods", [])
|
||||||
|
count = len(mods)
|
||||||
|
tab_label = f"{group} ({count})"
|
||||||
|
tv.add(tab_label)
|
||||||
|
tab_frame = tv.tab(tab_label)
|
||||||
|
tab_frame.grid_columnconfigure(0, weight=1)
|
||||||
|
tab_frame.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Column header
|
||||||
|
col_hdr = ctk.CTkFrame(tab_frame,
|
||||||
|
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_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=t(lbl_key) if lbl_key else "",
|
||||||
|
font=ctk.CTkFont(weight="bold"),
|
||||||
|
anchor=anc, width=w or 1).grid(
|
||||||
|
row=0, column=col,
|
||||||
|
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)
|
||||||
|
scroll.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4))
|
||||||
|
|
||||||
|
self._build_group_rows(scroll, group, mods,
|
||||||
|
cfg, link_maps.get(group, {}))
|
||||||
|
|
||||||
|
if groups:
|
||||||
|
tv.set(f"{groups[0][0]} ({len(groups[0][1].get('mods', []))})")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — layout helpers
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _show_msg(self, text: str, error: bool = False) -> None:
|
||||||
|
self._msg_label = ctk.CTkLabel(
|
||||||
|
self._tab_area, text=text, justify="left",
|
||||||
|
text_color="#F44336" if error else "gray",
|
||||||
|
)
|
||||||
|
self._msg_label.grid(row=0, column=0, padx=24, pady=24, sticky="nw")
|
||||||
|
|
||||||
|
def _build_group_rows(
|
||||||
|
self,
|
||||||
|
parent: ctk.CTkScrollableFrame,
|
||||||
|
group: str,
|
||||||
|
mods: list[dict],
|
||||||
|
cfg,
|
||||||
|
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"], mod.get("steam_id"))
|
||||||
|
downloaded = folder_path is not None
|
||||||
|
linked = (link_map.get(folder_path.name.lower(), False)
|
||||||
|
if folder_path else False)
|
||||||
|
|
||||||
|
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
|
||||||
|
row = ctk.CTkFrame(parent, fg_color=bg, corner_radius=4)
|
||||||
|
row.pack(fill="x", pady=1)
|
||||||
|
row.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# Mod name
|
||||||
|
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="center",
|
||||||
|
).grid(row=0, column=1, padx=4)
|
||||||
|
|
||||||
|
# Linked
|
||||||
|
ctk.CTkLabel(
|
||||||
|
row,
|
||||||
|
text="✓" if linked else ("—" if not downloaded else "✗"),
|
||||||
|
text_color=COLOR_OK if linked else "gray",
|
||||||
|
width=80, anchor="center",
|
||||||
|
).grid(row=0, column=2, padx=4)
|
||||||
|
|
||||||
|
# Server status
|
||||||
|
status_lbl = ctk.CTkLabel(
|
||||||
|
row, text="—", text_color="gray", width=160, anchor="w",
|
||||||
|
)
|
||||||
|
status_lbl.grid(row=0, column=3, padx=4)
|
||||||
|
|
||||||
|
# Update button (hidden until stale detected)
|
||||||
|
folder_name = folder_path.name if folder_path else None
|
||||||
|
update_btn = ctk.CTkButton(
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
update_btn.grid(row=0, column=4, padx=(4, 8), pady=2)
|
||||||
|
update_btn.grid_remove() # hidden until check finds stale files
|
||||||
|
|
||||||
|
# Register in row map
|
||||||
|
key = f"{group}/{folder_name or mod['name']}"
|
||||||
|
self._mod_rows[key] = {
|
||||||
|
"status_label": status_lbl,
|
||||||
|
"update_btn": update_btn,
|
||||||
|
"name_label": name_lbl,
|
||||||
|
"row_frame": row,
|
||||||
|
"group": group,
|
||||||
|
"folder_path": folder_path,
|
||||||
|
"mod_dict": mod,
|
||||||
|
"mod_name": mod["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — server update check
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _check_updates(self) -> None:
|
||||||
|
if self._checking:
|
||||||
|
return
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if not cfg:
|
||||||
|
return
|
||||||
|
if not self._mod_rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._checking = True
|
||||||
|
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=t("mods.status_checking"), text_color=COLOR_RUNNING)
|
||||||
|
else:
|
||||||
|
row["status_label"].configure(text="—", text_color="gray")
|
||||||
|
|
||||||
|
# Snapshot rows for thread (avoid race with refresh)
|
||||||
|
rows_snapshot = dict(self._mod_rows)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
from arma_modlist_tools.fetcher import (
|
||||||
|
build_server_index, list_mod_updates, make_session, find_mod_folder,
|
||||||
|
)
|
||||||
|
results: dict[str, tuple[str, int]] = {}
|
||||||
|
try:
|
||||||
|
idx = build_server_index(cfg.server_url, cfg.server_auth)
|
||||||
|
session = make_session(cfg.server_auth)
|
||||||
|
for key, row in rows_snapshot.items():
|
||||||
|
if not row["folder_path"]:
|
||||||
|
results[key] = ("not_downloaded", 0)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
folder_url = find_mod_folder(row["mod_dict"], idx)
|
||||||
|
if not folder_url:
|
||||||
|
results[key] = ("not_on_server", 0)
|
||||||
|
continue
|
||||||
|
stale = list_mod_updates(
|
||||||
|
folder_url, row["folder_path"], session)
|
||||||
|
results[key] = ("stale" if stale else "ok", len(stale))
|
||||||
|
except Exception:
|
||||||
|
results[key] = ("error", 0)
|
||||||
|
except Exception:
|
||||||
|
for key in rows_snapshot:
|
||||||
|
results[key] = ("error", 0)
|
||||||
|
self.after(0, lambda: self._apply_check_results(results))
|
||||||
|
|
||||||
|
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": (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"))
|
||||||
|
# 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=t("mods.check_btn"), state="normal")
|
||||||
|
|
||||||
|
def _update_mod(self, group: str, folder_name: str) -> None:
|
||||||
|
self.app.navigate_to("Logs")
|
||||||
|
self.app.run_tool(["update_mods.py", "--group", group, "--mod", folder_name])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — search filter
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _apply_search(self) -> None:
|
||||||
|
search = self._search_var.get().lower()
|
||||||
|
for row in self._mod_rows.values():
|
||||||
|
frame = row["row_frame"]
|
||||||
|
if not search or search in row["mod_name"].lower():
|
||||||
|
frame.pack(fill="x", pady=1)
|
||||||
|
else:
|
||||||
|
frame.pack_forget()
|
||||||
96
gui/views/settings.py
Normal file
96
gui/views/settings.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui.locales import t
|
||||||
|
from gui.views.base import BaseView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView(BaseView):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
self._build_cards()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
def _build_cards(self) -> None:
|
||||||
|
# ── Server & Paths ────────────────────────────────────────────────────
|
||||||
|
c1 = ctk.CTkFrame(self._scroll)
|
||||||
|
c1.pack(fill="x", pady=6)
|
||||||
|
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=t("settings.server_card_desc"),
|
||||||
|
text_color="gray", wraplength=600, justify="left").pack(
|
||||||
|
anchor="w", padx=16, pady=(0, 8))
|
||||||
|
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=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())
|
||||||
|
ctk.CTkOptionMenu(c2, values=["Dark", "Light", "System"],
|
||||||
|
variable=mode_var,
|
||||||
|
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=t("settings.config_title"),
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold")).pack(
|
||||||
|
anchor="w", padx=16, pady=(14, 3))
|
||||||
|
info = (
|
||||||
|
f"Server: {cfg.server_url}\n"
|
||||||
|
f"Arma dir: {cfg.arma_dir}\n"
|
||||||
|
f"Downloads: {cfg.downloads}\n"
|
||||||
|
f"Presets: {cfg.modlist_html}\n"
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(c3, text=info, justify="left",
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11),
|
||||||
|
text_color="gray").pack(anchor="w", padx=16, pady=(0, 14))
|
||||||
646
gui/views/tools.py
Normal file
646
gui/views/tools.py
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from tkinter import messagebox
|
||||||
|
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:
|
||||||
|
from gui.app import ArmaModManagerApp
|
||||||
|
|
||||||
|
_WARN_COLOR = COLOR_WARN
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsView(BaseView):
|
||||||
|
"""Per-tool panels inside a CTkTabview."""
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.grid_columnconfigure(0, weight=1)
|
||||||
|
self.grid_rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# 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 = [t("tools.all_groups")] + groups
|
||||||
|
|
||||||
|
# Repopulate generic group menus
|
||||||
|
for menu, var in self._group_menus:
|
||||||
|
prev = var.get()
|
||||||
|
menu.configure(values=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 [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])
|
||||||
|
|
||||||
|
# Info labels
|
||||||
|
self._update_sm_label()
|
||||||
|
self._update_rm_label()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Private — tab builders
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
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_lbl = _desc(tab, row=0, text=t("tools.cn_desc"))
|
||||||
|
self._translatable.append((desc_lbl, "tools.cn_desc"))
|
||||||
|
|
||||||
|
# Group
|
||||||
|
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=[t("tools.all_groups")], width=200)
|
||||||
|
menu.pack(side="left")
|
||||||
|
self._group_menus.append((menu, self._cn_group_var))
|
||||||
|
|
||||||
|
# Checkboxes
|
||||||
|
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)
|
||||||
|
cn_fix_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_chk"),
|
||||||
|
variable=self._cn_fix_var,
|
||||||
|
command=self._cn_on_toggle)
|
||||||
|
cn_fix_chk.pack(side="left", padx=(0, 16))
|
||||||
|
self._translatable.append((cn_fix_chk, "tools.cn_fix_chk"))
|
||||||
|
cn_ids_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_ids_chk"),
|
||||||
|
variable=self._cn_fix_ids_var,
|
||||||
|
command=self._cn_on_toggle)
|
||||||
|
cn_ids_chk.pack(side="left")
|
||||||
|
self._translatable.append((cn_ids_chk, "tools.cn_fix_ids_chk"))
|
||||||
|
|
||||||
|
# Warning (hidden until checkbox ticked)
|
||||||
|
self._cn_warn = ctk.CTkLabel(tab, text=t("tools.cn_warn"),
|
||||||
|
text_color=_WARN_COLOR, anchor="w")
|
||||||
|
self._translatable.append((self._cn_warn, "tools.cn_warn"))
|
||||||
|
|
||||||
|
# Run button
|
||||||
|
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():
|
||||||
|
self._cn_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._cn_warn.grid_forget()
|
||||||
|
|
||||||
|
def _cn_run(self) -> None:
|
||||||
|
args = ["check_names.py"]
|
||||||
|
g = self._cn_group_var.get()
|
||||||
|
if g != t("tools.all_groups"):
|
||||||
|
args += ["--group", g]
|
||||||
|
if self._cn_fix_var.get():
|
||||||
|
args.append("--fix")
|
||||||
|
if self._cn_fix_ids_var.get():
|
||||||
|
args.append("--fix-ids")
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_update_mods_tab(self) -> None:
|
||||||
|
self._tab_view.add("Update Mods")
|
||||||
|
tab = self._tab_view.tab("Update Mods")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
desc_lbl = _desc(tab, row=0, text=t("tools.um_desc"))
|
||||||
|
self._translatable.append((desc_lbl, "tools.um_desc"))
|
||||||
|
|
||||||
|
# Group
|
||||||
|
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=[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, 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=t("tools.um_mod_placeholder"), width=220,
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self._um_mod_entry.pack(side="left")
|
||||||
|
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, 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)
|
||||||
|
um_force_chk = ctk.CTkCheckBox(
|
||||||
|
ff, text=t("tools.um_force_chk"),
|
||||||
|
variable=self._um_force_var,
|
||||||
|
command=self._um_on_toggle,
|
||||||
|
)
|
||||||
|
um_force_chk.pack(side="left")
|
||||||
|
self._translatable.append((um_force_chk, "tools.um_force_chk"))
|
||||||
|
|
||||||
|
# Warning
|
||||||
|
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"))
|
||||||
|
|
||||||
|
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() != 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")
|
||||||
|
|
||||||
|
def _um_on_toggle(self) -> None:
|
||||||
|
if self._um_force_var.get():
|
||||||
|
self._um_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._um_warn.grid_forget()
|
||||||
|
|
||||||
|
def _um_run(self) -> None:
|
||||||
|
args = ["update_mods.py"]
|
||||||
|
g = self._um_group_var.get()
|
||||||
|
if g != t("tools.all_groups"):
|
||||||
|
args += ["--group", g]
|
||||||
|
mod = self._um_mod_entry.get().strip()
|
||||||
|
if mod:
|
||||||
|
args += ["--mod", mod]
|
||||||
|
if self._um_force_var.get():
|
||||||
|
args.append("--force")
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_link_mods_tab(self) -> None:
|
||||||
|
self._tab_view.add("Link Mods")
|
||||||
|
tab = self._tab_view.tab("Link Mods")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
desc_lbl = _desc(tab, row=0, text=t("tools.lm_desc"))
|
||||||
|
self._translatable.append((desc_lbl, "tools.lm_desc"))
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
values=["Status", "Link", "Unlink"],
|
||||||
|
variable=self._lm_cmd_var,
|
||||||
|
command=self._lm_on_change,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Group (required — no "All groups")
|
||||||
|
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=[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=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=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()
|
||||||
|
# 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":
|
||||||
|
self._lm_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w")
|
||||||
|
else:
|
||||||
|
self._lm_warn.grid_forget()
|
||||||
|
|
||||||
|
def _lm_run(self) -> None:
|
||||||
|
cmd = self._lm_cmd_var.get().lower()
|
||||||
|
group = self._lm_group_var.get()
|
||||||
|
|
||||||
|
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(
|
||||||
|
t("tools.lm_confirm_title"),
|
||||||
|
t("tools.lm_confirm_body", group=group),
|
||||||
|
)
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
args.append("--yes")
|
||||||
|
|
||||||
|
self._launch(args)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_sync_missing_tab(self) -> None:
|
||||||
|
self._tab_view.add("Sync Missing")
|
||||||
|
tab = self._tab_view.tab("Sync Missing")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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
|
||||||
|
if not cfg:
|
||||||
|
self._sm_info.configure(text="")
|
||||||
|
return
|
||||||
|
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||||
|
cfg.modlist_json / "missing_report.json"
|
||||||
|
if hasattr(cfg, "modlist_json") else None
|
||||||
|
)
|
||||||
|
if report_path and report_path.exists():
|
||||||
|
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=t("tools.sm_count", count=count))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._sm_info.configure(text=t("tools.sm_no_report"))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_report_missing_tab(self) -> None:
|
||||||
|
self._tab_view.add("Report Missing")
|
||||||
|
tab = self._tab_view.tab("Report Missing")
|
||||||
|
tab.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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
|
||||||
|
if not cfg:
|
||||||
|
self._rm_info.configure(text="")
|
||||||
|
return
|
||||||
|
report_path = cfg.missing_report if hasattr(cfg, "missing_report") else (
|
||||||
|
cfg.modlist_json / "missing_report.json"
|
||||||
|
if hasattr(cfg, "modlist_json") else None
|
||||||
|
)
|
||||||
|
if report_path and report_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
ts = data.get("generated_at", "unknown")
|
||||||
|
self._rm_info.configure(text=t("tools.rm_last", ts=ts))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _get_groups(self) -> list[str]:
|
||||||
|
cfg = self.app.cfg
|
||||||
|
if cfg and cfg.downloads.is_dir():
|
||||||
|
return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir())
|
||||||
|
# Fallback: read comparison.json
|
||||||
|
if cfg and cfg.comparison.exists():
|
||||||
|
try:
|
||||||
|
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
|
||||||
|
return ["shared"] + list(comp.get("unique", {}).keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _launch(self, args: list[str]) -> None:
|
||||||
|
self.app.navigate_to("Logs")
|
||||||
|
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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _desc(parent, row: int, text: str) -> ctk.CTkLabel:
|
||||||
|
lbl = ctk.CTkLabel(parent, text=text, justify="left",
|
||||||
|
wraplength=700, text_color="gray", anchor="w")
|
||||||
|
lbl.grid(row=row, column=0, padx=24, pady=(16, 8), sticky="ew")
|
||||||
|
return lbl
|
||||||
|
|
||||||
|
|
||||||
|
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, lbl
|
||||||
205
gui/wizard.py
Normal file
205
gui/wizard.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from tkinter import TclError, filedialog
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui._constants import COLOR_OK, COLOR_ERROR, PROJECT_ROOT
|
||||||
|
from gui.locales import t
|
||||||
|
|
||||||
|
|
||||||
|
class SetupWizard(ctk.CTkToplevel):
|
||||||
|
"""Modal first-run wizard that writes config.json."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: ctk.CTk,
|
||||||
|
on_complete: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title(t("wizard.title"))
|
||||||
|
self.geometry("500x420")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.grab_set()
|
||||||
|
self.lift()
|
||||||
|
self.focus_force()
|
||||||
|
|
||||||
|
self._on_complete = on_complete
|
||||||
|
self._url = ctk.StringVar(value="https://")
|
||||||
|
self._user = ctk.StringVar()
|
||||||
|
self._pw = ctk.StringVar()
|
||||||
|
self._arma = ctk.StringVar()
|
||||||
|
|
||||||
|
self._body = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
self._body.pack(fill="both", expand=True, padx=28, pady=24)
|
||||||
|
|
||||||
|
self._show(0)
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
for w in self._body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
def _show(self, step: int) -> None:
|
||||||
|
self._clear()
|
||||||
|
[self._page_server, self._page_paths, self._page_review][step]()
|
||||||
|
|
||||||
|
# ── Page 1: server ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _page_server(self) -> None:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text=t("wizard.step1_title"),
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text=t("wizard.step1_desc"),
|
||||||
|
text_color="gray",
|
||||||
|
).pack(anchor="w", pady=(4, 18))
|
||||||
|
|
||||||
|
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=t(lbl_key)).pack(anchor="w")
|
||||||
|
ctk.CTkEntry(self._body, textvariable=var, width=440, show=show).pack(
|
||||||
|
anchor="w", pady=(2, 10))
|
||||||
|
|
||||||
|
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
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=t("wizard.btn_next"), width=90,
|
||||||
|
command=lambda: self._show(1)).pack(side="right")
|
||||||
|
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)
|
||||||
|
self._test_btn.pack(side="right", padx=(0, 8))
|
||||||
|
|
||||||
|
def _test(self) -> None:
|
||||||
|
# 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(url, auth=auth, timeout=8)
|
||||||
|
if r.ok:
|
||||||
|
result = (t("wizard.connected"), COLOR_OK)
|
||||||
|
else:
|
||||||
|
result = (t("wizard.http_error", code=r.status_code), COLOR_ERROR)
|
||||||
|
except Exception as e:
|
||||||
|
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=t("wizard.step2_title"),
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
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=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=t("wizard.btn_browse"), width=80,
|
||||||
|
command=self._browse_arma).pack(side="left", padx=8)
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
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=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=t("wizard.btn_next"), width=80,
|
||||||
|
command=lambda: self._show(2)).pack(side="right")
|
||||||
|
|
||||||
|
def _browse_arma(self) -> None:
|
||||||
|
d = filedialog.askdirectory(title=t("wizard.browse_title"))
|
||||||
|
if d:
|
||||||
|
self._arma.set(d)
|
||||||
|
|
||||||
|
# ── Page 3: review + save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _page_review(self) -> None:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self._body, text=t("wizard.step3_title"),
|
||||||
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
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 t('wizard.not_set')}\n"
|
||||||
|
)
|
||||||
|
box = ctk.CTkTextbox(self._body, height=90,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12))
|
||||||
|
box.insert("1.0", summary)
|
||||||
|
box.configure(state="disabled")
|
||||||
|
box.pack(fill="x", pady=(0, 16))
|
||||||
|
|
||||||
|
foot = ctk.CTkFrame(self._body, fg_color="transparent")
|
||||||
|
foot.pack(fill="x")
|
||||||
|
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=t("wizard.btn_save"), width=120,
|
||||||
|
command=self._save).pack(side="right")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
cfg = {
|
||||||
|
"server": {
|
||||||
|
"base_url": self._url.get(),
|
||||||
|
"username": self._user.get(),
|
||||||
|
"password": self._pw.get(),
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"arma_dir": self._arma.get(),
|
||||||
|
"downloads": "downloads",
|
||||||
|
"modlist_html": "modlist_html",
|
||||||
|
"modlist_json": "modlist_json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(PROJECT_ROOT / "config.json").write_text(
|
||||||
|
json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
self.destroy()
|
||||||
|
self._on_complete()
|
||||||
17
link_mods.py
17
link_mods.py
@@ -99,7 +99,7 @@ def cmd_link(cfg, group: str) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
def cmd_unlink(cfg, group: str) -> None:
|
def cmd_unlink(cfg, group: str, yes: bool = False) -> None:
|
||||||
group_dir = cfg.downloads / group
|
group_dir = cfg.downloads / group
|
||||||
if not group_dir.is_dir():
|
if not group_dir.is_dir():
|
||||||
print(f"ERROR: group folder not found: {group_dir}")
|
print(f"ERROR: group folder not found: {group_dir}")
|
||||||
@@ -118,10 +118,13 @@ def cmd_unlink(cfg, group: str) -> None:
|
|||||||
print(f" Group: {group} ({linked_count} linked mod(s))")
|
print(f" Group: {group} ({linked_count} linked mod(s))")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
choice = input(" Continue? [y/N]: ").strip().lower()
|
if yes:
|
||||||
if choice not in ("y", "yes"):
|
print(" (--yes flag set — skipping confirmation)")
|
||||||
print(" Aborted.\n")
|
else:
|
||||||
sys.exit(0)
|
choice = input(" Continue? [y/N]: ").strip().lower()
|
||||||
|
if choice not in ("y", "yes"):
|
||||||
|
print(" Aborted.\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -158,6 +161,8 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
parser.add_argument("command", choices=["status", "link", "unlink"])
|
parser.add_argument("command", choices=["status", "link", "unlink"])
|
||||||
parser.add_argument("--group", "-g", metavar="GROUP")
|
parser.add_argument("--group", "-g", metavar="GROUP")
|
||||||
|
parser.add_argument("--yes", "-y", action="store_true",
|
||||||
|
help="Skip confirmation prompt (for non-interactive use)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.group:
|
if not args.group:
|
||||||
@@ -176,7 +181,7 @@ def main() -> None:
|
|||||||
elif args.command == "link":
|
elif args.command == "link":
|
||||||
cmd_link(cfg, args.group)
|
cmd_link(cfg, args.group)
|
||||||
elif args.command == "unlink":
|
elif args.command == "unlink":
|
||||||
cmd_unlink(cfg, args.group)
|
cmd_unlink(cfg, args.group, yes=args.yes)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
121
modlist_html/Test_Preset_A.html
Normal file
121
modlist_html/Test_Preset_A.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html>
|
||||||
|
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||||
|
<head>
|
||||||
|
<meta name="arma:Type" content="list" />
|
||||||
|
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||||
|
<title>Arma 3</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, th, td {
|
||||||
|
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 3px 30px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding: 20px 20px 0 20px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 200;
|
||||||
|
font-family: segoe ui;
|
||||||
|
font-size: 3em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-variant: italic;
|
||||||
|
color:silver;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before-list {
|
||||||
|
padding: 5px 20px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlc-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whups {
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #D18F21;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color:#F1AF41;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-steam {
|
||||||
|
color: #449EBD;
|
||||||
|
}
|
||||||
|
.from-local {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Arma 3 Mods</h1>
|
||||||
|
<p class="before-list">
|
||||||
|
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||||
|
</p>
|
||||||
|
<div class="mod-list">
|
||||||
|
<table>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">CBA_A3</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Enhanced Movement</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=333310405" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=333310405</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>Created by Arma 3 Launcher (https://arma3.com)</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
modlist_html/Test_Preset_B.html
Normal file
121
modlist_html/Test_Preset_B.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html>
|
||||||
|
<!--Created by Arma 3 Launcher: https://arma3.com-->
|
||||||
|
<head>
|
||||||
|
<meta name="arma:Type" content="list" />
|
||||||
|
<meta name="generator" content="Arma 3 Launcher - https://arma3.com" />
|
||||||
|
<title>Arma 3</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, th, td {
|
||||||
|
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 3px 30px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding: 20px 20px 0 20px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 200;
|
||||||
|
font-family: segoe ui;
|
||||||
|
font-size: 3em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-variant: italic;
|
||||||
|
color:silver;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before-list {
|
||||||
|
padding: 5px 20px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlc-list {
|
||||||
|
background: #222222;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whups {
|
||||||
|
color:gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #D18F21;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color:#F1AF41;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-steam {
|
||||||
|
color: #449EBD;
|
||||||
|
}
|
||||||
|
.from-local {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Arma 3 Mods</h1>
|
||||||
|
<p class="before-list">
|
||||||
|
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
|
||||||
|
</p>
|
||||||
|
<div class="mod-list">
|
||||||
|
<table>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">CBA_A3</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">Zeus Enhanced</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1779063631</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr data-type="ModContainer">
|
||||||
|
<td data-type="DisplayName">DUI - Squad Radar</td>
|
||||||
|
<td>
|
||||||
|
<span class="from-steam">Steam</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id=1638341685</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>Created by Arma 3 Launcher (https://arma3.com)</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
requests
|
requests
|
||||||
tqdm
|
tqdm
|
||||||
|
customtkinter
|
||||||
|
|||||||
33
run.py
33
run.py
@@ -78,6 +78,22 @@ def step_compare(cfg) -> None:
|
|||||||
print(f" -> {cfg.comparison}")
|
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:
|
def step_fetch(cfg) -> None:
|
||||||
if not cfg.comparison.exists():
|
if not cfg.comparison.exists():
|
||||||
print(f" ERROR: {cfg.comparison} not found. Run parse + compare first.")
|
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"]:
|
for mod in data["mods"]:
|
||||||
queue.append((mod, preset_name))
|
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...")
|
print(f" Building server index...")
|
||||||
index = build_server_index(cfg.server_url, cfg.server_auth)
|
index = build_server_index(cfg.server_url, cfg.server_auth, progress_fn=_index_progress)
|
||||||
print(f" Indexed {len(index['by_steam_id'])} mods\n")
|
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)
|
session = make_session(cfg.server_auth)
|
||||||
resolved = []
|
resolved = []
|
||||||
@@ -173,6 +194,7 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(description="Run the full mod management pipeline.")
|
parser = argparse.ArgumentParser(description="Run the full mod management pipeline.")
|
||||||
parser.add_argument("--skip-parse", action="store_true")
|
parser.add_argument("--skip-parse", action="store_true")
|
||||||
parser.add_argument("--skip-compare", 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-fetch", action="store_true")
|
||||||
parser.add_argument("--skip-link", action="store_true")
|
parser.add_argument("--skip-link", action="store_true")
|
||||||
parser.add_argument("--group", "-g", metavar="GROUP",
|
parser.add_argument("--group", "-g", metavar="GROUP",
|
||||||
@@ -182,13 +204,15 @@ def main() -> None:
|
|||||||
steps = [
|
steps = [
|
||||||
(not args.skip_parse, "Parse presets"),
|
(not args.skip_parse, "Parse presets"),
|
||||||
(not args.skip_compare, "Compare presets"),
|
(not args.skip_compare, "Compare presets"),
|
||||||
|
(not args.skip_migrate, "Migrate mods"),
|
||||||
(not args.skip_fetch, "Fetch mods"),
|
(not args.skip_fetch, "Fetch mods"),
|
||||||
(not args.skip_link, "Link mods"),
|
(not args.skip_link, "Link mods"),
|
||||||
]
|
]
|
||||||
active_count = sum(1 for run, _ in steps if run)
|
active_count = sum(1 for run, _ in steps if run)
|
||||||
step_num = 0
|
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.")
|
print("All steps skipped — nothing to do.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
@@ -196,6 +220,7 @@ def main() -> None:
|
|||||||
(cfg,),
|
(cfg,),
|
||||||
(cfg,),
|
(cfg,),
|
||||||
(cfg,),
|
(cfg,),
|
||||||
|
(cfg,),
|
||||||
None, # handled separately
|
None, # handled separately
|
||||||
]):
|
]):
|
||||||
if not run:
|
if not run:
|
||||||
@@ -216,6 +241,8 @@ def main() -> None:
|
|||||||
step_parse(cfg)
|
step_parse(cfg)
|
||||||
elif name == "Compare presets":
|
elif name == "Compare presets":
|
||||||
step_compare(cfg)
|
step_compare(cfg)
|
||||||
|
elif name == "Migrate mods":
|
||||||
|
step_migrate(cfg)
|
||||||
elif name == "Fetch mods":
|
elif name == "Fetch mods":
|
||||||
step_fetch(cfg)
|
step_fetch(cfg)
|
||||||
|
|
||||||
|
|||||||
6
selection.json
Normal file
6
selection.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"selected": [
|
||||||
|
"Test_Preset_A.html",
|
||||||
|
"Test_Preset_B.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
1135
test_suite.py
1135
test_suite.py
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,7 @@ def main() -> None:
|
|||||||
COL_GROUP = 24
|
COL_GROUP = 24
|
||||||
|
|
||||||
total_checked = total_updated = total_bytes = 0
|
total_checked = total_updated = total_bytes = 0
|
||||||
|
total_removed = 0
|
||||||
not_on_server = []
|
not_on_server = []
|
||||||
|
|
||||||
for group, folder_name, mod_dir in targets:
|
for group, folder_name, mod_dir in targets:
|
||||||
@@ -123,46 +124,65 @@ def main() -> None:
|
|||||||
all_files = list_mod_files(folder_url, session) if not args.force else stale
|
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)
|
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")
|
print(f" [=] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} {checked} files up-to-date")
|
||||||
total_checked += checked
|
total_checked += checked
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Download stale files
|
# Download stale files
|
||||||
mod_bytes = 0
|
mod_bytes = 0
|
||||||
with tqdm(
|
if stale:
|
||||||
total=len(stale), unit="file",
|
with tqdm(
|
||||||
desc=f" {folder_name[-COL_MOD:]:<{COL_MOD}}",
|
total=len(stale), unit="file",
|
||||||
position=0, leave=True, dynamic_ncols=True,
|
desc=f" {folder_name[-COL_MOD:]:<{COL_MOD}}",
|
||||||
) as file_bar:
|
position=0, leave=True, dynamic_ncols=True,
|
||||||
for rel, url, size in stale:
|
) as file_bar:
|
||||||
dest_file = mod_dir / rel
|
for rel, url, size in stale:
|
||||||
with tqdm(
|
dest_file = mod_dir / rel
|
||||||
total=size if size else None,
|
with tqdm(
|
||||||
unit="B", unit_scale=True, unit_divisor=1024,
|
total=size if size else None,
|
||||||
desc=f" {rel[-40:]:40s}",
|
unit="B", unit_scale=True, unit_divisor=1024,
|
||||||
position=1, leave=False, dynamic_ncols=True,
|
desc=f" {rel[-40:]:40s}",
|
||||||
) as chunk_bar:
|
position=1, leave=False, dynamic_ncols=True,
|
||||||
n = download_file(url, dest_file, session,
|
) as chunk_bar:
|
||||||
on_chunk=lambda b: chunk_bar.update(b))
|
n = download_file(url, dest_file, session,
|
||||||
mod_bytes += n
|
on_chunk=lambda b: chunk_bar.update(b))
|
||||||
file_bar.update(1)
|
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_checked += checked
|
||||||
total_updated += len(stale)
|
total_updated += len(stale)
|
||||||
total_bytes += mod_bytes
|
total_bytes += mod_bytes
|
||||||
print(f" [+] {folder_name:<{COL_MOD}} {group:<{COL_GROUP}} "
|
total_removed += len(orphans)
|
||||||
f"{checked} files {len(stale)} updated ({_fmt_bytes(mod_bytes)})")
|
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"\n{'='*56}")
|
||||||
print(f" Total: {total_checked} files checked, "
|
print(f" Total: {total_checked} files checked, "
|
||||||
f"{total_updated} updated, "
|
f"{total_updated} updated, "
|
||||||
|
f"{total_removed} orphan(s) removed, "
|
||||||
f"{_fmt_bytes(total_bytes)} downloaded")
|
f"{_fmt_bytes(total_bytes)} downloaded")
|
||||||
if not_on_server:
|
if not_on_server:
|
||||||
print(f" Not found on server ({len(not_on_server)}): {', '.join(not_on_server)}")
|
print(f" Not found on server ({len(not_on_server)}): {', '.join(not_on_server)}")
|
||||||
print(f"{'='*56}\n")
|
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")
|
print(" All mods are up-to-date.\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user