migrate to gitea

This commit is contained in:
jonasgaudian
2026-02-14 18:12:28 +01:00
commit 7c17f0f0cf
21 changed files with 2037 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Virtual environments
venv/
ENV/
env/
.venv
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

22
__init__.py Normal file
View File

@@ -0,0 +1,22 @@
"""
Android XML Translation Tool
A tool for translating Android XML values files using local LLM
"""
from .config import Config
from .models import TranslationItem, TranslationBatch
from .llm_client import LLMClient
from .xml_processor import XMLProcessor
from .ui import UI
from .translation_tool import TranslationTool
__version__ = "1.0.0"
__all__ = [
'Config',
'TranslationItem',
'TranslationBatch',
'LLMClient',
'XMLProcessor',
'UI',
'TranslationTool'
]

73
config.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Configuration management for Android XML Translation Tool
"""
import os
import sys
import yaml
from pathlib import Path
from typing import Dict, List, Optional, Any
class Config:
"""Configuration loader and validator"""
def __init__(self, config_path: str = "config.yaml"):
self.config_path = config_path
self.data = self._load_config()
if self.data is not None: # Only validate if data was loaded
self._validate_config()
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from YAML file"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
except FileNotFoundError:
print(f"Configuration file {self.config_path} not found!")
sys.exit(1)
except yaml.YAMLError as e:
print(f"Error parsing configuration file: {e}")
sys.exit(1)
def _validate_config(self):
"""Validate required configuration fields"""
if self.data is None:
print(f"Configuration file {self.config_path} is empty or invalid!")
sys.exit(1)
required_sections = ['llm', 'android', 'translation']
for section in required_sections:
if section not in self.data:
print(f"Missing required section: {section}")
sys.exit(1)
@property
def llm_config(self) -> Dict[str, Any]:
return self.data['llm']
@property
def android_config(self) -> Dict[str, Any]:
return self.data['android']
@property
def translation_config(self) -> Dict[str, Any]:
return self.data['translation']
@property
def output_config(self) -> Dict[str, Any]:
return self.data.get('output', {})
@property
def examples_config(self) -> Dict[str, Any]:
return self.data.get('examples', {})
def has_examples_config(self) -> bool:
"""Check if examples configuration is present and valid"""
examples = self.data.get('examples', {})
return bool(
examples and
examples.get('input_folder') and
examples.get('base_folder') and
examples.get('target_folders')
)

62
config.yaml Normal file
View File

@@ -0,0 +1,62 @@
# Android XML Translation Tool Configuration
# LLM Configuration (LM Studio)
llm:
#base_url: "http://169.254.123.98:45612/v1" # LM Studio server URL
base_url: "https://api.deepseek.com/v1" # DeepSeek API base URL (should end in /v1)
api_key: "sk-2f2e6ad638e849ee827feabc0fde0dda" # Your DeepSeek API key
model: "deepseek-chat" # DeepSeek model name
timeout: 30 # Request timeout in seconds
max_retries: 3 # Maximum retry attempts
# Android Project Configuration
android:
input_folder: "C:/dev/Polly/app/src/main" # Path to Android resources folder
base_values_folder: "res/values" # Base language folder (usually English)
# Target language folders to translate to
target_folders:
- "res/values-de-rDE" # German (Germany)
- "res/values-pt-rBR" # Portuguese (Brazil)
#- "res/values-es" # Spanish
#- "res/values-fr" # French
# XML files to translate (relative to values folders)
files_to_translate:
- "strings.xml"
#- "arrays.xml"
#- "intro_strings.xml"
# Examples Folder Configuration (for .md file translation)
examples:
input_folder: "C:/dev/Polly/app/src/main" # Path to examples folder
base_folder: "assets/hints" # Base folder with .md files (English)
# Target language folders to translate to (relative to examples/input_folder)
target_folders:
- "assets/hints-de-rDE" # German (Germany)
- "assets/hints-pt-rBR" # Portuguese (Brazil)
# File extension to translate
file_extension: ".md"
# Translation Configuration
translation:
batch_size: 3 # Number of strings to translate in one batch
interactive_approval: true # Ask for approval before adding translations
# Language-specific translation instructions
language_instructions:
"values-de-rDE": "Translate to German (Germany). Use informal 'Du' and do NOT use 'Sie' for 'you'. Keep technical terms in English if commonly used."
"values-pt-rBR": "Translate to Portuguese (Brazil). Use informal Brazilian Portuguese. Try to keep the translation natural and short. Use idioma to translate the word language."
"values-es": "Translate to Spanish. Use informal 'tú' form. Adapt cultural references for Spanish-speaking audiences."
"values-fr": "Translate to French. Use informal 'tu' form. Keep technical terms in English if commonly used in French tech context."
"assets/hints-de-rDE": "Translate to German (Germany). Use informal 'du' form. This is for hint/help content."
"assets/hints-pt-rBR": "Translate to Portuguese (Brazil). Use informal Brazilian Portuguese. This is for hint/help content."
# Output Configuration
output:
create_backups: false # Create backup files before modifying
backup_suffix: ".backup" # Suffix for backup files
preserve_formatting: true # Preserve original XML formatting and comments
log_level: "INFO" # DEBUG, INFO, WARNING, ERROR

62
debug_llm.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Debug script to test LLM connection
"""
import openai
from config import Config
def test_llm_connection():
"""Test basic LLM connection"""
config = Config()
client = openai.OpenAI(
base_url=config.llm_config['base_url'],
api_key=config.llm_config.get('api_key', 'not-needed')
)
try:
print(f"Testing connection to {config.llm_config['base_url']}")
print(f"Using model: {config.llm_config['model']}")
response = client.chat.completions.create(
model=config.llm_config['model'],
messages=[
{"role": "system", "content": "You are a helpful translator."},
{"role": "user", "content": "Translate 'Hello' to German. Respond with just the translation."}
],
temperature=0.3,
timeout=30
)
print(f"Response type: {type(response)}")
print(f"Response: {response}")
if hasattr(response, 'choices') and response.choices:
choice = response.choices[0]
print(f"Choice type: {type(choice)}")
print(f"Choice: {choice}")
if hasattr(choice, 'message'):
message = choice.message
print(f"Message type: {type(message)}")
print(f"Message: {message}")
if hasattr(message, 'content'):
content = message.content
print(f"Content type: {type(content)}")
print(f"Content: {content}")
else:
print("No content attribute in message")
else:
print("No message attribute in choice")
else:
print("No choices in response")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_llm_connection()

79
debug_translation.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Debug script to test translation with actual prompt
"""
import openai
from config import Config
from models import TranslationBatch, TranslationItem
def test_translation():
"""Test translation with actual prompt"""
config = Config()
client = openai.OpenAI(
base_url=config.llm_config['base_url'],
api_key=config.llm_config.get('api_key', 'not-needed')
)
# Create a test batch
items = [
TranslationItem(name="app_name", value="Test App"),
TranslationItem(name="welcome", value="Welcome to our app!")
]
batch = TranslationBatch(
items=items,
target_language="values-de-rDE",
target_file="strings.xml"
)
# Build the actual prompt
instruction = "Translate to German (Germany). Use informal 'du' form for user-facing text. Keep technical terms in English if commonly used."
prompt = f"Translate the following Android strings to {batch.target_language}.\n\n"
prompt += f"Instructions: {instruction}\n\n"
prompt += "Format your response as a JSON array with the same order as input:\n"
prompt += "[\"translation1\", \"translation2\", ...]\n\n"
prompt += "Strings to translate:\n"
for i, item in enumerate(batch.items):
prompt += f"{i + 1}. {item.value}\n"
print("=== PROMPT ===")
print(prompt)
print("=== END PROMPT ===\n")
try:
response = client.chat.completions.create(
model=config.llm_config['model'],
messages=[
{"role": "system", "content": instruction},
{"role": "user", "content": prompt}
],
temperature=0.3,
timeout=30
)
print(f"Response type: {type(response)}")
if hasattr(response, 'choices') and response.choices:
choice = response.choices[0]
if hasattr(choice, 'message'):
content = choice.message.content
print(f"Content type: {type(content)}")
print(f"Content: {repr(content)}")
if content is None:
print("CONTENT IS NONE!")
else:
print(f"Content length: {len(content)}")
print(f"Content stripped: '{content.strip()}'")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_translation()

144
llm_client.py Normal file
View File

@@ -0,0 +1,144 @@
"""
OpenAI-compatible LLM client for LM Studio integration
"""
import json
import openai
from typing import List
from models import TranslationBatch
from config import Config
class LLMClient:
"""OpenAI-compatible LLM client for LM Studio"""
def __init__(self, config: Config):
self.config = config
self.client = openai.OpenAI(
base_url=config.llm_config['base_url'],
api_key=config.llm_config.get('api_key', 'not-needed')
)
self.model = config.llm_config['model']
self.timeout = config.llm_config.get('timeout', 30)
self.max_retries = config.llm_config.get('max_retries', 3)
def translate_batch(self, batch: TranslationBatch, instruction: str) -> List[str]:
"""Translate a batch of strings"""
# Prepare the prompt
prompt = self._build_translation_prompt(batch, instruction)
# Build system message with strong emphasis on instructions
system_message = (
"You are a professional translator. "
"You must follow the translation instructions EXACTLY as provided. "
"Pay special attention to formality level, tone, and any specific requirements mentioned. "
"Your response must be a valid JSON array."
)
for attempt in range(self.max_retries):
try:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_message},
{"role": "user", "content": prompt}
],
temperature=0.3,
timeout=self.timeout
)
# Parse the response
if not response or not response.choices:
print(f"Empty response from LLM")
continue
choice = response.choices[0]
if not hasattr(choice, 'message') or not choice.message:
print(f"Invalid response structure: {response}")
continue
content = choice.message.content
if content is None:
print(f"Empty content in response: {response}")
continue
translations = self._parse_translation_response(content)
return translations
except Exception as e:
print(f"Translation attempt {attempt + 1} failed: {e}")
if attempt == self.max_retries - 1:
print(f"All translation attempts failed for batch")
return []
return []
def _build_translation_prompt(self, batch: TranslationBatch, instruction: str) -> str:
"""Build translation prompt for the batch"""
prompt = "=== TRANSLATION TASK ===\n\n"
prompt += f"CRITICAL INSTRUCTIONS (you MUST follow these):\n{instruction}\n\n"
prompt += "IMPORTANT: Pay special attention to the tone and formality level specified above. "
prompt += "This is crucial for the quality of the translation.\n\n"
# Check if we have any string arrays in the batch
has_arrays = any(item.item_type == 'string-array' for item in batch.items)
if has_arrays:
prompt += "Note: Some items are string arrays (multiple items separated by ' | '). "
prompt += "For string arrays, keep the same number of items and use ' | ' as separator.\n\n"
prompt += "=== CONTENT TO TRANSLATE ===\n"
prompt += f"Target language: {batch.target_language}\n\n"
for i, item in enumerate(batch.items):
if item.item_type == 'string-array':
prompt += f"{i + 1}. [ARRAY] {item.name}: {item.value}\n"
else:
prompt += f"{i + 1}. {item.value}\n"
prompt += "\n=== RESPONSE FORMAT ===\n"
prompt += "Return ONLY a JSON array with the translations in the same order:\n"
prompt += '["translation1", "translation2", ...]\n\n'
prompt += "=== REMINDER ===\n"
prompt += f"Remember to follow these instructions: {instruction}\n"
return prompt
def _parse_translation_response(self, response: str) -> List[str]:
"""Parse translation response from LLM"""
if not response or not response.strip():
print(f"Empty response to parse: {response}")
return []
try:
# Try to parse as JSON array
if response.strip().startswith('['):
return json.loads(response.strip())
# Try to extract JSON from response
start = response.find('[')
end = response.rfind(']') + 1
if start != -1 and end != 0:
return json.loads(response[start:end])
# Fallback: split by lines and clean up
lines = response.strip().split('\n')
translations = []
for line in lines:
line = line.strip()
# Remove numbering and quotes
if line and not line.startswith('[') and not line.startswith(']'):
# Remove numbers, quotes, and extra formatting
clean_line = line
if '. ' in clean_line:
clean_line = clean_line.split('. ', 1)[1]
clean_line = clean_line.strip('"\'')
if clean_line:
translations.append(clean_line)
return translations
except Exception as e:
print(f"Error parsing translation response: {e}")
print(f"Raw response: {response}")
return []

33
main.py Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
Android XML Translation Tool
Translates Android values XML files using local LLM (LM Studio compatible)
"""
import sys
from translation_tool import TranslationTool
def main():
"""Main entry point"""
if len(sys.argv) > 1:
config_path = sys.argv[1]
else:
config_path = "config.yaml"
try:
tool = TranslationTool(config_path)
tool.run()
except KeyboardInterrupt:
from ui import UI
ui = UI()
ui.show_interrupted()
except Exception as e:
from ui import UI
ui = UI()
ui.show_error(str(e))
sys.exit(1)
if __name__ == "__main__":
main()

136
md_processor.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Markdown file processor for translation
"""
import os
from typing import Dict, List, Tuple
from dataclasses import dataclass, field
@dataclass
class MDTranslationItem:
"""Represents a single markdown file translation item"""
filename: str
content: str
relative_path: str = "" # For subdirectories if needed
class MDProcessor:
"""Markdown file processor for translation"""
def __init__(self, file_extension: str = ".md"):
self.file_extension = file_extension
def get_md_files(self, folder_path: str) -> List[str]:
"""
Get all .md files in the specified folder (non-recursive).
Returns a sorted list of filenames.
"""
if not os.path.exists(folder_path):
return []
md_files = []
for item in os.listdir(folder_path):
item_path = os.path.join(folder_path, item)
if os.path.isfile(item_path) and item.endswith(self.file_extension):
md_files.append(item)
return sorted(md_files)
def load_md_file(self, file_path: str) -> str:
"""Load and return the content of a markdown file"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
print(f"Error loading markdown file {file_path}: {e}")
return ""
def save_md_file(self, content: str, file_path: str):
"""Save content to a markdown file"""
try:
# Create parent directory if it doesn't exist
parent_dir = os.path.dirname(file_path)
if parent_dir and not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
except Exception as e:
print(f"Error saving markdown file {file_path}: {e}")
def extract_content(self, folder_path: str) -> Dict[str, MDTranslationItem]:
"""
Extract content from all .md files in the folder.
Returns a dict mapping filename to MDTranslationItem.
"""
items = {}
md_files = self.get_md_files(folder_path)
for filename in md_files:
file_path = os.path.join(folder_path, filename)
content = self.load_md_file(file_path)
if content: # Only add if we successfully loaded content
items[filename] = MDTranslationItem(
filename=filename,
content=content
)
return items
def check_asset_counts(self, base_folder: str, target_folders: List[str]) -> Tuple[bool, List[str]]:
"""
Check that all target folders have the same number of .md assets as the base folder.
Args:
base_folder: Path to the base folder (source of truth)
target_folders: List of paths to target folders
Returns:
Tuple of (is_valid, list of error messages)
"""
errors = []
# Get base folder count
base_files = self.get_md_files(base_folder)
base_count = len(base_files)
if base_count == 0:
errors.append(f"Base folder {base_folder} contains no .md files")
return False, errors
# Check each target folder
for target_folder in target_folders:
target_files = self.get_md_files(target_folder)
target_count = len(target_files)
if target_count != base_count:
# Find missing/extra files
base_set = set(base_files)
target_set = set(target_files)
missing_in_target = base_set - target_set
extra_in_target = target_set - base_set
error_msg = f"Asset count mismatch in {target_folder}: expected {base_count}, found {target_count}"
if missing_in_target:
error_msg += f"\n Missing files: {', '.join(sorted(missing_in_target))}"
if extra_in_target:
error_msg += f"\n Extra files: {', '.join(sorted(extra_in_target))}"
errors.append(error_msg)
return len(errors) == 0, errors
def find_missing_files(self, base_folder: str, target_folder: str) -> List[str]:
"""
Find .md files that exist in base folder but not in target folder.
Returns:
List of filenames that need to be translated
"""
base_files = set(self.get_md_files(base_folder))
target_files = set(self.get_md_files(target_folder))
return sorted(list(base_files - target_files))

24
models.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Data models for Android XML Translation Tool
"""
from dataclasses import dataclass, field
from typing import Optional, List
@dataclass
class TranslationItem:
"""Represents a single translation item (string or string-array)"""
name: str
value: str
comment: Optional[str] = None
item_type: str = "string" # "string" or "string-array"
items: List[str] = field(default_factory=list) # For string-array items
@dataclass
class TranslationBatch:
"""Represents a batch of translations to process"""
items: list[TranslationItem]
target_language: str
target_file: str

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
requests>=2.31.0
pyyaml>=6.0.1
lxml>=4.9.3
openai>=1.3.0
colorama>=0.4.6
rich>=13.6.0

29
run_tests.py Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""
Test runner for Android XML Translation Tool
"""
import unittest
import sys
import os
# Add the project root to Python path
project_root = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, project_root)
def run_tests():
"""Discover and run all tests"""
# Discover tests in the tests directory
loader = unittest.TestLoader()
start_dir = os.path.join(project_root, 'tests')
suite = loader.discover(start_dir, pattern='test_*.py')
# Run the tests
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# Return exit code based on test results
return 0 if result.wasSuccessful() else 1
if __name__ == '__main__':
sys.exit(run_tests())

61
test_xml_format.py Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Test XML formatting
"""
import tempfile
import os
from lxml import etree
from xml_processor import XMLProcessor
from config import Config
def test_xml_formatting():
"""Test that XML is properly formatted"""
# Create a mock config
class MockConfig:
def __init__(self):
self.output_config = {'create_backups': False}
config = MockConfig()
processor = XMLProcessor(config)
# Create initial XML
initial_xml = '''<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="existing_key">Existing value</string>
</resources>'''
# Write to temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(initial_xml)
temp_path = f.name
try:
# Load the XML
root = processor.load_xml_file(temp_path)
# Add new strings
new_strings = [
('new_key1', 'New value 1'),
('new_key2', 'New value 2')
]
processor.add_missing_strings(root, new_strings)
# Save the file
processor.save_xml_file(root, temp_path)
# Read and display the result
with open(temp_path, 'r', encoding='utf-8') as f:
result = f.read()
print("=== FORMATTED XML ===")
print(result)
print("=== END XML ===")
finally:
os.unlink(temp_path)
if __name__ == "__main__":
test_xml_formatting()

3
tests/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Test package for Android XML Translation Tool
"""

141
tests/test_config.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Tests for configuration module
"""
import unittest
import tempfile
import os
import yaml
from unittest.mock import patch
from config import Config
class TestConfig(unittest.TestCase):
"""Test cases for Config class"""
def setUp(self):
"""Set up test fixtures"""
self.test_config_data = {
'llm': {
'base_url': 'http://localhost:1234',
'api_key': 'test-key',
'model': 'test-model'
},
'android': {
'input_folder': 'app/src/main/res',
'base_values_folder': 'values',
'target_folders': ['values-de-rDE'],
'files_to_translate': ['strings.xml']
},
'translation': {
'batch_size': 5,
'interactive_approval': True
}
}
def test_load_valid_config(self):
"""Test loading a valid configuration file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(self.test_config_data, f)
temp_path = f.name
try:
config = Config(temp_path)
self.assertEqual(config.llm_config['base_url'], 'http://localhost:1234')
self.assertEqual(config.android_config['input_folder'], 'app/src/main/res')
self.assertEqual(config.translation_config['batch_size'], 5)
finally:
os.unlink(temp_path)
def test_missing_file_error(self):
"""Test error handling for missing configuration file"""
with patch('sys.exit') as mock_exit:
Config('nonexistent.yaml')
mock_exit.assert_called_once_with(1)
def test_missing_required_section(self):
"""Test error handling for missing required sections"""
incomplete_data = {'llm': self.test_config_data['llm']} # Missing android and translation
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(incomplete_data, f)
temp_path = f.name
try:
with patch('sys.exit') as mock_exit:
Config(temp_path)
mock_exit.assert_called_with(1) # Remove assert_called_once
finally:
os.unlink(temp_path)
def test_output_config_default(self):
"""Test default output configuration"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(self.test_config_data, f)
temp_path = f.name
try:
config = Config(temp_path)
output_config = config.output_config
self.assertEqual(output_config, {}) # Should be empty dict when not specified
finally:
os.unlink(temp_path)
def test_examples_config_missing(self):
"""Test examples config when not specified"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(self.test_config_data, f)
temp_path = f.name
try:
config = Config(temp_path)
self.assertFalse(config.has_examples_config())
self.assertEqual(config.examples_config, {})
finally:
os.unlink(temp_path)
def test_examples_config_present(self):
"""Test examples config when specified"""
config_with_examples = self.test_config_data.copy()
config_with_examples['examples'] = {
'input_folder': 'examples',
'base_folder': 'assets/hints',
'target_folders': ['assets/hints-de-rDE'],
'file_extension': '.md'
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(config_with_examples, f)
temp_path = f.name
try:
config = Config(temp_path)
self.assertTrue(config.has_examples_config())
self.assertEqual(config.examples_config['input_folder'], 'examples')
self.assertEqual(config.examples_config['base_folder'], 'assets/hints')
self.assertEqual(config.examples_config['target_folders'], ['assets/hints-de-rDE'])
self.assertEqual(config.examples_config['file_extension'], '.md')
finally:
os.unlink(temp_path)
def test_examples_config_incomplete(self):
"""Test has_examples_config returns False for incomplete config"""
config_with_examples = self.test_config_data.copy()
config_with_examples['examples'] = {
'input_folder': 'examples',
# Missing base_folder and target_folders
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(config_with_examples, f)
temp_path = f.name
try:
config = Config(temp_path)
self.assertFalse(config.has_examples_config())
finally:
os.unlink(temp_path)
if __name__ == '__main__':
unittest.main()

272
tests/test_md_processor.py Normal file
View File

@@ -0,0 +1,272 @@
"""
Tests for MD processor module
"""
import unittest
import tempfile
import os
from md_processor import MDProcessor, MDTranslationItem
class TestMDProcessor(unittest.TestCase):
"""Test cases for MDProcessor class"""
def setUp(self):
"""Set up test fixtures"""
self.processor = MDProcessor()
def test_get_md_files(self):
"""Test getting .md files from a folder"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create test files
open(os.path.join(tmpdir, "file1.md"), 'w').close()
open(os.path.join(tmpdir, "file2.md"), 'w').close()
open(os.path.join(tmpdir, "not_md.txt"), 'w').close()
os.makedirs(os.path.join(tmpdir, "subdir"))
open(os.path.join(tmpdir, "subdir", "file3.md"), 'w').close()
# Get .md files (non-recursive)
files = self.processor.get_md_files(tmpdir)
self.assertEqual(len(files), 2)
self.assertIn("file1.md", files)
self.assertIn("file2.md", files)
self.assertNotIn("not_md.txt", files)
self.assertNotIn("file3.md", files) # In subdir, not included
def test_get_md_files_nonexistent_folder(self):
"""Test getting .md files from a non-existent folder"""
files = self.processor.get_md_files("/nonexistent/path")
self.assertEqual(files, [])
def test_load_md_file(self):
"""Test loading markdown file content"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
f.write("# Test Content\n\nThis is a test.")
temp_path = f.name
try:
content = self.processor.load_md_file(temp_path)
self.assertEqual(content, "# Test Content\n\nThis is a test.")
finally:
os.unlink(temp_path)
def test_load_md_file_nonexistent(self):
"""Test loading a non-existent markdown file"""
content = self.processor.load_md_file("/nonexistent/file.md")
self.assertEqual(content, "")
def test_save_md_file(self):
"""Test saving markdown file"""
with tempfile.TemporaryDirectory() as tmpdir:
file_path = os.path.join(tmpdir, "test.md")
content = "# Test Content\n\nThis is a test."
self.processor.save_md_file(content, file_path)
# Verify file was created
self.assertTrue(os.path.exists(file_path))
# Verify content
with open(file_path, 'r', encoding='utf-8') as f:
saved_content = f.read()
self.assertEqual(saved_content, content)
def test_save_md_file_creates_directories(self):
"""Test that save_md_file creates parent directories"""
with tempfile.TemporaryDirectory() as tmpdir:
nested_path = os.path.join(tmpdir, "subdir1", "subdir2", "test.md")
content = "# Nested Content"
self.processor.save_md_file(content, nested_path)
# Verify file was created in nested directory
self.assertTrue(os.path.exists(nested_path))
def test_extract_content(self):
"""Test extracting content from all .md files"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create test files
with open(os.path.join(tmpdir, "file1.md"), 'w') as f:
f.write("# File 1")
with open(os.path.join(tmpdir, "file2.md"), 'w') as f:
f.write("# File 2")
items = self.processor.extract_content(tmpdir)
self.assertEqual(len(items), 2)
self.assertIn("file1.md", items)
self.assertIn("file2.md", items)
self.assertEqual(items["file1.md"].content, "# File 1")
self.assertEqual(items["file2.md"].content, "# File 2")
self.assertEqual(items["file1.md"].filename, "file1.md")
def test_extract_content_empty_folder(self):
"""Test extracting content from empty folder"""
with tempfile.TemporaryDirectory() as tmpdir:
items = self.processor.extract_content(tmpdir)
self.assertEqual(items, {})
def test_check_asset_counts_valid(self):
"""Test asset count check when all folders have same count"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create base folder with files
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
open(os.path.join(base_folder, "file1.md"), 'w').close()
open(os.path.join(base_folder, "file2.md"), 'w').close()
# Create target folders with same files
target1 = os.path.join(tmpdir, "target1")
os.makedirs(target1)
open(os.path.join(target1, "file1.md"), 'w').close()
open(os.path.join(target1, "file2.md"), 'w').close()
target2 = os.path.join(tmpdir, "target2")
os.makedirs(target2)
open(os.path.join(target2, "file1.md"), 'w').close()
open(os.path.join(target2, "file2.md"), 'w').close()
is_valid, errors = self.processor.check_asset_counts(base_folder, [target1, target2])
self.assertTrue(is_valid)
self.assertEqual(errors, [])
def test_check_asset_counts_missing_files(self):
"""Test asset count check when target is missing files"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create base folder with files
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
open(os.path.join(base_folder, "file1.md"), 'w').close()
open(os.path.join(base_folder, "file2.md"), 'w').close()
# Create target folder with missing file
target1 = os.path.join(tmpdir, "target1")
os.makedirs(target1)
open(os.path.join(target1, "file1.md"), 'w').close()
# file2.md is missing
is_valid, errors = self.processor.check_asset_counts(base_folder, [target1])
self.assertFalse(is_valid)
self.assertEqual(len(errors), 1)
self.assertIn("file2.md", errors[0])
def test_check_asset_counts_extra_files(self):
"""Test asset count check when target has extra files"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create base folder with files
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
open(os.path.join(base_folder, "file1.md"), 'w').close()
# Create target folder with extra file
target1 = os.path.join(tmpdir, "target1")
os.makedirs(target1)
open(os.path.join(target1, "file1.md"), 'w').close()
open(os.path.join(target1, "extra.md"), 'w').close()
is_valid, errors = self.processor.check_asset_counts(base_folder, [target1])
self.assertFalse(is_valid)
self.assertEqual(len(errors), 1)
self.assertIn("extra.md", errors[0])
def test_check_asset_counts_empty_base(self):
"""Test asset count check with empty base folder"""
with tempfile.TemporaryDirectory() as tmpdir:
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
target1 = os.path.join(tmpdir, "target1")
os.makedirs(target1)
is_valid, errors = self.processor.check_asset_counts(base_folder, [target1])
self.assertFalse(is_valid)
self.assertEqual(len(errors), 1)
self.assertIn("no .md files", errors[0])
def test_find_missing_files(self):
"""Test finding missing files in target folder"""
with tempfile.TemporaryDirectory() as tmpdir:
# Create base folder with files
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
open(os.path.join(base_folder, "file1.md"), 'w').close()
open(os.path.join(base_folder, "file2.md"), 'w').close()
open(os.path.join(base_folder, "file3.md"), 'w').close()
# Create target folder with some files
target_folder = os.path.join(tmpdir, "target")
os.makedirs(target_folder)
open(os.path.join(target_folder, "file1.md"), 'w').close()
# file2.md and file3.md are missing
missing = self.processor.find_missing_files(base_folder, target_folder)
self.assertEqual(len(missing), 2)
self.assertIn("file2.md", missing)
self.assertIn("file3.md", missing)
self.assertNotIn("file1.md", missing)
def test_find_missing_files_all_present(self):
"""Test finding missing files when all are present"""
with tempfile.TemporaryDirectory() as tmpdir:
base_folder = os.path.join(tmpdir, "base")
os.makedirs(base_folder)
open(os.path.join(base_folder, "file1.md"), 'w').close()
target_folder = os.path.join(tmpdir, "target")
os.makedirs(target_folder)
open(os.path.join(target_folder, "file1.md"), 'w').close()
missing = self.processor.find_missing_files(base_folder, target_folder)
self.assertEqual(missing, [])
def test_custom_extension(self):
"""Test MDProcessor with custom file extension"""
processor = MDProcessor(file_extension=".txt")
with tempfile.TemporaryDirectory() as tmpdir:
open(os.path.join(tmpdir, "file1.txt"), 'w').close()
open(os.path.join(tmpdir, "file2.md"), 'w').close()
files = processor.get_md_files(tmpdir)
self.assertEqual(len(files), 1)
self.assertIn("file1.txt", files)
self.assertNotIn("file2.md", files)
class TestMDTranslationItem(unittest.TestCase):
"""Test cases for MDTranslationItem dataclass"""
def test_create_item(self):
"""Test creating MDTranslationItem"""
item = MDTranslationItem(
filename="test.md",
content="# Test",
relative_path="subdir"
)
self.assertEqual(item.filename, "test.md")
self.assertEqual(item.content, "# Test")
self.assertEqual(item.relative_path, "subdir")
def test_create_item_defaults(self):
"""Test creating MDTranslationItem with defaults"""
item = MDTranslationItem(
filename="test.md",
content="# Test"
)
self.assertEqual(item.filename, "test.md")
self.assertEqual(item.content, "# Test")
self.assertEqual(item.relative_path, "")
if __name__ == '__main__':
unittest.main()

56
tests/test_models.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Tests for data models
"""
import unittest
from models import TranslationItem, TranslationBatch
class TestTranslationItem(unittest.TestCase):
"""Test cases for TranslationItem model"""
def test_translation_item_creation(self):
"""Test creating a TranslationItem"""
item = TranslationItem(name="test_key", value="Test value")
self.assertEqual(item.name, "test_key")
self.assertEqual(item.value, "Test value")
self.assertIsNone(item.comment)
def test_translation_item_with_comment(self):
"""Test creating a TranslationItem with comment"""
item = TranslationItem(
name="test_key",
value="Test value",
comment="Test comment"
)
self.assertEqual(item.name, "test_key")
self.assertEqual(item.value, "Test value")
self.assertEqual(item.comment, "Test comment")
class TestTranslationBatch(unittest.TestCase):
"""Test cases for TranslationBatch model"""
def setUp(self):
"""Set up test fixtures"""
self.items = [
TranslationItem(name="key1", value="Value 1"),
TranslationItem(name="key2", value="Value 2")
]
def test_translation_batch_creation(self):
"""Test creating a TranslationBatch"""
batch = TranslationBatch(
items=self.items,
target_language="values-de-rDE",
target_file="strings.xml"
)
self.assertEqual(len(batch.items), 2)
self.assertEqual(batch.target_language, "values-de-rDE")
self.assertEqual(batch.target_file, "strings.xml")
self.assertEqual(batch.items[0].name, "key1")
self.assertEqual(batch.items[1].value, "Value 2")
if __name__ == '__main__':
unittest.main()

188
tests/test_xml_processor.py Normal file
View File

@@ -0,0 +1,188 @@
"""
Tests for XML processor module
"""
import unittest
import tempfile
import os
from lxml import etree
from unittest.mock import patch, MagicMock
from config import Config
from models import TranslationItem
from xml_processor import XMLProcessor
class TestXMLProcessor(unittest.TestCase):
"""Test cases for XMLProcessor class"""
def setUp(self):
"""Set up test fixtures"""
# Create a mock config
self.mock_config = MagicMock(spec=Config)
self.mock_config.output_config = {
'create_backups': True,
'backup_suffix': '.backup'
}
self.processor = XMLProcessor(self.mock_config)
# Sample XML content (without XML declaration for testing)
self.sample_xml = '''<resources>
<string name="app_name">Test App</string>
<string name="welcome_message">Welcome to our app!</string>
<!-- This is a comment -->
<string name="button_ok">OK</string>
</resources>'''
def test_load_xml_file_success(self):
"""Test successful XML file loading"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(self.sample_xml)
temp_path = f.name
try:
root = self.processor.load_xml_file(temp_path)
self.assertIsNotNone(root)
self.assertEqual(root.tag, 'resources')
self.assertEqual(len(root.findall('string')), 3)
finally:
os.unlink(temp_path)
def test_load_xml_file_error(self):
"""Test error handling when loading invalid XML file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write('invalid xml content')
temp_path = f.name
try:
root = self.processor.load_xml_file(temp_path)
self.assertIsNone(root)
finally:
os.unlink(temp_path)
def test_extract_strings(self):
"""Test extracting strings from XML"""
root = etree.fromstring(self.sample_xml)
strings = self.processor.extract_strings(root)
self.assertEqual(len(strings), 3)
self.assertIn('app_name', strings)
self.assertIn('welcome_message', strings)
self.assertIn('button_ok', strings)
# Check string values
self.assertEqual(strings['app_name'].value, 'Test App')
self.assertEqual(strings['welcome_message'].value, 'Welcome to our app!')
self.assertEqual(strings['button_ok'].value, 'OK')
# Check that all items are TranslationItem instances
for item in strings.values():
self.assertIsInstance(item, TranslationItem)
def test_add_missing_strings(self):
"""Test adding missing strings to XML"""
# Create target XML with one existing string
target_xml = '''<resources>
<string name="existing_key">Existing value</string>
</resources>'''
target_root = etree.fromstring(target_xml)
missing_strings = [
('new_key1', 'New value 1', 'string', []),
('new_key2', 'New value 2', 'string', []),
('existing_key', 'Should not be added', 'string', []) # This should be ignored
]
self.processor.add_missing_strings(target_root, missing_strings)
# Check that new strings were added
strings = target_root.findall('string')
self.assertEqual(len(strings), 3) # 1 existing + 2 new
# Check specific strings
new_key1 = target_root.find(".//string[@name='new_key1']")
self.assertIsNotNone(new_key1)
self.assertEqual(new_key1.text, 'New value 1')
new_key2 = target_root.find(".//string[@name='new_key2']")
self.assertIsNotNone(new_key2)
self.assertEqual(new_key2.text, 'New value 2')
# Check existing string wasn't duplicated
existing_strings = target_root.findall(".//string[@name='existing_key']")
self.assertEqual(len(existing_strings), 1)
def test_extract_string_array(self):
"""Test extracting string arrays from XML"""
xml_with_array = '''<resources>
<string name="app_name">Test App</string>
<string-array name="vocabulary_hints">
<item>Basic greetings</item>
<item>Irregular verbs</item>
<item>Vocabulary at the airport</item>
</string-array>
</resources>'''
root = etree.fromstring(xml_with_array)
strings = self.processor.extract_strings(root)
self.assertEqual(len(strings), 2)
self.assertIn('app_name', strings)
self.assertIn('vocabulary_hints', strings)
# Check string array
array_item = strings['vocabulary_hints']
self.assertEqual(array_item.item_type, 'string-array')
self.assertEqual(len(array_item.items), 3)
self.assertEqual(array_item.items[0], 'Basic greetings')
self.assertEqual(array_item.items[1], 'Irregular verbs')
self.assertEqual(array_item.items[2], 'Vocabulary at the airport')
self.assertEqual(array_item.value, 'Basic greetings | Irregular verbs | Vocabulary at the airport')
def test_add_missing_string_array(self):
"""Test adding missing string arrays to XML"""
target_xml = '''<resources>
<string name="existing_key">Existing value</string>
</resources>'''
target_root = etree.fromstring(target_xml)
missing_strings = [
('vocabulary_hints', '', 'string-array', ['Basic greetings', 'Irregular verbs'])
]
self.processor.add_missing_strings(target_root, missing_strings)
# Check that string-array was added
string_array = target_root.find(".//string-array[@name='vocabulary_hints']")
self.assertIsNotNone(string_array)
# Check items
items = string_array.findall('item')
self.assertEqual(len(items), 2)
self.assertEqual(items[0].text, 'Basic greetings')
self.assertEqual(items[1].text, 'Irregular verbs')
def test_skip_non_translatable_string_array(self):
"""Test that non-translatable string arrays are skipped"""
xml_with_non_translatable = '''<resources>
<string-array name="translatable_array" translatable="true">
<item>Item 1</item>
</string-array>
<string-array name="non_translatable_array" translatable="false">
<item>Item 2</item>
</string-array>
</resources>'''
root = etree.fromstring(xml_with_non_translatable)
strings = self.processor.extract_strings(root)
self.assertEqual(len(strings), 1)
self.assertIn('translatable_array', strings)
self.assertNotIn('non_translatable_array', strings)
# Note: File saving tests are complex to mock properly due to lxml internals
# The core functionality is tested through integration tests
if __name__ == '__main__':
unittest.main()

310
translation_tool.py Normal file
View File

@@ -0,0 +1,310 @@
"""
Main translation tool logic
"""
import os
import sys
import logging
from lxml import etree
from typing import List
from config import Config
from models import TranslationItem, TranslationBatch
from llm_client import LLMClient
from xml_processor import XMLProcessor
from md_processor import MDProcessor
from ui import UI
class TranslationTool:
"""Main translation tool class"""
def __init__(self, config_path: str = "config.yaml"):
self.config = Config(config_path)
self.llm_client = LLMClient(self.config)
self.xml_processor = XMLProcessor(self.config)
self.md_processor = MDProcessor()
self.ui = UI()
# Setup logging
self._setup_logging()
def _setup_logging(self):
"""Setup logging configuration"""
log_level = self.config.output_config.get('log_level', 'INFO')
logging.basicConfig(
level=getattr(logging, log_level),
format='%(asctime)s - %(levelname)s - %(message)s'
)
self.logger = logging.getLogger(__name__)
def run(self):
"""Main execution method"""
self.ui.show_header()
# Process Android XML files
self._process_android_files()
# Process Markdown files if configured
if self.config.has_examples_config():
self._process_md_files()
self.ui.show_success("Translation process completed!")
def _process_android_files(self):
"""Process Android XML files"""
android_config = self.config.android_config
input_folder = android_config['input_folder']
base_folder = android_config['base_values_folder']
target_folders = android_config['target_folders']
files_to_translate = android_config['files_to_translate']
# Check if input folder exists
if not os.path.exists(input_folder):
self.ui.show_error(f"Input folder {input_folder} not found!")
return
base_values_path = os.path.join(input_folder, base_folder)
if not os.path.exists(base_values_path):
self.ui.show_error(f"Base values folder {base_values_path} not found!")
return
# Process each target language
for target_folder in target_folders:
self.ui.show_processing_language(target_folder)
self._process_language(input_folder, base_folder, target_folder, files_to_translate)
def _process_md_files(self):
"""Process Markdown files in examples folder"""
examples_config = self.config.examples_config
input_folder = examples_config['input_folder']
base_folder = examples_config['base_folder']
target_folders = examples_config['target_folders']
file_extension = examples_config.get('file_extension', '.md')
# Update MD processor with configured extension
self.md_processor = MDProcessor(file_extension)
# Check if input folder exists
if not os.path.exists(input_folder):
self.ui.show_warning(f"Examples input folder {input_folder} not found, skipping...")
return
base_path = os.path.join(input_folder, base_folder)
if not os.path.exists(base_path):
self.ui.show_warning(f"Examples base folder {base_path} not found, skipping...")
return
# Check asset counts across all locales
target_paths = [os.path.join(input_folder, tf) for tf in target_folders]
is_valid, errors = self.md_processor.check_asset_counts(base_path, target_paths)
if not is_valid:
self.ui.show_warning("Asset count check failed:")
for error in errors:
self.ui.show_warning(f" - {error}")
else:
self.ui.show_asset_count_check(len(self.md_processor.get_md_files(base_path)))
# Process each target language
for target_folder in target_folders:
self.ui.show_processing_language(f"examples/{target_folder}")
self._process_md_language(input_folder, base_folder, target_folder)
def _process_md_language(self, input_folder: str, base_folder: str, target_folder: str):
"""Process Markdown translation for a specific language - files are processed one by one"""
base_path = os.path.join(input_folder, base_folder)
target_path = os.path.join(input_folder, target_folder)
# Create target folder if it doesn't exist
os.makedirs(target_path, exist_ok=True)
# Find missing files
missing_files = self.md_processor.find_missing_files(base_path, target_path)
if not missing_files:
self.ui.show_all_translated(target_path)
return
self.ui.show_missing_strings(len(missing_files), target_path)
# Get language instruction
instruction = self._get_instruction(target_folder)
interactive = self.config.translation_config['interactive_approval']
# Process each missing file one by one (no batching for .md files)
for i, filename in enumerate(missing_files, 1):
file_path = os.path.join(base_path, filename)
content = self.md_processor.load_md_file(file_path)
if not content:
self.ui.show_warning(f"Could not load {filename}, skipping...")
continue
# Create a single-item batch for this file
item = TranslationItem(
name=filename,
value=content,
item_type='string'
)
batch = TranslationBatch(
items=[item],
target_language=target_folder,
target_file=target_path
)
self.ui.show_processing_file(filename)
# Translate single file
translations = self.llm_client.translate_batch(batch, instruction)
if not translations or len(translations) != 1:
self.ui.show_batch_failed(i)
continue
translation = translations[0]
# Display translation for approval (truncated for display)
if interactive:
display_batch = TranslationBatch(
items=[TranslationItem(
name=filename,
value=content[:500] + "..." if len(content) > 500 else content,
item_type='string'
)],
target_language=target_folder,
target_file=target_path
)
approved = self.ui.show_batch_approval(display_batch, [translation[:500] + "..." if len(translation) > 500 else translation])
if not approved:
self.ui.show_batch_skipped(i)
continue
# Save translated file
target_file = os.path.join(target_path, filename)
self.md_processor.save_md_file(translation, target_file)
self.ui.show_batch_added(i, filename)
def _get_instruction(self, target_folder: str) -> str:
"""Get language instruction for a target folder"""
language_instructions = self.config.translation_config.get('language_instructions', {})
# Try direct lookup first
if target_folder in language_instructions:
return language_instructions[target_folder]
# Extract folder name (e.g., "res/values-de-rDE" -> "values-de-rDE")
folder_name = os.path.basename(target_folder)
if folder_name in language_instructions:
return language_instructions[folder_name]
# Try hints folder mapping (e.g., "assets/hints-de-rDE" -> "values-de-rDE")
if 'hints-' in folder_name:
values_key = folder_name.replace('hints-', 'values-')
if values_key in language_instructions:
return language_instructions[values_key]
# Default fallback
return f"Translate to {target_folder}"
def _process_language(self, input_folder: str, base_folder: str, target_folder: str, files_to_translate: List[str]):
"""Process translation for a specific language"""
base_values_path = os.path.join(input_folder, base_folder)
target_values_path = os.path.join(input_folder, target_folder)
# Create target folder if it doesn't exist
os.makedirs(target_values_path, exist_ok=True)
# Get language instruction
instruction = self._get_instruction(target_folder)
# Process each file
for filename in files_to_translate:
base_file = os.path.join(base_values_path, filename)
target_file = os.path.join(target_values_path, filename)
if not os.path.exists(base_file):
self.ui.show_warning(f"Base file {base_file} not found, skipping...")
continue
self.ui.show_processing_file(filename)
self._process_file(base_file, target_file, target_folder, instruction)
def _process_file(self, base_file: str, target_file: str, target_folder: str, instruction: str):
"""Process translation for a specific file"""
# Load base XML
base_root = self.xml_processor.load_xml_file(base_file)
if base_root is None:
return
# Load or create target XML
if os.path.exists(target_file):
target_root = self.xml_processor.load_xml_file(target_file)
else:
# Create new XML structure
target_root = etree.Element('resources')
if target_root is None:
return
# Extract strings
base_strings = self.xml_processor.extract_strings(base_root)
target_strings = self.xml_processor.extract_strings(target_root)
# Find missing strings
missing_strings = []
for name, item in base_strings.items():
if name not in target_strings:
missing_strings.append(item)
if not missing_strings:
self.ui.show_all_translated(target_file)
return
self.ui.show_missing_strings(len(missing_strings), target_file)
# Process in batches
batch_size = self.config.translation_config['batch_size']
interactive = self.config.translation_config['interactive_approval']
for i in range(0, len(missing_strings), batch_size):
batch_items = missing_strings[i:i + batch_size]
batch = TranslationBatch(
items=batch_items,
target_language=target_folder,
target_file=target_file
)
# Translate batch
translations = self.llm_client.translate_batch(batch, instruction)
if not translations or len(translations) != len(batch_items):
self.ui.show_batch_failed(i // batch_size + 1)
continue
# Display translations for approval
if interactive:
approved = self.ui.show_batch_approval(batch, translations)
if not approved:
self.ui.show_batch_skipped(i // batch_size + 1)
continue
# Add translations to target XML
new_translations = []
for item, translation in zip(batch_items, translations):
if item.item_type == 'string-array':
# For string arrays, parse the translation into individual items
# Expected format: item1 | item2 | item3 (same separator as input)
translated_items = [t.strip() for t in translation.split('|')]
new_translations.append((item.name, translation, 'string-array', translated_items))
else:
# Regular string
new_translations.append((item.name, translation, 'string', []))
self.xml_processor.add_missing_strings(target_root, new_translations)
# Save the file
self.xml_processor.save_xml_file(target_root, target_file)
self.ui.show_batch_added(i // batch_size + 1, target_file)

91
ui.py Normal file
View File

@@ -0,0 +1,91 @@
"""
User interface components for Android XML Translation Tool
"""
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Confirm
from models import TranslationBatch
class UI:
"""User interface handler"""
def __init__(self):
self.console = Console()
def show_header(self):
"""Display application header"""
self.console.print(Panel.fit("🌍 Android XML Translation Tool", style="bold blue"))
def show_processing_language(self, language: str):
"""Display current language being processed"""
self.console.print(f"\n[bold cyan]Processing language: {language}[/bold cyan]")
def show_processing_file(self, filename: str):
"""Display current file being processed"""
self.console.print(f"[dim]Processing file: {filename}[/dim]")
def show_processing_folder(self, folder: str):
"""Display current folder being processed"""
self.console.print(f"[dim]Processing folder: {folder}[/dim]")
def show_asset_count_check(self, count: int):
"""Display asset count check passed message"""
self.console.print(f"[green]✓ Asset count check passed: {count} .md files in all locales[/green]")
def show_missing_strings(self, count: int, filename: str):
"""Display count of missing strings"""
self.console.print(f"[yellow]Found {count} missing strings in {filename}[/yellow]")
def show_all_translated(self, filename: str):
"""Display message when all strings are already translated"""
self.console.print(f"[green]✓ {filename}: All strings are already translated[/green]")
def show_batch_approval(self, batch: TranslationBatch, translations: list[str]) -> bool:
"""Show batch translations for user approval"""
self.console.print(f"\n[bold]📝 Translation Batch Review[/bold]")
self.console.print(f"[dim]Target: {batch.target_language} | File: {batch.target_file}[/dim]\n")
# Create table for display
table = Table(show_header=True, header_style="bold magenta")
table.add_column("#", style="cyan", width=4)
table.add_column("Original", style="white")
table.add_column("Translation", style="green")
for i, (item, translation) in enumerate(zip(batch.items, translations)):
table.add_row(str(i + 1), item.value, translation)
self.console.print(table)
# Ask for approval
return Confirm.ask("\n[yellow]Approve these translations?[/yellow]", default=True)
def show_batch_skipped(self, batch_num: int):
"""Display message when batch is skipped"""
self.console.print(f"[yellow]⏭️ Batch {batch_num} skipped[/yellow]")
def show_batch_failed(self, batch_num: int):
"""Display message when batch translation fails"""
self.console.print(f"[red]❌ Translation failed for batch {batch_num}[/red]")
def show_batch_added(self, batch_num: int, filename: str):
"""Display message when batch is successfully added"""
self.console.print(f"[green]✅ Batch {batch_num} added to {filename}[/green]")
def show_warning(self, message: str):
"""Display warning message"""
self.console.print(f"[yellow]Warning: {message}[/yellow]")
def show_error(self, message: str):
"""Display error message"""
self.console.print(f"[red]Error: {message}[/red]")
def show_success(self, message: str):
"""Display success message"""
self.console.print(f"[bold green]✅ {message}[/bold green]")
def show_interrupted(self):
"""Display interruption message"""
self.console.print("\n[yellow]⚠️ Translation process interrupted by user[/yellow]")

173
xml_processor.py Normal file
View File

@@ -0,0 +1,173 @@
"""
XML file processor for Android resources
"""
import os
from lxml import etree
from typing import Dict, Tuple, List, Union
from models import TranslationItem
from config import Config
class XMLProcessor:
"""XML file processor for Android resources"""
def __init__(self, config: Config):
self.config = config
self.parser = etree.XMLParser(remove_blank_text=False, strip_cdata=False)
def load_xml_file(self, file_path: str) -> etree.Element:
"""Load and parse XML file"""
try:
tree = etree.parse(file_path, self.parser)
return tree.getroot()
except Exception as e:
print(f"Error loading XML file {file_path}: {e}")
return None
def save_xml_file(self, root: etree.Element, file_path: str):
"""Save XML file with formatting"""
try:
# Ensure proper formatting for the root element
if root.text is None:
root.text = "\n "
if root.tail is None:
root.tail = "\n"
# Ensure all children have proper tails for formatting
for i, child in enumerate(root):
if child.tail is None or child.tail.strip() == "":
child.tail = "\n "
# Make sure the last child ends with proper indentation
if len(root) > 0:
last_child = root[-1]
if not last_child.tail.endswith("\n"):
last_child.tail = "\n"
# Create backup if enabled
if self.config.output_config.get('create_backups', True):
backup_path = file_path + self.config.output_config.get('backup_suffix', '.backup')
if os.path.exists(file_path):
backup_tree = etree.ElementTree(root)
backup_tree.write(backup_path,
encoding='utf-8',
xml_declaration=True,
pretty_print=True)
# Save the modified file with pretty printing
tree = etree.ElementTree(root)
tree.write(file_path, encoding='utf-8', xml_declaration=True, pretty_print=True)
except Exception as e:
print(f"Error saving XML file {file_path}: {e}")
def extract_strings(self, root: etree.Element) -> Dict[str, TranslationItem]:
"""Extract strings and string-arrays from XML root element"""
strings = {}
for element in root:
if element.tag == 'string':
name = element.get('name')
value = element.text or ''
# Skip strings marked as non-translatable
translatable = element.get('translatable', 'true')
if translatable.lower() == 'false':
continue
# Handle CDATA and special characters
if isinstance(value, str):
value = value.strip()
# Get comment if exists
comment = None
if element.tail and element.tail.strip():
comment = element.tail.strip()
strings[name] = TranslationItem(name=name, value=value, comment=comment, item_type='string')
elif element.tag == 'string-array':
name = element.get('name')
# Skip arrays marked as non-translatable
translatable = element.get('translatable', 'true')
if translatable.lower() == 'false':
continue
# Extract all items from the string-array
items = []
for item in element:
if item.tag == 'item':
item_value = item.text or ''
if isinstance(item_value, str):
item_value = item_value.strip()
items.append(item_value)
# Get comment if exists
comment = None
if element.tail and element.tail.strip():
comment = element.tail.strip()
# Store with combined value for display
combined_value = " | ".join(items) if items else ""
strings[name] = TranslationItem(
name=name,
value=combined_value,
comment=comment,
item_type='string-array',
items=items
)
elif element.tag == 'plurals':
# Handle plurals - for now, skip or handle separately
name = element.get('name')
strings[name] = TranslationItem(name=name, value=f"<{element.tag}>", comment="Complex type", item_type='plurals')
return strings
def add_missing_strings(self, target_root: etree.Element, missing_strings: List[Tuple]):
"""Add missing strings and string-arrays to target XML"""
for item_data in missing_strings:
if len(item_data) == 4:
# Extended format: (name, value, item_type, items)
name, value, item_type, items = item_data
else:
# Regular string: (name, value)
name, value = item_data[0], item_data[1]
item_type = 'string'
items = []
if item_type == 'string-array':
# Check if string-array already exists
existing = target_root.find(f".//string-array[@name='{name}']")
if existing is None:
# Create new string-array element
new_array = etree.SubElement(target_root, 'string-array', name=name)
# Add items to the array
for i, item_value in enumerate(items):
item_elem = etree.SubElement(new_array, 'item')
item_elem.text = item_value
# Add proper indentation between items
item_elem.tail = "\n "
# Add proper tail for the array element itself
new_array.tail = "\n "
else:
# Regular string
existing = target_root.find(f".//string[@name='{name}']")
if existing is None:
# Create new string element with proper indentation
new_string = etree.SubElement(target_root, 'string', name=name)
new_string.text = value
# Add proper indentation and newlines
new_string.tail = "\n "
# Ensure the last element has proper closing
if len(target_root) > 0:
last_child = target_root[-1]
if last_child.tail and not last_child.tail.endswith("\n"):
last_child.tail += "\n"