migrate to gitea
This commit is contained in:
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal 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
22
__init__.py
Normal 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
73
config.py
Normal 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
62
config.yaml
Normal 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
62
debug_llm.py
Normal 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
79
debug_translation.py
Normal 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
144
llm_client.py
Normal 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
33
main.py
Normal 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
136
md_processor.py
Normal 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
24
models.py
Normal 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
6
requirements.txt
Normal 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
29
run_tests.py
Normal 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
61
test_xml_format.py
Normal 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
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test package for Android XML Translation Tool
|
||||
"""
|
||||
141
tests/test_config.py
Normal file
141
tests/test_config.py
Normal 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
272
tests/test_md_processor.py
Normal 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
56
tests/test_models.py
Normal 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
188
tests/test_xml_processor.py
Normal 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
310
translation_tool.py
Normal 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
91
ui.py
Normal 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
173
xml_processor.py
Normal 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"
|
||||
Reference in New Issue
Block a user