├── .github └── workflows │ └── pull-request.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ └── xcschemes │ ├── SwiftStringCatalog.xcscheme │ ├── SwiftTranslate-Package.xcscheme │ ├── SwiftTranslate.xcscheme │ └── swift-translate.xcscheme ├── ExampleCatalogs ├── BasicCatalog.xcstrings ├── LargeTestBench.xcstrings └── PluralTest.xcstrings ├── LICENSE ├── Package.resolved ├── Package.swift ├── Playground.xcstrings ├── Plugins └── SwiftTranslate │ ├── SwiftTranslate.swift │ └── SwiftTranslateError.swift ├── README.md ├── Sources ├── SwiftStringCatalog │ ├── Bootstrap │ │ └── StringCatalog.swift │ ├── Extensions │ │ └── LocalizableString+Lookup.swift │ ├── Models │ │ ├── DeviceCategory.swift │ │ ├── ExtractionState.swift │ │ ├── Language.swift │ │ ├── LocalizableString.swift │ │ ├── LocalizableStringGroup.swift │ │ ├── TranslationState.swift │ │ ├── _CatalogEntry.swift │ │ ├── _Localization.swift │ │ ├── _StringCatalog.swift │ │ ├── _StringUnit.swift │ │ ├── _Substitution.swift │ │ └── _Variations.swift │ ├── Protocols │ │ ├── LocalizableStringConstructor.swift │ │ └── PluralQualifier.swift │ └── Utilities │ │ └── CodableKeyDictionary.swift └── SwiftTranslate │ ├── Bootstrap │ ├── SwiftTranslate.swift │ ├── TranslatableFileFinder.swift │ └── TranslationCoordinator.swift │ ├── Extensions │ └── SwiftStringCatalog+SwiftTranslate.swift │ ├── FileTranslators │ └── StringCatalogTranslator.swift │ ├── Models │ ├── OpenAIModel.swift │ ├── SwiftTranslateError.swift │ └── TranslationServiceArgument.swift │ ├── Protocols │ ├── FileTranslator.swift │ └── TranslationService.swift │ ├── TranslationServices │ ├── GoogleTranslator.swift │ └── OpenAITranslator.swift │ └── Utilities │ └── Log.swift └── Tests └── SwiftStringCatalogTests ├── Models └── StringCatalogTests.swift ├── Resources └── BasicCatalog.json └── SwiftStringCatalog.xctestplan /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | pull_request: 8 | branches: [main, develop] 9 | push: 10 | branches: [main, develop] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-14 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Build 19 | run: swift build -v 20 | - name: Run tests 21 | run: swift test -v 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftStringCatalog.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 48 | 49 | 55 | 56 | 62 | 63 | 64 | 65 | 67 | 68 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftTranslate-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 62 | 68 | 69 | 70 | 71 | 72 | 82 | 83 | 89 | 90 | 91 | 92 | 98 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftTranslate.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 69 | 75 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-translate.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 70 | 76 | 77 | 78 | 79 | 85 | 87 | 93 | 94 | 95 | 96 | 98 | 99 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /ExampleCatalogs/BasicCatalog.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "I really like tests!" : { 5 | "extractionState" : "manual", 6 | "localizations" : { 7 | "it" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "I should learn italian!" 11 | } 12 | } 13 | } 14 | }, 15 | "This is a test" : { 16 | "extractionState" : "manual" 17 | } 18 | }, 19 | "version" : "1.0" 20 | } -------------------------------------------------------------------------------- /ExampleCatalogs/LargeTestBench.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "- or -" : { 5 | 6 | }, 7 | "%lld additional files not shown" : { 8 | 9 | }, 10 | "%lld audio files" : { 11 | 12 | }, 13 | "%lld convertible audio files (%@)" : { 14 | 15 | }, 16 | "%lld files converted with %lld warnings and %lld failures" : { 17 | 18 | }, 19 | "%lld of %lld files processed" : { 20 | 21 | }, 22 | "%lld of %lld files successfully converted" : { 23 | "localizations" : { 24 | "en" : { 25 | "stringUnit" : { 26 | "state" : "needs_review", 27 | "value" : "%lld of %lld files successfully converted" 28 | } 29 | } 30 | } 31 | }, 32 | "AAC (Universal)" : { 33 | 34 | }, 35 | "AC_ALERT_APPLE_LOOPS_NOT_FOUND" : { 36 | "localizations" : { 37 | "en" : { 38 | "stringUnit" : { 39 | "state" : "new", 40 | "value" : "Apple Loops not found in default directory\n%@\nPlease select the folder manually" 41 | } 42 | } 43 | } 44 | }, 45 | "AC_ALERT_LARGE_BATCH_NO_DESTINATION" : { 46 | "localizations" : { 47 | "en" : { 48 | "stringUnit" : { 49 | "state" : "translated", 50 | "value" : "Are you sure you want to convert %lld files in place instead of selecting a destination folder?

This will not overwrite your files." 51 | } 52 | } 53 | } 54 | }, 55 | "Add" : { 56 | 57 | }, 58 | "ALAC (Apple Lossless Audio Codec)" : { 59 | 60 | }, 61 | "Apple Loops not found at default directory" : { 62 | 63 | }, 64 | "audioConverterQueue.lockedFileFormats" : { 65 | "localizations" : { 66 | "en" : { 67 | "stringUnit" : { 68 | "state" : "translated", 69 | "value" : "
Get Pro for multi-file conversion and additional formats:
%@" 70 | } 71 | } 72 | } 73 | }, 74 | "audioConverterQueue.supportedFileFormats" : { 75 | "localizations" : { 76 | "en" : { 77 | "stringUnit" : { 78 | "state" : "translated", 79 | "value" : "Supported file formats:
%@" 80 | } 81 | } 82 | } 83 | }, 84 | "Best Quality" : { 85 | 86 | }, 87 | "Bitrate" : { 88 | 89 | }, 90 | "Cancel" : { 91 | 92 | }, 93 | "Change" : { 94 | 95 | }, 96 | "Clear" : { 97 | 98 | }, 99 | "Conversion canceled after %@" : { 100 | 101 | }, 102 | "Conversion failed, click icon for more info" : { 103 | 104 | }, 105 | "Conversion Options" : { 106 | 107 | }, 108 | "Conversion to MP3 made possible by [LAME](https://lame.sourceforge.io)" : { 109 | 110 | }, 111 | "Convert" : { 112 | 113 | }, 114 | "Converted %lld files with %lld failures and %lld warnings in %@" : { 115 | "localizations" : { 116 | "en" : { 117 | "stringUnit" : { 118 | "state" : "translated", 119 | "value" : "Converted %#@arg1@ with %#@arg2@ and %#@arg3@ in %4$@" 120 | }, 121 | "substitutions" : { 122 | "arg1" : { 123 | "argNum" : 1, 124 | "formatSpecifier" : "lld", 125 | "variations" : { 126 | "plural" : { 127 | "one" : { 128 | "stringUnit" : { 129 | "state" : "translated", 130 | "value" : "%arg file" 131 | } 132 | }, 133 | "other" : { 134 | "stringUnit" : { 135 | "state" : "translated", 136 | "value" : "%arg files" 137 | } 138 | } 139 | } 140 | } 141 | }, 142 | "arg2" : { 143 | "argNum" : 2, 144 | "formatSpecifier" : "lld", 145 | "variations" : { 146 | "plural" : { 147 | "one" : { 148 | "stringUnit" : { 149 | "state" : "translated", 150 | "value" : "%arg failure" 151 | } 152 | }, 153 | "other" : { 154 | "stringUnit" : { 155 | "state" : "translated", 156 | "value" : "%arg failures" 157 | } 158 | } 159 | } 160 | } 161 | }, 162 | "arg3" : { 163 | "argNum" : 3, 164 | "formatSpecifier" : "lld", 165 | "variations" : { 166 | "plural" : { 167 | "one" : { 168 | "stringUnit" : { 169 | "state" : "translated", 170 | "value" : "%arg warning" 171 | } 172 | }, 173 | "other" : { 174 | "stringUnit" : { 175 | "state" : "translated", 176 | "value" : "%arg warnings" 177 | } 178 | } 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | }, 186 | "Converted %lld files with %lld failures in %@" : { 187 | "comment" : "Number of files converted", 188 | "localizations" : { 189 | "en" : { 190 | "stringUnit" : { 191 | "state" : "translated", 192 | "value" : "Converted %#@arg1@ with %#@arg2@ in %3$@" 193 | }, 194 | "substitutions" : { 195 | "arg1" : { 196 | "argNum" : 1, 197 | "formatSpecifier" : "lld", 198 | "variations" : { 199 | "plural" : { 200 | "one" : { 201 | "stringUnit" : { 202 | "state" : "translated", 203 | "value" : "%arg file" 204 | } 205 | }, 206 | "other" : { 207 | "stringUnit" : { 208 | "state" : "translated", 209 | "value" : "%arg files" 210 | } 211 | } 212 | } 213 | } 214 | }, 215 | "arg2" : { 216 | "argNum" : 2, 217 | "formatSpecifier" : "lld", 218 | "variations" : { 219 | "plural" : { 220 | "one" : { 221 | "stringUnit" : { 222 | "state" : "translated", 223 | "value" : "%arg failure" 224 | } 225 | }, 226 | "other" : { 227 | "stringUnit" : { 228 | "state" : "translated", 229 | "value" : "%arg failures" 230 | } 231 | } 232 | } 233 | } 234 | } 235 | } 236 | } 237 | } 238 | }, 239 | "Converted %lld files with %lld warnings in %@" : { 240 | "localizations" : { 241 | "en" : { 242 | "stringUnit" : { 243 | "state" : "translated", 244 | "value" : "Converted %#@arg1@ with %#@arg2@ in %3$@" 245 | }, 246 | "substitutions" : { 247 | "arg1" : { 248 | "argNum" : 1, 249 | "formatSpecifier" : "lld", 250 | "variations" : { 251 | "plural" : { 252 | "one" : { 253 | "stringUnit" : { 254 | "state" : "translated", 255 | "value" : "%arg file" 256 | } 257 | }, 258 | "other" : { 259 | "stringUnit" : { 260 | "state" : "translated", 261 | "value" : "%arg files" 262 | } 263 | } 264 | } 265 | } 266 | }, 267 | "arg2" : { 268 | "argNum" : 2, 269 | "formatSpecifier" : "lld", 270 | "variations" : { 271 | "plural" : { 272 | "one" : { 273 | "stringUnit" : { 274 | "state" : "translated", 275 | "value" : "%arg warning" 276 | } 277 | }, 278 | "other" : { 279 | "stringUnit" : { 280 | "state" : "translated", 281 | "value" : "%arg warnings" 282 | } 283 | } 284 | } 285 | } 286 | } 287 | } 288 | } 289 | } 290 | }, 291 | "Converting..." : { 292 | 293 | }, 294 | "Data format" : { 295 | 296 | }, 297 | "Destination" : { 298 | 299 | }, 300 | "Destination required for Apple Loops" : { 301 | 302 | }, 303 | "Done" : { 304 | 305 | }, 306 | "Failed to convert %lld files in %@" : { 307 | "localizations" : { 308 | "en" : { 309 | "variations" : { 310 | "plural" : { 311 | "one" : { 312 | "stringUnit" : { 313 | "state" : "translated", 314 | "value" : "Failed to convert %1$lld file in %2$@" 315 | } 316 | }, 317 | "other" : { 318 | "stringUnit" : { 319 | "state" : "new", 320 | "value" : "Failed to convert %1$lld files in %2$@" 321 | } 322 | } 323 | } 324 | } 325 | } 326 | } 327 | }, 328 | "Fast Conversion" : { 329 | 330 | }, 331 | "File Already Added" : { 332 | 333 | }, 334 | "File already added to queue: %@" : { 335 | 336 | }, 337 | "File already exists, skipping" : { 338 | 339 | }, 340 | "Finished converting %lld files in %@" : { 341 | 342 | }, 343 | "Flatten into target directory" : { 344 | 345 | }, 346 | "Format" : { 347 | 348 | }, 349 | "Highest possible from source file %lld" : { 350 | 351 | }, 352 | "In order to convert one or more of your selected files, you must select a destination." : { 353 | 354 | }, 355 | "Internal conversion tool failed to convert (Code: %i)" : { 356 | 357 | }, 358 | "No Destination Set" : { 359 | 360 | }, 361 | "No permission to write to directory" : { 362 | 363 | }, 364 | "None (file will be placed in same folder)" : { 365 | 366 | }, 367 | "Open System Settings" : { 368 | 369 | }, 370 | "PCM 16-bit (Apple Platforms)" : { 371 | 372 | }, 373 | "PCM 16-bit (Universal)" : { 374 | 375 | }, 376 | "PCM 24-bit (Apple Platforms)" : { 377 | 378 | }, 379 | "PCM 24-bit (Universal)" : { 380 | 381 | }, 382 | "PCM 32-bit (Apple Platforms)" : { 383 | 384 | }, 385 | "PCM 32-bit (Universal)" : { 386 | 387 | }, 388 | "Results" : { 389 | 390 | }, 391 | "runtime.fix" : { 392 | "extractionState" : "manual", 393 | "localizations" : { 394 | "cs" : { 395 | "stringUnit" : { 396 | "state" : "translated", 397 | "value" : "validate czech" 398 | } 399 | }, 400 | "en" : { 401 | "stringUnit" : { 402 | "state" : "translated", 403 | "value" : "validate this value in unit test" 404 | } 405 | }, 406 | "es" : { 407 | "stringUnit" : { 408 | "state" : "translated", 409 | "value" : "validate spanish" 410 | } 411 | }, 412 | "fi" : { 413 | "stringUnit" : { 414 | "state" : "translated", 415 | "value" : "validate finnish" 416 | } 417 | }, 418 | "fr" : { 419 | "stringUnit" : { 420 | "state" : "translated", 421 | "value" : "validate french" 422 | } 423 | }, 424 | "hu" : { 425 | "stringUnit" : { 426 | "state" : "translated", 427 | "value" : "validate hungarian" 428 | } 429 | }, 430 | "it" : { 431 | "stringUnit" : { 432 | "state" : "translated", 433 | "value" : "validate italian" 434 | } 435 | }, 436 | "ja" : { 437 | "stringUnit" : { 438 | "state" : "translated", 439 | "value" : "validate japanese" 440 | } 441 | }, 442 | "ko" : { 443 | "stringUnit" : { 444 | "state" : "translated", 445 | "value" : "validate korean" 446 | } 447 | }, 448 | "nl" : { 449 | "stringUnit" : { 450 | "state" : "translated", 451 | "value" : "validate dutch" 452 | } 453 | }, 454 | "pt" : { 455 | "stringUnit" : { 456 | "state" : "translated", 457 | "value" : "validate portuguese" 458 | } 459 | }, 460 | "sv" : { 461 | "stringUnit" : { 462 | "state" : "translated", 463 | "value" : "validate swedish" 464 | } 465 | }, 466 | "zh-HK" : { 467 | "stringUnit" : { 468 | "state" : "translated", 469 | "value" : "validate english" 470 | } 471 | } 472 | } 473 | }, 474 | "Same as source file" : { 475 | 476 | }, 477 | "Sample rate" : { 478 | 479 | }, 480 | "Scanning folders..." : { 481 | 482 | }, 483 | "Select" : { 484 | 485 | }, 486 | "Select Apple Loops" : { 487 | 488 | }, 489 | "Select Destination" : { 490 | 491 | }, 492 | "Select file..." : { 493 | 494 | }, 495 | "Select files or folders..." : { 496 | 497 | }, 498 | "Select target format" : { 499 | 500 | }, 501 | "Source Files" : { 502 | 503 | }, 504 | "Successfully converted" : { 505 | 506 | }, 507 | "To avoid having to select one in the future, please enable Full Disk Access in System Settings and then restart the application." : { 508 | 509 | }, 510 | "User canceled conversion" : { 511 | 512 | }, 513 | "User denied access to read file" : { 514 | 515 | }, 516 | "Yes, Convert Files" : { 517 | 518 | } 519 | }, 520 | "version" : "1.0" 521 | } -------------------------------------------------------------------------------- /ExampleCatalogs/PluralTest.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "pluralTest1" : { 5 | "extractionState" : "manual", 6 | "localizations" : { 7 | "en" : { 8 | "variations" : { 9 | "plural" : { 10 | "one" : { 11 | "stringUnit" : { 12 | "state" : "translated", 13 | "value" : "I have %lld cat" 14 | } 15 | }, 16 | "other" : { 17 | "stringUnit" : { 18 | "state" : "translated", 19 | "value" : "I have %lld cats" 20 | } 21 | }, 22 | "zero" : { 23 | "stringUnit" : { 24 | "state" : "translated", 25 | "value" : "I have no cats :(" 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | }, 33 | "substitutionTest1" : { 34 | "extractionState" : "manual", 35 | "localizations" : { 36 | "en" : { 37 | "stringUnit" : { 38 | "state" : "translated", 39 | "value" : "Found %#@arg1@ with %#@arg2@" 40 | }, 41 | "substitutions" : { 42 | "arg1" : { 43 | "argNum" : 1, 44 | "formatSpecifier" : "lld", 45 | "variations" : { 46 | "plural" : { 47 | "one" : { 48 | "stringUnit" : { 49 | "state" : "translated", 50 | "value" : "%arg cat" 51 | } 52 | }, 53 | "other" : { 54 | "stringUnit" : { 55 | "state" : "translated", 56 | "value" : "%arg cats" 57 | } 58 | } 59 | } 60 | } 61 | }, 62 | "arg2" : { 63 | "argNum" : 2, 64 | "formatSpecifier" : "lld", 65 | "variations" : { 66 | "plural" : { 67 | "one" : { 68 | "stringUnit" : { 69 | "state" : "translated", 70 | "value" : "%arg kitten" 71 | } 72 | }, 73 | "other" : { 74 | "stringUnit" : { 75 | "state" : "translated", 76 | "value" : "%arg kittens" 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | }, 87 | "version" : "1.0" 88 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Qutheory, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "071676d25da1757ee1707bea68390c034d7e4abce3826204326b59486b00c766", 3 | "pins" : [ 4 | { 5 | "identity" : "openai", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/MacPaw/OpenAI.git", 8 | "state" : { 9 | "revision" : "843e087929aa806adb611dbca93f9a4a7f28be04", 10 | "version" : "0.3.0" 11 | } 12 | }, 13 | { 14 | "identity" : "rainbow", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/onevcat/Rainbow.git", 17 | "state" : { 18 | "revision" : "7c3dad0e918534c6d19dd1048bde734c246d05fe", 19 | "version" : "4.0.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-argument-parser", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-argument-parser", 26 | "state" : { 27 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 28 | "version" : "1.5.0" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | 7 | let package = Package( 8 | name: "SwiftTranslate", 9 | platforms: [ 10 | .macOS(.v12) 11 | ], 12 | products: [ 13 | .plugin( 14 | name: "SwiftTranslate", 15 | targets: ["SwiftTranslate"] 16 | ), 17 | .executable( 18 | name: "swift-translate", 19 | targets: ["swift-translate"] 20 | ), 21 | .library( 22 | name: "SwiftStringCatalog", 23 | targets: ["SwiftStringCatalog"] 24 | ) 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "1.5.0")), 28 | .package(url: "https://github.com/MacPaw/OpenAI.git", .upToNextMajor(from: "0.3.0")), 29 | .package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMajor(from: "4.0.0")), 30 | ], 31 | targets: [ 32 | 33 | // Main Plugin 34 | 35 | .plugin( 36 | name: "SwiftTranslate", 37 | capability: .command( 38 | intent: .custom( 39 | verb: "swift-translate", 40 | description: "Translates project String Catalogs using OpenAI's GPT 3.5 model" 41 | ), 42 | permissions: [ 43 | .writeToPackageDirectory(reason: "Translates string catalogs in your project"), 44 | .allowNetworkConnections(scope: .all(ports: []), reason: "Needs access to OpenAI servers") 45 | ] 46 | ), 47 | dependencies: [ 48 | .target(name: "swift-translate") 49 | ] 50 | ), 51 | 52 | // Libraries 53 | 54 | .executableTarget( 55 | name: "swift-translate", 56 | dependencies: [ 57 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 58 | .product(name: "OpenAI", package: "OpenAI"), 59 | .product(name: "Rainbow", package: "Rainbow"), 60 | "SwiftStringCatalog" 61 | ], 62 | path: "Sources/SwiftTranslate" 63 | ), 64 | 65 | .target( 66 | name: "SwiftStringCatalog" 67 | ), 68 | 69 | // Tests 70 | 71 | .testTarget( 72 | name: "SwiftStringCatalogTests", 73 | dependencies: ["SwiftStringCatalog"], 74 | exclude: ["SwiftStringCatalog.xctestplan"], 75 | resources: [.process("Resources")] 76 | ) 77 | ] 78 | ) 79 | -------------------------------------------------------------------------------- /Playground.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "testKey" : { 5 | "comment" : "The number of piggies someone has", 6 | "extractionState" : "manual", 7 | "localizations" : { 8 | "en" : { 9 | "variations" : { 10 | "plural" : { 11 | "one" : { 12 | "stringUnit" : { 13 | "state" : "translated", 14 | "value" : "There is a single piggy" 15 | } 16 | }, 17 | "other" : { 18 | "stringUnit" : { 19 | "state" : "translated", 20 | "value" : "There are %i piggies" 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | }, 28 | "This is a test" : { 29 | "comment" : "This is a comment", 30 | "extractionState" : "manual" 31 | } 32 | }, 33 | "version" : "1.0" 34 | } -------------------------------------------------------------------------------- /Plugins/SwiftTranslate/SwiftTranslate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import PackagePlugin 7 | 8 | 9 | @main 10 | struct SwiftTranslatePlugin: CommandPlugin { 11 | 12 | let fileManager = FileManager.default 13 | 14 | func performCommand(context: PluginContext, arguments: [String]) async throws { 15 | let apiKey = try preflight(with: arguments) 16 | 17 | let swiftTranslate = try context.tool(named: "swift-translate") 18 | let swiftTranslateUrl = URL(fileURLWithPath: swiftTranslate.path.string) 19 | let targets = context.package.targets 20 | 21 | for target in targets { 22 | guard let target = target.sourceModule else { 23 | continue 24 | } 25 | try _performCommand( 26 | toolUrl: swiftTranslateUrl, 27 | apiKey: apiKey, 28 | targetName: target.name, 29 | directoryPath: target.directory.string 30 | ) 31 | } 32 | } 33 | 34 | private func preflight(with arguments: [String]) throws -> String { 35 | var argumentExtractor = ArgumentExtractor(arguments) 36 | guard let apiKey = argumentExtractor.extractOption(named: "api-key").last else { 37 | throw SwiftTranslatePluginError.apiKeyMissing 38 | } 39 | return apiKey 40 | } 41 | 42 | private func _performCommand(toolUrl: URL, apiKey: String, targetName: String, directoryPath: String) throws { 43 | let swiftTranslateArgs = ["--api-key", apiKey, "--skip-confirmation", "--overwrite", directoryPath] 44 | 45 | let process = try Process.run(toolUrl, arguments: swiftTranslateArgs) 46 | process.waitUntilExit() 47 | 48 | if process.terminationReason != .exit || process.terminationStatus != 0 { 49 | let problem = "\(process.terminationReason):\(process.terminationStatus)" 50 | Diagnostics.error("Translating catalog failed: \(problem)") 51 | } 52 | } 53 | } 54 | 55 | #if canImport(XcodeProjectPlugin) 56 | import XcodeProjectPlugin 57 | 58 | extension SwiftTranslatePlugin: XcodeCommandPlugin { 59 | func performCommand(context: XcodePluginContext, arguments: [String]) throws { 60 | let apiKey = try preflight(with: arguments) 61 | let swiftTranslate = try context.tool(named: "swift-translate") 62 | let swiftTranslateUrl = URL(fileURLWithPath: swiftTranslate.path.string) 63 | 64 | try _performCommand( 65 | toolUrl: swiftTranslateUrl, 66 | apiKey: apiKey, 67 | targetName: context.xcodeProject.displayName, 68 | directoryPath: context.xcodeProject.directory.string 69 | ) 70 | } 71 | } 72 | 73 | #endif 74 | -------------------------------------------------------------------------------- /Plugins/SwiftTranslate/SwiftTranslateError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | enum SwiftTranslatePluginError: Error { 9 | case apiKeyMissing 10 | } 11 | 12 | extension SwiftTranslatePluginError: CustomStringConvertible { 13 | var description: String { 14 | switch self { 15 | case .apiKeyMissing: 16 | return "No API key argument provided (--api-key)" 17 | } 18 | } 19 | } 20 | 21 | extension SwiftTranslatePluginError: LocalizedError { 22 | var errorDescription: String? { self.description } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Swift Translate](https://github.com/hidden-spectrum/swift-translate/assets/469799/1cf0355f-429b-4fa4-9fe1-0b8e777db63e) 2 | 3 | Swift Translate is a CLI tool and Swift Package Plugin that makes it easy to localize your app. It deconstructs your string catalogs and sends them to OpenAI's GPT-3.5-Turbo/GPT-4o models or Google Cloud Translate (v2) for translation. See it in action: 4 | 5 | https://github.com/hidden-spectrum/swift-translate/assets/469799/ae5066fa-336c-4bab-8f80-1ec5659008d9 6 | 7 | ## 📋 Requirements 8 | - macOS 13+ 9 | - Xcode 15+ 10 | - Project utilizing [String Catalogs](https://developer.apple.com/videos/play/wwdc2023/10155/) for localization 11 | - [OpenAI API key](https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key) or [Google Cloud Translate (v2)](https://cloud.google.com/translate/docs/overview) API key 12 | 13 | ## ⭐️ Features 14 | - ✅ Translate individual string catalogs or all catalogs in a folder 15 | - ✅ Translate from English to ar, ca, zh-HK, zh-Hans, zh-Hant, hr, cs, da, nl, en, fi, fr, de, el, he, hi, hu, id, it, ja, ko, ms, nb, pl, pt-BR, pt-PT, ro, ru, sk, es, sv, th, tr 16 | - ✅ Support for complex string catalogs with plural & device variations or replacements 17 | - ✅ Translate brand new catalogs or fill in missing translations for existing catalogs 18 | - ✅ Supports ChatGPT (3.5-Turbo and 4o models) and Google Translate (v2) 19 | - 🚧 Documentation ([#2](/../../issues/2)) 20 | - 🚧 Unit tests ([#3](/../../issues/3)) 21 | - ❌ Translate from non-English source language ([#23](/../../issues/23)) 22 | - ❌ Translate text files (useful for fastlane metadata) ([#12](/../../issues/12)) 23 | - ❌ "Confidence check": ask GPT to translate text back into source language to compare against the original string ([#14](/../../issues/14)) 24 | 25 | ## 🛑 Stop Here 26 | Before continuing, please read the following: 27 | - This project is in very early stages. 🐣 28 | - It is **NOT** recommended for production use. ⛔️ 29 | - Like any tool built on ChatGPT, responses may be inaccurate or broken completely. 🤪 30 | - Hidden Spectrum is not liable for loss of data, file corruption, or inaccurate/offensive translations (or any subsequent bad app reviews due to aforementioned inaccuracies) 🙅🏻‍♂️ 31 | 32 | **👉 Note:** By default, your catalogs *WILL NOT* be overwritten, instead a copy will be made with `.loc` extension. 33 | If you wish to overwrite your catalogs, be sure they are checked into your repository or backed up, then use the `--overwrite` CLI argument. 34 | 35 | Ok, with that out of the way let's get into the fun stuff... 36 | 37 | ## 🧑‍💻 Usage 38 | 39 | ### Option 1: Via Repo Clone 40 | **👉 Note:** While this plugin is still in development, this is the recommended way of trying it with your projects. 41 | 42 | 1. Clone this repository or download a zip from GitHub. 43 | 2. Open terminal and `cd` to the repo on your machine. 44 | 3. Test your API key with a basic text translation: 45 | ```shell 46 | swift run swift-translate --verbose -k --text "This is a test" --lang de 47 | ``` 48 | 4. You should see the following output: 49 | 50 | ```shell 51 | Building for debugging... 52 | Build complete! (0.59s) 53 | 54 | Translating `This is a test`: 55 | de: Dies ist ein Test 56 | ✅ Translated 1 key(s) (0.384 seconds) 57 | ``` 58 | 5. Next, run the `--help` command to learn more: 59 | ```shell 60 | swift run swift-translate --help 61 | ``` 62 | 63 | ### Option 2: Via Package Plugin 64 | 65 | 1. Add the depedency to your `Package.swift` file. 66 | ```swift 67 | dependencies: [ 68 | .package(url: "https://github.com/hidden-spectrum/swift-translate", .upToNextMajor(from: "0.1.0")) 69 | ] 70 | ``` 71 | 2. Add the plugin to your target: 72 | ```swift 73 | .target( 74 | name: "App", 75 | // ... 76 | plugins: [ 77 | .plugin(name: "SwiftTranslate", package: "swift-translate") 78 | ] 79 | ) 80 | ``` 81 | 3. Open terminal and `cd` to your package directory. 82 | 4. Try translating a catalog in your package: 83 | ```shell 84 | swift package plugin swift-translate -k --lang de --verbose 85 | ``` 86 | 5. Enter `Y` when prompted for write access to your package folder and for outgoing network connections. 87 | 6. After translation is finished, check for a new `YourFile.loc.xcstrings` file in the same directory as the original file. 88 | 89 | ### Option 3: Inside Xcode 90 | 🚧 *Not yet supported* 91 | 92 | 93 | ## 🙏 Help Wanted 94 | If you're a GPT Guru, we'd love to hear from you about how we can improve our use of the OpenAI API. Open a ticket with your suggestions or [contact us](https://hiddenspectrum.io/contact) to get involved directly. 95 | 96 | ## 🤝 Contributing 97 | We're still working out a proper process for contributing to this project. In the meantime, check out [open issues](https://github.com/hidden-spectrum/swift-translate/issues) to see where you may be able to help. If something isn't listed, feel free to open a ticket or PR and we'll take a look! 98 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Bootstrap/StringCatalog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public final class StringCatalog { 9 | 10 | // MARK: Public 11 | 12 | public enum Error: Swift.Error { 13 | case catalogVersionNotSupported(String) 14 | case substitionsNotYetSupported 15 | } 16 | 17 | public let sourceLanguage: Language 18 | public let version = "1.0" // Only version 1.0 supported for now 19 | 20 | public private(set) var allKeys = [String]() 21 | 22 | // MARK: Internal 23 | 24 | var sourceLanguageStrings = [String: [LocalizableString]]() 25 | 26 | // MARK: Public private(set) 27 | 28 | public private(set) var localizableStringGroups: [String: LocalizableStringGroup] = [:] 29 | public private(set) var localizableStringsCount: Int = 0 30 | 31 | public private(set) var targetLanguages: Set = [] 32 | 33 | // MARK: Lifecycle 34 | 35 | public init(url: URL, configureWith targetLanguages: Set? = nil) throws { 36 | let data = try Data(contentsOf: url) 37 | let decoder = JSONDecoder() 38 | let catalog = try decoder.decode(_StringCatalog.self, from: data) 39 | if catalog.version != version { 40 | throw Error.catalogVersionNotSupported(catalog.version) 41 | } 42 | 43 | self.allKeys = Array(catalog.strings.keys) 44 | self.sourceLanguage = catalog.sourceLanguage 45 | self.targetLanguages = { 46 | if let targetLanguages { 47 | targetLanguages 48 | } else { 49 | detectedTargetLanguages(in: catalog) 50 | } 51 | }() 52 | 53 | try loadAllLocalizableStrings(from: catalog) 54 | } 55 | 56 | public init(sourceLanguage: Language, targetLanguages: Set = []) { 57 | self.sourceLanguage = sourceLanguage 58 | self.targetLanguages = targetLanguages 59 | } 60 | 61 | // MARK: Loading 62 | 63 | private func detectedTargetLanguages(in catalog: _StringCatalog) -> Set { 64 | var targetLanguages = Set() 65 | for (_, entry) in catalog.strings { 66 | guard let localizations = entry.localizations else { 67 | continue 68 | } 69 | for (language, _) in localizations { 70 | targetLanguages.insert(language) 71 | } 72 | } 73 | return targetLanguages 74 | } 75 | 76 | private func loadAllLocalizableStrings(from catalog: _StringCatalog) throws { 77 | localizableStringsCount = 0 78 | for (key, entry) in catalog.strings { 79 | let sourceLanguageStrings = try sourceLanguageStrings(in: entry, for: key) 80 | self.sourceLanguageStrings[key] = sourceLanguageStrings 81 | 82 | let localizableStrings = try localizableStrings(in: entry, for: key, referencing: sourceLanguageStrings) 83 | localizableStringsCount += localizableStrings.count 84 | localizableStringGroups[key] = LocalizableStringGroup( 85 | comment: entry.comment, 86 | extractionState: entry.extractionState, 87 | strings: localizableStrings 88 | ) 89 | } 90 | } 91 | 92 | private func sourceLanguageStrings(in entry: _CatalogEntry, for key: String) throws -> [LocalizableString] { 93 | if let localization = entry.localizations?[sourceLanguage] { 94 | return try localization.constructLocalizableStrings( 95 | with: .sourceLanguageContext(sourceLanguage: sourceLanguage) 96 | ) 97 | } else { 98 | return [ 99 | LocalizableString( 100 | kind: .standalone, 101 | sourceKey: key, 102 | targetLanguage: sourceLanguage, 103 | translatedValue: key, 104 | state: .translated 105 | ) 106 | ] 107 | } 108 | } 109 | 110 | private func localizableStrings( 111 | in entry: _CatalogEntry, 112 | for key: String, 113 | referencing sourceLanguageStrings: [LocalizableString] 114 | ) throws -> [LocalizableString] { 115 | var localizableStrings = [LocalizableString]() 116 | 117 | for language in targetLanguages { 118 | if let localization = entry.localizations?[language] { 119 | localizableStrings += try localization.constructLocalizableStrings( 120 | with: .targetLanguageContext(targetLanguage: language, sourceLanguageStrings: sourceLanguageStrings) 121 | ) 122 | } else { 123 | localizableStrings += sourceLanguageStrings.map { 124 | $0.emptyCopy(for: language) 125 | } 126 | } 127 | } 128 | return localizableStrings 129 | } 130 | 131 | // MARK: - Create / Save Catalog 132 | 133 | public func write(to url: URL) throws { 134 | let entries = try buildCatalogEntries() 135 | let catalog = _StringCatalog( 136 | sourceLanguage: sourceLanguage, 137 | strings: entries 138 | ) 139 | let encoder = JSONEncoder() 140 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 141 | let data = try encoder.encode(catalog) 142 | 143 | let fileManager = FileManager.default 144 | if fileManager.fileExists(atPath: url.path) { 145 | try fileManager.removeItem(at: url) 146 | } 147 | fileManager.createFile(atPath: url.path, contents: data) 148 | } 149 | 150 | func buildCatalogEntries() throws -> [String: _CatalogEntry] { 151 | var entries = [String: _CatalogEntry]() 152 | for (key, stringGroup) in localizableStringGroups { 153 | entries[key] = try _CatalogEntry(from: stringGroup) 154 | } 155 | return entries 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Extensions/LocalizableString+Lookup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | extension Array where Element == LocalizableString { 9 | func sourceKeyLookup(matchingKind kind: LocalizableString.Kind) throws -> String { 10 | let filteredResults = filter { $0.kind == kind } 11 | guard let sourceLocalizableString = filteredResults.first else { 12 | throw SourceKeyLookupError.notFound 13 | } 14 | guard filteredResults.count == 1 else { 15 | throw SourceKeyLookupError.multipleFound 16 | } 17 | return sourceLocalizableString.sourceKey 18 | } 19 | } 20 | 21 | enum SourceKeyLookupError: Error { 22 | case notFound 23 | case multipleFound 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/DeviceCategory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public enum DeviceCategory: String, Codable { 9 | case iPad = "ipad" 10 | case iPhone = "iphone" 11 | case iPod = "ipod" 12 | case mac = "mac" 13 | case other = "other" 14 | case tv = "appletv" 15 | case vision = "applevision" 16 | case watch = "applewatch" 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/ExtractionState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public enum ExtractionState: String, Codable { 9 | case extractedWithValue = "extracted_with_value" 10 | case manual 11 | case migrated 12 | case unknown 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public struct Language: Codable, Equatable, Hashable, RawRepresentable, Sendable { 9 | 10 | // MARK: Public 11 | 12 | public let code: String 13 | 14 | public var rawValue: String { 15 | return code 16 | } 17 | 18 | // MARK: Lifecycle 19 | 20 | public init(rawValue: String) { 21 | self.code = rawValue 22 | } 23 | 24 | public init(_ code: StringLiteralType) { 25 | self.code = code 26 | } 27 | 28 | // MARK: Hash 29 | } 30 | 31 | // MARK: - Common Languages 32 | 33 | public extension Language { 34 | 35 | static var allCommon: [Language] { 36 | return [ 37 | .arabic, 38 | .catalan, 39 | .chineseHongKong, 40 | .chineseSimplified, 41 | .chineseTraditional, 42 | .croatian, 43 | .czech, 44 | .danish, 45 | .dutch, 46 | .english, 47 | .finnish, 48 | .french, 49 | .german, 50 | .greek, 51 | .hebrew, 52 | .hindi, 53 | .hungarian, 54 | .indonesian, 55 | .italian, 56 | .japanese, 57 | .korean, 58 | .malay, 59 | .norwegianBokmal, 60 | .polish, 61 | .portugueseBrazil, 62 | .portuguesePortugal, 63 | .romanian, 64 | .russian, 65 | .slovak, 66 | .spanish, 67 | .swedish, 68 | .thai, 69 | .turkish 70 | ] 71 | } 72 | 73 | static let arabic = Self("ar") 74 | static let catalan = Self("ca") 75 | static let chineseHongKong = Self("zh-HK") 76 | static let chineseSimplified = Self("zh-Hans") 77 | static let chineseTraditional = Self("zh-Hant") 78 | static let croatian = Self("hr") 79 | static let czech = Self("cs") 80 | static let danish = Self("da") 81 | static let dutch = Self("nl") 82 | static let english = Self("en") 83 | static let finnish = Self("fi") 84 | static let french = Self("fr") 85 | static let german = Self("de") 86 | static let greek = Self("el") 87 | static let hebrew = Self("he") 88 | static let hindi = Self("hi") 89 | static let hungarian = Self("hu") 90 | static let indonesian = Self("id") 91 | static let italian = Self("it") 92 | static let japanese = Self("ja") 93 | static let korean = Self("ko") 94 | static let malay = Self("ms") 95 | static let norwegianBokmal = Self("nb") 96 | static let polish = Self("pl") 97 | static let portugueseBrazil = Self("pt-BR") 98 | static let portuguesePortugal = Self("pt-PT") 99 | static let romanian = Self("ro") 100 | static let russian = Self("ru") 101 | static let slovak = Self("sk") 102 | static let spanish = Self("es") 103 | static let swedish = Self("sv") 104 | static let thai = Self("th") 105 | static let turkish = Self("tr") 106 | static let ukrainian = Self("uk") 107 | static let vietnamese = Self("vi") 108 | } 109 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/LocalizableString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public final class LocalizableString { 9 | 10 | // MARK: Public 11 | 12 | public let sourceKey: String 13 | public let targetLanguage: Language 14 | 15 | // MARK: Public private(set) 16 | 17 | public private(set) var kind: Kind 18 | public private(set) var translatedValue: String? 19 | public private(set) var state: TranslationState 20 | 21 | // MARK: Lifecycle 22 | 23 | init( 24 | kind: Kind, 25 | sourceKey: String, 26 | targetLanguage: Language, 27 | translatedValue: String?, 28 | state: TranslationState 29 | ) { 30 | self.sourceKey = sourceKey 31 | self.targetLanguage = targetLanguage 32 | self.translatedValue = translatedValue 33 | self.state = state 34 | self.kind = kind 35 | } 36 | 37 | // MARK: Translation 38 | 39 | public func setTranslation(_ translation: String) { 40 | translatedValue = translation 41 | state = .translated 42 | } 43 | 44 | // MARK: Utility 45 | 46 | func convertKindToSubstitution(argNum: Int, formatSpecifier: String) { 47 | guard case .variation(let variationKind) = kind else { 48 | return 49 | } 50 | kind = .replacement( 51 | Replacement( 52 | argNumber: argNum, 53 | formatSpecifier: formatSpecifier, 54 | variation: variationKind 55 | ) 56 | ) 57 | } 58 | 59 | func emptyCopy(for targetLanguage: Language) -> LocalizableString { 60 | return LocalizableString( 61 | kind: kind, 62 | sourceKey: sourceKey, 63 | targetLanguage: targetLanguage, 64 | translatedValue: nil, 65 | state: .new 66 | ) 67 | } 68 | } 69 | 70 | public extension LocalizableString { 71 | enum Kind: Equatable { 72 | case standalone 73 | case replacement(Replacement) 74 | case variation(Variation) 75 | } 76 | 77 | enum Variation: Equatable { 78 | case device(DeviceCategory) 79 | case plural(PluralQualifier) 80 | } 81 | 82 | struct Replacement: Equatable { 83 | let argNumber: Int 84 | let formatSpecifier: String 85 | let variation: Variation? 86 | } 87 | } 88 | 89 | extension LocalizableString: Equatable { 90 | public static func == (lhs: LocalizableString, rhs: LocalizableString) -> Bool { 91 | return lhs.kind == rhs.kind 92 | && lhs.sourceKey == rhs.sourceKey 93 | && lhs.targetLanguage == rhs.targetLanguage 94 | && lhs.translatedValue == rhs.translatedValue 95 | && lhs.state == rhs.state 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/LocalizableStringGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public struct LocalizableStringGroup { 9 | 10 | // MARK: Public 11 | 12 | public let comment: String? 13 | public let extractionState: ExtractionState? 14 | public let strings: [LocalizableString] 15 | 16 | // MARK: Lifecycle 17 | 18 | init( 19 | comment: String?, 20 | extractionState: ExtractionState?, 21 | strings: [LocalizableString] 22 | ) { 23 | self.comment = comment 24 | self.extractionState = extractionState 25 | self.strings = strings 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/TranslationState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public enum TranslationState: String, Codable, Equatable { 9 | case new 10 | case needsReview = "needs_review" 11 | case stale 12 | case translated 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_CatalogEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import os.log 7 | 8 | 9 | struct _CatalogEntry: Codable { 10 | 11 | // MARK: Internal 12 | 13 | let comment: String? 14 | let extractionState: ExtractionState? 15 | 16 | var localizations: CodableKeyDictionary? 17 | 18 | // MARK: Lifecycle 19 | 20 | init( 21 | comment: String?, 22 | extractionState: ExtractionState?, 23 | localizations: CodableKeyDictionary 24 | ) { 25 | self.comment = comment 26 | self.extractionState = extractionState 27 | self.localizations = localizations 28 | } 29 | } 30 | 31 | extension _CatalogEntry { 32 | init(from stringsGroup: LocalizableStringGroup) throws { 33 | var localizations = CodableKeyDictionary() 34 | for localizableString in stringsGroup.strings { 35 | guard let translatedValue = localizableString.translatedValue else { 36 | continue 37 | } 38 | 39 | let language = localizableString.targetLanguage 40 | var localization = localizations[language] ?? _Localization() 41 | 42 | defer { 43 | localizations[language] = localization 44 | } 45 | 46 | switch localizableString.kind { 47 | case .standalone: 48 | localization.stringUnit = _StringUnit(state: localizableString.state, value: translatedValue) 49 | continue 50 | case .replacement: 51 | localization.addSubstitution(from: localizableString) 52 | case .variation: 53 | localization.addVariations(from: localizableString) 54 | } 55 | 56 | } 57 | self.init(comment: stringsGroup.comment, extractionState: stringsGroup.extractionState, localizations: localizations) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_Localization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct _Localization: Codable { 9 | 10 | // MARK: Internal 11 | 12 | var stringUnit: _StringUnit? 13 | var substitutions: [String: _Substitution]? 14 | var variations: _Variations? 15 | } 16 | 17 | extension _Localization: LocalizableStringConstructor { 18 | func constructLocalizableStrings(with context: LocalizableStringConstructionContext) throws -> [LocalizableString] { 19 | if let stringUnit { 20 | var localizableStrings = [ 21 | LocalizableString( 22 | kind: .standalone, 23 | sourceKey: try context.embeddedSourceKey(matching: .standalone, or: stringUnit.value), 24 | targetLanguage: context.targetLanguage, 25 | translatedValue: stringUnit.value, 26 | state: stringUnit.state 27 | ) 28 | ] 29 | if let substitutions { 30 | localizableStrings += try substitutions.flatMap { key, substitution in 31 | try substitution.constructLocalizableStrings(with: context) 32 | } 33 | } 34 | return localizableStrings 35 | } else if let variations { 36 | return try variations.constructLocalizableStrings(with: context) 37 | } else { 38 | return [] 39 | } 40 | } 41 | } 42 | 43 | extension _Localization { 44 | mutating func addVariations(from localizedString: LocalizableString) { 45 | if variations == nil { 46 | variations = _Variations() 47 | } 48 | variations?.addVariation(from: localizedString) 49 | } 50 | 51 | mutating func addSubstitution(from localizedString: LocalizableString) { 52 | guard case .replacement(let replacement) = localizedString.kind else { 53 | return 54 | } 55 | if substitutions == nil { 56 | substitutions = [:] 57 | } 58 | let substitutionKey = "arg\(replacement.argNumber)" 59 | var substitution = substitutions?[substitutionKey] 60 | ?? _Substitution( 61 | argNum: replacement.argNumber, 62 | formatSpecifier: replacement.formatSpecifier, 63 | variations: _Variations() 64 | ) 65 | substitution.variations?.addVariation(from: localizedString) 66 | substitutions?[substitutionKey] = substitution 67 | } 68 | } 69 | 70 | 71 | final class LocalizableStringConstructionContext { 72 | 73 | // MARK: Internal 74 | 75 | let isSource: Bool 76 | let sourceLanguageStrings: [LocalizableString] 77 | let targetLanguage: Language 78 | 79 | var replacement: LocalizableString.Replacement? 80 | 81 | // MARK: Lifecycle 82 | 83 | static func sourceLanguageContext(sourceLanguage: Language) -> Self { 84 | return .init( 85 | isSource: true, 86 | targetLanguage: sourceLanguage, 87 | sourceLanguageStrings: [] 88 | ) 89 | } 90 | 91 | static func targetLanguageContext( 92 | targetLanguage: Language, 93 | sourceLanguageStrings: [LocalizableString] 94 | ) -> Self { 95 | return .init( 96 | isSource: false, 97 | targetLanguage: targetLanguage, 98 | sourceLanguageStrings: sourceLanguageStrings 99 | ) 100 | } 101 | 102 | private init(isSource: Bool, targetLanguage: Language, sourceLanguageStrings: [LocalizableString]) { 103 | self.isSource = true 104 | self.targetLanguage = targetLanguage 105 | self.sourceLanguageStrings = [] 106 | } 107 | 108 | func embeddedSourceKey(matching kind: LocalizableString.Kind, or givenSourceKey: String) throws -> String { 109 | if isSource { 110 | return givenSourceKey 111 | } else { 112 | return try sourceLanguageStrings.sourceKeyLookup(matchingKind: kind) 113 | } 114 | } 115 | 116 | func constructKind(variation: LocalizableString.Variation) -> LocalizableString.Kind { 117 | if let replacement = replacement { 118 | let updatedReplacement = LocalizableString.Replacement( 119 | argNumber: replacement.argNumber, 120 | formatSpecifier: replacement.formatSpecifier, 121 | variation: variation 122 | ) 123 | return .replacement(updatedReplacement) 124 | } else { 125 | return .variation(variation) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_StringCatalog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct _StringCatalog: Codable { 9 | 10 | // MARK: Internal 11 | 12 | let sourceLanguage: Language 13 | let strings: [String: _CatalogEntry] 14 | let version: String 15 | 16 | // MARK: Lifecycle 17 | 18 | init(sourceLanguage: Language, strings: [String: _CatalogEntry]) { 19 | self.sourceLanguage = sourceLanguage 20 | self.strings = strings 21 | self.version = "1.0" // Only 1.0 supported 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_StringUnit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct _StringUnit: Codable { 9 | 10 | // MARK: Internal 11 | 12 | let state: TranslationState 13 | let value: String 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_Substitution.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct _Substitution: Codable { 9 | let argNum: Int 10 | let formatSpecifier: String 11 | var variations: _Variations? 12 | } 13 | 14 | 15 | extension _Substitution: LocalizableStringConstructor { 16 | func constructLocalizableStrings(with context: LocalizableStringConstructionContext) throws -> [LocalizableString] { 17 | if let variations { 18 | context.replacement = .init(argNumber: argNum, formatSpecifier: formatSpecifier, variation: nil) 19 | return try variations.constructLocalizableStrings(with: context) 20 | } else { 21 | return [] 22 | } 23 | } 24 | 25 | mutating func addVariations(from localizedString: LocalizableString) { 26 | if variations == nil { 27 | variations = _Variations() 28 | } 29 | variations?.addVariation(from: localizedString) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Models/_Variations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct _Variation: Codable { 9 | let stringUnit: _StringUnit 10 | 11 | enum Error: Swift.Error { 12 | case translatedValueMissing 13 | } 14 | 15 | init?(state: TranslationState, translatedValue: String?) { 16 | guard let translatedValue else { 17 | return nil 18 | } 19 | self.stringUnit = _StringUnit(state: state, value: translatedValue) 20 | } 21 | } 22 | 23 | struct _Variations: Codable { 24 | var device: CodableKeyDictionary? 25 | var plural: CodableKeyDictionary? 26 | } 27 | 28 | extension _Variations: LocalizableStringConstructor { 29 | func constructLocalizableStrings(with context: LocalizableStringConstructionContext) throws -> [LocalizableString] { 30 | if let deviceVariations = device { 31 | return try deviceVariations.map { deviceCategory, variation in 32 | let kind = context.constructKind(variation: .device(deviceCategory)) 33 | let stringUnit = variation.stringUnit 34 | return LocalizableString( 35 | kind: kind, 36 | sourceKey: try context.embeddedSourceKey(matching: kind, or: stringUnit.value), 37 | targetLanguage: context.targetLanguage, 38 | translatedValue: variation.stringUnit.value, 39 | state: stringUnit.state 40 | ) 41 | } 42 | } else if let pluralVariations = plural { 43 | return pluralVariations.compactMap { qualifier, variation in 44 | let kind = context.constructKind(variation: .plural(qualifier)) 45 | let stringUnit = variation.stringUnit 46 | guard let sourceKey = try? context.embeddedSourceKey(matching: kind, or: stringUnit.value) else { 47 | return nil 48 | } 49 | return LocalizableString( 50 | kind: kind, 51 | sourceKey: sourceKey, 52 | targetLanguage: context.targetLanguage, 53 | translatedValue: variation.stringUnit.value, 54 | state: variation.stringUnit.state 55 | ) 56 | } 57 | } else { 58 | return [] 59 | } 60 | } 61 | 62 | mutating func addVariation(from localizedString: LocalizableString) { 63 | var unpackedVariation: LocalizableString.Variation 64 | 65 | if case .variation(let variation) = localizedString.kind { 66 | unpackedVariation = variation 67 | } else if case .replacement(let replacement) = localizedString.kind, let variation = replacement.variation { 68 | unpackedVariation = variation 69 | } else { 70 | return 71 | } 72 | 73 | switch unpackedVariation { 74 | case .device(let category): 75 | if device == nil { 76 | device = CodableKeyDictionary() 77 | } 78 | device?[category] = _Variation(state: localizedString.state, translatedValue: localizedString.translatedValue) 79 | case .plural(let qualifier): 80 | if plural == nil { 81 | plural = CodableKeyDictionary() 82 | } 83 | plural?[qualifier] = _Variation(state: localizedString.state, translatedValue: localizedString.translatedValue) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Protocols/LocalizableStringConstructor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | protocol LocalizableStringConstructor { 9 | func constructLocalizableStrings(with context: LocalizableStringConstructionContext) throws -> [LocalizableString] 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Protocols/PluralQualifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | public enum PluralQualifier: String, Codable { 9 | case zero 10 | case one 11 | case two 12 | case few 13 | case many 14 | case other 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftStringCatalog/Utilities/CodableKeyDictionary.swift: -------------------------------------------------------------------------------- 1 | // Borrowed with ❤️ from https://github.com/liamnichols/xcstrings-tool 2 | 3 | import Foundation 4 | 5 | 6 | // Workaround for https://forums.swift.org/t/using-rawrepresentable-string-and-int-keys-for-codable-dictionaries/26899 7 | // Cannot be a property wrapper due to https://forums.swift.org/t/using-property-wrappers-with-codable/29804 8 | // Assumes `Key.RawValue.init(rawValue:)` is non-failable 9 | public struct CodableKeyDictionary where Key.RawValue: Hashable { 10 | public typealias WrappedValue = Dictionary 11 | 12 | public var wrappedValue: WrappedValue 13 | 14 | public init(wrappedValue: WrappedValue) { 15 | self.wrappedValue = wrappedValue 16 | } 17 | } 18 | 19 | // MARK: - Subscripting 20 | 21 | extension CodableKeyDictionary { 22 | public subscript(_ key: Key) -> Value? { 23 | get { 24 | wrappedValue[key.rawValue] 25 | } 26 | mutating set { 27 | wrappedValue[key.rawValue] = newValue 28 | } 29 | } 30 | 31 | public subscript(key: Key, default defaultValue: @autoclosure () -> Value) -> Value { 32 | get { 33 | wrappedValue[key.rawValue, default: defaultValue()] 34 | } 35 | mutating set { 36 | wrappedValue[key.rawValue] = newValue 37 | } 38 | } 39 | } 40 | 41 | // MARK: - ExpressibleByDictionaryLiteral 42 | 43 | extension CodableKeyDictionary: ExpressibleByDictionaryLiteral { 44 | public init(dictionaryLiteral elements: (Key, Value)...) { 45 | self.init( 46 | wrappedValue: Dictionary( 47 | uniqueKeysWithValues: elements.map { ($0.0.rawValue, $0.1) } 48 | ) 49 | ) 50 | } 51 | } 52 | 53 | // MARK: - Codable 54 | 55 | extension CodableKeyDictionary: Decodable where Key.RawValue: Decodable, Value: Decodable { 56 | public init(from decoder: Decoder) throws { 57 | let container = try decoder.singleValueContainer() 58 | self.init(wrappedValue: try container.decode(WrappedValue.self)) 59 | } 60 | } 61 | 62 | extension CodableKeyDictionary: Encodable where Key.RawValue: Encodable, Value: Encodable { 63 | public func encode(to encoder: Encoder) throws { 64 | var container = encoder.singleValueContainer() 65 | try container.encode(wrappedValue) 66 | } 67 | } 68 | 69 | // MARK: - Equatable 70 | 71 | extension CodableKeyDictionary: Equatable where Self.WrappedValue: Equatable { 72 | } 73 | 74 | // MARK: - Hashable 75 | 76 | extension CodableKeyDictionary: Hashable where Self.WrappedValue: Hashable { 77 | } 78 | 79 | // MARK: - Sequence 80 | 81 | extension CodableKeyDictionary: Sequence { 82 | public typealias Element = (key: Key, value: Value) 83 | 84 | public struct Iterator: IteratorProtocol { 85 | public var base: WrappedValue.Iterator 86 | 87 | public mutating func next() -> CodableKeyDictionary.Element? { 88 | if let next = base.next() { 89 | return (key: Key(rawValue: next.key)!, value: next.value) 90 | } else { 91 | return nil 92 | } 93 | } 94 | } 95 | 96 | public func makeIterator() -> Iterator { 97 | Iterator(base: wrappedValue.makeIterator()) 98 | } 99 | } 100 | 101 | // MARK: - Values 102 | 103 | extension CodableKeyDictionary { 104 | public typealias Values = WrappedValue.Values 105 | 106 | public var values: Values { 107 | wrappedValue.values 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Bootstrap/SwiftTranslate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import ArgumentParser 6 | import Foundation 7 | import OpenAI 8 | import SwiftStringCatalog 9 | 10 | 11 | @main 12 | struct SwiftTranslate: AsyncParsableCommand { 13 | 14 | // MARK: Command Line Options 15 | 16 | @Option( 17 | name: [.customLong("service"), .customShort("s")], 18 | help: "Service to use. Either `openai` (default) or `google`" 19 | ) 20 | private var service: TranslationServiceArgument = .openAI 21 | 22 | @Option( 23 | name: [.customLong("api-key"), .customShort("k")], 24 | help: "OpenAI or Google Cloud Translate (v2) API key" 25 | ) 26 | private var apiToken: String 27 | 28 | @Option( 29 | name: [.customLong("model"), .customShort("m")], 30 | help: "OpenAI model to use. Either `gpt-3.5-turbo` (default) or `gpt-4o`. Ignored when using Google Translate" 31 | ) 32 | private var model: OpenAIModel = .gpt3_5Turbo 33 | 34 | @OptionGroup( 35 | title: "Translate text" 36 | ) 37 | private var textOptions: TextTranslationOptions 38 | 39 | @OptionGroup( 40 | title: "Translate string catalogs" 41 | ) 42 | private var catalogOptions: CatalogTranlationOptions 43 | 44 | @Option( 45 | name: [.customLong("lang"), .short], 46 | parsing: .upToNextOption, 47 | help: "Target language(s) or `all` for all common languages. Omitting this option will use existing langauges in the String Catalog(s)\n", 48 | completion: .list(Language.allCommon.map(\.rawValue)) 49 | ) 50 | private var languages: [Language] = [Language("__in_catalog")] 51 | 52 | @Flag( 53 | name: [.customLong("skip-confirmation"), .customShort("y")], 54 | help: "Skips confirmation for translating large string files" 55 | ) 56 | var skipConfirmation: Bool = false 57 | 58 | @Option( 59 | name: [.customLong("retries"), .short], 60 | help: "Retries for OpenAI API requests in case of errors. Ignored when using Google Translate" 61 | ) 62 | private var requestRetry: Int = 1 63 | 64 | @Option( 65 | name: [.customLong("timeout")], 66 | help: "Timeout interval for API requests" 67 | ) 68 | private var timeoutInterval: Int = 60 69 | 70 | @Flag( 71 | name: [.long, .short], 72 | help: "Enables verbose log output" 73 | ) 74 | private var verbose: Bool = false 75 | 76 | // MARK: Private 77 | 78 | private static let languageList = [Language("all-common")] + Language.allCommon 79 | 80 | // MARK: Lifecycle 81 | 82 | func run() async throws { 83 | var translator: TranslationService 84 | 85 | switch service { 86 | case .google: 87 | translator = GoogleTranslator(apiKey: apiToken, timeoutInterval: timeoutInterval) 88 | case .openAI: 89 | translator = OpenAITranslator(with: apiToken, model: model, timeoutInterval: timeoutInterval, retries: requestRetry) 90 | } 91 | 92 | var targetLanguages: Set? 93 | if languages.first?.rawValue == "__in_catalog" { 94 | targetLanguages = nil 95 | } else if languages.first?.rawValue == "all" { 96 | targetLanguages = Set(Language.allCommon) 97 | } else { 98 | let invalidLanguages = languages.filter({ !Language.allCommon.contains($0) }).map(\.rawValue) 99 | guard invalidLanguages.isEmpty else { 100 | throw ValidationError("Invalid language(s) provided: \(invalidLanguages.joined(separator: ", "))") 101 | } 102 | targetLanguages = Set(languages) 103 | } 104 | 105 | var mode: TranslationCoordinator.Mode 106 | if let text = textOptions.text { 107 | guard let targetLanguages else { 108 | throw ValidationError("Target language(s) is required for text translation") 109 | } 110 | mode = .text(text, targetLanguages) 111 | } else if let fileOrDirectory = catalogOptions.fileOrDirectory.first { 112 | if let unwrappedTargetLanguages = targetLanguages, !unwrappedTargetLanguages.contains(.english) { 113 | targetLanguages?.insert(.english) 114 | } 115 | mode = .fileOrDirectory( 116 | URL(fileURLWithPath: fileOrDirectory), 117 | targetLanguages, 118 | overwrite: catalogOptions.overwriteExisting 119 | ) 120 | } else { 121 | throw ValidationError("No text or string catalog file to translate provided") 122 | } 123 | 124 | let coordinator = TranslationCoordinator( 125 | mode: mode, 126 | translator: translator, 127 | skipConfirmation: skipConfirmation, 128 | verbose: verbose 129 | ) 130 | try await coordinator.translate() 131 | } 132 | } 133 | 134 | 135 | fileprivate struct TextTranslationOptions: ParsableArguments { 136 | 137 | @Option( 138 | name: [.long, .short], 139 | help: "Text to translate" 140 | ) 141 | var text: String? 142 | } 143 | 144 | fileprivate struct CatalogTranlationOptions: ParsableArguments { 145 | 146 | @Flag( 147 | name: [.customLong("overwrite")], 148 | help: "Overwrite string catalog files instead of creating a new file" 149 | ) 150 | var overwriteExisting: Bool = false 151 | 152 | @Argument( 153 | parsing: .remaining, 154 | help: "File or directory containing string catalogs to translate" 155 | ) 156 | var fileOrDirectory: [String] = [] 157 | } 158 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Bootstrap/TranslatableFileFinder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | struct TranslatableFileFinder { 9 | 10 | // MARK: Internal 11 | 12 | enum FileType: String { 13 | case stringCatalog = "xcstrings" 14 | } 15 | 16 | // MARK: Private 17 | 18 | private let fileManager = FileManager.default 19 | private let fileOrDirectoryURL: URL 20 | private let type: FileType 21 | 22 | // MARK: Lifecycle 23 | 24 | init(fileOrDirectoryURL: URL, type: FileType) { 25 | self.fileOrDirectoryURL = fileOrDirectoryURL 26 | self.type = type 27 | } 28 | 29 | // MARK: Main 30 | 31 | func findTranslatableFiles() throws -> [URL] { 32 | var isDirectory: ObjCBool = false 33 | guard fileManager.fileExists(atPath: fileOrDirectoryURL.path, isDirectory: &isDirectory) else { 34 | logNoFilesFound() 35 | return [] 36 | } 37 | 38 | if isDirectory.boolValue { 39 | return try searchDirectory(at: fileOrDirectoryURL) 40 | } else if isTranslatable(fileOrDirectoryURL) { 41 | return [fileOrDirectoryURL] 42 | } else { 43 | logNoFilesFound() 44 | return [] 45 | } 46 | } 47 | 48 | private func isTranslatable(_ fileUrl: URL) -> Bool { 49 | fileUrl.pathExtension == type.rawValue 50 | } 51 | 52 | private func searchDirectory(at directoryUrl: URL) throws -> [URL] { 53 | guard let fileEnumerator = fileManager.enumerator(at: directoryUrl, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) else { 54 | throw SwiftTranslateError.couldNotSearchDirectoryAt(directoryUrl) 55 | } 56 | 57 | var translatableUrls = [URL]() 58 | for case let fileURL as URL in fileEnumerator { 59 | if isTranslatable(fileURL) { 60 | translatableUrls.append(fileURL) 61 | } 62 | } 63 | if translatableUrls.isEmpty { 64 | logNoFilesFound() 65 | return [] 66 | } 67 | return translatableUrls 68 | } 69 | 70 | private func logNoFilesFound() { 71 | Log.warning("No translatable files found at path \(fileOrDirectoryURL.path)") 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Bootstrap/TranslationCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import ArgumentParser 6 | import Foundation 7 | import Rainbow 8 | import SwiftStringCatalog 9 | 10 | 11 | struct TranslationCoordinator { 12 | 13 | // MARK: Internal 14 | 15 | enum Mode { 16 | case fileOrDirectory(URL, Set?, overwrite: Bool) 17 | case text(String, Set) 18 | } 19 | 20 | let mode: Mode 21 | let translator: TranslationService 22 | let skipConfirmation: Bool 23 | let verbose: Bool 24 | 25 | // MARK: Lifecycle 26 | 27 | init(mode: Mode, translator: TranslationService, skipConfirmation: Bool, verbose: Bool) { 28 | self.mode = mode 29 | self.translator = translator 30 | self.skipConfirmation = skipConfirmation 31 | self.verbose = verbose 32 | } 33 | 34 | // MARK: Main 35 | 36 | func translate() async throws { 37 | let startDate = Date() 38 | var translatedKeysCount: Int = 1 39 | 40 | switch mode { 41 | case .fileOrDirectory(let fileOrDirectoryUrl, let targetLanguages, let overwrite): 42 | translatedKeysCount = try await translateFiles(at: fileOrDirectoryUrl, to: targetLanguages, overwrite: overwrite) 43 | case .text(let string, let targetLanguages): 44 | try await translate(string, to: targetLanguages) 45 | } 46 | if translatedKeysCount > 0 { 47 | Log.success(newline: .after, startDate: startDate, "Translated \(translatedKeysCount) key(s)") 48 | } 49 | } 50 | 51 | // MARK: Translate Text 52 | 53 | private func translate(_ string: String, to targetLanguages: Set) async throws { 54 | Log.info(newline: .before, "Translating `", string, "`:") 55 | for language in targetLanguages { 56 | let translation = try await translator.translate(string, to: language, comment: nil) 57 | Log.structured( 58 | .init(width: 8, language.rawValue + ":"), 59 | .init(translation) 60 | ) 61 | } 62 | } 63 | 64 | // MARK: Translate Files 65 | 66 | private func translateFiles(at url: URL, to targetLanguages: Set?, overwrite: Bool) async throws -> Int { 67 | let fileFinder = TranslatableFileFinder(fileOrDirectoryURL: url, type: .stringCatalog) 68 | let translatableFiles = try fileFinder.findTranslatableFiles() 69 | 70 | if translatableFiles.isEmpty { 71 | return 0 72 | } 73 | 74 | let fileTranslator = StringCatalogTranslator( 75 | with: translator, 76 | targetLanguages: targetLanguages, 77 | overwrite: overwrite, 78 | skipConfirmations: skipConfirmation, 79 | verbose: verbose 80 | ) 81 | 82 | var translatedKeys = 0 83 | for file in translatableFiles { 84 | translatedKeys += try await fileTranslator.translate(fileAt: file) 85 | } 86 | 87 | return translatedKeys 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Extensions/SwiftStringCatalog+SwiftTranslate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import ArgumentParser 6 | import Foundation 7 | import SwiftStringCatalog 8 | 9 | 10 | extension Language: ExpressibleByArgument { 11 | public static var allValueStrings: [String] { 12 | Self.allCommon.map { $0.rawValue } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/FileTranslators/StringCatalogTranslator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import SwiftStringCatalog 7 | 8 | 9 | struct StringCatalogTranslator: FileTranslator { 10 | 11 | // MARK: Internal 12 | 13 | let overwrite: Bool 14 | let skipConfirmations: Bool 15 | let targetLanguages: Set? 16 | let service: TranslationService 17 | let verbose: Bool 18 | 19 | // MARK: Lifecycle 20 | 21 | init(with translator: TranslationService, targetLanguages: Set?, overwrite: Bool, skipConfirmations: Bool, verbose: Bool) { 22 | self.skipConfirmations = skipConfirmations 23 | self.overwrite = overwrite 24 | self.targetLanguages = targetLanguages 25 | self.service = translator 26 | self.verbose = verbose 27 | } 28 | 29 | func translate(fileAt fileUrl: URL) async throws -> Int { 30 | let catalog = try loadStringCatalog(from: fileUrl) 31 | 32 | if !skipConfirmations { 33 | verifyLargeTranslation(of: catalog.allKeys.count, to: catalog.targetLanguages.count) 34 | } 35 | 36 | if catalog.allKeys.isEmpty { 37 | return 0 38 | } 39 | 40 | for key in catalog.allKeys { 41 | try await translate(key: key, in: catalog) 42 | } 43 | 44 | var targetUrl = fileUrl 45 | if !overwrite { 46 | targetUrl = targetUrl.deletingPathExtension().appendingPathExtension("loc.xcstrings") 47 | } 48 | try catalog.write(to: targetUrl) 49 | return catalog.allKeys.count 50 | } 51 | 52 | private func loadStringCatalog(from url: URL) throws -> StringCatalog { 53 | Log.info(newline: .before, "Loading catalog \(url.path) into memory...") 54 | let catalog = try StringCatalog(url: url, configureWith: targetLanguages) 55 | Log.info("Found \(catalog.allKeys.count) keys targeting \(catalog.targetLanguages.count) languages for a total of \(catalog.localizableStringsCount) localizable strings") 56 | return catalog 57 | } 58 | 59 | private func translate(key: String, in catalog: StringCatalog) async throws { 60 | guard let localizableStringGroup = catalog.localizableStringGroups[key] else { 61 | return 62 | } 63 | Log.info(newline: verbose ? .before : .none, "Translating key `\(key.truncatedRemovingNewlines(to: 64))` " + "[Comment: \(localizableStringGroup.comment ?? "n/a")]".dim) 64 | 65 | await withThrowingTaskGroup(of: Void.self) { taskGroup in 66 | for localizableString in localizableStringGroup.strings { 67 | let isSource = catalog.sourceLanguage == localizableString.targetLanguage 68 | let targetLanguage = localizableString.targetLanguage 69 | 70 | if localizableString.state == .translated || isSource { 71 | if verbose { 72 | let result = isSource 73 | ? localizableString.sourceKey.truncatedRemovingNewlines(to: 64) 74 | : "[Already translated]".dim 75 | logTranslationResult(to: targetLanguage, result: result, isSource: isSource) 76 | } 77 | continue 78 | } 79 | 80 | taskGroup.addTask { 81 | do { 82 | let translatedString = try await service.translate(localizableString.sourceKey, to: targetLanguage, comment: localizableStringGroup.comment) 83 | localizableString.setTranslation(translatedString) 84 | if verbose { 85 | logTranslationResult(to: targetLanguage, result: translatedString.truncatedRemovingNewlines(to: 64), isSource: isSource) 86 | } 87 | } catch { 88 | logTranslationResult(to: targetLanguage, result: "[Error: \(error.localizedDescription)]".red, isSource: isSource) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | // MARK: Utilities 96 | 97 | private func verifyLargeTranslation(of stringsCount: Int, to languageCount: Int) { 98 | guard stringsCount * languageCount > 200 else { 99 | return 100 | } 101 | print("\n?".yellow, "Are you sure you wish to translate \(stringsCount) keys into \(languageCount) languages? Y/n") 102 | let yesNo = readLine() 103 | guard yesNo == "Y" else { 104 | print("Translation canceled 🫡".yellow) 105 | exit(0) 106 | } 107 | } 108 | 109 | private func logTranslationResult(to language: Language, result: String, isSource: Bool) { 110 | Log.structured( 111 | level: isSource ? .unimportant : .info, 112 | .init(width: 8, language.rawValue + ":"), 113 | .init(result) 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Models/OpenAIModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import ArgumentParser 6 | import Foundation 7 | 8 | 9 | public enum OpenAIModel: String, ExpressibleByArgument { 10 | case gpt3_5Turbo = "gpt-3.5-turbo" 11 | case gpt4o = "gpt-4o" 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Models/SwiftTranslateError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | 7 | 8 | enum SwiftTranslateError: Error { 9 | case couldNotCreateGoogleTranslateURL 10 | case couldNotDecodeTranslationResponse 11 | case couldNotSearchDirectoryAt(URL) 12 | case noTranslationReturned 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Models/TranslationServiceArgument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import ArgumentParser 6 | import Foundation 7 | 8 | 9 | public enum TranslationServiceArgument: String, ExpressibleByArgument { 10 | case openAI = "openai" 11 | case google = "google" 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Protocols/FileTranslator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import SwiftStringCatalog 7 | 8 | 9 | protocol FileTranslator { 10 | var service: TranslationService { get } 11 | var targetLanguages: Set? { get } 12 | var overwrite: Bool { get } 13 | var skipConfirmations: Bool { get } 14 | var verbose: Bool { get } 15 | 16 | func translate(fileAt fileUrl: URL) async throws -> Int 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Protocols/TranslationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import SwiftStringCatalog 7 | 8 | 9 | public protocol TranslationService { 10 | func translate(_ string: String, to targetLanguage: Language, comment: String?) async throws -> String 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/TranslationServices/GoogleTranslator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import Rainbow 7 | import SwiftStringCatalog 8 | 9 | 10 | struct GoogleTranslator { 11 | 12 | // MARK: Private 13 | 14 | private let apiKey: String 15 | private let apiUrl = URL(string: "https://translation.googleapis.com/language/translate/v2")! 16 | private let timeoutInterval: TimeInterval 17 | 18 | // MARK: Lifecycle 19 | 20 | init(apiKey: String, timeoutInterval: Int) { 21 | self.apiKey = apiKey 22 | self.timeoutInterval = TimeInterval(timeoutInterval) 23 | } 24 | 25 | // MARK: Utility 26 | 27 | func buildRequest(for translatableText: String, targetLanguage: Language) throws -> URLRequest { 28 | var urlComponents = URLComponents() 29 | urlComponents.scheme = "https" 30 | urlComponents.host = "translation.googleapis.com" 31 | urlComponents.path = "/language/translate/v2" 32 | urlComponents.queryItems = [ 33 | URLQueryItem(name: "key", value: apiKey), 34 | URLQueryItem(name: "source", value: Language.english.rawValue), 35 | URLQueryItem(name: "q", value: translatableText), 36 | URLQueryItem(name: "target", value: targetLanguage.rawValue), 37 | URLQueryItem(name: "format", value: "text") 38 | ] 39 | guard let url = urlComponents.url else { 40 | throw SwiftTranslateError.couldNotCreateGoogleTranslateURL 41 | } 42 | 43 | var request = URLRequest(url: url, timeoutInterval: timeoutInterval) 44 | request.httpMethod = "POST" 45 | return request 46 | } 47 | } 48 | 49 | extension GoogleTranslator: TranslationService { 50 | func translate(_ string: String, to targetLanguage: Language, comment: String?) async throws -> String { 51 | if targetLanguage == .english { 52 | return string 53 | } 54 | 55 | let request = try buildRequest(for: string, targetLanguage: targetLanguage) 56 | let (data, _) = try await URLSession.shared.data(for: request) 57 | 58 | guard let response = try? JSONDecoder().decode(GoogleTranslationResponse.self, from: data) else { 59 | throw SwiftTranslateError.couldNotDecodeTranslationResponse 60 | } 61 | guard let translation = response.data.translations.first else { 62 | throw SwiftTranslateError.noTranslationReturned 63 | } 64 | 65 | return translation.translatedText 66 | } 67 | } 68 | 69 | 70 | struct GoogleTranslationResponse: Decodable { 71 | let data: Data 72 | 73 | struct Data: Decodable { 74 | let translations: [Translation] 75 | 76 | struct Translation: Decodable { 77 | let translatedText: String 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/TranslationServices/OpenAITranslator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import OpenAI 7 | import Rainbow 8 | import SwiftStringCatalog 9 | 10 | 11 | struct OpenAITranslator { 12 | 13 | // MARK: Private 14 | 15 | private let openAI: OpenAI 16 | private let model: OpenAIModel 17 | private let retries: Int 18 | 19 | // MARK: Lifecycle 20 | 21 | init(with apiToken: String, model: OpenAIModel, timeoutInterval: Int, retries: Int) { 22 | self.openAI = OpenAI(configuration: OpenAI.Configuration(token: apiToken, timeoutInterval: TimeInterval(timeoutInterval))) 23 | self.model = model 24 | self.retries = retries 25 | } 26 | 27 | // MARK: Helpers 28 | 29 | private func chatQuery(for translatableText: String, targetLanguage: Language, comment: String?) -> ChatQuery { 30 | 31 | var systemPrompt = 32 | """ 33 | You are a helpful professional translator designated to translate text from English to the language with ISO 639-1 code: \(targetLanguage.rawValue) 34 | If the input text contains argument placeholders (%arg, @arg1, %lld, etc), it's important they are preserved in the translated text. 35 | You should not output anything other than the translated text. 36 | Avoid using the same word more than once in a row. 37 | Avoid using the same character more than 3 times in a row. 38 | Trim extra spaces and the beginning and end of the translated text. 39 | Do not provide blank translations. Do not hallucinate. Do not provide translations that are not faithful to the original text. 40 | Put particular attention to languages that use different characters and symbols than English. 41 | """ 42 | if let comment { 43 | systemPrompt += "\nTake into consideration the following context when translating, but do not completely change the translation because of it: \(comment)\n" 44 | } 45 | 46 | return ChatQuery( 47 | messages: [ 48 | .system(.init(content: systemPrompt)), 49 | .user(.init(content: .string(translatableText))), 50 | ], 51 | model: model.rawValue, 52 | frequencyPenalty: -2, 53 | presencePenalty: -2, 54 | responseFormat: .text 55 | ) 56 | } 57 | } 58 | 59 | extension OpenAITranslator: TranslationService { 60 | 61 | // MARK: Translate 62 | 63 | func translate(_ string: String, to targetLanguage: Language, comment: String?) async throws -> String { 64 | guard !string.isEmpty else { 65 | return string 66 | } 67 | 68 | var attempt = 0 69 | repeat { 70 | attempt += 1 71 | let result = try? await openAI.chats( 72 | query: chatQuery(for: string, targetLanguage: targetLanguage, comment: comment) 73 | ) 74 | guard let result = result, let translatedText = result.choices.first?.message.content?.string, !translatedText.isEmpty else { 75 | continue 76 | } 77 | return translatedText 78 | } while attempt < retries 79 | 80 | throw SwiftTranslateError.noTranslationReturned 81 | } 82 | } 83 | 84 | extension String { 85 | func truncatedRemovingNewlines(to length: Int) -> String { 86 | let newlinesRemoved = replacingOccurrences(of: "\n", with: " ") 87 | guard newlinesRemoved.count > length else { 88 | return self 89 | } 90 | return String(newlinesRemoved.prefix(length) + "...") 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/SwiftTranslate/Utilities/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | import Foundation 6 | import Rainbow 7 | 8 | 9 | struct Log { 10 | 11 | enum Level: String { 12 | case unimportant 13 | case info 14 | case warning 15 | case error 16 | case success 17 | 18 | func format(_ message: String) -> String { 19 | switch self { 20 | case .unimportant: message.dim 21 | case .info: message 22 | case .warning: "⚠️ " + message.yellow 23 | case .error: "❗️ " + message.red 24 | case .success: "✅ " + message.green 25 | } 26 | } 27 | } 28 | 29 | enum Newline { 30 | case none 31 | case before 32 | case after 33 | case both 34 | 35 | var prefix: String { 36 | switch self { 37 | case .none, .after: return "" 38 | case .before, .both: return "\n" 39 | } 40 | } 41 | var suffix: String { 42 | switch self { 43 | case .none, .before: return "" 44 | case .after, .both: return "\n" 45 | } 46 | } 47 | } 48 | 49 | // MARK: Basic 50 | 51 | static func unimportant(newline: Newline = .none, _ message: String...) { 52 | _log(newline, .unimportant, message.joined()) 53 | } 54 | 55 | static func info(newline: Newline = .none, _ message: String...) { 56 | _log(newline, .info, message.joined()) 57 | } 58 | 59 | static func warning(newline: Newline = .none, _ message: String...) { 60 | _log(newline, .warning, message.joined()) 61 | } 62 | 63 | static func error(newline: Newline = .none, _ message: String...) { 64 | _log(newline, .error, message.joined()) 65 | } 66 | 67 | static func success(newline: Newline = .none, startDate: Date? = nil, _ message: String...) { 68 | if let startDate { 69 | timedResult(newline: newline, level: .success, startDate: startDate, message.joined()) 70 | } else { 71 | _log(newline, .success, message.joined()) 72 | } 73 | } 74 | 75 | private static func _log(_ newline: Newline = .none, _ level: Level, _ message: String) { 76 | print(newline.prefix + level.format(message) + newline.suffix) 77 | } 78 | 79 | // MARK: Error 80 | 81 | static func error(newline: Newline = .none, error: LocalizedError) { 82 | Log.error(newline: newline, error.localizedDescription) 83 | } 84 | 85 | // MARK: Timed 86 | 87 | static func timed(newline: Newline = .none, level: Level = .info, _ message: String...) -> Date { 88 | _log(newline, level, message.joined()) 89 | return Date() 90 | } 91 | 92 | static func timedResult(newline: Newline = .none, level: Level = .info, startDate: Date, _ message: String...) { 93 | let timeString = " (" + String(format: "%.3f seconds", startDate.timeIntervalSinceNow * -1) + ")" 94 | _log(newline, level, message.joined() + timeString) 95 | } 96 | 97 | // MARK: Structured 98 | 99 | struct Column { 100 | 101 | // MARK: Internal 102 | 103 | let width: Int? 104 | let message: String 105 | 106 | // MARK: Lifecycle 107 | 108 | init(width: Int? = nil, _ message: String) { 109 | self.width = width 110 | self.message = message 111 | } 112 | } 113 | 114 | static func structured(level: Level = .info, _ columns: Column...) { 115 | var formattedMessage = "" 116 | for column in columns { 117 | let message = column.message 118 | if let width = column.width { 119 | let padding = String(repeating: " ", count: max(0, width - message.count)) 120 | formattedMessage += message + padding 121 | } else { 122 | formattedMessage += message 123 | } 124 | formattedMessage += " " // add space between columns 125 | } 126 | _log(.none, level, formattedMessage.trimmingCharacters(in: .whitespaces)) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Tests/SwiftStringCatalogTests/Models/StringCatalogTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2024 Hidden Spectrum, LLC. 3 | // 4 | 5 | @testable import SwiftStringCatalog 6 | import Foundation 7 | import XCTest 8 | 9 | 10 | class StringCatalogTests: XCTestCase { 11 | 12 | // MARK: Private 13 | 14 | let basicTestCatalog = Bundle.module.url(forResource: "BasicCatalog", withExtension: "json")! 15 | let basicTestKey = "This is a test" 16 | 17 | // MARK: Basic Tests 18 | 19 | func testLoad_Basic() throws { 20 | let stringCatalog = try StringCatalog(url: basicTestCatalog) 21 | 22 | XCTAssertEqual(stringCatalog.sourceLanguage, .english) 23 | } 24 | 25 | func testSourceLocalizableStrings_Basic() throws { 26 | let stringCatalog = try StringCatalog(url: basicTestCatalog) 27 | 28 | let localizableStrings = stringCatalog.sourceLanguageStrings[basicTestKey] 29 | 30 | XCTAssertEqual( 31 | localizableStrings?.first, 32 | LocalizableString( 33 | kind: .standalone, 34 | sourceKey: basicTestKey, 35 | targetLanguage: .english, 36 | translatedValue: basicTestKey, 37 | state: .translated 38 | ) 39 | ) 40 | } 41 | 42 | func testLocalizableStrings_Basic() throws { 43 | let targetLanguages: Set = [.english, .french, .german, .italian] 44 | let stringCatalog = try StringCatalog(url: basicTestCatalog, configureWith: targetLanguages) 45 | 46 | let localizableStrings = stringCatalog.localizableStringGroups[basicTestKey]?.strings ?? [] 47 | 48 | XCTAssertEqual(localizableStrings.count, 4) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/SwiftStringCatalogTests/Resources/BasicCatalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "I really like tests!" : { 5 | "extractionState" : "manual", 6 | "localizations" : { 7 | "it" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "I should learn italian!" 11 | } 12 | } 13 | } 14 | }, 15 | "This is a test" : { 16 | "extractionState" : "manual" 17 | } 18 | }, 19 | "version" : "1.0" 20 | } -------------------------------------------------------------------------------- /Tests/SwiftStringCatalogTests/SwiftStringCatalog.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "4F669705-A630-4491-91FC-793F216D3B3A", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:", 18 | "identifier" : "SwiftStringCatalogTests", 19 | "name" : "SwiftStringCatalogTests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | --------------------------------------------------------------------------------