Wesley de Groot's Blog
Building xcstrings-translator

Back

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.

xcstrings-translator

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

Read more

Share


Share Bluesky Mastodon Twitter LinkedIn Facebook
x-twitter mastodon github linkedin discord threads instagram whatsapp bluesky square-rss sitemap