diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index dc786b30bd..c39994d3ba 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -28,3 +28,6 @@ jobs: - name: Validate Localizable.strings run: Scripts/translation-validator/.build/debug/translation-validator Signal/translations/en.lproj/Localizable.strings + + - name: Validate PluralAware.stringsdict + run: Scripts/translation-validator/.build/debug/translation-validator Signal/translations/en.lproj/PluralAware.stringsdict diff --git a/Scripts/translation-validator/src/TranslationValidator.swift b/Scripts/translation-validator/src/TranslationValidator.swift index 1268e8ab54..144e698ac4 100644 --- a/Scripts/translation-validator/src/TranslationValidator.swift +++ b/Scripts/translation-validator/src/TranslationValidator.swift @@ -10,9 +10,18 @@ struct TranslationValidator { static func main() { let sourcePath = CommandLine.arguments.dropFirst().first! let sourceUrl = URL(filePath: sourcePath, relativeTo: URL.currentDirectory()).standardizedFileURL + switch sourceUrl.pathExtension { + case "strings": + checkStrings(at: sourceUrl) + case "stringsdict": + checkStringsDict(at: sourceUrl) + default: + fatalError("invalid file extension") + } + } + + private static func checkStrings(at sourceUrl: URL) { let sourceStrings = fetchStrings(at: sourceUrl) - let sourceDir = sourceUrl.deletingLastPathComponent().deletingLastPathComponent() - let enumerator = FileManager.default.enumerator(at: sourceDir, includingPropertiesForKeys: nil, errorHandler: nil)! var hasError = false @@ -32,38 +41,82 @@ struct TranslationValidator { // Keys that only appear in the translations. var translatedOnly = [String: Set]() - for fileUrl in enumerator { - let fileUrl = fileUrl as! URL - if fileUrl.lastPathComponent == sourceUrl.lastPathComponent { - let localeName = fileUrl.deletingLastPathComponent().deletingPathExtension().lastPathComponent - let translatedStrings = fetchStrings(at: fileUrl) - for key in Set(sourceStrings.keys).subtracting(translatedStrings.keys) { - sourceOnly[key, default: []].insert(localeName) + enumerateLocalizedFiles(sourceUrl: sourceUrl) { fileUrl, localeName in + let translatedStrings = fetchStrings(at: fileUrl) + for key in Set(sourceStrings.keys).subtracting(translatedStrings.keys) { + sourceOnly[key, default: []].insert(localeName) + } + for key in Set(translatedStrings.keys).subtracting(sourceStrings.keys) { + translatedOnly[key, default: []].insert(localeName) + } + for (key, translatedString) in translatedStrings { + guard let sourceSpecifiers = sourceSpecifiers[key] else { + continue } - for key in Set(translatedStrings.keys).subtracting(sourceStrings.keys) { - translatedOnly[key, default: []].insert(localeName) + let translatedSpecifiers: [String] + do { + translatedSpecifiers = try formatSpecifiers(in: translatedString) + } catch { + print("The translation for", key, "in", localeName, "has a malformed format specifier:", error) + hasError = true + continue } - for (key, translatedString) in translatedStrings { - guard let sourceSpecifiers = sourceSpecifiers[key] else { - continue - } - let translatedSpecifiers: [String] - do { - translatedSpecifiers = try formatSpecifiers(in: translatedString) - } catch { - print("The translation for", key, "in", localeName, "has a malformed format specifier:", error) - hasError = true - continue - } - guard sourceSpecifiers.sorted() == translatedSpecifiers.sorted() else { - print("The translation for", key, "in", localeName, "has an incorrect set of format specifiers:", translatedSpecifiers, "vs.", sourceSpecifiers) - hasError = true - continue - } + guard sourceSpecifiers.sorted() == translatedSpecifiers.sorted() else { + print("The translation for", key, "in", localeName, "has an incorrect set of format specifiers:", translatedSpecifiers, "vs.", sourceSpecifiers) + hasError = true + continue } } } + printMissingExtraSummary(sourceOnly: sourceOnly, translatedOnly: translatedOnly, hasError: &hasError) + + if hasError { + exit(1) + } + } + + private static func checkStringsDict(at sourceUrl: URL) { + let sourceStrings = fetchStringsDict(at: sourceUrl) + + var hasError = false + + // Keys that only appear in the source. + var sourceOnly = [String: Set]() + + // Keys that only appear in the translations. + var translatedOnly = [String: Set]() + + enumerateLocalizedFiles(sourceUrl: sourceUrl) { fileUrl, localeName in + let translatedStrings = fetchStringsDict(at: fileUrl) + for key in Set(sourceStrings.keys).subtracting(translatedStrings.keys) { + sourceOnly[key, default: []].insert(localeName) + } + for key in Set(translatedStrings.keys).subtracting(sourceStrings.keys) { + translatedOnly[key, default: []].insert(localeName) + } + } + + printMissingExtraSummary(sourceOnly: sourceOnly, translatedOnly: translatedOnly, hasError: &hasError) + + if hasError { + exit(1) + } + } + + private static func enumerateLocalizedFiles(sourceUrl: URL, block: (URL, String) -> Void) { + let sourceDir = sourceUrl.deletingLastPathComponent().deletingLastPathComponent() + let enumerator = FileManager.default.enumerator(at: sourceDir, includingPropertiesForKeys: nil, errorHandler: nil)! + for fileUrl in enumerator { + let fileUrl = fileUrl as! URL + if fileUrl.lastPathComponent == sourceUrl.lastPathComponent { + let localeName = fileUrl.deletingLastPathComponent().deletingPathExtension().lastPathComponent + block(fileUrl, localeName) + } + } + } + + private static func printMissingExtraSummary(sourceOnly: [String: Set], translatedOnly: [String: Set], hasError: inout Bool) { if !sourceOnly.isEmpty { hasError = true print("The following strings are missing from some translated files:") @@ -81,16 +134,42 @@ struct TranslationValidator { } print() } - - if hasError { - exit(1) - } } private static func fetchStrings(at url: URL) -> [String: String] { return try! PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as! [String: String] } + private static func fetchStringsDict(at url: URL) -> [String: PluralTranslation] { + let translations = try! PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as! [String: [String: Any]] + var results = [String: PluralTranslation]() + for (key, translation) in translations { + var translation = translation + let format = translation.removeValue(forKey: "NSStringLocalizedFormatKey") as! String + let replacements = translation.mapValues { replacement in + var replacement = replacement as! [String: String] + return PluralTranslation.Replacement( + specType: replacement.removeValue(forKey: "NSStringFormatSpecTypeKey")!, + valueType: replacement.removeValue(forKey: "NSStringFormatValueTypeKey")!, + cases: replacement, + ) + } + results[key] = PluralTranslation(format: format, replacements: replacements) + } + return results + } + + struct PluralTranslation { + var format: String + var replacements: [String: Replacement] + + struct Replacement { + var specType: String + var valueType: String + var cases: [String: String] + } + } + private enum FormatSpecifierError: Error { case notTerminated case unknownCharacter(Character) diff --git a/Scripts/translation/pull-translations b/Scripts/translation/pull-translations index 811a9340c5..504c0b2f35 100755 --- a/Scripts/translation/pull-translations +++ b/Scripts/translation/pull-translations @@ -10,25 +10,8 @@ cd $REPO_ROOT swift run --package-path Scripts/translation-tool translation-tool download-resources swift build --package-path Scripts/translation-validator -LOCALIZATION_ROOT=$REPO_ROOT/Signal/translations -cd $LOCALIZATION_ROOT +LOCALIZATION_ROOT=Signal/translations -# Parse the PluralAware.stringsdict files to ensure they're not malformed. -lang_errors=() -for dir in *.lproj -do - pushd "$dir" - if [ -e PluralAware.stringsdict ]; then - plutil PluralAware.stringsdict || lang_errors+=("$dir") - fi - popd -done -if [ "${#lang_errors[@]}" -gt 0 ]; then - 1>&2 echo "Some languages have malformed .stringsdict files: ${lang_errors[*]}" - exit 1 -fi - -../../Scripts/translation-validator/.build/debug/translation-validator $LOCALIZATION_ROOT/en.lproj/Localizable.strings || true -../../Scripts/translation-validator/.build/debug/translation-validator $LOCALIZATION_ROOT/en.lproj/InfoPlist.strings || true - -echo "Make sure you register any new localizations in XCode! (Go to Project > Signal > Localizations > Add Localizations)" +Scripts/translation-validator/.build/debug/translation-validator $LOCALIZATION_ROOT/en.lproj/Localizable.strings || true +Scripts/translation-validator/.build/debug/translation-validator $LOCALIZATION_ROOT/en.lproj/InfoPlist.strings || true +Scripts/translation-validator/.build/debug/translation-validator $LOCALIZATION_ROOT/en.lproj/PluralAware.stringsdict || true