mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 00:33:06 +08:00
[feat] add system language support across desktop and mobile
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# CrossDesk 界面本地化
|
||||
|
||||
桌面端和 Android、iOS、HarmonyOS 移动端共用 `catalog.json` 中的界面文案,支持以下 10 种语言版本:
|
||||
|
||||
| 语言 | 标识 |
|
||||
| --- | --- |
|
||||
| 简体中文 | `zh-Hans` |
|
||||
| English | `en` |
|
||||
| Русский | `ru` |
|
||||
| 繁體中文 | `zh-Hant` |
|
||||
| 日本語 | `ja` |
|
||||
| 한국어 | `ko` |
|
||||
| Deutsch | `de` |
|
||||
| Français | `fr` |
|
||||
| Español | `es` |
|
||||
| Português (Brasil) | `pt-BR` |
|
||||
|
||||
## 语言选择
|
||||
|
||||
- 新安装的桌面端默认“跟随系统”,也可以在设置中手动指定语言。已有配置中的中文、英文、俄文选择继续保留。
|
||||
- 移动端使用系统语言;Android 和 iOS 同时支持操作系统提供的应用语言设置。
|
||||
- 地区变体匹配相应语言;中文优先识别 `Hans` / `Hant`,台湾、香港、澳门使用繁体。葡萄牙语地区变体共用巴西葡萄牙语文案。
|
||||
- 支持系统首选语言列表的平台使用其中可用的语言,没有匹配项时回退到英文。修改系统语言后重新打开应用可确保所有页面与状态文案更新。
|
||||
- 只翻译应用界面。远端设备名称、文件名、用户输入、服务器公告和协议内容不作翻译。隐私政策正文使用项目已发布的中文或英文版本,第三方许可证保留原文。
|
||||
|
||||
## 维护资源
|
||||
|
||||
`catalog.json` 是唯一编辑入口,生成结果提交到各平台目录,应用运行时不依赖翻译服务。
|
||||
|
||||
```sh
|
||||
python3 localization/generate.py
|
||||
python3 localization/generate.py --check
|
||||
```
|
||||
|
||||
生成器使用 Python 标准库,输出桌面 C++ 文案表、Android 原生资源及语言声明、iOS `.lproj` 资源,以及 HarmonyOS 文案表。`--check` 检查语言覆盖、格式占位符和生成文件是否同步。
|
||||
|
||||
新增文案时,在 `strings` 中提供所有语言的译文,并加入 `platforms` 中对应的平台列表。桌面命名键同时加入 `desktop`;Slint 使用的键追加到 `slint` 列表末尾,已有索引不能重排。桌面语言顺序中的前 3 项与历史配置兼容,修改顺序需要配置迁移。
|
||||
|
||||
占位符须完整保留:Android/HarmonyOS 使用 `{0}` 等位置占位符;iOS 使用 `%1$@` 等位置格式;已有桌面文案使用其原有格式。不同语言可以调整占位符位置,但不能更改数量或类型。新语言的初始译文包含机器辅助翻译,发布前应由对应语言使用者复核术语、语气及长文本。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user