Skip to content

Instantly share code, notes, and snippets.

@adrianByv
Forked from Maxdw/localize.swift
Last active May 2, 2018 18:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save adrianByv/4546b21df378c05b978375f446379754 to your computer and use it in GitHub Desktop.
Save adrianByv/4546b21df378c05b978375f446379754 to your computer and use it in GitHub Desktop.
genstrings for custom translation function. And generate for all project language a file with new translations. It also check for repeated translations.

Usage:

  1. localize app using "translatable string".localize(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 ByvLocalizableStringsGenerator.swift in a folder inside project root, (e.g. /generator/ByvLocalizableStringsGenerator.swift)
  4. run swift ByvLocalizableStringsGenerator.swift from the terminal (swift -swift-version 3 ByvLocalizableStringsGenerator.swift). It will create an all.strings file with all translations and a file with new translations in all languages of your proyect (e.g. all.strings, Base.string, en.string)
  5. copy the generated Base.strings to the 'Base.lproj' folder
  6. rename 'Base.strings' to 'Localizable.strings' (If file exist add Base.strings new lines to your previous file)
  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.

Added support for ByvLocalizations (https://github.com/byvapps/ByvLocalizations) and it's extensions

USE AT OWN RISK

//
// ByvLocalizableStringsGenerator.swift
// ByvLocalizableStringsGenerator
//
// Created by Adrian Apodaca on 2/5/17.
// Copyright © 2017 byvapps. All rights reserved.
//
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
typealias JsonCallback = (_ value: JsonObject, _ 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()
var callback: LeafCallback?
var jsonCallback: JsonCallback?
func select(key: Any, value: Any) {
if let object = value as? JsonObject {
path.append(key: key, value: object)
enumerate(object: object)
jsonCallback?(object, path)
}
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)
}
init(json: Any, jsonLeaf: @escaping JsonCallback) {
jsonCallback = jsonLeaf
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.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 + "/all.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()
}
class Language {
let langId:String
var translated:[String:String] = [:]
let newPath:String
let stringsFileHandle:FileHandle?
init(file: File) {
var lang = "base"
for comp in file.path.components(separatedBy: "/") {
let comps = comp.components(separatedBy: ".lproj")
if comps[0] != comp {
lang = comps[0]
print("Language: \(lang)")
break
}
}
self.langId = lang
do {
let text = try String(contentsOfFile: file.path)
self.translated = text.propertyListFromStringsFileFormat()
} catch {
self.translated = [:]
}
newPath = path + "/\(self.langId).strings"
_ = shell(
launchPath: "/usr/bin/env",
arguments: [
"touch",
"\(newPath)"
])
self.stringsFileHandle = FileHandle(forWritingAtPath: newPath)
}
static func allLanguages() -> [Language] {
guard let lacalizables = dir?.findFilesRecursively(byName: "Localizable.strings") else {
abort()
}
var response:[Language] = []
for file in lacalizables {
print("Localizable: \(file.path)")
response.append(Language(file: file))
}
return response
}
}
let languages = Language.allLanguages()
struct Translation {
let comment: String
var files: [String]
}
var translations: [String:Translation] = [:]
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
)
_ = Recursive(json: json) {
value, path in
guard let name = value["key.name"] as? String else {
return
}
//check .localize()
if name.contains("\".localize") {
// this part gets the actual string that needs translating
var parts = name.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(["\""]))
}
}
}
}
if string.count > 0 {
var translation = Translation(comment: comment, files: [file.name])
if let storedTranslation = translations[string] {
var files = storedTranslation.files
files.append(file.name)
translation.files = files
}
translations[string] = translation
}
} else if name.contains(".locText") ||
name.contains(".locTitle") ||
name.contains(".locPlaceholder") ||
name.contains("NSLocalizedString") {
var string = ""
var stringOffset = -1
var stringLength = -1
var commentOffset = -1
var commentLength = -1
var comment = ""
if let subArray = value["key.substructure"] as? [[String: Any]] {
// With more than one param => .locText("", args: [], comment: "")
for sub in subArray {
guard let offset = sub["key.bodyoffset"] as? NSNumber else {return}
guard let length = sub["key.bodylength"] as? NSNumber else {return}
if let name = sub["key.name"] as? String {
if name == "comment" {
commentOffset = offset.intValue
commentLength = length.intValue
} else if name == "format" {
stringOffset = offset.intValue
stringLength = length.intValue
}
} else {
// Can only be string
stringOffset = offset.intValue
stringLength = length.intValue
}
}
} else {
// Only text -> string => .locText("")
if let offset = value["key.bodyoffset"] as? NSNumber ,
let length = value["key.bodylength"] as? NSNumber {
stringOffset = offset.intValue
stringLength = length.intValue
}
}
if contents == nil {
contents = file.contents()
}
guard let contents = contents else {return}
// string
if stringOffset > 0 && stringLength > 0 {
let quotedString = contents.substring(
with: (stringOffset)
..< (stringOffset + stringLength)
)
string = quotedString.trimmingCharacters(in: CharacterSet(["\""]))
if quotedString == string {
// No quoted string, so it should be a variable
string = ""
}
}
// comment
if commentOffset > 0 && commentLength > 0 {
comment = contents.substring(
with: (commentOffset)
..< (commentOffset + commentLength)
)
comment = comment.trimmingCharacters(in: CharacterSet(["\""]))
}
if string.count > 0 {
var translation = Translation(comment: comment, files: [file.name])
if let storedTranslation = translations[string] {
var files = storedTranslation.files
files.append(file.name)
translation.files = files
}
translations[string] = translation
}
}
}
}
catch let error as NSError {
print(error.localizedDescription)
}
}
for text in translations.keys {
let newline = "\r\n"
if let translation = translations[text] {
let rule = "\(newline)/* Files = \(translation.files); comment = \"\(translation.comment)\" */\(newline)\"\(text)\" = \"\(text)\";\(newline)"
stringsFileHandle.write(rule.data(using: .utf8)!)
//Stored Languages
let emptyRule = "\(newline)/* Files = \(translation.files); comment = \"\(text)\" */\(newline)\"\(text)\" = \"\";\(newline)"
for language in languages {
if language.translated[text] == nil {
//Translation not stored
if language.langId.uppercased() == "BASE" {
language.stringsFileHandle?.write(rule.data(using: .utf8)!)
} else {
language.stringsFileHandle?.write(emptyRule.data(using: .utf8)!)
}
}
}
}
}
print("Outputted base.strings file in \(stringsFilePath)")
stringsFileHandle.closeFile()
for language in languages {
print("Outputted base.strings file in \(language.newPath)")
language.stringsFileHandle?.closeFile()
}
extension String {
func localize(comment:String = "") -> String {
return NSLocalizedString(self, tableName: nil, bundle: Bundle.main, value: "", comment: comment)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment