snbt-tr 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snbt_tr-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ronnikols
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include resources *
snbt_tr-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: snbt-tr
3
+ Version: 1.0.0
4
+ Summary: Advanced SNBT quest localizer using AI key pools
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: PyQt6>=6.4.0
12
+ Requires-Dist: httpx>=0.24.0
13
+ Requires-Dist: aiofiles>=23.1.0
14
+ Dynamic: license-file
15
+
16
+ <p align="center">
17
+ <img src="resources/logo.png" alt="SNBT AI Localizer Logo" width="200" height="200">
18
+ </p> # SNBT AI Localizer
19
+
20
+ English | [Русский](README_RU.md)
21
+
22
+ Advanced asynchronous translator for Minecraft FTB Quests files (`.snbt`) with support for multiple local and cloud translation engines, featuring a 4-tab GUI and intelligent SQLite caching system.
23
+
24
+ ## Installation
25
+
26
+ ### Recommended Methods
27
+ | Platform | Command |
28
+ |----------------|----------------------------------|
29
+ | Arch Linux AUR | `yay -S snbt-tr` |
30
+ | Flatpak | `flatpak install org.mineai.snbt-tr` |
31
+ | pipx | `pipx install snbt-tr` |
32
+ | PyPI | `pip install snbt-tr` |
33
+
34
+ ### Manual Installation
35
+ ```bash
36
+ git clone https://github.com/ronnikols/SNBT-AI-Localizer.git
37
+ cd SNBT-AI-Localizer
38
+ pip install -r requirements.txt
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Launch Methods
44
+ - `snbt-tr` - Launches the interactive CLI setup wizard
45
+ - `snbt-tr --gui` - Launches the PyQt6 GUI with dual-tab interface
46
+ - `snbt-tr [options]` - Unattended batch execution
47
+
48
+ ### Examples
49
+ ```bash
50
+ snbt-tr --list-models -p groq
51
+ snbt-tr -d /path/to/quests -p groq -k YOUR_API_KEY
52
+ snbt-tr --mix -d /path/to/quests
53
+ snbt-tr --clear-cache
54
+ snbt-tr --fastdir
55
+ ```
56
+
57
+ ## Features
58
+
59
+ ### 4-Tab GUI Architecture
60
+ - **Workspace Tab**: Primary translation interface with provider/model selection, API key pool management (up to 10 keys), custom context input, target language dropdown, batch processing controls (Start/Pause/Stop), and live logging output
61
+ - **Translation Memory Tab**: Visual cache manager with:
62
+ - Real-time search across original and translated text with 300ms debounce
63
+ - Modpack combobox filter for isolating translations by modpack
64
+ - Target language filter synchronized with Workspace tab
65
+ - 4-column QTableWidget (Original, Translation, Modpack, Added) with auto-stretch column width layout
66
+ - Inline editing of translations directly in the table
67
+ - Bulk operations: Load More (pagination), Delete Selected, Save Changes, Clear Cache
68
+ - **Settings Tab**: Configuration management for providers, models, API keys, and application settings
69
+ - **Credits Tab**: Displays project credits and acknowledgments
70
+
71
+ ### Asynchronous Processing
72
+ - Non-blocking I/O with `asyncio` and `httpx.AsyncClient`
73
+ - Configurable concurrency (1-10 threads)
74
+ - Adaptive chunking:
75
+ - 5 items per chunk for Ollama (local inference)
76
+ - Configurable batch size (default: 50) for cloud providers
77
+ - Automatic delays between chunks to prevent rate limiting
78
+
79
+ ### Fault Tolerance
80
+ - Round-Robin multi-key pool with automatic load balancing
81
+ - Invalid keys (HTTP 401/403) are permanently removed from the pool
82
+ - Rate-limited keys (HTTP 429) enter exponential backoff (0.2s to 64s)
83
+ - Fallback to single-item translation on chunk failure
84
+ - Mixed Provider Mode: Auto-detects provider from key prefix and uses saved defaults
85
+ - Binary split fallback for parse errors with recursive chunk splitting
86
+
87
+ ### Smart Caching System
88
+ - SQLite-based cache with WAL mode for concurrent access
89
+ - Per-language tables (`cache_{lang_code}`) to prevent cross-contamination
90
+ - Modpack isolation: Translations can be filtered and managed by modpack
91
+ - Thread-safe operations with locking
92
+ - Real-time persistence between chunks
93
+ - Defensive sanitization: Automatic cleanup of nested dictionaries in SNBT and protection against malformed JSON in responses
94
+
95
+ ### 🛡️ Pluralization Guard
96
+ **Prevents redundant API calls for highly similar strings with a 90% similarity threshold**
97
+
98
+ The Pluralization Guard is a sophisticated fuzzy-matching system that automatically detects and reuses translations for similar phrases, preventing duplicate API calls and saving costs. It works by:
99
+
100
+ 1. **Exact Match Check**: First attempts to find an exact match in the cache
101
+ 2. **Length Filtering**: Searches for candidates within ±15% length of the input text
102
+ 3. **Number Preservation**: Ensures strings with different numbers (e.g., "item 1" vs "item 2") are NOT matched
103
+ 4. **Fuzzy Matching**: Uses `difflib.SequenceMatcher` with a **90% similarity threshold**
104
+ 5. **Tail Preservation**: Maintains Minecraft formatting codes at the end of strings (e.g., "hello§a" → cached "привет" + "§a")
105
+
106
+ **Example:**
107
+ - Input: `"Get 5 diamonds"` → Cache miss
108
+ - Input: `"Get 6 diamonds"` → **Cache hit!** (90%+ similarity, same structure)
109
+ - Input: `"Get diamond"` → Cache miss (different number count)
110
+ - Input: `"Get 5 diamonds§a"` → **Cache hit!** (matches "Get 5 diamonds" + preserves "§a")
111
+
112
+ This prevents charging for translations of:
113
+ - Plural variations: "apple" → "apples"
114
+ - Number variations: "item 1" → "item 2" (if structure matches)
115
+ - Minor formatting differences: "text" → "text§a"
116
+
117
+ ### Minecraft Formatting Protection
118
+ - Regex shielding for namespace tags (`#c:ender_pearl_dusts`), UUIDs, and entity IDs
119
+ - Temporary placeholder substitution (`__TAG_N__`) during translation
120
+ - Preserves all Minecraft-specific formatting codes
121
+
122
+ ## Supported Providers
123
+
124
+ | Provider | Default Model | Free Tier | Notes |
125
+ |----------|----------------|-----------|-------|
126
+ | Groq Cloud (Fast) | llama-3.3-70b-versatile | Yes | Low-latency inference |
127
+ | NVIDIA NIM | nvidia/nemotron-4-340b-instruct | Yes | Enterprise-grade models |
128
+ | OpenRouter (Cloud AI) | google/gemma-4-31b:free | Yes | 100+ free models |
129
+ | Google Gemini (Free API) | models/gemini-3.1-flash-lite | Yes | Google's latest free model |
130
+ | Sambanova | DeepSeek-V3.1 | No | High-performance inference |
131
+ | OpenAI | gpt-4o-mini | No | Optimized for speed |
132
+ | Mistral AI | mistral-large-latest | Yes | Open-source frontier models |
133
+ | Anthropic (Claude) | claude-3-5-sonnet-20241022 | No | High-quality responses |
134
+ | Cohere | command-r-plus | No | Production-ready |
135
+ | Ollama (Local / Free) | qwen2.5:7b | Yes | Self-hosted, no API key |
136
+ | Google Translate (Free) | N/A | Yes | Traditional MT, no API key |
137
+ | Local LLM / Custom | (Custom) | Yes | Custom local models |
138
+
139
+ ## Feedback & Community
140
+ For feedback, suggestions, and bug reports, please contact us via:
141
+ - **Telegram**: [https://t.me/ronnikols](https://t.me/ronnikols)
142
+
143
+ ## License
144
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,129 @@
1
+ <p align="center">
2
+ <img src="resources/logo.png" alt="SNBT AI Localizer Logo" width="200" height="200">
3
+ </p> # SNBT AI Localizer
4
+
5
+ English | [Русский](README_RU.md)
6
+
7
+ Advanced asynchronous translator for Minecraft FTB Quests files (`.snbt`) with support for multiple local and cloud translation engines, featuring a 4-tab GUI and intelligent SQLite caching system.
8
+
9
+ ## Installation
10
+
11
+ ### Recommended Methods
12
+ | Platform | Command |
13
+ |----------------|----------------------------------|
14
+ | Arch Linux AUR | `yay -S snbt-tr` |
15
+ | Flatpak | `flatpak install org.mineai.snbt-tr` |
16
+ | pipx | `pipx install snbt-tr` |
17
+ | PyPI | `pip install snbt-tr` |
18
+
19
+ ### Manual Installation
20
+ ```bash
21
+ git clone https://github.com/ronnikols/SNBT-AI-Localizer.git
22
+ cd SNBT-AI-Localizer
23
+ pip install -r requirements.txt
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Launch Methods
29
+ - `snbt-tr` - Launches the interactive CLI setup wizard
30
+ - `snbt-tr --gui` - Launches the PyQt6 GUI with dual-tab interface
31
+ - `snbt-tr [options]` - Unattended batch execution
32
+
33
+ ### Examples
34
+ ```bash
35
+ snbt-tr --list-models -p groq
36
+ snbt-tr -d /path/to/quests -p groq -k YOUR_API_KEY
37
+ snbt-tr --mix -d /path/to/quests
38
+ snbt-tr --clear-cache
39
+ snbt-tr --fastdir
40
+ ```
41
+
42
+ ## Features
43
+
44
+ ### 4-Tab GUI Architecture
45
+ - **Workspace Tab**: Primary translation interface with provider/model selection, API key pool management (up to 10 keys), custom context input, target language dropdown, batch processing controls (Start/Pause/Stop), and live logging output
46
+ - **Translation Memory Tab**: Visual cache manager with:
47
+ - Real-time search across original and translated text with 300ms debounce
48
+ - Modpack combobox filter for isolating translations by modpack
49
+ - Target language filter synchronized with Workspace tab
50
+ - 4-column QTableWidget (Original, Translation, Modpack, Added) with auto-stretch column width layout
51
+ - Inline editing of translations directly in the table
52
+ - Bulk operations: Load More (pagination), Delete Selected, Save Changes, Clear Cache
53
+ - **Settings Tab**: Configuration management for providers, models, API keys, and application settings
54
+ - **Credits Tab**: Displays project credits and acknowledgments
55
+
56
+ ### Asynchronous Processing
57
+ - Non-blocking I/O with `asyncio` and `httpx.AsyncClient`
58
+ - Configurable concurrency (1-10 threads)
59
+ - Adaptive chunking:
60
+ - 5 items per chunk for Ollama (local inference)
61
+ - Configurable batch size (default: 50) for cloud providers
62
+ - Automatic delays between chunks to prevent rate limiting
63
+
64
+ ### Fault Tolerance
65
+ - Round-Robin multi-key pool with automatic load balancing
66
+ - Invalid keys (HTTP 401/403) are permanently removed from the pool
67
+ - Rate-limited keys (HTTP 429) enter exponential backoff (0.2s to 64s)
68
+ - Fallback to single-item translation on chunk failure
69
+ - Mixed Provider Mode: Auto-detects provider from key prefix and uses saved defaults
70
+ - Binary split fallback for parse errors with recursive chunk splitting
71
+
72
+ ### Smart Caching System
73
+ - SQLite-based cache with WAL mode for concurrent access
74
+ - Per-language tables (`cache_{lang_code}`) to prevent cross-contamination
75
+ - Modpack isolation: Translations can be filtered and managed by modpack
76
+ - Thread-safe operations with locking
77
+ - Real-time persistence between chunks
78
+ - Defensive sanitization: Automatic cleanup of nested dictionaries in SNBT and protection against malformed JSON in responses
79
+
80
+ ### 🛡️ Pluralization Guard
81
+ **Prevents redundant API calls for highly similar strings with a 90% similarity threshold**
82
+
83
+ The Pluralization Guard is a sophisticated fuzzy-matching system that automatically detects and reuses translations for similar phrases, preventing duplicate API calls and saving costs. It works by:
84
+
85
+ 1. **Exact Match Check**: First attempts to find an exact match in the cache
86
+ 2. **Length Filtering**: Searches for candidates within ±15% length of the input text
87
+ 3. **Number Preservation**: Ensures strings with different numbers (e.g., "item 1" vs "item 2") are NOT matched
88
+ 4. **Fuzzy Matching**: Uses `difflib.SequenceMatcher` with a **90% similarity threshold**
89
+ 5. **Tail Preservation**: Maintains Minecraft formatting codes at the end of strings (e.g., "hello§a" → cached "привет" + "§a")
90
+
91
+ **Example:**
92
+ - Input: `"Get 5 diamonds"` → Cache miss
93
+ - Input: `"Get 6 diamonds"` → **Cache hit!** (90%+ similarity, same structure)
94
+ - Input: `"Get diamond"` → Cache miss (different number count)
95
+ - Input: `"Get 5 diamonds§a"` → **Cache hit!** (matches "Get 5 diamonds" + preserves "§a")
96
+
97
+ This prevents charging for translations of:
98
+ - Plural variations: "apple" → "apples"
99
+ - Number variations: "item 1" → "item 2" (if structure matches)
100
+ - Minor formatting differences: "text" → "text§a"
101
+
102
+ ### Minecraft Formatting Protection
103
+ - Regex shielding for namespace tags (`#c:ender_pearl_dusts`), UUIDs, and entity IDs
104
+ - Temporary placeholder substitution (`__TAG_N__`) during translation
105
+ - Preserves all Minecraft-specific formatting codes
106
+
107
+ ## Supported Providers
108
+
109
+ | Provider | Default Model | Free Tier | Notes |
110
+ |----------|----------------|-----------|-------|
111
+ | Groq Cloud (Fast) | llama-3.3-70b-versatile | Yes | Low-latency inference |
112
+ | NVIDIA NIM | nvidia/nemotron-4-340b-instruct | Yes | Enterprise-grade models |
113
+ | OpenRouter (Cloud AI) | google/gemma-4-31b:free | Yes | 100+ free models |
114
+ | Google Gemini (Free API) | models/gemini-3.1-flash-lite | Yes | Google's latest free model |
115
+ | Sambanova | DeepSeek-V3.1 | No | High-performance inference |
116
+ | OpenAI | gpt-4o-mini | No | Optimized for speed |
117
+ | Mistral AI | mistral-large-latest | Yes | Open-source frontier models |
118
+ | Anthropic (Claude) | claude-3-5-sonnet-20241022 | No | High-quality responses |
119
+ | Cohere | command-r-plus | No | Production-ready |
120
+ | Ollama (Local / Free) | qwen2.5:7b | Yes | Self-hosted, no API key |
121
+ | Google Translate (Free) | N/A | Yes | Traditional MT, no API key |
122
+ | Local LLM / Custom | (Custom) | Yes | Custom local models |
123
+
124
+ ## Feedback & Community
125
+ For feedback, suggestions, and bug reports, please contact us via:
126
+ - **Telegram**: [https://t.me/ronnikols](https://t.me/ronnikols)
127
+
128
+ ## License
129
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,366 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List, Dict, Optional
7
+ from core import is_valid_custom_instance, PROVIDER_DEFAULTS
8
+
9
+ try:
10
+ from PyQt6.QtCore import QSettings
11
+ except ImportError:
12
+ QSettings = None
13
+
14
+ PROVIDER_ALIASES = {
15
+ "google": "Google Translate (Free)",
16
+ "google_free": "Google Translate (Free)",
17
+ "gemini": "Google Gemini (Free API)",
18
+ "ollama": "Ollama (Local / Free)",
19
+ "groq": "Groq Cloud (Fast)",
20
+ "openrouter": "OpenRouter (Cloud AI)",
21
+ "nvidia": "NVIDIA NIM",
22
+ "nim": "NVIDIA NIM",
23
+ "sambanova": "Sambanova",
24
+ "mixed": "Mixed Providers",
25
+ "mix": "Mixed Providers",
26
+ "openai": "OpenAI",
27
+ "mistral": "Mistral AI",
28
+ "anthropic": "Anthropic (Claude)",
29
+ "claude": "Anthropic (Claude)",
30
+ "cohere": "Cohere",
31
+ "local": "Local LLM / Custom",
32
+ "custom": "Local LLM / Custom",
33
+ }
34
+
35
+ PROVIDER_ENDPOINTS = {
36
+ "Google Gemini (Free API)": "https://generativelanguage.googleapis.com/v1beta/openai/models",
37
+ "Groq Cloud (Fast)": "https://api.groq.com/openai/v1/models",
38
+ "OpenRouter (Cloud AI)": "https://openrouter.ai/api/v1/models",
39
+ "NVIDIA NIM": "https://integrate.api.nvidia.com/v1/models",
40
+ "Ollama (Local / Free)": "http://localhost:11434/v1/models",
41
+ "Sambanova": "https://api.sambanova.ai/v1/models",
42
+ "OpenAI": "https://api.openai.com/v1/models",
43
+ "Mistral AI": "https://api.mistral.ai/v1/models",
44
+ "Anthropic (Claude)": "https://api.anthropic.com/v1/messages",
45
+ "Cohere": "https://api.cohere.ai/v1/chat",
46
+ "Local LLM / Custom": "",
47
+ }
48
+
49
+ LANG_ALIASES = {
50
+ "ru": ("Russian", "ru_ru"),
51
+ "en": ("English", "en_us"),
52
+ "es": ("Spanish", "es_es"),
53
+ "zh": ("Chinese Simplified", "zh_cn"),
54
+ "zh-cn": ("Chinese Simplified", "zh_cn"),
55
+ "zh-tw": ("Chinese Traditional", "zh_tw"),
56
+ "de": ("German", "de_de"),
57
+ "fr": ("French", "fr_fr"),
58
+ "pt": ("Portuguese", "pt_br"),
59
+ "pt-br": ("Portuguese", "pt_br"),
60
+ "ja": ("Japanese", "ja_jp"),
61
+ "ko": ("Korean", "ko_kr"),
62
+ }
63
+
64
+ ENV_KEY_MAP = {
65
+ "Google Gemini (Free API)": "GEMINI_API_KEY",
66
+ "Groq Cloud (Fast)": "GROQ_API_KEY",
67
+ "OpenRouter (Cloud AI)": "OPENROUTER_API_KEY",
68
+ "NVIDIA NIM": "NVIDIA_API_KEY",
69
+ "Sambanova": "SAMBANOVA_API_KEY",
70
+ "OpenAI": "OPENAI_API_KEY",
71
+ "Mistral AI": "MISTRAL_API_KEY",
72
+ "Anthropic (Claude)": "ANTHROPIC_API_KEY",
73
+ "Cohere": "COHERE_API_KEY",
74
+ "Local LLM / Custom": "",
75
+ }
76
+
77
+ SETTINGS_KEY_MAP = {
78
+ "Google Gemini (Free API)": "gemini_api_key",
79
+ "Groq Cloud (Fast)": "groq_api_key",
80
+ "OpenRouter (Cloud AI)": "openrouter_api_key",
81
+ "NVIDIA NIM": "nvidia_api_key",
82
+ "Sambanova": "sambanova_api_key",
83
+ "OpenAI": "openai_api_key",
84
+ "Mistral AI": "mistral_api_key",
85
+ "Anthropic (Claude)": "anthropic_api_key",
86
+ "Cohere": "cohere_api_key",
87
+ "Local LLM / Custom": "",
88
+ }
89
+
90
+ class ConfigManager:
91
+ AVAILABLE_PROVIDERS = [
92
+ "Groq Cloud (Fast)",
93
+ "NVIDIA NIM",
94
+ "OpenRouter (Cloud AI)",
95
+ "Google Gemini (Free API)",
96
+ "Sambanova",
97
+ "OpenAI",
98
+ "Mistral AI",
99
+ "Anthropic (Claude)",
100
+ "Cohere",
101
+ "Google Translate (Free)",
102
+ "Ollama (Local / Free)",
103
+ "Local LLM / Custom"
104
+ ]
105
+
106
+ def __init__(self):
107
+ self.quest_dir: Optional[Path] = None
108
+ self.target_lang: str = "ru_ru"
109
+ self.concurrency: int = 2
110
+ self.provider: str = "Google Translate (Free)"
111
+ self.model: Optional[str] = None
112
+ self.custom_context: str = ""
113
+ self.policy: str = "Complement (Дополнить)"
114
+ self.resource_pack_mode: bool = False
115
+ self.api_keys_pool: Dict[str, List[str]] = {prov: [] for prov in PROVIDER_ALIASES.values()}
116
+ self.custom_instances_paths: List[str] = []
117
+ self.batch_size: int = 50
118
+ self.min_batch_size: int = 1
119
+ self.max_concurrent_requests: int = 10
120
+
121
+ self.load_from_settings()
122
+
123
+ def _settings(self):
124
+ if QSettings is None:
125
+ return None
126
+ return QSettings("MineAI", "SNBT-Localizer")
127
+
128
+ def load_from_settings(self):
129
+ s = self._settings()
130
+ if s is None:
131
+ return
132
+
133
+ self.provider = s.value("provider", self.provider)
134
+ self.model = s.value("model", self.model)
135
+ if self.provider and self.provider != "Mixed Providers":
136
+ provider_model = s.value(f"model_{self.provider}", "")
137
+ if provider_model:
138
+ self.model = provider_model
139
+ self.target_lang = s.value("target_lang", self.target_lang)
140
+ self.concurrency = int(s.value("concurrency_limit", self.concurrency))
141
+ self.custom_context = s.value("custom_context", self.custom_context)
142
+ self.policy = s.value("policy", self.policy)
143
+
144
+ raw = s.value("custom_instances_paths", "")
145
+ if raw:
146
+ self.custom_instances_paths = [p.strip() for p in str(raw).splitlines() if p.strip()]
147
+
148
+ self.batch_size = int(s.value("batch_size", 50))
149
+ self.min_batch_size = int(s.value("min_batch_size", 1))
150
+ self.max_concurrent_requests = int(s.value("max_concurrent_requests", 10))
151
+ self.resource_pack_mode = s.value("resource_pack_mode", self.resource_pack_mode)
152
+ if isinstance(self.resource_pack_mode, str):
153
+ self.resource_pack_mode = self.resource_pack_mode.lower() == "true"
154
+ quest_dir_value = s.value("quest_dir", "")
155
+ self.quest_dir = Path(quest_dir_value) if quest_dir_value else None
156
+
157
+ for prov in PROVIDER_ALIASES.values():
158
+ raw = s.value(f"api_keys_pool_{prov}", "")
159
+ if raw:
160
+ lines = [line.strip() for line in str(raw).splitlines() if line.strip()]
161
+ seen = set()
162
+ unique = []
163
+ for k in lines:
164
+ if k not in seen:
165
+ seen.add(k)
166
+ unique.append(k)
167
+ self.api_keys_pool[prov] = unique[:10]
168
+ if prov not in self.api_keys_pool:
169
+ self.api_keys_pool[prov] = []
170
+
171
+ def save_to_settings(self):
172
+ s = self._settings()
173
+ if s is None:
174
+ return
175
+
176
+ s.setValue("provider", self.provider)
177
+ s.setValue("model", self.model or "")
178
+ if self.provider and self.provider != "Mixed Providers":
179
+ s.setValue(f"model_{self.provider}", self.model or "")
180
+ s.setValue("target_lang", self.target_lang)
181
+ s.setValue("concurrency_limit", self.concurrency)
182
+ s.setValue("custom_context", self.custom_context)
183
+ s.setValue("policy", self.policy)
184
+ s.setValue("custom_instances_paths", "\n".join(self.custom_instances_paths))
185
+
186
+ for prov, keys in self.api_keys_pool.items():
187
+ s.setValue(f"api_keys_pool_{prov}", "\n".join(keys))
188
+
189
+ s.setValue("batch_size", self.batch_size)
190
+ s.setValue("min_batch_size", self.min_batch_size)
191
+ s.setValue("max_concurrent_requests", self.max_concurrent_requests)
192
+ s.setValue("resource_pack_mode", self.resource_pack_mode)
193
+ s.setValue("quest_dir", str(self.quest_dir) if self.quest_dir else "")
194
+ s.sync()
195
+
196
+ def parse_cli_args(self, args: Optional[List[str]] = None):
197
+ parser = argparse.ArgumentParser(prog='snbt-tr', add_help=False)
198
+ parser.add_argument("-h", "--help", action="help", help="Show this help message and exit")
199
+ parser.add_argument("-p", "--provider", help="Provider alias (google, gemini, groq, openrouter, nvidia, nim, sambanova)")
200
+ parser.add_argument("-m", "--model", help="Model name (default: provider default)")
201
+ parser.add_argument("-k", "--key", help="API key(s), comma-separated (env/QSettings fallback)")
202
+ parser.add_argument("-l", "--lang", default="ru", help="Language code (default: ru)")
203
+ parser.add_argument("-d", "--dir", default=".", help="Path to quests directory (default: .)")
204
+ parser.add_argument("-c", "--context", default="", help="Custom translation context")
205
+ parser.add_argument("--policy", default="complement", choices=["complement", "overwrite", "skip"],
206
+ help="Existing files policy: complement, overwrite, skip (default: complement)")
207
+ parser.add_argument("--concurrency", type=int, default=2,
208
+ help="Number of parallel translation threads (1-10, default: 3)")
209
+ parser.add_argument("--list-models", action="store_true", help="List available models for provider and exit")
210
+ parser.add_argument("--fastdir", "--fd", action="store_true", help="Scan launcher paths for instances and exit")
211
+ parser.add_argument("--clear-cache", "--clear", action="store_true", help="Clear translation cache and exit")
212
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging to console")
213
+ parser.add_argument('--mix', action='store_true', help='Enable Mixed Provider mode using QSettings key pool')
214
+ parser.add_argument('--gui', action='store_true', help='Launch Graphical User Interface (GUI)')
215
+ parser.add_argument("--batch-size", type=int, default=50,
216
+ help="Batch size for translation (default: 50)")
217
+ parser.add_argument("--min-batch-size", type=int, default=1,
218
+ help="Minimum batch size before failing (default: 1)")
219
+ parser.add_argument("--max-concurrent-requests", type=int, default=10,
220
+ help="Max concurrent API requests (default: 10)")
221
+ parser.add_argument("--resource-pack", "-r", action="store_true", help="Enable Resource Pack Mode for JSON translation")
222
+
223
+ parsed = parser.parse_args(args)
224
+
225
+ if parsed.provider:
226
+ alias = parsed.provider.lower()
227
+ if alias in PROVIDER_ALIASES:
228
+ self.provider = PROVIDER_ALIASES[alias]
229
+ else:
230
+ logging.getLogger("snbt_localizer.cli").error(f"Unknown provider alias: {parsed.provider}")
231
+ self.provider = "Google Translate (Free)"
232
+ sys.exit(1)
233
+
234
+ if parsed.mix:
235
+ self.provider = "Mixed Providers"
236
+
237
+ if parsed.model:
238
+ self.model = parsed.model
239
+
240
+ if parsed.lang:
241
+ lang_input = parsed.lang.lower()
242
+ if lang_input in LANG_ALIASES:
243
+ self.target_lang = LANG_ALIASES[lang_input][1]
244
+ else:
245
+ for alias, (name, code) in LANG_ALIASES.items():
246
+ if lang_input == code or lang_input == name.lower():
247
+ self.target_lang = code
248
+ break
249
+ else:
250
+ logging.getLogger("snbt_localizer.cli").error(f"Unknown language: {parsed.lang}")
251
+ sys.exit(1)
252
+
253
+ if parsed.dir:
254
+ self.quest_dir = Path(parsed.dir)
255
+
256
+ if parsed.context:
257
+ self.custom_context = parsed.context
258
+
259
+ if parsed.policy:
260
+ mapping = {
261
+ "complement": "Complement (Дополнить)",
262
+ "overwrite": "Overwrite (Перезаписать)",
263
+ "skip": "Skip (Пропустить)",
264
+ }
265
+ self.policy = mapping.get(parsed.policy, "Complement (Дополнить)")
266
+
267
+ if parsed.concurrency:
268
+ self.concurrency = max(1, min(parsed.concurrency, 10))
269
+
270
+ if parsed.batch_size:
271
+ self.batch_size = max(1, parsed.batch_size)
272
+ if parsed.min_batch_size:
273
+ self.min_batch_size = max(1, parsed.min_batch_size)
274
+ if parsed.max_concurrent_requests:
275
+ self.max_concurrent_requests = max(1, parsed.max_concurrent_requests)
276
+
277
+
278
+ if parsed.key:
279
+ keys = [k.strip() for k in parsed.key.split(",") if k.strip()]
280
+ seen = set()
281
+ unique = []
282
+ for k in keys:
283
+ if k not in seen:
284
+ seen.add(k)
285
+ unique.append(k)
286
+ self.api_keys_pool[self.provider] = unique[:10]
287
+
288
+ return parsed
289
+
290
+ def get_api_keys(self, provider: Optional[str] = None) -> List[str]:
291
+ prov = provider or self.provider
292
+ return self.api_keys_pool.get(prov, [])
293
+
294
+ def set_api_keys(self, provider: str, keys: List[str]):
295
+ seen = set()
296
+ unique = []
297
+ for k in keys:
298
+ k = k.strip()
299
+ if k and k not in seen:
300
+ seen.add(k)
301
+ unique.append(k)
302
+ self.api_keys_pool[provider] = unique[:10]
303
+
304
+ def smart_parse_key(self, entry: str) -> tuple[str, str]:
305
+ entry = entry.strip()
306
+ if "\\" in entry:
307
+ parts = entry.split("\\", 1)
308
+ key = parts[0].strip()
309
+ model = parts[1].strip() if parts[1].strip() else ""
310
+ if model:
311
+ return key, model
312
+ # Trailing backslash with no model - use key part for prefix detection
313
+ entry = key
314
+
315
+ if entry.startswith("gsk_"):
316
+ return entry, "llama-3.3-70b-versatile"
317
+ if entry.startswith("nvapi-"):
318
+ return entry, "nvidia/nemotron-3-ultra"
319
+ if entry.startswith("sk-or-"):
320
+ return entry, "meta-llama/llama-3.3-70b-instruct:free"
321
+
322
+ raise ValueError(f"Unknown key format or provider prefix: {entry[:10]}...")
323
+
324
+ def get_all_available_keys(self) -> list[tuple[str, str, str]]:
325
+ result = []
326
+ for provider in self.AVAILABLE_PROVIDERS:
327
+ keys = self.get_api_keys(provider)
328
+ if not keys:
329
+ continue
330
+
331
+ saved_model = ""
332
+ if QSettings is not None:
333
+ settings = QSettings("MineAI", "SNBT-Localizer")
334
+ saved_model = settings.value(f"model_{provider}", "") or ""
335
+
336
+ default_model = PROVIDER_DEFAULTS.get(provider, "")
337
+ model = saved_model if saved_model else default_model
338
+
339
+ for key in keys:
340
+ result.append((provider, key, model))
341
+
342
+ return result
343
+
344
+ def resolve_language(self, lang_input: str) -> tuple:
345
+ lang_input = lang_input.lower()
346
+ if lang_input in LANG_ALIASES:
347
+ return LANG_ALIASES[lang_input]
348
+ for alias, (name, code) in LANG_ALIASES.items():
349
+ if lang_input == code or lang_input == name.lower():
350
+ return (name, code)
351
+ raise ValueError(f"Unknown language: {lang_input}")
352
+
353
+ def add_custom_path(self, path: str):
354
+ path = str(Path(path).resolve())
355
+ if path not in self.custom_instances_paths:
356
+ self.custom_instances_paths.append(path)
357
+ self.save_to_settings()
358
+
359
+ def prune_invalid_paths(self):
360
+ valid_paths = []
361
+ for p in self.custom_instances_paths:
362
+ if is_valid_custom_instance(Path(p)):
363
+ valid_paths.append(p)
364
+ if valid_paths != self.custom_instances_paths:
365
+ self.custom_instances_paths = valid_paths
366
+ self.save_to_settings()