Files
crossdesk/localization/generate.py
T

135 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Generate offline UI resources from catalog.json. No network access required."""
import argparse
from collections import Counter
import hashlib
import json
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parents[1]
CATALOG = Path(__file__).with_name('catalog.json')
def quoted(value):
return json.dumps(value, ensure_ascii=False)
def resource_id(key):
return 'text_' + hashlib.sha256(key.encode()).hexdigest()[:16]
def render(data):
languages = data['languages']
strings = data['strings']
def translated(key, language):
return strings[key].get(language, strings[key].get('en', key))
outputs = {}
header = '''/*
* @Author: DI JUNKUN
* @Date: 2024-05-29
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LOCALIZATION_DATA_H_
#define _LOCALIZATION_DATA_H_
// Generated by localization/generate.py. Edit localization/catalog.json.
namespace crossdesk {
namespace localization {
namespace detail {
struct TranslationRow { const char* key; const char* text[10]; };
inline constexpr TranslationRow kTranslationRows[] = {
'''
for key in data['platforms']['desktop']:
header += ' {' + quoted(key) + ', {' + ', '.join(quoted(translated(key, lang)) for lang in languages) + '}},\n'
header += '};\n\nstruct NamedTranslation { const char* name; const char* literal; };\ninline constexpr NamedTranslation kNamedTranslations[] = {\n'
for name,key in data['desktop'].items():
header += ' {' + quoted(name) + ', ' + quoted(key) + '},\n'
header += '};\ninline constexpr const char* kSlintLiterals[] = {\n'
header += ''.join(' ' + quoted(key) + ',\n' for key in data['slint'])
header += '};\n} // namespace detail\n\n#define CROSSDESK_LOCALIZATION_ALL(X) \\\n'
header += ' \\\n'.join(' X(' + name + ')' for name in data['desktop'])
header += '\n\n} // namespace localization\n} // namespace crossdesk\n\n#endif\n'
outputs['apps/desktop/src/gui/assets/localization/localization_data.h'] = header
# Android resources participate in native locale matching and per-app language settings.
qualifiers = {'en':'values','zh-Hans':'values-b+zh+Hans','zh-Hant':'values-b+zh+Hant','pt-BR':'values-pt'}
for language in languages:
text = '<?xml version="1.0" encoding="utf-8"?>\n<!-- Generated by localization/generate.py. -->\n<resources>\n'
for key in data['platforms']['android']:
value = translated(key,language).replace('\\','\\\\').replace('\n','\\n').replace('\t','\\t').replace('"','\\"').replace("'","\\'")
text += ' <string name="'+resource_id(key)+'" formatted="false">"'+escape(value)+'"</string>\n'
text += '</resources>\n'
qualifier = qualifiers.get(language,'values-'+language)
outputs['apps/android/app/src/main/res/'+qualifier+'/strings.xml'] = text
android = '''package cn.crossdesk.mobile;
import java.util.HashMap;
import java.util.Map;
/** Generated resource keys; text and locale selection live in Android resources. */
final class LocalizationKeys {
static final Map<String, Integer> IDS = new HashMap<>();
static {
'''
android += ''.join(' IDS.put('+quoted(key)+', R.string.'+resource_id(key)+');\n' for key in data['platforms']['android'])
android += ' }\n private LocalizationKeys() {}\n}\n'
outputs['apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java'] = android
locales = ['en','zh-Hans','zh-Hant','ja','ko','de','fr','es','pt-BR','ru']
outputs['apps/android/app/src/main/res/xml/locales_config.xml'] = '<?xml version="1.0" encoding="utf-8"?>\n<locale-config xmlns:android="http://schemas.android.com/apk/res/android">\n' + ''.join(' <locale android:name="'+code+'" />\n' for code in locales) + '</locale-config>\n'
# iOS uses Foundation/SwiftUI localization directly, including strings from models.
permission = 'CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。'
for language in languages:
directory = 'apps/ios/CrossDeskMobile/Resources/'+language+'.lproj/'
outputs[directory+'Localizable.strings'] = '// Generated by localization/generate.py.\n' + ''.join(quoted(key)+' = '+quoted(translated(key,language))+';\n' for key in data['platforms']['ios'])
outputs[directory+'InfoPlist.strings'] = '"NSLocalNetworkUsageDescription" = '+quoted(translated(permission,language))+';\n'
# A generic Portuguese fallback also serves Portuguese locales outside Brazil.
for name in ['Localizable.strings','InfoPlist.strings']:
outputs['apps/ios/CrossDeskMobile/Resources/pt.lproj/'+name] = outputs['apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/'+name]
# Harmony keeps lookup pure; EntryAbility injects the system locale provider.
harmony = '// Generated by localization/generate.py.\nexport const translations: Record<string, string[]> = {\n'
for key in data['platforms']['harmony']:
harmony += ' '+quoted(key)+': ['+', '.join(quoted(translated(key,lang)) for lang in languages)+'],\n'
harmony += '};\n'
outputs['apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets'] = harmony
return outputs
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check',action='store_true',help='check catalog completeness and generated resources without writing')
args=parser.parse_args()
data=json.loads(CATALOG.read_text())
missing=[(key,lang) for key,values in data['strings'].items() for lang in data['languages'] if lang not in values]
if missing:
print(f'{len(missing)} translations missing',file=sys.stderr)
return 1
token = re.compile(r'%(?:\d+\$)?[-+0 #]*\d*(?:\.\d+)?(?:ll|l|z)?[sduif@]|%%|\{\d+\}')
for key, values in data['strings'].items():
expected = Counter(token.findall(key))
for language, value in values.items():
if Counter(token.findall(value)) != expected:
raise ValueError(f'Format placeholders differ: {language}: {key}')
if re.search(r'ZXQ\d+QXZ|[【[]\d+[】]]', value):
raise ValueError(f'Translation marker left in {language}: {key}')
for keys in data['platforms'].values():
if len(keys) != len(set(keys)):
raise ValueError('Duplicate platform resource key')
for key in keys:
if key not in data['strings']: raise ValueError(f'Unknown resource: {key}')
mismatches=[]
for relative,content in render(data).items():
path=ROOT/relative
if args.check:
if not path.exists() or path.read_text()!=content:mismatches.append(relative)
else:
if not path.exists() or path.read_text()!=content:
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(content)
if mismatches:
print('Stale generated resources:\n'+'\n'.join(mismatches),file=sys.stderr)
return 1
print(f'{len(data["strings"])} strings, {len(data["languages"])} language variants; resources '+('verified' if args.check else 'generated'))
return 0
if __name__=='__main__':
raise SystemExit(main())