feat(localization): add Japanese language support and CJK validation
This commit is contained in:
1 parent
19beba46c9
commit
7148691114
20 files changed
+12692
-161
No files matched your search
@@ -5,7 +5,8 @@ on:
|
||||
paths:
|
||||
- "Frameworks/Resources/*.xcstrings"
|
||||
- "Scripts/normalize-string-catalogs.py"
|
||||
- "Scripts/check-zh-localization.sh"
|
||||
- "Scripts/check-cjk-localization.py"
|
||||
- "Scripts/Tests/test_cjk_localization.py"
|
||||
- "Scripts/Tests/test_normalize_string_catalogs.py"
|
||||
- ".github/workflows/localization.yml"
|
||||
push:
|
||||
@@ -13,7 +14,8 @@ on:
|
||||
paths:
|
||||
- "Frameworks/Resources/*.xcstrings"
|
||||
- "Scripts/normalize-string-catalogs.py"
|
||||
- "Scripts/check-zh-localization.sh"
|
||||
- "Scripts/check-cjk-localization.py"
|
||||
- "Scripts/Tests/test_cjk_localization.py"
|
||||
- "Scripts/Tests/test_normalize_string_catalogs.py"
|
||||
- ".github/workflows/localization.yml"
|
||||
|
||||
@@ -28,5 +30,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Test catalog normalization
|
||||
run: python3 -m unittest discover -s Scripts/Tests -p 'test_normalize_string_catalogs.py'
|
||||
- name: Check catalog stability and Chinese translations
|
||||
run: Scripts/check-zh-localization.sh
|
||||
- name: Test localization checks
|
||||
run: python3 -m unittest discover -s Scripts/Tests -p 'test_cjk_localization.py'
|
||||
- name: Check catalog stability and CJK translations
|
||||
run: python3 Scripts/check-cjk-localization.py
|
||||
+1
-1
Submodule AgentKit updated: d6f4692de8...8108c9bb87.
@@ -11,6 +11,12 @@
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -35,6 +41,12 @@
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -59,6 +71,12 @@
|
||||
"value" : "Termind uses the camera to attach photos to your chat."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "チャットに写真を添付するために、Termindがカメラを使用します。"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -82,6 +100,12 @@
|
||||
"value" : "Authenticate to use your SSH keys."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH鍵を使用するために認証します。"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -105,6 +129,12 @@
|
||||
"value" : "Termind connects to servers and model providers on your local network."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termindがローカルネットワーク上のサーバやモデルプロバイダに接続します。"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -129,6 +159,12 @@
|
||||
"value" : "Termind uses the microphone to transcribe speech into your chat draft."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "音声をチャットの下書きに文字起こしするために、Termindがマイクを使用します。"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -153,6 +189,12 @@
|
||||
"value" : "Termind uses Apple Speech Recognition to turn your recording into text. Depending on system settings and language support, Apple may process the audio online."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "TermindはAppleの音声認識を使用して録音をテキストに変換します。システム設定や言語の対応状況によっては、Appleが音声をオンラインで処理する場合があります。"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -177,6 +219,12 @@
|
||||
"value" : "Connect"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "接続"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -194,6 +242,12 @@
|
||||
"Termind SFTP Items" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind SFTP項目"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3482,10 +3482,11 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
|
||||
|
||||
extension ConversationModel {
|
||||
public static func demo(language: AppLanguage = .english) -> ConversationModel {
|
||||
func text(_ en: String, _ zhHans: String, _ zhHant: String) -> String {
|
||||
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
|
||||
switch language {
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
}
|
||||
}
|
||||
@@ -3495,8 +3496,7 @@ extension ConversationModel {
|
||||
text: text(
|
||||
"Find the cause of the 502 responses. Inspect only—do not change configuration.",
|
||||
"查明 502 响应的原因。仅检查,不要更改配置。",
|
||||
"找出 502 回應的原因。僅進行檢查,不要變更設定。"
|
||||
),
|
||||
"找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。"),
|
||||
createdAt: now.addingTimeInterval(-18)
|
||||
)
|
||||
let answer = ChatMessage(
|
||||
@@ -3504,14 +3504,15 @@ extension ConversationModel {
|
||||
text: text(
|
||||
"The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.",
|
||||
"上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。",
|
||||
"上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。"
|
||||
"上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。",
|
||||
"上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。"
|
||||
),
|
||||
createdAt: now
|
||||
)
|
||||
let runID = UUID()
|
||||
let descriptor = AgentToolDescriptor(
|
||||
name: "ssh_exec",
|
||||
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。"),
|
||||
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。"),
|
||||
inputSchema: .object([:]), target: .host, approvalPolicy: .approve,
|
||||
concurrency: .parallel,
|
||||
presentation: AgentToolDescriptor.Presentation(
|
||||
@@ -3548,7 +3549,7 @@ extension ConversationModel {
|
||||
)
|
||||
]
|
||||
)
|
||||
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應")
|
||||
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断")
|
||||
conversation.messages = [
|
||||
user, answer,
|
||||
]
|
||||
|
||||
@@ -27,6 +27,7 @@ public nonisolated enum AppLocalization {
|
||||
switch locale.language.languageCode?.identifier {
|
||||
case "zh": locale.language.script?.identifier == "Hant" ? "zh-Hant" : "zh-Hans"
|
||||
case "en": "en"
|
||||
case "ja": "ja"
|
||||
default: nil
|
||||
}
|
||||
guard let localization,
|
||||
@@ -40,6 +41,7 @@ public nonisolated enum AppLocalization {
|
||||
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
|
||||
case system
|
||||
case english = "en"
|
||||
case japanese = "ja"
|
||||
case simplifiedChinese = "zh-Hans"
|
||||
case traditionalChinese = "zh-Hant"
|
||||
|
||||
@@ -49,6 +51,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch self {
|
||||
case .system: String(localized: "Follow System")
|
||||
case .english: "English"
|
||||
case .japanese: "日本語"
|
||||
case .simplifiedChinese: "简体中文"
|
||||
case .traditionalChinese: "繁體中文"
|
||||
}
|
||||
@@ -87,7 +90,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch language {
|
||||
case .system:
|
||||
defaults.removeObject(forKey: "AppleLanguages")
|
||||
case .english, .simplifiedChinese, .traditionalChinese:
|
||||
case .english, .japanese, .simplifiedChinese, .traditionalChinese:
|
||||
defaults.set([language.rawValue], forKey: "AppleLanguages")
|
||||
}
|
||||
}
|
||||
@@ -96,7 +99,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch self {
|
||||
case .system: .autoupdatingCurrent
|
||||
case .english: Locale(identifier: "en")
|
||||
case .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
|
||||
case .japanese, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale, approval: true) {
|
||||
let action = localizedActionVerb(operation.action, locale: locale)
|
||||
let title =
|
||||
isChinese(locale)
|
||||
usesLocalizedActionTemplates(locale)
|
||||
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
|
||||
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
|
||||
+ operation.activity
|
||||
@@ -92,14 +92,14 @@ nonisolated public enum AgentToolPresentation {
|
||||
guard let kind = actionKind(for: card) else {
|
||||
let action = localized(AppStrings.Assistant.run(activity), locale: locale)
|
||||
guard let hostName, !hostName.isEmpty else { return action }
|
||||
if isChinese(locale) {
|
||||
if usesLocalizedActionTemplates(locale) {
|
||||
return localized("Approval action target: \(action) \(hostName)", locale: locale)
|
||||
}
|
||||
return "\(action) on \(hostName)"
|
||||
}
|
||||
|
||||
let action = localizedActionVerb(kind, locale: locale)
|
||||
if isChinese(locale) {
|
||||
if usesLocalizedActionTemplates(locale) {
|
||||
guard let hostName, !hostName.isEmpty else {
|
||||
return localized("Approval tool action: \(action) \(activity)", locale: locale)
|
||||
}
|
||||
@@ -285,6 +285,10 @@ nonisolated public enum AgentToolPresentation {
|
||||
let clauses = order.compactMap { key in
|
||||
grouped[key].map { batchClause($0, locale: locale) }
|
||||
}
|
||||
if locale.language.languageCode?.identifier == "ja" {
|
||||
// These clauses are sentences. The enumeration comma cannot join them.
|
||||
return clauses.joined(separator: "。")
|
||||
}
|
||||
if locale.language.languageCode?.identifier == "zh" {
|
||||
return clauses.joined(separator: ",")
|
||||
}
|
||||
@@ -630,7 +634,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
return RenderedOperation(text: activityName(.suggestion, locale: locale), isArgumentValue: false)
|
||||
}
|
||||
guard let presentation = card.descriptor.presentation else {
|
||||
if isChinese(locale),
|
||||
if usesLocalizedActionTemplates(locale),
|
||||
let recovered = recoveredBuiltInOperation(
|
||||
for: card, locale: locale
|
||||
)
|
||||
@@ -1058,9 +1062,12 @@ nonisolated public enum AgentToolPresentation {
|
||||
state: AgentToolCard.State, activity: String, isArgumentValue: Bool,
|
||||
target: String?, locale: Locale
|
||||
) -> String {
|
||||
if isChinese(locale) {
|
||||
if usesLocalizedActionTemplates(locale) {
|
||||
let action = localizedActionVerb(kind, locale: locale)
|
||||
let displayedActivity = isArgumentValue ? "“\(activity)”" : activity
|
||||
let displayedActivity =
|
||||
isArgumentValue
|
||||
? (locale.language.languageCode?.identifier == "ja" ? "「\(activity)」" : "“\(activity)”")
|
||||
: activity
|
||||
let rendered: String =
|
||||
switch state {
|
||||
case .proposed, .reviewing:
|
||||
@@ -1129,9 +1136,9 @@ nonisolated public enum AgentToolPresentation {
|
||||
) -> String {
|
||||
// Commands already carry their own shell punctuation. English reads
|
||||
// more naturally without another pair of prose quotes around the
|
||||
// entire command, while Chinese uses quotation marks to separate the
|
||||
// entire command, while Chinese and Japanese use quotation marks to separate the
|
||||
// command from the surrounding sentence.
|
||||
if !isChinese(locale) {
|
||||
if !usesLocalizedActionTemplates(locale) {
|
||||
return switch kind {
|
||||
case .sshPreparingCommand:
|
||||
target.map { "Preparing to run \(activity) on \($0)" }
|
||||
@@ -1208,7 +1215,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
_ kind: AgentToolDescriptor.Presentation.ActionKind,
|
||||
form: ActionForm, locale: Locale
|
||||
) -> String {
|
||||
if isChinese(locale) { return localizedActionVerb(kind, locale: locale) }
|
||||
if usesLocalizedActionTemplates(locale) { return localizedActionVerb(kind, locale: locale) }
|
||||
let forms: ActionForms =
|
||||
switch kind {
|
||||
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
|
||||
@@ -1286,8 +1293,8 @@ nonisolated public enum AgentToolPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isChinese(_ locale: Locale) -> Bool {
|
||||
locale.language.languageCode?.identifier == "zh"
|
||||
private static func usesLocalizedActionTemplates(_ locale: Locale) -> Bool {
|
||||
["zh", "ja"].contains(locale.language.languageCode?.identifier ?? "")
|
||||
}
|
||||
|
||||
private static func activityName(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
switch language {
|
||||
case .simplifiedChinese: prose = "部署之前先检查状态,并保留可复用的验证步骤。"
|
||||
case .traditionalChinese: prose = "部署之前先檢查狀態,並保留可重用的驗證步驟。"
|
||||
case .japanese: prose = "デプロイ前に状態を確認し、再利用できる検証手順を残してください。"
|
||||
case .english, .system: prose = "Check status before deploying and retain reusable verification steps."
|
||||
}
|
||||
let body = Array(repeating: prose, count: 80).joined(separator: "\n")
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"**检查上游服务。**\n先读取 `nginx` 日志,再核对监听端口;暂不更改配置。"
|
||||
case .traditionalChinese:
|
||||
"**檢查上游服務。**\n先讀取 `nginx` 日誌,再核對監聽連接埠;暫不變更設定。"
|
||||
case .japanese:
|
||||
"**上流サービスを確認します。**\nまず`nginx`のログを読み、待ち受けポートを確認します。設定はまだ変更しません。"
|
||||
case .system, .english:
|
||||
"**Inspect the upstream service.**\nRead the `nginx` logs, then check listening ports before changing configuration."
|
||||
}
|
||||
@@ -41,6 +43,7 @@
|
||||
switch language {
|
||||
case .simplifiedChinese: "连接上游服务失败:连接被拒绝。"
|
||||
case .traditionalChinese: "連線至上游服務失敗:連線遭拒。"
|
||||
case .japanese: "上流サービスに接続できませんでした:接続が拒否されました。"
|
||||
case .system, .english: "connect() failed: connection refused"
|
||||
}
|
||||
cards[1].result = AgentToolResult(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"check_localization", Path(__file__).resolve().parents[1] / "check-cjk-localization.py"
|
||||
)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
CHECK = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(CHECK)
|
||||
|
||||
|
||||
def catalog(key: str, value: str, state: str = "translated") -> dict:
|
||||
return {
|
||||
"sourceLanguage": "en",
|
||||
"strings": {key: {"localizations": {"ja": {"stringUnit": {"state": state, "value": value}}}}},
|
||||
}
|
||||
|
||||
|
||||
def japanese_problems(data):
|
||||
return CHECK.problems(data, languages=("ja",))
|
||||
|
||||
|
||||
class JapaneseLocalizationTests(unittest.TestCase):
|
||||
def test_rejects_spaces_in_both_directions_for_all_japanese_scripts(self):
|
||||
for value in ["日本語 API", "API 日本語", "ひらがな SSH", "SSH ひらがな", "サーバ API", "API サーバ",
|
||||
"サーバ\tSSH", "SSH\u00a0サーバ", "サーバ\u3000SSH", "3 台", "サーバ 3"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertTrue(any("spacing" in p for p in japanese_problems(catalog("Label", value))))
|
||||
|
||||
def test_retains_english_protocol_spaces_and_japanese_label_spacing(self):
|
||||
for key, value in [("Label", "ssh user@hostで接続"), ("Label", "種類 不明"), ("Label", "SSHサーバ"),
|
||||
("Type %@", "種類 %@"), ("Type %@", "種類 %1$@")]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(japanese_problems(catalog(key, value)), [])
|
||||
|
||||
def test_type_exception_is_narrow(self):
|
||||
self.assertTrue(japanese_problems(catalog("Label %@", "種類 %@")))
|
||||
self.assertTrue(japanese_problems(catalog("Type %@", "種類 %@")))
|
||||
self.assertTrue(japanese_problems(catalog("Type %@", "種類 %@の SSH鍵")))
|
||||
|
||||
def test_reordered_arguments_keep_their_original_types(self):
|
||||
self.assertEqual(japanese_problems(catalog("%lld on %@", "%2$@で%1$lld件")), [])
|
||||
self.assertTrue(japanese_problems(catalog("%lld on %@", "%1$@で%2$lld件")))
|
||||
self.assertTrue(japanese_problems(catalog("%lld on %@", "%@で%lld件")))
|
||||
|
||||
def test_rejects_lost_arguments_and_literal_percent(self):
|
||||
self.assertTrue(japanese_problems(catalog("%lld%% complete", "%lld完了")))
|
||||
self.assertTrue(japanese_problems(catalog("Host %@", "ホスト")))
|
||||
self.assertEqual(japanese_problems(catalog("%lld%% complete", "%lld%%完了")), [])
|
||||
|
||||
def test_uses_localized_english_value_for_metadata_keys(self):
|
||||
data = catalog("NSCameraUsageDescription", "カメラを使用します。")
|
||||
data["strings"]["NSCameraUsageDescription"]["localizations"]["en"] = {
|
||||
"stringUnit": {"state": "translated", "value": "Use the camera."}
|
||||
}
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
|
||||
def test_rejects_missing_unfinished_and_empty_translations(self):
|
||||
data = catalog("Settings", "設定")
|
||||
data["strings"]["Settings"]["localizations"] = {}
|
||||
self.assertTrue(japanese_problems(data))
|
||||
self.assertTrue(japanese_problems(catalog("Settings", "設定", "needs_review")))
|
||||
self.assertTrue(japanese_problems(catalog("Settings", "")))
|
||||
self.assertEqual(japanese_problems(catalog("", "")), [])
|
||||
|
||||
def test_checks_plural_units_and_rejects_non_japanese_categories(self):
|
||||
data = catalog("%lld files", "%lld個のファイル")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations["ja"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(japanese_problems(data))
|
||||
localizations["ja"] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
unit["stringUnit"]["value"] = "%lld ファイル"
|
||||
self.assertTrue(japanese_problems(data))
|
||||
unit["stringUnit"]["value"] = "%lld個のファイル"
|
||||
localizations["ja"]["variations"]["plural"]["one"] = unit
|
||||
self.assertTrue(japanese_problems(data))
|
||||
|
||||
def test_respects_nontranslatable_entries(self):
|
||||
data = catalog("Identifier", "")
|
||||
data["strings"]["Identifier"]["shouldTranslate"] = False
|
||||
self.assertEqual(japanese_problems(data), [])
|
||||
|
||||
|
||||
class ChineseLocalizationTests(unittest.TestCase):
|
||||
def check(self, key, value, language):
|
||||
data = catalog(key, value)
|
||||
localizations = data["strings"][key]["localizations"]
|
||||
localizations[language] = localizations.pop("ja")
|
||||
return CHECK.problems(data, languages=(language,))
|
||||
|
||||
def test_preserves_chinese_spacing_and_punctuation_checks(self):
|
||||
for language in ["zh-Hans", "zh-Hant"]:
|
||||
for value in ["中文 SSH", "SSH 中文", "中文,", "中文: SSH", "SSH :中文"]:
|
||||
with self.subTest(language=language, value=value):
|
||||
self.assertTrue(self.check("Label", value, language))
|
||||
self.assertEqual(self.check("Label", "中文SSH", language), [])
|
||||
|
||||
def test_preserves_type_subtitle_exception(self):
|
||||
for language, label in [("zh-Hans", "类型"), ("zh-Hant", "類型")]:
|
||||
self.assertEqual(self.check("Type %@", label + " %@", language), [])
|
||||
self.assertTrue(self.check("Other %@", label + " %@", language))
|
||||
|
||||
def test_shared_validation_requires_every_supported_language(self):
|
||||
data = catalog("Settings", "設定")
|
||||
failures = CHECK.problems(data)
|
||||
self.assertEqual(len(failures), 2)
|
||||
self.assertTrue(any("zh-Hans" in failure for failure in failures))
|
||||
self.assertTrue(any("zh-Hant" in failure for failure in failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Chinese and Japanese catalog coverage, format arguments, and spacing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
SPEC = importlib.util.spec_from_file_location("catalog_normalizer", SCRIPTS / "normalize-string-catalogs.py")
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
CATALOGS = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(CATALOGS)
|
||||
|
||||
JAPANESE = r"\u3005\u3006\u3041-\u3096\u30A1-\u30FA\u30FC\u3400-\u4DBF\u4E00-\u9FFF"
|
||||
LATIN = r"A-Za-z0-9%@`"
|
||||
SPACE = r"[ \t\u00A0\u3000]+"
|
||||
MIXED_SPACING = re.compile(rf"[{JAPANESE}]{SPACE}[{LATIN}]|[{LATIN}]{SPACE}[{JAPANESE}]")
|
||||
FORMAT = re.compile(r"%(?:(?P<position>[1-9][0-9]*)\$)?[-+ #0]*[0-9]*(?:\.[0-9]+)?(?P<type>hh[diouxX]|h[diouxX]|ll[diouxX]|l[diouxXfFeEgG]|z[diouxX]|[diouxXfFeEgGaAcCsSp@%])")
|
||||
|
||||
|
||||
def units(value: dict):
|
||||
if "stringUnit" in value:
|
||||
yield value["stringUnit"]
|
||||
for key, child in value.items():
|
||||
if key != "stringUnit" and isinstance(child, dict):
|
||||
yield from units(child)
|
||||
|
||||
|
||||
def arguments(value: str) -> Counter:
|
||||
"""Positions matter: translating word order must not exchange argument types."""
|
||||
result = Counter()
|
||||
next_position = 1
|
||||
for match in FORMAT.finditer(value):
|
||||
kind = match["type"]
|
||||
if kind == "%":
|
||||
result[(0, "%")] += 1
|
||||
continue
|
||||
position = int(match["position"]) if match["position"] else next_position
|
||||
next_position += match["position"] is None
|
||||
result[(position, kind)] += 1
|
||||
return result
|
||||
|
||||
|
||||
def problems(catalog: dict, languages=("zh-Hans", "zh-Hant", "ja")) -> list[str]:
|
||||
return [problem for language in languages for problem in language_problems(catalog, language)]
|
||||
|
||||
|
||||
def language_problems(catalog: dict, language: str) -> list[str]:
|
||||
failures = []
|
||||
for key, entry in catalog["strings"].items():
|
||||
if entry.get("shouldTranslate") is False:
|
||||
continue
|
||||
localizations = entry.get("localizations", {})
|
||||
localization = localizations.get(language, {})
|
||||
translated = list(units(localization))
|
||||
if not translated:
|
||||
failures.append(f"{key!r}: missing {language} translation")
|
||||
continue
|
||||
english = localizations.get(catalog["sourceLanguage"], {})
|
||||
source_unit = english.get("stringUnit") or english.get("variations", {}).get("plural", {}).get(
|
||||
"other", {}
|
||||
).get("stringUnit", {"value": key})
|
||||
expected = arguments(source_unit["value"])
|
||||
if "plural" in english.get("variations", {}):
|
||||
if set(localization.get("variations", {}).get("plural", {})) != {"other"}:
|
||||
failures.append(f"{key!r}: {language} plural rules must contain only 'other'")
|
||||
for unit in translated:
|
||||
value = unit.get("value")
|
||||
if unit.get("state") != "translated" or not isinstance(value, str) or (key and not value):
|
||||
failures.append(f"{key!r}: unfinished {language} translation")
|
||||
continue
|
||||
if arguments(value) != expected:
|
||||
failures.append(f"{key!r}: {language} format arguments differ: {value!r}")
|
||||
# Key-card subtitles intentionally separate the label and value.
|
||||
spacing_value = value
|
||||
if language == "ja":
|
||||
if key == "Type %@":
|
||||
spacing_value = re.sub(r"^種類 (?=%(?:1\$)?@)", "種類", value)
|
||||
invalid_spacing = MIXED_SPACING.search(spacing_value)
|
||||
else:
|
||||
if key == "Type %@":
|
||||
spacing_value = re.sub(r"^(类型|類型) (?=%@)", r"\1", value)
|
||||
han = r"\u3400-\u4DBF\u4E00-\u9FFF"
|
||||
invalid_spacing = re.search(rf"[{han}]", spacing_value) and (
|
||||
re.search(rf"[{han}]{SPACE}[{LATIN}]|[{LATIN}]{SPACE}[{han}]", spacing_value)
|
||||
or re.search(r"[ \t]+[—·→:,。!?;、/:]|[—·→:,。!?;、/:][ \t]+", spacing_value)
|
||||
or re.search(rf"[{han}][,.!?;]|[,.!?;][{han}]", spacing_value)
|
||||
)
|
||||
if invalid_spacing:
|
||||
failures.append(f"{key!r}: invalid {language} spacing: {value!r}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("catalogs", nargs="*", type=Path)
|
||||
args = parser.parse_args()
|
||||
failed = False
|
||||
for path in args.catalogs or CATALOGS.DEFAULT_CATALOGS:
|
||||
try:
|
||||
original = path.read_bytes()
|
||||
failures = problems(CATALOGS.load_catalog(original))
|
||||
if CATALOGS.normalize(original) != original:
|
||||
failures.append("expected manual extraction state and canonical Xcode formatting; "
|
||||
"run Scripts/normalize-string-catalogs.py --write")
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"{path}: {error}", file=sys.stderr)
|
||||
failed = True
|
||||
continue
|
||||
for failure in failures:
|
||||
print(f"{path}: {failure}", file=sys.stderr)
|
||||
failed |= bool(failures)
|
||||
if not failed:
|
||||
print("zh-Hans, zh-Hant, and ja coverage, format arguments, spacing, and catalog checks passed.")
|
||||
return int(failed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
if [[ $# -gt 1 ]]; then
|
||||
echo "Usage: $0 [catalog]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
catalogs=("$1")
|
||||
else
|
||||
catalogs=(
|
||||
"$script_dir/../Frameworks/Resources/Localizable.xcstrings"
|
||||
"$script_dir/../Frameworks/Resources/InfoPlist.xcstrings"
|
||||
)
|
||||
fi
|
||||
|
||||
python3 "$script_dir/normalize-string-catalogs.py" --check "${catalogs[@]}"
|
||||
|
||||
for catalog in "${catalogs[@]}"; do
|
||||
|
||||
if ! jq empty "$catalog"; then
|
||||
echo "Invalid string catalog: $catalog" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for language in zh-Hans zh-Hant; do
|
||||
missing=$(jq -r --arg language "$language" '
|
||||
.strings | to_entries[]
|
||||
| select(.value.shouldTranslate != false and .value.localizations[$language] == null)
|
||||
| .key
|
||||
' "$catalog")
|
||||
if [[ -n "$missing" ]]; then
|
||||
echo "Missing $language localizations:" >&2
|
||||
echo "$missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unfinished=$(jq -r --arg language "$language" '
|
||||
.strings | to_entries[] as $entry
|
||||
| select($entry.value.shouldTranslate != false)
|
||||
| [
|
||||
$entry.value.localizations[$language]
|
||||
| .. | objects | .stringUnit?
|
||||
| select(. != null and (.state != "translated" or (.value == "" and $entry.key != "")))
|
||||
]
|
||||
| select(length > 0)
|
||||
| $entry.key
|
||||
' "$catalog")
|
||||
if [[ -n "$unfinished" ]]; then
|
||||
echo "Unfinished $language localizations:" >&2
|
||||
echo "$unfinished" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
violations=$(jq -r --arg language "$language" '
|
||||
.strings | to_entries[] as $entry
|
||||
| select($entry.value.shouldTranslate != false)
|
||||
| [
|
||||
($entry.value.localizations[$language] // {})
|
||||
| .. | objects | .stringUnit?.value? // empty
|
||||
]
|
||||
| .[]
|
||||
| select(
|
||||
# Key-card type subtitles intentionally separate the label and value.
|
||||
(if $entry.key == "Type %@" then sub("^(?<label>类型|類型) (?=%@)"; "\(.label)") else . end)
|
||||
|
|
||||
test("\\p{Han}")
|
||||
and (
|
||||
test("\\p{Han}[ \\t]+[A-Za-z0-9%@`]|[A-Za-z0-9%@`][ \\t]+\\p{Han}")
|
||||
or test("[ \\t]+[—·→:,。!?;、/:]|[—·→:,。!?;、/:][ \\t]+")
|
||||
or test("\\p{Han}[,.!?;]|[,.!?;]\\p{Han}")
|
||||
)
|
||||
)
|
||||
| "\($entry.key): \(.)"
|
||||
' "$catalog")
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo "Invalid $language spacing:" >&2
|
||||
echo "$violations" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
done
|
||||
|
||||
echo "zh-Hans and zh-Hant localization checks passed."
|
||||
@@ -3,7 +3,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|zh-Hans|zh-Hant>"
|
||||
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|ja|zh-Hans|zh-Hant>"
|
||||
}
|
||||
|
||||
if (( $# != 2 )); then
|
||||
@@ -28,6 +28,10 @@ case "$language" in
|
||||
apple_language="en"
|
||||
apple_locale="en_US"
|
||||
;;
|
||||
ja)
|
||||
apple_language="ja"
|
||||
apple_locale="ja_JP"
|
||||
;;
|
||||
zh-Hans)
|
||||
apple_language="zh-Hans"
|
||||
apple_locale="zh-Hans"
|
||||
|
||||
@@ -29,12 +29,12 @@ import TermindApplication
|
||||
return "Pass --termind-demo only once."
|
||||
case .invalidFormat(let value):
|
||||
return "Invalid Demo argument \(value). Expected "
|
||||
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|zh-Hans|zh-Hant>."
|
||||
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|ja|zh-Hans|zh-Hant>."
|
||||
case .unknownScene(let value):
|
||||
return
|
||||
"Unknown Demo scene \(value). Expected hosts, workspace, agent, command, metrics, metrics-detail, or app-settings."
|
||||
case .unknownLanguage(let value):
|
||||
return "Unknown Demo language \(value). Expected en or zh."
|
||||
return "Unknown Demo language \(value). Expected en, ja, zh-Hans, or zh-Hant."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ import TermindApplication
|
||||
let language: AppLanguage
|
||||
switch languageValue {
|
||||
case "en": language = .english
|
||||
case "ja": language = .japanese
|
||||
case "zh-Hans": language = .simplifiedChinese
|
||||
case "zh-Hant": language = .traditionalChinese
|
||||
default: throw ParseError.unknownLanguage(languageValue)
|
||||
@@ -147,10 +148,10 @@ import TermindApplication
|
||||
|
||||
let session = SSHSessionModel.demo(host: host)
|
||||
let pane = Pane(.sshSession(session))
|
||||
pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷")
|
||||
pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断")
|
||||
let workspace = Workspace(pane: pane)
|
||||
workspace.customTitle = text(
|
||||
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷")
|
||||
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
|
||||
workspace.focusedPaneID = pane.id
|
||||
|
||||
let conversation = ConversationModel.demo(language: language)
|
||||
@@ -192,12 +193,12 @@ import TermindApplication
|
||||
let first = Pane(.sshSession(firstSession))
|
||||
first.customTitle =
|
||||
showsCommandResult
|
||||
? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌")
|
||||
? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ")
|
||||
: "api-01"
|
||||
let sftpChannel = SFTPChannelModel.demo(hostID: saved[0].id)
|
||||
sftpChannel.selection = ["/etc/nginx/nginx.conf"]
|
||||
let sftp = Pane(.sftpChannel(sftpChannel))
|
||||
sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定")
|
||||
sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定")
|
||||
let secondSession =
|
||||
showsCommandResult
|
||||
? SSHSessionModel.demo(host: saved[1])
|
||||
@@ -231,8 +232,8 @@ import TermindApplication
|
||||
workspace.focusedPaneID = first.id
|
||||
workspace.customTitle =
|
||||
showsCommandResult
|
||||
? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查")
|
||||
: text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷")
|
||||
? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査")
|
||||
: text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
|
||||
workspace.broadcastsInput = !showsCommandResult
|
||||
if showsCommandResult {
|
||||
workspace.commandGeneratorState = .inserted(targetCount: 1)
|
||||
@@ -277,10 +278,11 @@ import TermindApplication
|
||||
]).joined(separator: "\r\n")
|
||||
}
|
||||
|
||||
private func text(en: String, zhHans: String, zhHant: String) -> String {
|
||||
private func text(en: String, zhHans: String, zhHant: String, ja: String) -> String {
|
||||
switch language {
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ extension HostLibraryModel {
|
||||
/// Seed data for P0, so the sidebar and workspace list can be exercised before any
|
||||
/// persistence or networking exists.
|
||||
static func demo(language: AppLanguage = .english, includesConnectionActivity: Bool = true) -> HostLibraryModel {
|
||||
func text(_ en: String, _ zhHans: String, _ zhHant: String) -> String {
|
||||
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
|
||||
switch language {
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
}
|
||||
}
|
||||
@@ -24,22 +25,22 @@ extension HostLibraryModel {
|
||||
return distribution
|
||||
}
|
||||
|
||||
let production = HostGroup(name: text("Production", "生产环境", "正式環境"), sortIndex: 0)
|
||||
let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境"), sortIndex: 1)
|
||||
let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室"), sortIndex: 2)
|
||||
let production = HostGroup(name: text("Production", "生产环境", "正式環境", "本番環境"), sortIndex: 0)
|
||||
let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境", "ステージング"), sortIndex: 1)
|
||||
let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ"), sortIndex: 2)
|
||||
let edge = HostGroup(
|
||||
name: text("Edge", "边缘节点", "邊緣節點"), parentID: production.id, sortIndex: 0
|
||||
name: text("Edge", "边缘节点", "邊緣節點", "エッジ"), parentID: production.id, sortIndex: 0
|
||||
)
|
||||
|
||||
let now = Date(timeIntervalSinceReferenceDate: 776_000_000)
|
||||
|
||||
let personalKey = SSHKeyRef(
|
||||
label: text("Personal", "个人", "個人"), algorithm: .ed25519,
|
||||
label: text("Personal", "个人", "個人", "個人用"), algorithm: .ed25519,
|
||||
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
|
||||
credentialRef: "termind.key.personal", colorTag: .blue
|
||||
)
|
||||
let workKey = SSHKeyRef(
|
||||
label: text("Work (no sync)", "工作(不同步)", "工作(不同步)"), algorithm: .ecdsa256,
|
||||
label: text("Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)"), algorithm: .ecdsa256,
|
||||
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
|
||||
credentialRef: "termind.key.work",
|
||||
storage: .thisDevice, colorTag: .purple
|
||||
@@ -49,24 +50,25 @@ extension HostLibraryModel {
|
||||
// One of each shape, so all four combinations of the two secrets are on
|
||||
// screen at P0: key only, key *and* password, password only, neither.
|
||||
let deploy = Identity(
|
||||
name: text("Deploy", "部署账号", "部署帳號"), username: "deploy", keyID: personalKey.id,
|
||||
notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層"),
|
||||
name: text("Deploy", "部署账号", "部署帳號", "デプロイ"), username: "deploy", keyID: personalKey.id,
|
||||
notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層"),
|
||||
colorTag: .green
|
||||
)
|
||||
let postgres = Identity(
|
||||
name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員"), username: "postgres",
|
||||
name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者"), username: "postgres",
|
||||
credentialRef: "termind.password.postgres", keyID: workKey.id,
|
||||
notes: text("Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo"),
|
||||
notes: text(
|
||||
"Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード"),
|
||||
colorTag: .orange
|
||||
)
|
||||
let consoleRoot = Identity(
|
||||
name: text("Console root", "控制台 root", "主控台 root"), username: "root",
|
||||
name: text("Console root", "控制台 root", "主控台 root", "コンソールのroot"), username: "root",
|
||||
credentialRef: "termind.password.console-root",
|
||||
notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理"), colorTag: .red
|
||||
notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用"), colorTag: .red
|
||||
)
|
||||
let unfinished = Identity(
|
||||
name: text("New admin", "新管理员", "新增管理員"), username: "admin",
|
||||
notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊")
|
||||
name: text("New admin", "新管理员", "新增管理員", "新しい管理者"), username: "admin",
|
||||
notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択")
|
||||
)
|
||||
|
||||
// Half the fleet is left without an identity on purpose, so the "not set
|
||||
@@ -125,35 +127,35 @@ extension HostLibraryModel {
|
||||
]
|
||||
|
||||
let diagnostics = SnippetPackage(
|
||||
name: text("Diagnostics", "诊断", "診斷"), sortIndex: 0
|
||||
name: text("Diagnostics", "诊断", "診斷", "診断"), sortIndex: 0
|
||||
)
|
||||
let webOperations = SnippetPackage(
|
||||
name: text("Web Operations", "Web 运维", "Web 維運"),
|
||||
name: text("Web Operations", "Web 运维", "Web 維運", "Web運用"),
|
||||
parentID: diagnostics.id, sortIndex: 0
|
||||
)
|
||||
let snippets = [
|
||||
Snippet(
|
||||
title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量"),
|
||||
title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量"),
|
||||
body: "du -sh /* | sort -h",
|
||||
packageID: diagnostics.id, targetHostIDs: [hosts[0].id],
|
||||
sortIndex: 0
|
||||
),
|
||||
Snippet(
|
||||
title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序"),
|
||||
title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス"),
|
||||
body: "ps aux --sort=-%mem | head -20",
|
||||
packageID: diagnostics.id,
|
||||
targetHostIDs: [hosts[0].id, hosts[1].id],
|
||||
sortIndex: 1
|
||||
),
|
||||
Snippet(
|
||||
title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤"),
|
||||
title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡"),
|
||||
body: "tail -f /var/log/nginx/error.log",
|
||||
packageID: webOperations.id,
|
||||
targetHostIDs: [hosts[0].id, hosts[1].id],
|
||||
sortIndex: 0
|
||||
),
|
||||
Snippet(
|
||||
title: text("Listening ports", "监听中的端口", "監聽中的連接埠"),
|
||||
title: text("Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート"),
|
||||
body: "ss -tulpn", sortIndex: 3
|
||||
),
|
||||
]
|
||||
@@ -187,11 +189,11 @@ extension HostLibraryModel {
|
||||
PortForwardingRule(
|
||||
hostID: db.id, kind: .local, bindPort: 5432,
|
||||
targetHost: "localhost", targetPort: 5432,
|
||||
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫")
|
||||
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres")
|
||||
),
|
||||
PortForwardingRule(
|
||||
hostID: web01.id, kind: .dynamic, bindPort: 1080,
|
||||
label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy")
|
||||
label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ")
|
||||
),
|
||||
]
|
||||
|
||||
@@ -214,7 +216,7 @@ extension HostLibraryModel {
|
||||
hostID: web01.id, direction: .upload,
|
||||
localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf",
|
||||
byteCount: 8_192,
|
||||
state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒")),
|
||||
state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました")),
|
||||
queuedAt: now.addingTimeInterval(-3_600)
|
||||
),
|
||||
SFTPTransfer(
|
||||
@@ -245,7 +247,7 @@ extension HostLibraryModel {
|
||||
store.setStatus(.connecting, for: db.id)
|
||||
if let staging = hosts.first(where: { $0.name == "staging-app" }) {
|
||||
store.setStatus(
|
||||
.failed(text("Connection refused", "连接被拒绝", "連線遭拒")), for: staging.id
|
||||
.failed(text("Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました")), for: staging.id
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -994,6 +994,7 @@ struct AgentToolPresentationTests {
|
||||
]
|
||||
let locales: [(String, String?, String?)] = [
|
||||
("en", nil, nil),
|
||||
("ja", "「", "」"),
|
||||
("zh-Hans", "“", "”"),
|
||||
("zh-Hant", "「", "」"),
|
||||
]
|
||||
@@ -1182,6 +1183,29 @@ struct AgentToolPresentationTests {
|
||||
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: language))
|
||||
== expected)
|
||||
}
|
||||
#expect(
|
||||
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ja"))
|
||||
== "2個のファイルの書き込みが完了しました。ファイルの書き込みに失敗しました")
|
||||
}
|
||||
|
||||
@Test
|
||||
func japaneseApprovalUsesTheActionAndHostInJapaneseWordOrder() {
|
||||
let request = AgentApprovalRequest(
|
||||
invocation: AgentToolInvocation(
|
||||
runID: UUID(),
|
||||
call: AgentToolCall(id: "approval", name: "ssh_exec", arguments: .object(["command": .string("pwd")]))
|
||||
),
|
||||
descriptor: AgentToolDescriptor(
|
||||
name: "ssh_exec", summary: "Execute over SSH", inputSchema: .object([:]), target: .host,
|
||||
approvalPolicy: .ask,
|
||||
presentation: .init(
|
||||
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .sshCommand),
|
||||
actionKind: .run)),
|
||||
userIntent: "Inspect"
|
||||
)
|
||||
let presentation = AgentToolPresentation.approval(
|
||||
for: request, hostName: "web-01", locale: Locale(identifier: "ja-JP"))
|
||||
#expect(presentation.title == "web-01でコマンドを実行")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
@@ -23,6 +24,12 @@ struct AppLanguageTests {
|
||||
#expect(AppLanguage.stored(in: defaults) == .english)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["en"])
|
||||
|
||||
AppLanguage.store(.japanese, in: defaults)
|
||||
#expect(AppLanguage.stored(in: defaults) == .japanese)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["ja"])
|
||||
#expect(AppLanguage.japanese.locale.identifier == "ja")
|
||||
#expect(AppLanguage.japanese.displayName == "日本語")
|
||||
|
||||
AppLanguage.store(.simplifiedChinese, in: defaults)
|
||||
#expect(AppLanguage.stored(in: defaults) == .simplifiedChinese)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == ["zh-Hans"])
|
||||
@@ -84,6 +91,19 @@ struct AppLanguageTests {
|
||||
#expect(AppLocalization.string("Settings", locale: Locale(identifier: "zh-Hant")) == "設定")
|
||||
}
|
||||
|
||||
@Test(arguments: ["ja", "ja-JP", "ja_JP"])
|
||||
func japaneseLocalesResolveAppCopyAndPreserveInterpolations(identifier: String) {
|
||||
let locale = Locale(identifier: identifier)
|
||||
#expect(AppLocalization.string("Settings", locale: locale) == "設定")
|
||||
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "キャンセル")
|
||||
let host = "web-01"
|
||||
let command = "uptime"
|
||||
#expect(
|
||||
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
|
||||
== "web-01で「uptime」を実行しました")
|
||||
#expect(AppLocalization.string("\(3) files", locale: locale) == "3個のファイル")
|
||||
}
|
||||
|
||||
@Test(arguments: [("zh-Hans", "词元"), ("zh-Hant", "符元")])
|
||||
func assistantTokenCountsUseModelTerminology(language: String, unit: String) {
|
||||
let locale = Locale(identifier: language)
|
||||
@@ -125,9 +145,9 @@ struct AppLanguageTests {
|
||||
for (key, arguments) in interpolations.sorted(by: { $0.key < $1.key }) {
|
||||
let entry = try #require(strings[key] as? [String: Any], "Missing forwarding template: \(key)")
|
||||
let localizations = try #require(entry["localizations"] as? [String: Any])
|
||||
for language in ["zh-Hans", "zh-Hant"] {
|
||||
let chinese = try #require(localizations[language] as? [String: Any])
|
||||
let unit = try #require(chinese["stringUnit"] as? [String: Any])
|
||||
for language in ["ja", "zh-Hans", "zh-Hant"] {
|
||||
let localization = try #require(localizations[language] as? [String: Any])
|
||||
let unit = try #require(localization["stringUnit"] as? [String: Any])
|
||||
let translation = try #require(unit["value"] as? String)
|
||||
let placeholders = translation.matches(of: /%(?:[1-9][0-9]*\$)?@/)
|
||||
#expect(placeholders.count == arguments.count, "Placeholder mismatch: \(language) \(key)")
|
||||
@@ -164,9 +184,9 @@ struct AppLanguageTests {
|
||||
let localizations = try #require(entry["localizations"] as? [String: Any])
|
||||
let english = try #require(localizations["en"] as? [String: Any])
|
||||
#expect(pluralForms(in: english).keys.sorted() == ["one", "other"])
|
||||
for language in ["zh-Hans", "zh-Hant"] {
|
||||
let chinese = try #require(localizations[language] as? [String: Any])
|
||||
#expect(pluralForms(in: chinese).keys.sorted() == ["other"])
|
||||
for language in ["ja", "zh-Hans", "zh-Hant"] {
|
||||
let localization = try #require(localizations[language] as? [String: Any])
|
||||
#expect(pluralForms(in: localization).keys.sorted() == ["other"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct PresentationCopyTests {
|
||||
NativeMenuEntry(title: String("Custom server")),
|
||||
])
|
||||
]
|
||||
for language in ["en", "zh-Hans", "zh-Hant"] {
|
||||
for language in ["en", "ja", "zh-Hans", "zh-Hant"] {
|
||||
let locale = Locale(identifier: language)
|
||||
let menu = NativeMenuEntry.menu(entries, locale: locale)
|
||||
#expect(
|
||||
@@ -31,7 +31,7 @@ struct PresentationCopyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 7])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 7])
|
||||
func fileDeletionKeepsLiteralPathsAndLocalizesItsRemainder(language: String, remaining: Int) {
|
||||
let locale = Locale(identifier: language)
|
||||
let title = AppLocalization.resolve(AppStrings.SFTP.deleteConfirmation("file.txt"), locale: locale)
|
||||
@@ -54,7 +54,7 @@ struct PresentationCopyTests {
|
||||
#expect(!message.contains("NSLanguage"))
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 2])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 2])
|
||||
func groupDeletionUsesTheRequestedLanguageAndPreservesPluralRules(language: String, count: Int) {
|
||||
let locale = Locale(identifier: language)
|
||||
let withoutGroups = AppStrings.Vault.groupDeletionMessage(
|
||||
@@ -80,7 +80,7 @@ struct PresentationCopyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
|
||||
func snippetCountsKeepZeroSingularAndPluralMeanings(language: String) {
|
||||
let locale = Locale(identifier: language)
|
||||
for count in 0...2 {
|
||||
@@ -110,7 +110,7 @@ struct PresentationCopyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
|
||||
func modelAccessibilityOnlyIncludesAvailableConfiguration(language: String) {
|
||||
let locale = Locale(identifier: language)
|
||||
#expect(
|
||||
@@ -142,7 +142,7 @@ struct PresentationCopyTests {
|
||||
#expect(AppLocalization.resolve(reasoning, locale: locale).contains("High"))
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"], [0, 1, 2])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"], [0, 1, 2])
|
||||
func transferConflictsKeepTheirCount(language: String, count: Int) {
|
||||
let message = AppLocalization.resolve(
|
||||
AppStrings.SFTP.existingItemsMessage(count: count), locale: Locale(identifier: language))
|
||||
@@ -169,7 +169,7 @@ struct PresentationCopyTests {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
@Test(arguments: ["en", "ja", "zh-Hans", "zh-Hant"])
|
||||
func swiftUIResourceBridgePreservesEnvironmentLanguageAndMarkdown(language: String) throws {
|
||||
let locale = Locale(identifier: language)
|
||||
let resource = AppStrings.Settings.terminalConfigurationExplanation
|
||||
|
||||
@@ -16,6 +16,7 @@ import TermindPresentation
|
||||
func parsesEverySceneAndLanguageCombination() throws {
|
||||
let languages: [(argument: String, language: AppLanguage)] = [
|
||||
("en", .english),
|
||||
("ja", .japanese),
|
||||
("zh-Hans", .simplifiedChinese),
|
||||
("zh-Hant", .traditionalChinese),
|
||||
]
|
||||
@@ -96,7 +97,7 @@ import TermindPresentation
|
||||
|
||||
@Test
|
||||
func vaultCaptureStatusesMatchItsTwoWorkspacesInEveryLanguage() throws {
|
||||
for language in [AppLanguage.english, .simplifiedChinese, .traditionalChinese] {
|
||||
for language in [AppLanguage.english, .japanese, .simplifiedChinese, .traditionalChinese] {
|
||||
let configuration = ScreenshotDemoConfiguration(scene: .hosts, language: language)
|
||||
let hosts = configuration.makeHostLibrary()
|
||||
let window = configuration.makeWindow(hosts: hosts)
|
||||
|
||||
@@ -1381,6 +1381,7 @@
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
ja,
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
Base,
|
||||
|
||||
Reference in new issue
Block a user