├── .gitignore ├── DemoApp ├── AppDelegate.swift ├── Base.lproj │ ├── LaunchScreen.xib │ ├── Localizable.strings │ └── Main.storyboard ├── Images.xcassets │ └── AppIcon.appiconset │ │ └── Contents.json ├── Info.plist ├── en.lproj │ ├── LaunchScreen.strings │ ├── Localizable.strings │ └── Main.strings └── ja.lproj │ ├── LaunchScreen.strings │ ├── Localizable.strings │ └── Main.strings ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── README.md └── ls2xs │ ├── BaseLproj.swift │ ├── BaseStringsFile.swift │ ├── Extension │ └── FileManager.swift │ ├── IbFile.swift │ ├── LangStringsFile.swift │ ├── LocalizableStringsFile.swift │ ├── main.swift │ └── version.swift ├── Tests ├── LinuxMain.swift └── ls2xsTests │ ├── XCTestManifests.swift │ └── ls2xsTests.swift ├── UnitTests ├── Info.plist ├── LprojFileTests.swift ├── StringsFileTests.swift └── TargetTests.swift ├── circle.yml └── scripts ├── prepare_hotfix.sh └── prepare_release.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Emacs 93 | *~ 94 | 95 | # Mac 96 | .DS_Store -------------------------------------------------------------------------------- /DemoApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | 5 | class AppDelegate: UIResponder, UIApplicationDelegate { 6 | var window: UIWindow? 7 | 8 | func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool { 9 | return true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DemoApp/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /DemoApp/Base.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataka/ls2xs/ae548855cda6664247212359a3588b6693e6033d/DemoApp/Base.lproj/Localizable.strings -------------------------------------------------------------------------------- /DemoApp/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 34 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /DemoApp/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "xcode" 37 | } 38 | } -------------------------------------------------------------------------------- /DemoApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /DemoApp/en.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | "kId-c2-rCX.text" = "DemoApp"; 2 | "8ie-xW-0ye.text" = " Copyright (c) 2015 Yosuke Ishikawa. All rights reserved."; 3 | -------------------------------------------------------------------------------- /DemoApp/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "hello" = "Hello"; 2 | "getstarted" = "Get started"; 3 | "quoted" = "This is a \"quoted\" text."; 4 | -------------------------------------------------------------------------------- /DemoApp/en.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | "xUH-o8-DGc.text" = "Hello"; 2 | "zjA-Rl-7jE.text" = "This is a \"quoted\" text."; 3 | "uZj-A7-ihc.normalTitle" = "Get started"; 4 | -------------------------------------------------------------------------------- /DemoApp/ja.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | "kId-c2-rCX.text" = "DemoApp"; 2 | "8ie-xW-0ye.text" = " Copyright (c) 2015 Yosuke Ishikawa. All rights reserved."; 3 | -------------------------------------------------------------------------------- /DemoApp/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "hello" = "こんにちは"; 2 | "getstarted" = "はじめよう"; 3 | "quoted" = "これは\"引用\"です。"; 4 | -------------------------------------------------------------------------------- /DemoApp/ja.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | "xUH-o8-DGc.text" = "こんにちは"; 2 | "zjA-Rl-7jE.text" = "これは\"引用\"です。"; 3 | "uZj-A7-ihc.normalTitle" = "はじめよう"; 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Yosuke Ishikawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY : compile 2 | compile: 3 | swift build --disable-sandbox -c release 4 | 5 | .PHONY : prefix_install 6 | prefix_install: compile 7 | mkdir -p $(PREFIX)/bin 8 | cp -p ./.build/release/ls2xs $(PREFIX)/bin 9 | 10 | .PHONY : xcodeproj 11 | xcodeproj: 12 | swift package generate-xcodeproj 13 | 14 | .PHONY : test 15 | # test: 16 | # set -o pipefail && xcodebuild test -scheme ls2xs | xcpretty -c -r junit -o build/test-report.xml 17 | 18 | .PHONY : prepare_release 19 | prepare_release: 20 | scripts/prepare_release.sh 21 | .PHONY : prepare_hotfix 22 | prepare_hotfix: 23 | scripts/prepare_hotfix.sh 24 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser.git", 7 | "state" : { 8 | "revision" : "9f39744e025c7d377987f30b03770805dcb0bcd1", 9 | "version" : "1.1.4" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ls2xs", 8 | products: [ 9 | .executable(name: "ls2xs", targets: ["ls2xs"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.1.0"), 13 | ], 14 | targets: [ 15 | .executableTarget( 16 | name: "ls2xs", 17 | dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]), 18 | .testTarget( 19 | name: "ls2xsTests", 20 | dependencies: ["ls2xs"]), 21 | ] 22 | ) 23 | 24 | // Local Variables: 25 | // swift-mode:parenthesized-expression-offset: 4 26 | // End: 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ls2xs 2 |

3 | 4 | GitHub license 5 | 6 | 7 | Swift 5.6 8 | 9 | 10 | GitHub release (latest by date) 11 | 12 |

13 | 14 | 17 | 18 | A command line tool that updates .strings of .xib and .storyboard using Localizable.strings. 19 | 20 | ## Installation 21 | 22 | ### Brew 23 | 24 | ``` 25 | $ brew install ataka/formulae/ls2xs 26 | ``` 27 | 28 | ### Make 29 | 30 | ``` shellsession 31 | $ git pull https://github.com/ataka/ls2xs.git 32 | $ cd ls2xs 33 | $ PREFIX=/usr/local make prefix_install 34 | ``` 35 | 36 | ## Usage 37 | 38 | - Enable base internationalization. 39 | - Add `Localizable.strings` for each languages. 40 | - Set keys of `Localizable.strings` to text values in .xib or .storyboard, as you set them to `NSLocalizedString(_:comment:)` in code. 41 | - Add "New Run Script Phase" to "Build Phases" of your application target, and set contents: 42 | 43 | ```shell 44 | /usr/local/bin/ls2xs $TARGET_PATH 45 | ``` 46 | 47 | `ls2xs` generates .strings for .xib and .storyboard using `ibtool --generate-strings-file`. 48 | Then, `ls2xs` replaces value of .strings with value of `Localizable.strings` if it matches key of `Localizable.strings`. 49 | 50 | 51 | ## Example 52 | 53 | Suppose `Base.lproj/Main.storyboard` has `UILabel (Object ID: 4gA-LI-pd8, text: "hello")`. 54 | 55 | 56 | ### Input 57 | 58 | - `en.lproj/Localizable.strings`: `"hello" = "Hello";` 59 | - `ja.lproj/Localizable.strings`: `"hello" = "こんにちは";` 60 | 61 | 62 | ### Output 63 | 64 | - `en.lproj/Main.strings`: `"4gA-LI-pd8.title" = "Hello";` 65 | - `ja.lproj/Main.strings`: `"4gA-LI-pd8.title" = "こんにちは";` 66 | 67 | 68 | ## Contributing 69 | 70 | Please file issues or send pull requests for anything you notice. 71 | 72 | - Prefer pull request over issue because you must be a Cocoa developer. 73 | - Write tests to prevent making regression. 74 | - Keep in mind we make no primises that your proposal will be accepted. 75 | 76 | ## Attributions 77 | 78 | This tool is powered by: 79 | 80 | - [apple/swift\-argument\-parser](https://github.com/apple/swift-argument-parser) 81 | 82 | ## License 83 | 84 | Copyright (c) 2015 Yosuke Ishikawa 85 | 86 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 87 | 88 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 89 | 90 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /Sources/README.md: -------------------------------------------------------------------------------- 1 | # Localization Directory Structure 2 | 3 | ``` 4 | /AppName 5 | /TargetName 6 | /Supporting Files 7 | /en.lproj 8 | /Localisable.strings ... (1) 9 | /ja.lproj 10 | /Localisable.strings ... (2) 11 | /Sample 12 | /MainViewController.swift 13 | /Base.lproj ... (3) 14 | /Main.storyboard ... (4) 15 | /Sub.xib ... (4') 16 | /Main.strings ... (5) 17 | /Sub.strings ... (5') 18 | /en.lproj ... (6) 19 | /Main.strings ... (7) 20 | /Sub.strings ... (7') 21 | /ja.lproj ... (8) 22 | /Main.strings ... (9) 23 | /Sub.strings ... (9') 24 | ``` 25 | 26 | ## (1) Supporting Files/en.lproj/Localizable.strings 27 | 28 | `Localizable.strings` file for English. 29 | 30 | ``` 31 | /* "localization key" = "localized text in English"; */ 32 | "hello" = "Hello"; 33 | "bye" = "Bye"; 34 | ``` 35 | 36 | ## (2) Supporting Files/ja.lproj/Localizable.strings 37 | 38 | `Localizable.strings` file for Japanese. 39 | 40 | ``` 41 | /* "localization key" = "localized text in Japanese" */ 42 | "hello" = "こんにちは"; 43 | "bye" = "さようなら"; 44 | ``` 45 | 46 | ## (3) Base.lproj 47 | 48 | Localization base directory, that contains `*.xib` and `*.storyboard` files. 49 | 50 | ## (4) Main.storyboard 51 | 52 | Storyboard file (XML file). 53 | 54 | ``` xml 55 | 59 | ``` 60 | 61 | ## (4') Sub.xib 62 | 63 | Xib file (XML file). 64 | 65 | ``` xml 66 | 70 | ``` 71 | 72 | ## (5) Main.strings 73 | 74 | Temporary string file, which is generated by `ibtool --generate-strings-file Main.storyboard Main.strings`. 75 | 76 | ``` 77 | /* "Object ID" = "Localization key"; */ 78 | "4gA-LI-pd8.title" = "hello"; 79 | ``` 80 | 81 | ## (5') Sub.strings 82 | 83 | Temporary string file, which is generated by `ibtool --generate-strings-file Sub.xib Sub.strings`. 84 | 85 | ``` 86 | /* "Object ID.Attribute" = "Localization key"; */ 87 | "n5W-Qq-OA4.title" = "bye"; 88 | ``` 89 | 90 | ## (6) en.lproj 91 | 92 | Localization directory for LANG=en, which contains English localization files for *.storyboard and *.xib. 93 | 94 | ## (7) en.lproj/Main.strings 95 | 96 | English strings file for Main.storyboard. 97 | 98 | ``` 99 | /* "Object ID.Attribute" = "Localized text in English"; */ 100 | "4gA-LI-pd8.title" = "Hello"; 101 | ``` 102 | 103 | ## (7') en.lproj/Sub.strings 104 | 105 | English strings file for Sub.xib. 106 | 107 | ``` 108 | /* "Object ID" = "Localized text in English"; */ 109 | "n5W-Qq-OA4.title" = "Bye"; 110 | ``` 111 | 112 | 113 | ## (8) ja.lproj 114 | 115 | Localization directory for LANG=ja, which contains Japanese localization files for *.storyboard and *.xib. 116 | 117 | ## (9) ja.lproj/Main.strings 118 | 119 | Japanese strings file for Main.storyboard. 120 | 121 | ``` 122 | /* "Object ID.Attribute" = "Localized text in Japanese"; */ 123 | "4gA-LI-pd8.title" = "こんにちは"; 124 | ``` 125 | 126 | ## (9') ja.lproj/Sub.strings 127 | 128 | Japanese strings file for Sub.xib. 129 | 130 | ``` 131 | /* "Object ID.Attribute" = "Localized text in Japanese"; */ 132 | "n5W-Qq-OA4.title" = "さようなら"; 133 | ``` 134 | 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /Sources/ls2xs/BaseLproj.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseLproj.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// `Base.lproj` directory, which contains `*.storyboard` and `*.xib` files 11 | final class BaseLproj { 12 | let url: URL 13 | 14 | init?(url: URL) { 15 | guard url.lastPathComponent == "Base.lproj" else { return nil } 16 | self.url = url 17 | } 18 | 19 | var ibFiles: [IbFile] { 20 | FileManager.default.fileURLs(in: url).compactMap { 21 | XibFile(url: $0) ?? StoryboardFile(url: $0) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ls2xs/BaseStringsFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseStringsFile.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Temporary strings file in `Base.lproj`, containing key-value pair of `IbFile.ObjectId` and `Localization.Key` 11 | /// 12 | /// This file is generated by `ibtool`. 13 | /// Make sure that this file should be removed. 14 | final class BaseStringsFile: CustomStringConvertible { 15 | let url: URL 16 | let fullname: String 17 | var keyValues: [IbFile.ObjectId: Localize.Key] = [:] 18 | 19 | static func make(ibFile: IbFile) -> BaseStringsFile { 20 | generate(from: ibFile, to: "\(ibFile.name).strings") 21 | return BaseStringsFile(ibFile: ibFile) 22 | } 23 | 24 | private static func generate(from ibFile: IbFile, to baseStringsFileName: String) { 25 | let generateStringsFile: Process = { task, ibFileUrl, baseStringsFileUrl in 26 | task.launchPath = "/usr/bin/ibtool" 27 | task.arguments = [ 28 | ibFileUrl.path, 29 | "--generate-strings-file", 30 | baseStringsFileUrl.path, 31 | ] 32 | return task 33 | }(Process(), ibFile.url, ibFile.url.deletingLastPathComponent().appendingPathComponent(baseStringsFileName)) 34 | generateStringsFile.launch() 35 | generateStringsFile.waitUntilExit() 36 | } 37 | 38 | init(ibFile: IbFile) { 39 | let fullname = "\(ibFile.name).strings" 40 | url = ibFile.url 41 | .deletingLastPathComponent() 42 | .appendingPathComponent(fullname) 43 | self.fullname = fullname 44 | 45 | keyValues = { url in 46 | guard let keyValues = NSDictionary(contentsOf: url) as? [IbFile.ObjectId: Localize.Key] else { fatalError("Failed to load IbFile: \(url)") } 47 | return keyValues 48 | }(url) 49 | } 50 | 51 | func removeFile() { 52 | do { 53 | try FileManager.default.removeItem(at: url) 54 | } catch { 55 | fatalError("failed to remove file: \(url)") 56 | } 57 | return 58 | } 59 | 60 | var description: String { url.path } 61 | var isEmpty: Bool { keyValues.isEmpty } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/ls2xs/Extension/FileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileManager { 11 | /// Get all urls in URL 12 | /// - Parameter url: Root URL to find all urls 13 | /// - Returns: all urls in URL 14 | func fileURLs(in url: URL) -> [URL] { 15 | let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [], options: .skipsHiddenFiles) 16 | 17 | var urls: [URL] = [] 18 | while let url = enumerator?.nextObject() as? URL { 19 | urls.append(url) 20 | } 21 | return urls 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ls2xs/IbFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IbFile.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Protocol of storyboard and xib files 11 | protocol IbFile { 12 | typealias ObjectId = String 13 | var url: URL { get } 14 | /// File name without extension 15 | var name: String { get } 16 | } 17 | 18 | extension IbFile { 19 | fileprivate static func getName(from url: URL) -> String { 20 | url.deletingPathExtension().lastPathComponent 21 | } 22 | } 23 | 24 | /// Xib file 25 | struct XibFile: IbFile { 26 | let url: URL 27 | let name: String 28 | 29 | init?(url: URL) { 30 | guard url.pathExtension == "xib" else { return nil } 31 | self.url = url 32 | name = Self.getName(from: url) 33 | } 34 | } 35 | 36 | /// Storyboard file 37 | struct StoryboardFile: IbFile { 38 | let url: URL 39 | let name: String 40 | 41 | init?(url: URL) { 42 | guard url.pathExtension == "storyboard" else { return nil } 43 | self.url = url 44 | name = Self.getName(from: url) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/ls2xs/LangStringsFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LangStringsFile.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Strings file in `.lproj` directory, with key-value pairs of `IbFile.ObjectId` and `Localized.Value` 11 | struct LangStringsFile { 12 | let url: URL 13 | let lang: String 14 | var keyValues: [IbFile.ObjectId: Localized.Value] = [:] 15 | 16 | init(lang: String, baseStringsFile: BaseStringsFile) { 17 | url = baseStringsFile.url 18 | .deletingLastPathComponent() 19 | .deletingLastPathComponent() 20 | .appendingPathComponent("\(lang).lproj") 21 | .appendingPathComponent("\(baseStringsFile.fullname)") 22 | self.lang = lang 23 | } 24 | 25 | mutating func update(from baseStringsFile: BaseStringsFile, with localizableString: LocalizableStringsFile) { 26 | baseStringsFile.keyValues.forEach { (objectId, localizeKey) in 27 | if let localizedValue = localizableString.keyValues[localizeKey] { 28 | keyValues[objectId] = localizedValue 29 | } else { 30 | keyValues[objectId] = localizeKey 31 | } 32 | } 33 | } 34 | 35 | func save() { 36 | let output = keyValues.map({ objectId, localizedValue in 37 | #""\#(objectId)" = "\#(localizedValue)";\#n"# 38 | }) 39 | .sorted() 40 | .joined() 41 | 42 | do { 43 | let dir = url.deletingLastPathComponent() 44 | try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) 45 | try output.write(to: url, atomically: true, encoding: .utf8) 46 | } catch { 47 | fatalError("Failed to save strings file in .lproj: \(url)") 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/ls2xs/LocalizableStringsFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizableStringsFile.swift 3 | // ls2xs 4 | // 5 | // Created by 安宅正之 on 2020/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Localize { 11 | typealias Key = String 12 | } 13 | struct Localized { 14 | typealias Value = String 15 | } 16 | 17 | /// Localizable.strings file, with key-value pairs of `Localization.Key` and `Localized.Value` 18 | final class LocalizableStringsFile { 19 | let url: URL 20 | let lang: String 21 | private(set) var keyValues: [Localize.Key: Localized.Value] 22 | 23 | init?(name: String, url: URL) { 24 | let lang = url.deletingLastPathComponent().deletingPathExtension().lastPathComponent 25 | guard url.lastPathComponent == name 26 | && !lang.isEmpty, 27 | let keyValues = Self.readKeyValues(in: url) else { return nil } 28 | 29 | self.url = url 30 | self.lang = lang 31 | self.keyValues = keyValues 32 | } 33 | 34 | private static func readKeyValues(in url: URL) -> [Localize.Key: Localized.Value]? { 35 | guard let rawKeyValues = NSDictionary(contentsOf: url) as? [Localize.Key: Localized.Value] else { return nil } 36 | return rawKeyValues.mapValues { value in 37 | String(value.flatMap { (char: Character) -> [Character] in 38 | switch char { 39 | case "\n": return ["\\", "n"] 40 | case "\r": return ["\\", "r"] 41 | case "\\": return ["\\", "\\"] 42 | case "\"": return ["\\", "\""] 43 | default: return [char] 44 | } 45 | }) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/ls2xs/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ArgumentParser 3 | 4 | struct Ls2Xs: ParsableCommand { 5 | static let configuration = CommandConfiguration(abstract: "A command line tool that updates .strings of .xib and .storyboard using Localizable.strings.", 6 | version: Version.value) 7 | 8 | @Argument(help: "Path to target directory") 9 | var path: String 10 | 11 | @Option(name: .shortAndLong, help: "File name of *.strings file.") 12 | var stringsFile: String = "Localizable.strings" 13 | 14 | mutating func run() { 15 | let (stringFiles, baseLprojs) = collectingLocalizableStringsFilesAndBaseLprojs(in: path) 16 | makeLangStringsFiles(stringFiles: stringFiles, baseLprojs: baseLprojs) 17 | } 18 | 19 | private func collectingLocalizableStringsFilesAndBaseLprojs(in path: String) -> ([String: LocalizableStringsFile], [BaseLproj]) { 20 | let fileManager = FileManager.default 21 | let rootUrl = URL(fileURLWithPath: fileManager.currentDirectoryPath).appendingPathComponent(path) 22 | 23 | var stringFiles: [String: LocalizableStringsFile] = [:] 24 | var baseLprojs: [BaseLproj] = [] 25 | fileManager.fileURLs(in: rootUrl).forEach() { url in 26 | if let stringFile = LocalizableStringsFile(name: stringsFile, url: url) { 27 | stringFiles[stringFile.lang] = stringFile 28 | } 29 | if let baseLproj = BaseLproj(url: url) { 30 | baseLprojs.append(baseLproj) 31 | } 32 | } 33 | return (stringFiles, baseLprojs) 34 | } 35 | 36 | private func makeLangStringsFiles(stringFiles: [String: LocalizableStringsFile], baseLprojs: [BaseLproj]) { 37 | let langs = Array(stringFiles.keys) 38 | baseLprojs.forEach { baseLproj in 39 | baseLproj.ibFiles.forEach { ibFile in 40 | print("generating *.strings file for \(ibFile.url.path)") 41 | let baseStringsFile = BaseStringsFile.make(ibFile: ibFile) 42 | defer { baseStringsFile.removeFile() } 43 | 44 | guard !baseStringsFile.isEmpty else { return } 45 | langs.forEach { lang in 46 | guard let localizableStringsFile = stringFiles[lang] else { fatalError("Failed to find LANG in stringFiles: \(lang)") } 47 | var langStringsFile = LangStringsFile(lang: lang, baseStringsFile: baseStringsFile) 48 | langStringsFile.update(from: baseStringsFile, with: localizableStringsFile) 49 | langStringsFile.save() 50 | } 51 | } 52 | } 53 | } 54 | } 55 | 56 | Ls2Xs.main() 57 | -------------------------------------------------------------------------------- /Sources/ls2xs/version.swift: -------------------------------------------------------------------------------- 1 | enum Version { 2 | static let value = "0.4.0" 3 | } 4 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import ls2xsTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ls2xsTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ls2xsTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ls2xsTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/ls2xsTests/ls2xsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class ls2xsTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | 10 | // Some of the APIs that we use below are available in macOS 10.13 and above. 11 | guard #available(macOS 10.13, *) else { 12 | return 13 | } 14 | 15 | let fooBinary = productsDirectory.appendingPathComponent("ls2xs") 16 | 17 | let process = Process() 18 | process.executableURL = fooBinary 19 | 20 | let pipe = Pipe() 21 | process.standardOutput = pipe 22 | 23 | try process.run() 24 | process.waitUntilExit() 25 | 26 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 27 | let output = String(data: data, encoding: .utf8) 28 | 29 | XCTAssertEqual(output, "Hello, world!\n") 30 | } 31 | 32 | /// Returns path to the built products directory. 33 | var productsDirectory: URL { 34 | #if os(macOS) 35 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 36 | return bundle.bundleURL.deletingLastPathComponent() 37 | } 38 | fatalError("couldn't find the products directory") 39 | #else 40 | return Bundle.main.bundleURL 41 | #endif 42 | } 43 | 44 | static var allTests = [ 45 | ("testExample", testExample), 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /UnitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /UnitTests/LprojFileTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class LprojFileTests: XCTestCase { 5 | var lprojFile: LprojFile! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | 10 | let cwd = ProcessInfo.processInfo.environment["MY_SOURCE_ROOT"] 11 | let fileManager = FileManager() 12 | fileManager.changeCurrentDirectoryPath(cwd!) 13 | 14 | let path = FileManager.default.currentDirectoryPath 15 | let anURL = URL(fileURLWithPath: path).appendingPathComponent("DemoApp/Base.lproj") 16 | lprojFile = LprojFile(URL: anURL) 17 | } 18 | 19 | func testXibFiles() { 20 | XCTAssertEqual(lprojFile!.xibFiles.map({ $0.name }), ["LaunchScreen", "Main"]) 21 | } 22 | 23 | func testStringsFiles() { 24 | XCTAssertEqual(lprojFile!.stringsFiles.map({ $0.name }), ["Localizable"]) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /UnitTests/StringsFileTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class StringsFileTests: XCTestCase { 5 | var localizableStringsFile: StringsFile! 6 | var xibStringsFile: StringsFile! 7 | 8 | override func setUp() { 9 | super.setUp() 10 | 11 | let cwd = ProcessInfo.processInfo.environment["MY_SOURCE_ROOT"] 12 | let fileManager = FileManager() 13 | fileManager.changeCurrentDirectoryPath(cwd!) 14 | 15 | let directoryPath = FileManager.default.currentDirectoryPath 16 | let directoryURL = URL(fileURLWithPath: directoryPath).appendingPathComponent("DemoApp") 17 | localizableStringsFile = StringsFile(URL: directoryURL.appendingPathComponent("en.lproj/Localizable.strings")) 18 | xibStringsFile = StringsFile(URL: directoryURL.appendingPathComponent("en.lproj/Main.strings")) 19 | } 20 | 21 | func testUpdate() { 22 | xibStringsFile.updateValuesUsingLocalizableStringsFile(localizableStringsFile) 23 | 24 | XCTAssertEqual(xibStringsFile.dictionary["xUH-o8-DGc.text"]!, "Hello") 25 | XCTAssertEqual(xibStringsFile.dictionary["uZj-A7-ihc.normalTitle"]!, "Get started") 26 | XCTAssertEqual(xibStringsFile.dictionary["zjA-Rl-7jE.text"]!, "This is a \"quoted\" text.") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /UnitTests/TargetTests.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import XCTest 3 | 4 | class TargetTests: XCTestCase { 5 | var target: Target! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | 10 | let cwd = ProcessInfo.processInfo.environment["MY_SOURCE_ROOT"] 11 | let fileManager = FileManager() 12 | fileManager.changeCurrentDirectoryPath(cwd!) 13 | 14 | target = Target(path: "DemoApp") 15 | } 16 | 17 | func testInitializer() { 18 | XCTAssertNotNil(target) 19 | } 20 | 21 | func testBaseLprojFile() { 22 | XCTAssertEqual(target.baseLprojFile!.URL.lastPathComponent, "Base.lproj") 23 | } 24 | 25 | func testLangLprojFiles() { 26 | XCTAssertEqual(target.langLprojFiles.map({ $0.name }), ["en", "ja"]) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | xcode: 3 | version: 8.0 4 | environment: 5 | LANG: en_US.UTF-8 6 | 7 | dependencies: 8 | pre: 9 | - brew uninstall xctool && brew install --HEAD xctool 10 | - sudo gem install xcpretty 11 | 12 | test: 13 | override: 14 | - make test && cp build/test-report.xml $CIRCLE_TEST_REPORTS 15 | -------------------------------------------------------------------------------- /scripts/prepare_hotfix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Input hotfix version:" 4 | echo -n "? " 5 | 6 | read version 7 | git checkout -b hotfix/$version master 8 | 9 | version_swift=Sources/ls2xs/version.swift 10 | sed -ie "s/\( *static let value = \"\)[^\"]*\"/\1$version\"/" $version_swift 11 | git add $version_swift 12 | git commit -m "Set version $version" 13 | -------------------------------------------------------------------------------- /scripts/prepare_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Input release version:" 4 | echo -n "? " 5 | 6 | read version 7 | git checkout -b release/$version 8 | 9 | version_swift=Sources/ls2xs/version.swift 10 | sed -ie "s/\( *static let value = \"\)[^\"]*\"/\1$version\"/" $version_swift 11 | git add $version_swift 12 | git commit -m "Set version $version" 13 | --------------------------------------------------------------------------------