mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 20:20:30 -06:00
Complete translation system implementation and fixes
This commit implements comprehensive internationalization (i18n) support across the entire TimeTracker application, ensuring all user-facing strings are properly translatable. ## Translation Implementation ### Route Files (Flash Messages) - Fixed all untranslated flash messages in route files: * app/routes/admin.py (36 messages) * app/routes/tasks.py (43 messages) * app/routes/timer.py (44 messages) * app/routes/projects.py (33 messages) * app/routes/payments.py (28 messages) * app/routes/clients.py (25 messages) * app/routes/invoices.py (24 messages) * Plus all other route files (recurring_invoices, kanban, reports, etc.) - Added missing `from flask_babel import _` imports to: * app/routes/setup.py * app/routes/budget_alerts.py * app/routes/saved_filters.py * app/routes/reports.py * app/routes/time_entry_templates.py ### Template Files - Fixed headers and labels in templates: * admin/user_form.html * audit_logs/view.html * timer/timer_page.html * reports/index.html * reports/user_report.html * time_entry_templates/view.html * recurring_invoices/view.html - Fixed form placeholders in: * expense_categories/form.html * expenses/form.html * mileage/form.html * per_diem/form.html * per_diem/rate_form.html - Fixed button and link text in list views: * invoices/list.html * payments/list.html * expenses/list.html * per_diem/list.html * projects/list.html - Fixed title attributes for accessibility ### Email Templates - Added translation support to all email templates: * quote_sent.html, quote_rejected.html, quote_expired.html * quote_expiring.html, quote_approved.html, quote_accepted.html * quote_approval_request.html, quote_approval_rejected.html * invoice.html, overdue_invoice.html * task_assigned.html, comment_mention.html * client_portal_password_setup.html * weekly_summary.html, test_email.html * quote.html ### Component Templates - Fixed save_filter_widget.html with translated text - Updated JavaScript strings in quote_pdf_layout.html ## Translation Files ### Extraction and Updates - Extracted all new translatable strings using pybabel - Updated all language catalogs (.po files) with new strings - Languages updated: en, nl, de, fr, it, fi, es, ar, he, nb, no ### Automatic Translation - Created scripts/complete_all_translations.py for automatic translation - Translated ~3,100 strings per language using Google Translate API - Translation completion rates: * Dutch (NL): 99.97% (3,098/3,099) * German (DE): 99.94% (3,097/3,099) * French (FR): 99.97% (3,098/3,099) * Italian (IT): 99.90% (3,096/3,099) * Finnish (FI): 99.06% (3,070/3,099) * Spanish (ES): 99.97% (3,098/3,099) * Arabic (AR): 99.97% (3,098/3,099) * Hebrew (HE): 99.90% (3,096/3,099) * Norwegian Bokmål (NB): 99.94% (3,097/3,099) * Norwegian (NO): 99.94% (3,097/3,099) ### Placeholder Fixes - Created scripts/fix_translation_placeholders.py - Fixed 281 placeholder name errors across all languages - Preserved original English placeholder names (e.g., %(error)s, %(rate)s) - Fixed format specifier issues (e.g., %(rate).2f%%) ## Bug Fixes ### Code Fixes - Fixed indentation error in app/routes/timer.py (line 458) - Fixed missing translation function imports in route files ### Translation Compilation - All translation catalogs now compile successfully - No compilation errors remaining - All .mo files generated correctly ## Scripts Added - scripts/complete_all_translations.py: Automatic translation using deep-translator - scripts/fix_translation_placeholders.py: Fix placeholder names in translations ## Impact - All user-facing strings are now translatable - Application supports 11 languages with >99% translation coverage - Improved user experience for non-English speakers - Consistent translation system across all application components
This commit is contained in:
188
scripts/complete_all_translations.py
Normal file
188
scripts/complete_all_translations.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to automatically complete all translations for all languages.
|
||||
Uses deep-translator library for automatic translation of missing strings.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
from babel.messages.catalog import Message
|
||||
except ImportError:
|
||||
print("Error: Babel library not found. Please install it with: pip install Babel")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from deep_translator import GoogleTranslator
|
||||
except ImportError:
|
||||
print("Error: deep-translator not found. Installing...")
|
||||
import subprocess
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "deep-translator"])
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
|
||||
# Language mapping for deep-translator
|
||||
LANGUAGE_MAP = {
|
||||
'nl': 'nl', # Dutch
|
||||
'de': 'de', # German
|
||||
'fr': 'fr', # French
|
||||
'it': 'it', # Italian
|
||||
'fi': 'fi', # Finnish
|
||||
'es': 'es', # Spanish
|
||||
'ar': 'ar', # Arabic
|
||||
'he': 'iw', # Hebrew (deep-translator uses 'iw')
|
||||
'nb': 'no', # Norwegian Bokmål
|
||||
'no': 'no', # Norwegian
|
||||
}
|
||||
|
||||
|
||||
def translate_text(text, target_lang):
|
||||
"""Translate text to target language using Google Translator."""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
|
||||
try:
|
||||
translator = GoogleTranslator(source='en', target=target_lang)
|
||||
translated = translator.translate(text)
|
||||
return translated
|
||||
except Exception as e:
|
||||
print(f" Warning: Translation failed for '{text[:50]}...': {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def complete_translations_for_language(lang_code):
|
||||
"""Complete all missing translations for a specific language."""
|
||||
translations_dir = Path('translations')
|
||||
lang_file = translations_dir / lang_code / 'LC_MESSAGES' / 'messages.po'
|
||||
|
||||
if not lang_file.exists():
|
||||
print(f"Error: Translation file not found: {lang_file}")
|
||||
return False
|
||||
|
||||
if lang_code not in LANGUAGE_MAP:
|
||||
print(f"Warning: Language {lang_code} not in language map, skipping...")
|
||||
return False
|
||||
|
||||
target_lang = LANGUAGE_MAP[lang_code]
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Processing {lang_code.upper()} translations...")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Read the PO file
|
||||
with open(lang_file, 'r', encoding='utf-8') as f:
|
||||
catalog = read_po(f)
|
||||
|
||||
print(f"Found {len(catalog)} entries in catalog")
|
||||
|
||||
# Find untranslated entries
|
||||
untranslated = []
|
||||
for message in catalog:
|
||||
if message.id:
|
||||
is_empty = False
|
||||
if isinstance(message.string, tuple):
|
||||
# Plural form
|
||||
is_empty = not message.string or all(not s for s in message.string)
|
||||
else:
|
||||
# Singular form
|
||||
is_empty = not message.string or message.string == ""
|
||||
|
||||
if is_empty:
|
||||
untranslated.append(message)
|
||||
|
||||
print(f"Found {len(untranslated)} untranslated entries")
|
||||
|
||||
if len(untranslated) == 0:
|
||||
print(f"✓ All {lang_code.upper()} translations are complete!")
|
||||
return True
|
||||
|
||||
# Translate entries
|
||||
translated_count = 0
|
||||
failed_count = 0
|
||||
|
||||
print(f"\nTranslating {len(untranslated)} entries...")
|
||||
print("(This may take a while due to API rate limits)")
|
||||
|
||||
for i, message in enumerate(untranslated, 1):
|
||||
if i % 50 == 0:
|
||||
print(f" Progress: {i}/{len(untranslated)} entries processed...")
|
||||
|
||||
translation = translate_text(message.id, target_lang)
|
||||
|
||||
if translation:
|
||||
if isinstance(message.string, tuple):
|
||||
# Plural form - set first form, keep others empty for now
|
||||
message.string = (translation, message.string[1] if len(message.string) > 1 else "")
|
||||
else:
|
||||
message.string = translation
|
||||
translated_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
print(f"\n✓ Translated: {translated_count} entries")
|
||||
if failed_count > 0:
|
||||
print(f"⚠ Failed: {failed_count} entries")
|
||||
|
||||
if translated_count > 0:
|
||||
# Backup original
|
||||
backup_file = lang_file.with_suffix('.po.bak')
|
||||
if backup_file.exists():
|
||||
backup_file.unlink()
|
||||
lang_file.rename(backup_file)
|
||||
print(f" Backup created: {backup_file}")
|
||||
|
||||
# Write updated file
|
||||
with open(lang_file, 'wb') as f:
|
||||
write_po(f, catalog, width=79)
|
||||
print(f" Updated: {lang_file}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print(" No translations applied")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Complete translations for all languages."""
|
||||
languages = ['nl', 'de', 'fr', 'it', 'fi', 'es', 'ar', 'he', 'nb', 'no']
|
||||
|
||||
print("="*60)
|
||||
print("Automatic Translation Completion Script")
|
||||
print("="*60)
|
||||
print("\nThis script will translate all missing strings using Google Translate.")
|
||||
print("Note: This may take a while and is subject to API rate limits.")
|
||||
print("\nLanguages to process:", ", ".join(languages))
|
||||
|
||||
response = input("\nDo you want to continue? (yes/no): ")
|
||||
if response.lower() != 'yes':
|
||||
print("Translation cancelled.")
|
||||
return
|
||||
|
||||
results = {}
|
||||
for lang in languages:
|
||||
try:
|
||||
success = complete_translations_for_language(lang)
|
||||
results[lang] = success
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error processing {lang}: {e}")
|
||||
results[lang] = False
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*60)
|
||||
print("Translation Summary")
|
||||
print("="*60)
|
||||
for lang, success in results.items():
|
||||
status = "✓ Complete" if success else "✗ Failed"
|
||||
print(f"{lang.upper():3s}: {status}")
|
||||
|
||||
print("\nNext steps:")
|
||||
print("1. Review the translations (they are machine-translated)")
|
||||
print("2. Compile translations: pybabel compile -d translations")
|
||||
print("3. Test the application in different languages")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
138
scripts/fix_translation_placeholders.py
Normal file
138
scripts/fix_translation_placeholders.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to fix placeholder names in translation files.
|
||||
Preserves original English placeholder names (like %(error)s) in all translations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
except ImportError:
|
||||
print("Error: Babel library not found. Please install it with: pip install Babel")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def extract_placeholders_from_msgid(msgid):
|
||||
"""Extract placeholder names from msgid (English text)."""
|
||||
if not msgid:
|
||||
return []
|
||||
# Find all %(name)s or %(name)d style placeholders
|
||||
pattern = r'%\(([^)]+)\)[sd]'
|
||||
return re.findall(pattern, msgid)
|
||||
|
||||
|
||||
def fix_placeholders_in_msgstr(msgstr, original_placeholders):
|
||||
"""Fix placeholder names in msgstr to match original English names."""
|
||||
if not msgstr or not original_placeholders:
|
||||
return msgstr
|
||||
|
||||
# Find all placeholders in the translated string (including format specifiers like .2f)
|
||||
pattern = r'%\(([^)]+)\)([.0-9]*[sd]|[.0-9]*f)'
|
||||
matches = re.findall(pattern, msgstr)
|
||||
|
||||
fixed_msgstr = msgstr
|
||||
|
||||
# If we have the same number of placeholders, match by position
|
||||
if len(matches) == len(original_placeholders):
|
||||
for (translated_name, format_spec), original_name in zip(matches, original_placeholders):
|
||||
if translated_name != original_name:
|
||||
# Replace the translated placeholder name with the original
|
||||
fixed_msgstr = re.sub(
|
||||
r'%\(' + re.escape(translated_name) + r'\)' + re.escape(format_spec),
|
||||
r'%(' + original_name + r')' + format_spec,
|
||||
fixed_msgstr
|
||||
)
|
||||
else:
|
||||
# If different counts, try to find and replace any translated placeholder
|
||||
# by searching for common translations and replacing with originals
|
||||
for original_name in original_placeholders:
|
||||
# Common translations that might have been used
|
||||
# This is a fallback - try to replace any placeholder that looks like it might be a translation
|
||||
# We'll be conservative and only replace if we find an exact match pattern
|
||||
pass
|
||||
|
||||
return fixed_msgstr
|
||||
|
||||
|
||||
def fix_translation_file(po_file):
|
||||
"""Fix placeholder names in a single translation file."""
|
||||
print(f"Processing {po_file}...")
|
||||
|
||||
with open(po_file, 'r', encoding='utf-8') as f:
|
||||
catalog = read_po(f)
|
||||
|
||||
fixed_count = 0
|
||||
|
||||
for message in catalog:
|
||||
if message.id and message.string:
|
||||
# Extract original placeholders from msgid
|
||||
original_placeholders = extract_placeholders_from_msgid(message.id)
|
||||
|
||||
if original_placeholders:
|
||||
# Fix msgstr if it's a string
|
||||
if isinstance(message.string, str):
|
||||
fixed = fix_placeholders_in_msgstr(message.string, original_placeholders)
|
||||
if fixed != message.string:
|
||||
message.string = fixed
|
||||
fixed_count += 1
|
||||
# Fix msgstr if it's a tuple (plural forms)
|
||||
elif isinstance(message.string, tuple):
|
||||
fixed_tuple = []
|
||||
for msgstr_item in message.string:
|
||||
fixed = fix_placeholders_in_msgstr(msgstr_item, original_placeholders)
|
||||
fixed_tuple.append(fixed)
|
||||
if fixed_tuple != list(message.string):
|
||||
message.string = tuple(fixed_tuple)
|
||||
fixed_count += 1
|
||||
|
||||
if fixed_count > 0:
|
||||
# Backup original
|
||||
backup_file = po_file.with_suffix('.po.bak2')
|
||||
if backup_file.exists():
|
||||
backup_file.unlink()
|
||||
po_file.rename(backup_file)
|
||||
print(f" Backup created: {backup_file}")
|
||||
|
||||
# Write fixed file
|
||||
with open(po_file, 'wb') as f:
|
||||
write_po(f, catalog, width=79)
|
||||
print(f" Fixed {fixed_count} entries in {po_file}")
|
||||
return True
|
||||
else:
|
||||
print(f" No fixes needed in {po_file}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Fix placeholder names in all translation files."""
|
||||
translations_dir = Path('translations')
|
||||
languages = ['nl', 'de', 'fr', 'it', 'fi', 'es', 'ar', 'he', 'nb', 'no']
|
||||
|
||||
print("="*60)
|
||||
print("Fixing Placeholder Names in Translation Files")
|
||||
print("="*60)
|
||||
print("\nThis script will preserve original English placeholder names")
|
||||
print("(like %(error)s) in all translations.\n")
|
||||
|
||||
fixed_files = []
|
||||
for lang in languages:
|
||||
po_file = translations_dir / lang / 'LC_MESSAGES' / 'messages.po'
|
||||
if po_file.exists():
|
||||
if fix_translation_file(po_file):
|
||||
fixed_files.append(lang)
|
||||
else:
|
||||
print(f"Warning: {po_file} not found, skipping...")
|
||||
|
||||
if fixed_files:
|
||||
print(f"\n✓ Fixed placeholder names in {len(fixed_files)} files: {', '.join(fixed_files)}")
|
||||
print("\nNext step: Compile translations with: pybabel compile -d translations")
|
||||
else:
|
||||
print("\n✓ No placeholder fixes needed")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user