feat(localization): complete German, French, and Korean support
Update AgentKit to 0.19.2, align the maintenance gitlink and resolved package revision, and unify localization validation.
This commit is contained in:
1 parent
7098f5f1fb
commit
0eba936203
19 files changed
+38696
-156
No files matched your search
@@ -5,8 +5,8 @@ on:
|
||||
paths:
|
||||
- "Frameworks/Resources/*.xcstrings"
|
||||
- "Scripts/normalize-string-catalogs.py"
|
||||
- "Scripts/check-cjk-localization.py"
|
||||
- "Scripts/Tests/test_cjk_localization.py"
|
||||
- "Scripts/check-localization.py"
|
||||
- "Scripts/Tests/test_localization.py"
|
||||
- "Scripts/Tests/test_normalize_string_catalogs.py"
|
||||
- ".github/workflows/localization.yml"
|
||||
push:
|
||||
@@ -14,8 +14,8 @@ on:
|
||||
paths:
|
||||
- "Frameworks/Resources/*.xcstrings"
|
||||
- "Scripts/normalize-string-catalogs.py"
|
||||
- "Scripts/check-cjk-localization.py"
|
||||
- "Scripts/Tests/test_cjk_localization.py"
|
||||
- "Scripts/check-localization.py"
|
||||
- "Scripts/Tests/test_localization.py"
|
||||
- "Scripts/Tests/test_normalize_string_catalogs.py"
|
||||
- ".github/workflows/localization.yml"
|
||||
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
- name: Test catalog normalization
|
||||
run: python3 -m unittest discover -s Scripts/Tests -p 'test_normalize_string_catalogs.py'
|
||||
- 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
|
||||
run: python3 -m unittest discover -s Scripts/Tests -p 'test_localization.py'
|
||||
- name: Check catalog stability and translations
|
||||
run: python3 Scripts/check-localization.py
|
||||
+1
-1
Submodule AgentKit updated: 8108c9bb87...ca325632bc.
@@ -5,18 +5,36 @@
|
||||
"comment" : "Bundle display name",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -35,18 +53,36 @@
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -65,18 +101,36 @@
|
||||
"comment" : "Privacy - Camera Usage Description",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind verwendet die Kamera, um Fotos an Ihren Chat anzuhängen."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind uses the camera to attach photos to your chat."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind utilise l’appareil photo pour joindre des photos à votre chat."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "チャットに写真を添付するために、Termindがカメラを使用します。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind는 채팅에 사진을 첨부하기 위해 카메라를 사용합니다."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -94,18 +148,36 @@
|
||||
"NSFaceIDUsageDescription" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Authentifizieren Sie sich, um Ihre SSH-Schlüssel zu verwenden."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Authenticate to use your SSH keys."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Authentifiez-vous pour utiliser vos clés SSH."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH鍵を使用するために認証します。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH 키를 사용하려면 인증하세요."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -123,18 +195,36 @@
|
||||
"NSLocalNetworkUsageDescription" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind verbindet sich mit Servern und Modellanbietern in Ihrem lokalen Netzwerk."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind connects to servers and model providers on your local network."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind se connecte aux serveurs et aux fournisseurs de modèles sur votre réseau local."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termindがローカルネットワーク上のサーバやモデルプロバイダに接続します。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind는 로컬 네트워크의 서버와 모델 제공업체에 연결합니다."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -153,18 +243,36 @@
|
||||
"comment" : "Privacy - Microphone Usage Description",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind verwendet das Mikrofon, um Sprache in Ihren Chatentwurf zu transkribieren."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Termind uses the microphone to transcribe speech into your chat draft."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind utilise le microphone pour transcrire vos paroles dans votre brouillon de chat."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "音声をチャットの下書きに文字起こしするために、Termindがマイクを使用します。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind는 음성을 채팅 초안의 텍스트로 변환하기 위해 마이크를 사용합니다."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -183,18 +291,36 @@
|
||||
"comment" : "Privacy - Speech Recognition Usage Description",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind verwendet die Apple-Spracherkennung, um Ihre Aufnahme in Text umzuwandeln. Je nach Systemeinstellungen und Sprachunterstützung verarbeitet Apple die Audiodaten möglicherweise online."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind utilise la reconnaissance vocale Apple pour convertir votre enregistrement en texte. Selon les réglages du système et les langues prises en charge, Apple peut traiter l’audio en ligne."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "TermindはAppleの音声認識を使用して録音をテキストに変換します。システム設定や言語の対応状況によっては、Appleが音声をオンラインで処理する場合があります。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind는 Apple 음성 인식을 사용하여 녹음을 텍스트로 변환합니다. 시스템 설정 및 언어 지원에 따라 Apple이 오디오를 온라인으로 처리할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -213,18 +339,36 @@
|
||||
"comment" : "Title of the Home Screen quick action that opens the connection picker.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Verbinden"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connect"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se connecter"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "接続"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "연결"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
@@ -242,12 +386,30 @@
|
||||
"Termind SFTP Items" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind-SFTP-Objekte"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Éléments SFTP Termind"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind SFTP項目"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termind SFTP 항목"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3431,7 +3431,7 @@ extension ConversationModel {
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
case .system, .english, .german, .french, .korean: en
|
||||
}
|
||||
}
|
||||
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
|
||||
|
||||
@@ -27,7 +27,10 @@ public nonisolated enum AppLocalization {
|
||||
switch locale.language.languageCode?.identifier {
|
||||
case "zh": locale.language.script?.identifier == "Hant" ? "zh-Hant" : "zh-Hans"
|
||||
case "en": "en"
|
||||
case "de": "de"
|
||||
case "fr": "fr"
|
||||
case "ja": "ja"
|
||||
case "ko": "ko"
|
||||
default: nil
|
||||
}
|
||||
guard let localization,
|
||||
@@ -41,7 +44,10 @@ public nonisolated enum AppLocalization {
|
||||
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
|
||||
case system
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
case french = "fr"
|
||||
case japanese = "ja"
|
||||
case korean = "ko"
|
||||
case simplifiedChinese = "zh-Hans"
|
||||
case traditionalChinese = "zh-Hant"
|
||||
|
||||
@@ -51,7 +57,10 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch self {
|
||||
case .system: String(localized: "Follow System")
|
||||
case .english: "English"
|
||||
case .german: "Deutsch"
|
||||
case .french: "Français"
|
||||
case .japanese: "日本語"
|
||||
case .korean: "한국어"
|
||||
case .simplifiedChinese: "简体中文"
|
||||
case .traditionalChinese: "繁體中文"
|
||||
}
|
||||
@@ -90,7 +99,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch language {
|
||||
case .system:
|
||||
defaults.removeObject(forKey: "AppleLanguages")
|
||||
case .english, .japanese, .simplifiedChinese, .traditionalChinese:
|
||||
case .english, .german, .french, .japanese, .korean, .simplifiedChinese, .traditionalChinese:
|
||||
defaults.set([language.rawValue], forKey: "AppleLanguages")
|
||||
}
|
||||
}
|
||||
@@ -99,7 +108,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
|
||||
switch self {
|
||||
case .system: .autoupdatingCurrent
|
||||
case .english: Locale(identifier: "en")
|
||||
case .japanese, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
|
||||
case .german, .french, .japanese, .korean, .simplifiedChinese, .traditionalChinese: Locale(identifier: rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
let action = localizedActionVerb(operation.action, locale: locale)
|
||||
let title =
|
||||
usesLocalizedActionTemplates(locale)
|
||||
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
|
||||
? uppercaseFirst(localized("Approval tool action: \(action) \(operation.activity)", locale: locale))
|
||||
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
|
||||
+ operation.activity
|
||||
return AgentKnowledgeToolPresentation.title(title)
|
||||
@@ -101,7 +101,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
let action = localizedActionVerb(kind, locale: locale)
|
||||
if usesLocalizedActionTemplates(locale) {
|
||||
guard let hostName, !hostName.isEmpty else {
|
||||
return localized("Approval tool action: \(action) \(activity)", locale: locale)
|
||||
return uppercaseFirst(localized("Approval tool action: \(action) \(activity)", locale: locale))
|
||||
}
|
||||
return localized(
|
||||
"Approval tool action target: \(action) \(activity) \(hostName)",
|
||||
@@ -292,6 +292,12 @@ nonisolated public enum AgentToolPresentation {
|
||||
if locale.language.languageCode?.identifier == "zh" {
|
||||
return clauses.joined(separator: ",")
|
||||
}
|
||||
if locale.language.languageCode?.identifier == "de" {
|
||||
return clauses.joined(separator: "; ")
|
||||
}
|
||||
if locale.language.languageCode?.identifier == "fr" {
|
||||
return clauses.joined(separator: "\u{202F}; ")
|
||||
}
|
||||
return clauses.enumerated()
|
||||
.map { index, clause in index == 0 ? clause : lowercaseFirst(clause) }
|
||||
.joined(separator: ", ")
|
||||
@@ -1066,7 +1072,7 @@ nonisolated public enum AgentToolPresentation {
|
||||
let action = localizedActionVerb(kind, locale: locale)
|
||||
let displayedActivity =
|
||||
isArgumentValue
|
||||
? (locale.language.languageCode?.identifier == "ja" ? "「\(activity)」" : "“\(activity)”")
|
||||
? quotedActivity(activity, locale: locale)
|
||||
: activity
|
||||
let rendered: String =
|
||||
switch state {
|
||||
@@ -1294,7 +1300,17 @@ nonisolated public enum AgentToolPresentation {
|
||||
}
|
||||
|
||||
private static func usesLocalizedActionTemplates(_ locale: Locale) -> Bool {
|
||||
["zh", "ja"].contains(locale.language.languageCode?.identifier ?? "")
|
||||
["de", "fr", "zh", "ja", "ko"].contains(locale.language.languageCode?.identifier ?? "")
|
||||
}
|
||||
|
||||
private static func quotedActivity(_ activity: String, locale: Locale) -> String {
|
||||
switch locale.language.languageCode?.identifier {
|
||||
case "de": "„\(activity)“"
|
||||
case "fr": "«\u{00A0}\(activity)\u{00A0}»"
|
||||
case "ja": "「\(activity)」"
|
||||
case "ko": "‘\(activity)’"
|
||||
default: "“\(activity)”"
|
||||
}
|
||||
}
|
||||
|
||||
private static func activityName(
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
case .simplifiedChinese: prose = "部署之前先检查状态,并保留可复用的验证步骤。"
|
||||
case .traditionalChinese: prose = "部署之前先檢查狀態,並保留可重用的驗證步驟。"
|
||||
case .japanese: prose = "デプロイ前に状態を確認し、再利用できる検証手順を残してください。"
|
||||
case .korean: prose = "배포 전에 상태를 확인하고 재사용할 수 있는 검증 절차를 남겨 주세요."
|
||||
case .german:
|
||||
prose =
|
||||
"Prüfen Sie vor der Bereitstellung den Status und bewahren Sie wiederverwendbare Prüfschritte auf."
|
||||
case .french:
|
||||
prose = "Vérifiez l’état avant le déploiement et conservez des étapes de vérification réutilisables."
|
||||
case .english, .system: prose = "Check status before deploying and retain reusable verification steps."
|
||||
}
|
||||
let body = Array(repeating: prose, count: 80).joined(separator: "\n")
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
"**檢查上游服務。**\n先讀取 `nginx` 日誌,再核對監聽連接埠;暫不變更設定。"
|
||||
case .japanese:
|
||||
"**上流サービスを確認します。**\nまず`nginx`のログを読み、待ち受けポートを確認します。設定はまだ変更しません。"
|
||||
case .korean:
|
||||
"**업스트림 서비스를 확인합니다.**\n먼저 `nginx` 로그를 읽고 수신 대기 포트를 확인합니다. 설정은 아직 변경하지 않습니다."
|
||||
case .german:
|
||||
"**Vorgelagerten Dienst prüfen.**\nZuerst die `nginx`-Protokolle lesen und dann die lauschenden Ports prüfen, ohne die Konfiguration zu ändern."
|
||||
case .french:
|
||||
"**Inspecter le service en amont.**\nLire les journaux `nginx`, puis vérifier les ports en écoute avant de modifier la configuration."
|
||||
case .system, .english:
|
||||
"**Inspect the upstream service.**\nRead the `nginx` logs, then check listening ports before changing configuration."
|
||||
}
|
||||
@@ -44,6 +50,9 @@
|
||||
case .simplifiedChinese: "连接上游服务失败:连接被拒绝。"
|
||||
case .traditionalChinese: "連線至上游服務失敗:連線遭拒。"
|
||||
case .japanese: "上流サービスに接続できませんでした:接続が拒否されました。"
|
||||
case .korean: "업스트림 서비스에 연결할 수 없습니다. 연결이 거부되었습니다."
|
||||
case .german: "Verbindung zum vorgelagerten Dienst fehlgeschlagen: Verbindung abgelehnt."
|
||||
case .french: "Échec de la connexion au service en amont : connexion refusée."
|
||||
case .system, .english: "connect() failed: connection refused"
|
||||
}
|
||||
cards[1].result = AgentToolResult(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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()
|
||||
@@ -0,0 +1,246 @@
|
||||
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-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), len(CHECK.SUPPORTED_LANGUAGES) - 1)
|
||||
self.assertTrue(any("zh-Hans" in failure for failure in failures))
|
||||
self.assertTrue(any("zh-Hant" in failure for failure in failures))
|
||||
|
||||
|
||||
class KoreanLocalizationTests(unittest.TestCase):
|
||||
def make_catalog(self, key, value):
|
||||
data = catalog(key, value)
|
||||
localizations = data["strings"][key]["localizations"]
|
||||
localizations["ko"] = localizations.pop("ja")
|
||||
return data
|
||||
|
||||
def test_allows_korean_word_spacing_and_protocol_names(self):
|
||||
for value in ["SSH 인증 실패", "파일 3개", "사용자 확인 요청 안 함", "암호 구문"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(CHECK.problems(self.make_catalog("Label", value), languages=("ko",)), [])
|
||||
|
||||
def test_reordered_arguments_preserve_positions_and_types(self):
|
||||
data = self.make_catalog("Command failed with exit code %lld after %@.",
|
||||
"명령이 %2$@ 후 종료 코드 %1$lld로 실패했습니다.")
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), [])
|
||||
unit = data["strings"]["Command failed with exit code %lld after %@."]["localizations"]["ko"]["stringUnit"]
|
||||
unit["value"] = "명령이 %@ 후 종료 코드 %lld로 실패했습니다."
|
||||
self.assertTrue(any("format arguments" in p for p in CHECK.problems(data, languages=("ko",))))
|
||||
|
||||
def test_requires_only_other_for_counted_resources(self):
|
||||
data = self.make_catalog("%lld files", "파일 %lld개")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations["ko"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=("ko",)))
|
||||
localizations["ko"] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), [])
|
||||
localizations["ko"]["variations"]["plural"]["one"] = unit
|
||||
self.assertTrue(CHECK.problems(data, languages=("ko",)))
|
||||
|
||||
def test_rejects_missing_and_unfinished_korean(self):
|
||||
data = self.make_catalog("Settings", "설정")
|
||||
unit = data["strings"]["Settings"]["localizations"]["ko"]["stringUnit"]
|
||||
unit["state"] = "needs_review"
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), ["'Settings': unfinished ko translation"])
|
||||
del data["strings"]["Settings"]["localizations"]["ko"]
|
||||
self.assertEqual(CHECK.problems(data, languages=("ko",)), ["'Settings': missing ko translation"])
|
||||
|
||||
|
||||
class EuropeanLocalizationTests(unittest.TestCase):
|
||||
def make_catalog(self, key, value, language):
|
||||
return {
|
||||
"sourceLanguage": "en",
|
||||
"strings": {key: {"localizations": {
|
||||
language: {"stringUnit": {"state": "translated", "value": value}},
|
||||
}}},
|
||||
}
|
||||
|
||||
def test_checks_coverage_state_and_positional_arguments(self):
|
||||
for language in ("de", "fr"):
|
||||
with self.subTest(language=language):
|
||||
data = self.make_catalog("%lld on %@", "%2$@ : %1$lld", language)
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
unit = data["strings"]["%lld on %@"]["localizations"][language]["stringUnit"]
|
||||
unit["value"] = "%1$@ : %2$lld"
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
unit["value"] = "%lld on %@"
|
||||
unit["state"] = "needs_review"
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
del data["strings"]["%lld on %@"]["localizations"][language]
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
|
||||
def test_requires_singular_and_plural_even_for_target_only_variations(self):
|
||||
for language in ("de", "fr"):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", language)
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
unit = localizations[language]
|
||||
localizations[language] = {"variations": {"plural": {"other": unit}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
forms = localizations[language]["variations"]["plural"]
|
||||
forms["one"] = unit
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
forms["few"] = unit
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
del forms["few"]
|
||||
forms["many"] = unit
|
||||
failures = CHECK.problems(data, languages=(language,))
|
||||
self.assertEqual(bool(failures), language == "de")
|
||||
|
||||
def test_english_plural_requires_translated_plural_variations(self):
|
||||
for language in ("de", "fr"):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", language)
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
localizations["en"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "%lld file"}},
|
||||
"other": {"stringUnit": {"state": "translated", "value": "%lld files"}},
|
||||
}}}
|
||||
self.assertTrue(CHECK.problems(data, languages=(language,)))
|
||||
|
||||
def test_each_plural_branch_preserves_format_arguments(self):
|
||||
data = self.make_catalog("%lld files", "%lld fichiers", "fr")
|
||||
localizations = data["strings"]["%lld files"]["localizations"]
|
||||
localizations["fr"] = {"variations": {"plural": {
|
||||
"one": {"stringUnit": {"state": "translated", "value": "un fichier"}},
|
||||
"other": localizations["fr"],
|
||||
}}}
|
||||
self.assertTrue(any("format arguments" in p for p in CHECK.problems(data, languages=("fr",))))
|
||||
|
||||
def test_preserves_european_typography_without_cjk_spacing_rules(self):
|
||||
for language, value in [("fr", "Échec\u00a0: «\u00a0clé SSH\u00a0»\u202f?"), ("de", "Schlüssel „SSH“")]:
|
||||
data = self.make_catalog("Label", value, language)
|
||||
self.assertEqual(CHECK.problems(data, languages=(language,)), [])
|
||||
|
||||
def test_explicit_scope_can_limit_checks_to_german_and_french(self):
|
||||
data = self.make_catalog("Settings", "Einstellungen", "de")
|
||||
self.assertEqual(CHECK.problems(data, languages=("de", "fr")), ["'Settings': missing fr translation"])
|
||||
data["strings"]["Settings"]["localizations"]["fr"] = {
|
||||
"stringUnit": {"state": "translated", "value": "Réglages"}
|
||||
}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de", "fr")), [])
|
||||
|
||||
def test_uses_english_metadata_value_and_skips_nontranslatable_entries(self):
|
||||
data = self.make_catalog("BundleKey", "Datei %@", "de")
|
||||
data["strings"]["BundleKey"]["localizations"]["en"] = {
|
||||
"stringUnit": {"state": "translated", "value": "File %@"}
|
||||
}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de",)), [])
|
||||
data["strings"]["Identifier"] = {"shouldTranslate": False}
|
||||
self.assertEqual(CHECK.problems(data, languages=("de",)), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable → Regular
+25
-11
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Chinese and Japanese catalog coverage, format arguments, and spacing."""
|
||||
"""Check all app translations, with language-specific plural and spacing rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,10 +17,6 @@ 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@%])")
|
||||
|
||||
|
||||
@@ -47,11 +43,21 @@ def arguments(value: str) -> Counter:
|
||||
return result
|
||||
|
||||
|
||||
def problems(catalog: dict, languages=("zh-Hans", "zh-Hant", "ja")) -> list[str]:
|
||||
SUPPORTED_LANGUAGES = ("zh-Hans", "zh-Hant", "ja", "ko", "de", "fr")
|
||||
|
||||
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}]")
|
||||
|
||||
|
||||
def problems(catalog: dict, languages=SUPPORTED_LANGUAGES) -> list[str]:
|
||||
return [problem for language in languages for problem in language_problems(catalog, language)]
|
||||
|
||||
|
||||
def language_problems(catalog: dict, language: str) -> list[str]:
|
||||
if language not in SUPPORTED_LANGUAGES:
|
||||
raise ValueError(f"unsupported localization: {language}")
|
||||
failures = []
|
||||
for key, entry in catalog["strings"].items():
|
||||
if entry.get("shouldTranslate") is False:
|
||||
@@ -67,9 +73,16 @@ def language_problems(catalog: dict, language: str) -> list[str]:
|
||||
"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'")
|
||||
plural = localization.get("variations", {}).get("plural", {})
|
||||
if "plural" in english.get("variations", {}) or plural:
|
||||
categories = set(plural)
|
||||
required = {"one", "other"} if language in ("de", "fr") else {"other"}
|
||||
allowed = required | {"many"} if language == "fr" else required
|
||||
if not required <= categories or not categories <= allowed:
|
||||
failures.append(
|
||||
f"{key!r}: {language} plural rules require {sorted(required)}"
|
||||
+ (" (optional 'many')" if language == "fr" else " only")
|
||||
)
|
||||
for unit in translated:
|
||||
value = unit.get("value")
|
||||
if unit.get("state") != "translated" or not isinstance(value, str) or (key and not value):
|
||||
@@ -79,11 +92,12 @@ def language_problems(catalog: dict, language: str) -> list[str]:
|
||||
failures.append(f"{key!r}: {language} format arguments differ: {value!r}")
|
||||
# Key-card subtitles intentionally separate the label and value.
|
||||
spacing_value = value
|
||||
invalid_spacing = None
|
||||
if language == "ja":
|
||||
if key == "Type %@":
|
||||
spacing_value = re.sub(r"^種類 (?=%(?:1\$)?@)", "種類", value)
|
||||
invalid_spacing = MIXED_SPACING.search(spacing_value)
|
||||
else:
|
||||
elif language in ("zh-Hans", "zh-Hant"):
|
||||
if key == "Type %@":
|
||||
spacing_value = re.sub(r"^(类型|類型) (?=%@)", r"\1", value)
|
||||
han = r"\u3400-\u4DBF\u4E00-\u9FFF"
|
||||
@@ -117,7 +131,7 @@ def main() -> int:
|
||||
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.")
|
||||
print(f"{', '.join(SUPPORTED_LANGUAGES)} coverage, format arguments, plurals, spacing, and catalog checks passed.")
|
||||
return int(failed)
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ import TermindApplication
|
||||
switch languageValue {
|
||||
case "en": language = .english
|
||||
case "ja": language = .japanese
|
||||
case "ko": language = .korean
|
||||
case "de": language = .german
|
||||
case "fr": language = .french
|
||||
case "zh-Hans": language = .simplifiedChinese
|
||||
case "zh-Hant": language = .traditionalChinese
|
||||
default: throw ParseError.unknownLanguage(languageValue)
|
||||
@@ -283,7 +286,7 @@ import TermindApplication
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
case .system, .english, .german, .french, .korean: en
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ extension HostLibraryModel {
|
||||
case .simplifiedChinese: zhHans
|
||||
case .traditionalChinese: zhHant
|
||||
case .japanese: ja
|
||||
case .system, .english: en
|
||||
case .system, .english, .german, .french, .korean: en
|
||||
}
|
||||
}
|
||||
func distribution(_ id: String) -> DetectedDistribution {
|
||||
|
||||
@@ -995,6 +995,7 @@ struct AgentToolPresentationTests {
|
||||
let locales: [(String, String?, String?)] = [
|
||||
("en", nil, nil),
|
||||
("ja", "「", "」"),
|
||||
("ko", "‘", "’"),
|
||||
("zh-Hans", "“", "”"),
|
||||
("zh-Hant", "「", "」"),
|
||||
]
|
||||
@@ -1186,6 +1187,9 @@ struct AgentToolPresentationTests {
|
||||
#expect(
|
||||
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ja"))
|
||||
== "2個のファイルの書き込みが完了しました。ファイルの書き込みに失敗しました")
|
||||
#expect(
|
||||
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: "ko"))
|
||||
== "파일 2개 쓰기 완료, 파일 쓰기 실패")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1208,6 +1212,101 @@ struct AgentToolPresentationTests {
|
||||
#expect(presentation.title == "web-01でコマンドを実行")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("de-DE", "Auf web-01: einen Befehl ausführen"),
|
||||
("fr-CA", "Sur web-01\u{00A0}: exécuter une commande"),
|
||||
])
|
||||
func europeanApprovalLocalizesTheActionWithoutExposingCommandArguments(language: String, expected: String) {
|
||||
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: language))
|
||||
#expect(presentation.title == expected)
|
||||
#expect(!presentation.title.contains("pwd"))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(
|
||||
"de", "Abgeschlossen: „/tmp/report.txt“ lesen (auf web-01)",
|
||||
"Fehlgeschlagen: „/tmp/report.txt“ lesen (auf web-01)"
|
||||
),
|
||||
(
|
||||
"fr", "Terminé\u{00A0}: lire «\u{00A0}/tmp/report.txt\u{00A0}» (sur web-01)",
|
||||
"Échec\u{00A0}: lire «\u{00A0}/tmp/report.txt\u{00A0}» (sur web-01)"
|
||||
),
|
||||
])
|
||||
func europeanToolLifecycleUsesLocalizedTemplatesAndQuotationMarks(
|
||||
language: String, completed: String, failed: String
|
||||
) {
|
||||
var card = makeCard(
|
||||
name: "sftp_read", target: .host, arguments: ["path": .string("/tmp/report.txt")],
|
||||
presentation: .init(
|
||||
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read))
|
||||
let locale = Locale(identifier: language)
|
||||
card.state = .completed
|
||||
#expect(AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale) == completed)
|
||||
card.state = .failed
|
||||
#expect(AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale) == failed)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(AgentToolCard.State.proposed, "준비 중"),
|
||||
(.reviewing, "준비 중"),
|
||||
(.waitingForApproval, "대기 중"),
|
||||
(.waitingForUser, "대기 중"),
|
||||
(.running, "중"),
|
||||
(.completed, "완료"),
|
||||
(.denied, "거부됨"),
|
||||
(.failed, "실패"),
|
||||
(.interrupted, "중단됨"),
|
||||
])
|
||||
func koreanToolLifecyclePlacesTheHostBeforeTheObjectAndAction(
|
||||
state: AgentToolCard.State, suffix: String
|
||||
) {
|
||||
var card = makeCard(
|
||||
name: "sftp_read", target: .host, arguments: ["path": .string("/tmp/report.txt")],
|
||||
presentation: .init(
|
||||
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read))
|
||||
card.state = state
|
||||
let locale = Locale(identifier: "ko-KR")
|
||||
#expect(
|
||||
AgentToolPresentation.summary(for: card, hostName: "web-01", locale: locale)
|
||||
== "web-01에서 ‘/tmp/report.txt’ 읽기 \(suffix)")
|
||||
#expect(
|
||||
AgentToolPresentation.summary(for: card, locale: locale) == "‘/tmp/report.txt’ 읽기 \(suffix)")
|
||||
}
|
||||
|
||||
@Test
|
||||
func koreanApprovalUsesTheSemanticObjectRatherThanAnEnglishVerbPrefix() {
|
||||
let request = AgentApprovalRequest(
|
||||
invocation: AgentToolInvocation(
|
||||
runID: UUID(),
|
||||
call: AgentToolCall(
|
||||
id: "approval", name: "sftp_read", arguments: .object(["path": .string("/tmp/report.txt")]))
|
||||
),
|
||||
descriptor: AgentToolDescriptor(
|
||||
name: "sftp_read", summary: "Read a remote file", inputSchema: .object([:]), target: .host,
|
||||
approvalPolicy: .ask,
|
||||
presentation: .init(
|
||||
symbol: "doc", activity: .semanticArgument(key: "path", fallback: .remoteFile), actionKind: .read)),
|
||||
userIntent: "Inspect"
|
||||
)
|
||||
let presentation = AgentToolPresentation.approval(
|
||||
for: request, hostName: "web-01", locale: Locale(identifier: "ko"))
|
||||
#expect(presentation.title == "web-01에서 파일 읽기")
|
||||
}
|
||||
|
||||
@Test
|
||||
func batchesCountArrayElementsAndKeepUnknownToolObjects() {
|
||||
for activity: AgentToolDescriptor.Presentation.Activity in [
|
||||
|
||||
@@ -12,6 +12,9 @@ struct AppLanguageTests {
|
||||
("zh-Hans", "SSH认证失败:key detail;password detail"),
|
||||
("zh-Hant", "SSH認證失敗:key detail;password detail"),
|
||||
("ja", "SSH認証に失敗しました:key detail;password detail"),
|
||||
("de", "SSH-Authentifizierung fehlgeschlagen: key detail; password detail"),
|
||||
("fr", "Échec de l’authentification SSH\u{00A0}: key detail; password detail"),
|
||||
("ko", "SSH 인증 실패: key detail; password detail"),
|
||||
])
|
||||
func authenticationFailuresUseOneLocalizedPrefixAndSemicolon(language: String, expected: String) {
|
||||
let locale = Locale(identifier: language)
|
||||
@@ -58,6 +61,28 @@ struct AppLanguageTests {
|
||||
#expect(AppLanguage.traditionalChinese.displayName == "繁體中文")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(AppLanguage.german, "de", "Deutsch"),
|
||||
(.french, "fr", "Français"),
|
||||
(.korean, "ko", "한국어"),
|
||||
])
|
||||
func additionalLanguageChoicesPersistAndRestoreSystemNegotiation(
|
||||
language: AppLanguage, tag: String, name: String
|
||||
) throws {
|
||||
let suiteName = "AppLanguageTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
#expect(AppLanguage.allCases.contains(language))
|
||||
#expect(language.displayName == name)
|
||||
#expect(language.locale.language.languageCode?.identifier == tag)
|
||||
AppLanguage.store(language, in: defaults)
|
||||
#expect(AppLanguage.stored(in: defaults) == language)
|
||||
#expect(defaults.stringArray(forKey: "AppleLanguages") == [tag])
|
||||
AppLanguage.store(.system, in: defaults)
|
||||
#expect(AppLanguage.stored(in: defaults) == .system)
|
||||
#expect(defaults.persistentDomain(forName: suiteName)?["AppleLanguages"] == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: ["zh-Hans-CN", "zh-CN", "zh_CN", "zh-Hans-SG", "zh"])
|
||||
func legacySimplifiedChinesePreferencesMigrateToCanonicalTag(identifier: String) throws {
|
||||
let defaults = try makeDefaults()
|
||||
@@ -109,6 +134,9 @@ struct AppLanguageTests {
|
||||
@Test(arguments: [
|
||||
(AppLanguage.english, "The new language will be used after Termind relaunches."),
|
||||
(.japanese, "新しい言語はTermindの再起動後に適用されます。"),
|
||||
(.german, "Die neue Sprache wird nach dem Neustart von Termind verwendet."),
|
||||
(.french, "La nouvelle langue sera utilisée après le redémarrage de Termind."),
|
||||
(.korean, "새 언어는 Termind를 다시 실행한 후 적용됩니다."),
|
||||
(.simplifiedChinese, "重新启动Termind后将使用新语言。"),
|
||||
(.traditionalChinese, "重新啟動Termind後將使用新語言。"),
|
||||
])
|
||||
@@ -132,6 +160,121 @@ struct AppLanguageTests {
|
||||
#expect(AppLocalization.string("\(3) files", locale: locale) == "3個のファイル")
|
||||
}
|
||||
|
||||
@Test(arguments: ["de", "de-DE", "de-AT", "de_CH"])
|
||||
func germanLocalesResolveCopyAndReorderedArguments(identifier: String) {
|
||||
let locale = Locale(identifier: identifier)
|
||||
#expect(AppLocalization.string("Settings", locale: locale) == "Einstellungen")
|
||||
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "Abbrechen")
|
||||
let duration = "2 s"
|
||||
#expect(
|
||||
AppLocalization.string("Command failed with exit code \(7) after \(duration).", locale: locale)
|
||||
== "Befehl nach 2 s mit Exit-Code 7 fehlgeschlagen."
|
||||
)
|
||||
#expect(AppLocalization.string("Memory", locale: locale) == "Arbeitsspeicher")
|
||||
#expect(AppLocalization.string("memory", locale: locale) == "Erinnerungen")
|
||||
}
|
||||
|
||||
@Test(arguments: ["fr", "fr-FR", "fr-CA", "fr_BE"])
|
||||
func frenchLocalesResolveCopyAndPreserveCommandAndHost(identifier: String) {
|
||||
let locale = Locale(identifier: identifier)
|
||||
#expect(AppLocalization.string("Settings", locale: locale) == "Réglages")
|
||||
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "Annuler")
|
||||
let command = "uptime"
|
||||
let host = "web-01"
|
||||
#expect(
|
||||
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
|
||||
== "«\u{00A0}uptime\u{00A0}» exécuté sur web-01"
|
||||
)
|
||||
let count = "1.2k"
|
||||
let prefix = "≈"
|
||||
#expect(AppLocalization.string("\(prefix)\(count) tokens", locale: locale) == "≈1.2k tokens")
|
||||
#expect(AppLocalization.string("Bearer Token", locale: locale) == "Jeton Bearer")
|
||||
}
|
||||
|
||||
@Test(arguments: ["ko", "ko-KR", "ko_KR"])
|
||||
func koreanLocalesResolveCopyAndReorderCommandArguments(identifier: String) {
|
||||
let locale = Locale(identifier: identifier)
|
||||
#expect(AppLocalization.string("Settings", locale: locale) == "설정")
|
||||
#expect(AppLocalization.resolve(AppStrings.Common.cancel, locale: locale) == "취소")
|
||||
let command = "uptime"
|
||||
let host = "web-01"
|
||||
#expect(
|
||||
AppLocalization.string("Ran “\(command)” on \(host)", locale: locale)
|
||||
== "web-01에서 ‘uptime’ 실행됨")
|
||||
let duration = "2 s"
|
||||
#expect(
|
||||
AppLocalization.string("Command failed with exit code \(7) after \(duration).", locale: locale)
|
||||
== "명령이 2 s 후 종료 코드 7로 실패했습니다.")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("Memory", "메모리"), ("memory", "기억"),
|
||||
("Identity", "인증 프로필"), ("Credential", "자격 증명"),
|
||||
("Password", "암호"), ("Passphrase", "암호 구문"),
|
||||
("Export and Attach", "내보내고 호스트에 연결"),
|
||||
("Discouraged", "사용자 확인 요청 안 함"),
|
||||
])
|
||||
func koreanTermsReflectTheirProductContext(key: String, expected: String) throws {
|
||||
let path = try #require(Bundle.main.path(forResource: "ko", ofType: "lproj"))
|
||||
let bundle = try #require(Bundle(path: path))
|
||||
#expect(bundle.localizedString(forKey: key, value: nil, table: nil) == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: ["Google", "OpenAI", "사용자 모델"])
|
||||
func koreanDynamicLabelsDoNotAssumeTheFinalConsonantOfAProviderName(provider: String) {
|
||||
let locale = Locale(identifier: "ko")
|
||||
#expect(
|
||||
AppLocalization.resolve(
|
||||
AppStrings.Assistant.modelHelp(modelName: "model-01", providerName: provider, effortName: nil),
|
||||
locale: locale
|
||||
) == "model-01 · 제공업체: \(provider)")
|
||||
#expect(
|
||||
AppLocalization.resolve(
|
||||
AppStrings.Assistant.modelHelp(modelName: "model-01", providerName: provider, effortName: "높음"),
|
||||
locale: locale
|
||||
) == "model-01 · 제공업체: \(provider) · 추론 강도: 높음")
|
||||
#expect(
|
||||
AppLocalization.resolve(
|
||||
AppStrings.Assistant.compactionSummary(summarized: "2,000 토큰", retained: "500 토큰"),
|
||||
locale: locale
|
||||
) == "압축됨 — 2,000 토큰 → 500 토큰")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("de", "Export and Attach", "Exportieren und zuordnen"),
|
||||
("fr", "Export and Attach", "Exporter et associer"),
|
||||
("de", "Retry Attachment", "Zuordnung erneut versuchen"),
|
||||
("fr", "Retry Attachment", "Réessayer l’association"),
|
||||
("de", "Discouraged", "Benutzerverifizierung nicht anfordern"),
|
||||
("fr", "Discouraged", "Ne pas demander de vérification"),
|
||||
])
|
||||
func europeanKeySettingsDistinguishAssociationFromFileAttachments(
|
||||
language: String, key: String, expected: String
|
||||
) throws {
|
||||
let path = try #require(Bundle.main.path(forResource: language, ofType: "lproj"))
|
||||
let bundle = try #require(Bundle(path: path))
|
||||
#expect(bundle.localizedString(forKey: key, value: nil, table: nil) == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
("de", 0, "0 Dateien"), ("de", 1, "1 Datei"), ("de", 2, "2 Dateien"),
|
||||
("fr", 0, "0 fichier"), ("fr", 1, "1 fichier"), ("fr", 2, "2 fichiers"),
|
||||
("ko", 0, "파일 0개"), ("ko", 1, "파일 1개"), ("ko", 2, "파일 2개"),
|
||||
])
|
||||
func countedResourcesUseCompiledPluralRules(language: String, count: Int, expected: String) {
|
||||
#expect(AppLocalization.string("\(count) files", locale: Locale(identifier: language)) == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [("de", "Verbinden"), ("fr", "Se connecter"), ("ko", "연결")])
|
||||
func additionalBundleMetadataIsLocalized(language: String, expected: String) throws {
|
||||
let path = try #require(Bundle.main.path(forResource: language, ofType: "lproj"))
|
||||
let bundle = try #require(Bundle(path: path))
|
||||
#expect(
|
||||
bundle.localizedString(forKey: "QUICK_ACTION_CONNECT_TITLE", value: nil, table: "InfoPlist") == expected
|
||||
)
|
||||
#expect(bundle.localizedString(forKey: "CFBundleDisplayName", value: nil, table: "InfoPlist") == "Termind")
|
||||
}
|
||||
|
||||
@Test(arguments: [("zh-Hans", "词元"), ("zh-Hant", "符元")])
|
||||
func assistantTokenCountsUseModelTerminology(language: String, unit: String) {
|
||||
let locale = Locale(identifier: language)
|
||||
@@ -173,7 +316,7 @@ 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 ["ja", "zh-Hans", "zh-Hant"] {
|
||||
for language in ["de", "fr", "ja", "ko", "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)
|
||||
@@ -212,7 +355,11 @@ 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 ["ja", "zh-Hans", "zh-Hant"] {
|
||||
for language in ["de", "fr"] {
|
||||
let localization = try #require(localizations[language] as? [String: Any])
|
||||
#expect(pluralForms(in: localization).keys.sorted() == ["one", "other"])
|
||||
}
|
||||
for language in ["ja", "ko", "zh-Hans", "zh-Hant"] {
|
||||
let localization = try #require(localizations[language] as? [String: Any])
|
||||
#expect(pluralForms(in: localization).keys.sorted() == ["other"])
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import TermindPresentation
|
||||
let languages: [(argument: String, language: AppLanguage)] = [
|
||||
("en", .english),
|
||||
("ja", .japanese),
|
||||
("ko", .korean),
|
||||
("de", .german),
|
||||
("fr", .french),
|
||||
("zh-Hans", .simplifiedChinese),
|
||||
("zh-Hant", .traditionalChinese),
|
||||
]
|
||||
@@ -65,9 +68,9 @@ import TermindPresentation
|
||||
"Termind", "--termind-demo=vault:en",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("fr")) {
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("unsupported")) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=hosts:fr",
|
||||
"Termind", "--termind-demo=hosts:unsupported",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("zh")) {
|
||||
|
||||
@@ -1381,7 +1381,10 @@
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
de,
|
||||
fr,
|
||||
ja,
|
||||
ko,
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
Base,
|
||||
@@ -3000,7 +3003,7 @@
|
||||
repositoryURL = "https://github.com/AkinoKaede/AgentKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.19.1;
|
||||
minimumVersion = 0.19.2;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/AkinoKaede/AgentKit.git",
|
||||
"state" : {
|
||||
"revision" : "8108c9bb87c546860cfc1769571e115af919fe8a",
|
||||
"version" : "0.19.1"
|
||||
"revision" : "ca325632bc91886702673a79522e3b862ac940ff",
|
||||
"version" : "0.19.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user