Skip to content

Instantly share code, notes, and snippets.

@Maxdw
Last active April 5, 2023 17:29
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save Maxdw/e9e89af731ae6c6b8d85f5fa60ba848c to your computer and use it in GitHub Desktop.
Save Maxdw/e9e89af731ae6c6b8d85f5fa60ba848c to your computer and use it in GitHub Desktop.
genstrings for custom translation function

Usage:

  1. localize app using "translatable string".localizeWith(comment: "this is a comment"), see localize.swift
  2. brew install sourcekitten (or use some other package manager, see https://github.com/jpsim/SourceKitten)
  3. place main.swift in project root
  4. run ./main.swift from the terminal (main.swift is a shell)
  5. copy the generated base.strings to the 'Base.lproj' folder
  6. rename 'base.strings' to 'Localizable.strings'
  7. repeat 5 and 6 for every language folder (e.g. 'en.lproj')
  8. translate
  9. add all Localizable.strings files to your "Copy bundle resources" under build phases
  10. clean build (Shift + Cmd + K) and run (Cmd + R)

Needs the sourcekitten framework to convert your project's swift code to AST (Abstract Syntax Tree) and extract strings translated using the localizedWith translation function.

USE AT OWN RISK

extension String {
func localizedWith(comment:String) -> String {
return NSLocalizedString(self, tableName: nil, bundle: Bundle.main, value: "", comment: comment)
}
}
#!/usr/bin/swift
import Foundation
extension FileHandle {
public func read (encoding: String.Encoding = String.Encoding.utf8) -> String {
let data: Data = self.readDataToEndOfFile()
guard let result = String(data: data, encoding: encoding) else {
fatalError("Could not convert binary data to text.")
}
return result
}
}
/**
* Filesystem node (either a file or directory)
* Is used as a base class for the file and directory classes
*/
class DiskNode {
let manager: FileManager = FileManager.default
let path: String
init?(path: String) {
self.path = path
if type(of: self) == DiskNode.Type.self {
return nil
}
if !exists() {
return nil
}
}
func exists() -> Bool {
var isDir : ObjCBool = false
let result: Bool
if manager.fileExists(atPath: path, isDirectory: &isDir) {
if type(of: self) == Directory.Type.self {
if isDir.boolValue {
result = true
}
else {
result = false
}
}
else {
result = true
}
}
else {
result = false
}
return result
}
}
/**
* Local filesystem file
*/
class File: DiskNode {
let handle: FileHandle
let name: String
let ext: String
override init?(path: String) {
guard let handle = FileHandle(forReadingAtPath: path) else {
return nil
}
let uri = URL(fileURLWithPath: path)
self.handle = handle
self.ext = URL(fileURLWithPath: path).pathExtension
self.name = uri.pathComponents.last ?? ""
super.init(path: path)
}
func contents() -> String {
return handle.read()
}
}
/**
* Local filesystem directory
*/
class Directory: DiskNode {
func findFilesRecursively(byExtension: String) -> [File]? {
guard let enumerator = manager.enumerator(atPath: path) else {
return nil
}
var result: [File] = []
enumerator.forEach({ (path) in
guard let path = path as? String else {
return
}
let ext = URL(fileURLWithPath: path).pathExtension
guard ext == byExtension else {
return
}
guard let file = File(path: self.path + "/" + path) else {
return
}
result.append(file)
})
return result
}
func findFilesRecursively(byName: String) -> [File]? {
guard let enumerator = manager.enumerator(atPath: path) else {
return nil
}
var result: [File] = []
enumerator.forEach({ (path) in
guard let path = path as? String else {
return
}
let filename = URL(fileURLWithPath: path).lastPathComponent
guard filename == byName else {
return
}
guard let file = File(path: self.path + "/" + path) else {
return
}
result.append(file)
})
return result
}
}
/**
* Recursively walk over all leafs of the
* provided JSON object.
*/
class Recursive {
typealias JsonObject = Dictionary<String, Any>
typealias JsonArray = Array<AnyObject>
typealias LeafCallback = (_ key: Any, _ value: Any, _ path: Recursive.Path) -> Swift.Void
/**
* Keeps track of JsonObjects found along the way.
*/
class Path {
class Step {
var key: Any
var value: JsonObject
init(key: Any, value: JsonObject) {
self.key = key
self.value = value
}
}
var steps: [Step] = []
func append(key: Any, value: JsonObject) {
steps.append(Step(key: key, value: value))
}
func last(count: Int = 0) -> JsonObject? {
if count == 0 {
if let last: Step = steps.last {
return last.value
}
}
let index = (steps.count - 1) - count
if index >= 0 {
return steps[index].value
}
return nil
}
init() {}
}
var path = Path()
let callback: LeafCallback
func select(key: Any, value: Any) {
if let object = value as? JsonObject {
path.append(key: key, value: object)
enumerate(object: object)
}
else if let array = value as? JsonArray {
enumerate(array: array)
}
else if let string = value as? String {
callback(key, string, path)
}
else if let number = value as? NSNumber {
callback(key, number, path)
}
}
func enumerate(array:JsonArray) {
for (key, value) in array.enumerated() {
select(key: key, value: value)
}
}
func enumerate(object:JsonObject) {
for (key, value) in object {
select(key: key, value: value)
}
}
init(json: Any, leaf: @escaping LeafCallback) {
callback = leaf
select(key: "", value: json)
}
}
extension String {
func index(from: Int) -> Index {
return self.index(startIndex, offsetBy: from)
}
func substring(from: Int) -> String {
let fromIndex = index(from: from)
return substring(from: fromIndex)
}
func substring(to: Int) -> String {
let toIndex = index(from: to)
return substring(to: toIndex)
}
func substring(with r: Range<Int>) -> String {
let startIndex = index(from: r.lowerBound)
let endIndex = index(from: r.upperBound)
return substring(with: startIndex..<endIndex)
}
}
func shell(launchPath: String, arguments: [String]) -> String {
let task = Process() // will throw unresolved identifier warning for iOS projects: IGNORE
task.launchPath = launchPath
task.arguments = arguments
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: String.Encoding.utf8)!
if output.characters.count > 0 {
//remove newline character.
let lastIndex = output.index(before: output.endIndex)
return output[output.startIndex ..< lastIndex]
}
return output
}
let manager = FileManager.default
let args = CommandLine.arguments
let path = manager.currentDirectoryPath
let dir = Directory(path: path)
//guard let files = dir?.findFilesRecursively(byName: "example.swift") else {
guard let files = dir?.findFilesRecursively(byExtension: "swift") else {
abort()
}
let stringsFilePath = path + "/base.strings"
// create new strings file
_ = shell(
launchPath: "/usr/bin/env",
arguments: [
"touch",
"\(stringsFilePath)"
]
)
guard let stringsFileHandle = FileHandle(forWritingAtPath: stringsFilePath) else {
print("no file to write to \(stringsFilePath) was not created or available")
abort()
}
for file in files {
// get AST output from sourcekitten library
let output = shell(
launchPath: "/usr/bin/env",
arguments: [
"sourcekitten",
"structure",
"--file",
"\(file.path)"
]
)
let data = output.data(using: .utf8)
var contents: String? = nil
do {
let json = try JSONSerialization.jsonObject(
with: data!,
options: JSONSerialization.ReadingOptions.allowFragments
)
struct Translation {
let string: String
let comment: String
}
var translations: [Translation] = []
_ = Recursive(json: json) {
key, value, path in
guard let key = key as? String else {
return
}
guard let value = value as? String else {
return
}
guard value.contains("\".localizedWith") else {
return
}
// this part gets the actual string that needs translating
var parts = value.components(separatedBy: ".")
_ = parts.popLast()
var string = parts.joined(separator: ".")
string = string.trimmingCharacters(in: CharacterSet(["\""]))
// now extract the comment if possible
var comment: String = ""
if let argument = path.last() , argument["key.name"] != nil {
if let argumentName = argument["key.name"] as? String , argumentName == "comment" {
if let argumentBodyOffset = argument["key.bodyoffset"] as? NSNumber ,
let argumentBodyLength = argument["key.bodylength"] as? NSNumber {
if contents == nil {
contents = file.contents()
}
if let contents = contents {
comment = contents.substring(
with: (argumentBodyOffset.intValue)
..< (argumentBodyOffset.intValue + argumentBodyLength.intValue)
)
comment = comment.trimmingCharacters(in: CharacterSet(["\""]))
}
}
}
translations.append(Translation(string: string, comment: comment))
print("\(file.name) found string \(string) with comment \(comment)")
}
}
let newline = "\r\n"
for translation in translations {
let rule = "\(newline)/* File = \"\(file.name)\"; comment = \"\(translation.comment)\" */\(newline)\"\(translation.string)\" = \"\(translation.string)\";\(newline)"
stringsFileHandle.write(rule.data(using: .utf8)!)
}
}
catch let error as NSError {
print(error.localizedDescription)
}
}
print("Outputted base.strings file in \(stringsFilePath)")
stringsFileHandle.closeFile()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment