├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
--------------------------------------------------------------------------------