|
// |
|
// 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() |
|
} |