Building xcstrings-translator
xcstrings-translator is a tool to (easily) translate .xcstrings
files, It is a GUI tool, written in Swift and SwiftUI to translate .xcstrings
files to different languages using Apple's Translation
Framework.
Idea
The idea behind this tool is to provide a simple and easy way to translate .xcstrings
files. The tool should be able to read the .xcstrings
file, my original idea was to make this a commandline application, but the Translation framework needs a SwiftUI view to work, so I decided to make this a GUI tool, then it needs to show the strings in a table view, and allow the user to translate the strings. The tool should also be able to save the translated strings back to the .xcstrings
file.
Implementation
The tool is implemented using Swift and SwiftUI. The tool uses the Translation
framework to translate the strings. The tool reads the .xcstrings
file, extracts the strings, and shows them in a table view. The user can then translate the strings and save the translated strings back to the .xcstrings
file.
Layout
The tool has a simple layout. The main window has a table view to show the strings. The table view has two columns, one for the original string and one for the translated string.
The build process
Building the layout
The layout is built using SwiftUI.
The main window has a "table view" to show the strings, and some elements to interact.
The view has two columns, one for the original string and one for the translated string.
This is achived using the LabeledContent
view.
List {
ForEach(languageParser.stringsToTranslate, id: \.self) { string in
HStack {
LabeledContent(
string,
value: translatedStrings[string] ?? ""
)
}
}
}
Opening and saving the .xcstrings
file
To allow the user to open and save the .xcstrings
file, the tool uses my FilePicker
package.
The FilePicker
package provides a simple way to open and save files in SwiftUI.
.cxstrings
file format
{
"sourceLanguage" : "en",
"strings" : {
"More apps from the developer" : {
"extractionState" : "translated",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Weitere Apps vom Entwickler"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Plus d'applications du développeur"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Meer apps van de ontwikkelaar"
}
}
}
}
}
}
Reading the .xcstrings
file
The .cxstrings
file is a easy file to read, the only "tricky" part is that the keys in this JSON file are the strings to translate, so we can't use a Codable
struct to decode the file.
To read the .xcstrings
file, we do this:
class LanguageParser: ObservableObject {
/// Create a language dictionary to store the strings
@Published var languageDictionary: [String: Any] = [:]
/// Create an array to store the strings to translate
@Published var stringsToTranslate: [String] = []
/// Create an array to store if the string should be translated
/// (there is a better way to do this)
@Published var shouldTranslate: [Bool] = []
func load(file url: URL) {
do {
/// Read the file
if let data = try? Data(contentsOf: url),
/// Try to decode the file to a [String: Any] dictionary.
let dict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
/// Save the dictionary
languageDictionary = dict
/// Parse the dictionary
parse()
}
} catch {
// Error
}
}
}
Parsing the .xcstrings
file
extension LanguageParser {
func parse() {
stringsToTranslate = []
if let strings = languageDictionary["strings"] as? [String: Any] {
for (key, value) in strings where !key.isEmpty {
guard let value = value as? [String: Any] else { continue }
stringsToTranslate.append(key)
shouldTranslate.append(value["shouldTranslate"] as? Bool ?? true)
}
}
}
}
Exposing the JSON data
To save the .xcstrings
file, we need to convert the language dictionary to JSON data and write the data to the file.
The LanguageParser
class has a data
property that converts the language dictionary to JSON data, the .filePicker
modifier will be used to save the file later.
extension LanguageParser {
var data: Data {
try! JSONSerialization.data(
withJSONObject: languageDictionary,
options: .prettyPrinted
)
}
}
Upating the translated strings
This is a bit complex to userstand, I made the mistake to not update the correct values to update the language dictionary.
extension LanguageParser {
func add(translation: String, forLanguage: String, original: String) {
// We want to get the "strings" key from the dictionary
if var strings = languageDictionary["strings"] as? [String: Any],
// We want to get the original string from the dictionary
var item = strings[original] as? [String: Any] {
// strings -> "original string" -> localizations -> "language"
// We want to check if there are already localizations
if var localizations = item["localizations"] as? [String: Any] {
// There is already a localization key, now we can update the value for this language
localizations[forLanguage] = [
"stringUnit": [
"state": "translated",
"value": translation
]
]
// Update the localizations key
item["localizations"] = localizations
// Save the item back to the strings dictionary
strings[original] = item
// Save the strings back to the language dictionary
languageDictionary["strings"] = strings
// Finish execution
return
} else {
// There are no localizations yet, so we need to create a new one
item["localizations"] = [
forLanguage: [
"stringUnit": [
"state": "translated",
"value": translation
]
]
]
// Save the item back to the strings dictionary
strings[original] = item
// Save the strings back to the language dictionary
languageDictionary["strings"] = strings
// Finish execution
return
}
}
// Something went wrong
print("Failed to get strings")
}
}
Final application
To translate the strings we need to setup a TranslationSession, and in this example we are always translating from English to Dutch.
Please note that this is a very simple example, for the best results you can look up the actual code for this view on GitHub.
struct ContentView: View {
/// This is the language parser we've built before.
@ObservedObject var languageParser = LanguageParser()
/// This is array which holds the translated strings
@State var translatedStrings: [String: String] = [:]
/// This is the source language (English)
@State var sourceLanguage: Locale.Language?
/// This is the destination language (Dutch)
@State var destinationLanguage: Locale.Language?
/// This is the translation session
@State var translationSession: TranslationSession?
/// Is the file picker open
@State var filePickerOpen = false
/// Is the file exporter open
@State var exportFile = false
/// Which files are selected
@State var filePickerFiles: [URL] = []
var body: some View {
VStack {
List {
ForEach(languageParser.stringsToTranslate, id: \.self) { string in
HStack {
LabeledContent(
string,
value: translatedStrings[string] ?? ""
)
}
}
Button("Open", systemImage: "square.and.arrow.down") {
filePickerOpen.toggle()
}
// Support ⌘O to open
.keyboardShortcut("o", modifiers: .command)
Button("Save", systemImage: "square.and.arrow.up") {
exportFile.toggle()
}
// Support ⌘S to save
.keyboardShortcut("s", modifiers: .command)
Button("Translate", systemImage: "translate") {
translate() // Translate the strings
}
// Support ⌘T to translate
.keyboardShortcut("t", modifiers: .command)
}
}
.task {
/// Set the destination language to Dutch
destinationLanguage = supportedLanguages.first(where: { $0.languageCode == "nl" })
/// Set the source language to English
sourceLanguage = supportedLanguages.first(where: { $0.languageCode == "en" })
}
/// Open a file
.filePicker(
isPresented: $filePickerOpen,
files: $filePickerFiles,
types: [.init(filenameExtension: "xcstrings")!]
)
/// Save a file
.filePicker(
isPresented: $exportFile,
fileName: "Localizable.strings",
data: languageParser.data, // Exposed JSON data
types: [.init(filenameExtension: "xcstrings")!]
)
/// File is opened
.onChange(of: $filePickerFiles.wrappedValue) {
if let val = $filePickerFiles.wrappedValue.first {
// Reset the translated strings
translatedStrings = [:]
// Load the file
languageParser.load(file: val)
}
}
// Setup the translation session
.translationTask(
source: sourceLanguage,
target: destinationLanguage
) { session in
// Save our translation session
translationSession = session
}
}
func translate() {
Task {
if let session = translationSession {
do {
for string in languageParser.stringsToTranslate where !string.isEmpty {
// Perform translation
let response = try await session.translate(string)
// Save the translated string
translatedStrings[string] = response.targetText
// Add the translation to the language parser (to save it to the file)
languageParser.add(translation: response)
}
} catch {
// code to handle error
}
}
}
}
}
Conclusion
This was a fun project to work on, I've created a new Swift package to open and save files in SwiftUI, and I've had fun to work on this project.
Special thanks
Special thanks goes to Zhenyi Tan to help me with fixing the issue I've made which prevented me from updating the language dictionary.
Dependencies used
- Translation framework
This is a framework provided by Apple to translate strings. - FilePicker
FilePicker is a Swift package to open and save files in SwiftUI. - SwiftExtras
SwiftExtras is a collection of Swift extensions and utilities, It is mainly used to create the settings view. - xcstrings-translator app page
The app page for "xcstrings-translator" on my website. - xcstrings-translator GitHub repository
The GitHub repository for "xcstrings-translator".
Read more
- Localizing In Xcode • 3 minutes reading time.
- Why You Should Avoid Using AnyView in SwiftUI • 4 minutes reading time.
- What powers this website • 5 minutes reading time.
Share
Share Bluesky Mastodon Twitter LinkedIn Facebook