├── .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 | 
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 |
--------------------------------------------------------------------------------