feat: add localization helper

This commit is contained in:
CMK 2021-02-22 16:20:23 +08:00
parent 8a48eb5847
commit 34191c921a
16 changed files with 513 additions and 2 deletions

8
Localization/README.md Normal file
View File

@ -0,0 +1,8 @@
# Localization
Mastodon localization template file
## How to contribute?
TBD

View File

@ -0,0 +1,25 @@
// swift-tools-version:5.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "StringsConvertor",
platforms: [
.macOS(.v10_15)
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "StringsConvertor",
dependencies: []),
.testTarget(
name: "StringsConvertorTests",
dependencies: ["StringsConvertor"]),
]
)

View File

@ -0,0 +1,12 @@
# StringsConvertor
Convert i18n JSON file to Stings file.
## Usage
```
chmod +x scripts/build.sh
./scripts/build.sh
# lproj files will locate in output/ directory
```

View File

@ -0,0 +1,100 @@
//
// File.swift
//
//
// Created by Cirno MainasuK on 2020-7-7.
//
import Foundation
class Parser {
let json: [String: Any]
init(data: Data) throws {
let dict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
self.json = dict ?? [:]
}
}
extension Parser {
enum KeyStyle {
case infoPlist
case swiftgen
}
}
extension Parser {
func generateStrings(keyStyle: KeyStyle = .swiftgen) -> String {
let pairs = traval(dictionary: json, prefixKeys: [])
var lines: [String] = []
for pair in pairs {
let key = [
"\"",
pair.prefix
.map { segment in
segment
.split(separator: "_")
.map { String($0) }
.map {
switch keyStyle {
case .infoPlist: return $0
case .swiftgen: return $0.capitalized
}
}
.joined()
}
.joined(separator: "."),
"\""
].joined()
let value = [
"\"",
pair.value.replacingOccurrences(of: "%s", with: "%@"),
"\""
].joined()
let line = [
[key, value].joined(separator: " = "),
";"
].joined()
lines.append(line)
}
let strings = lines
.sorted()
.joined(separator: "\n")
return strings
}
}
extension Parser {
typealias PrefixKeys = [String]
typealias LocalizationPair = (prefix: PrefixKeys, value: String)
private func traval(dictionary: [String: Any], prefixKeys: PrefixKeys) -> [LocalizationPair] {
var pairs: [LocalizationPair] = []
for (key, any) in dictionary {
let prefix = prefixKeys + [key]
// if leaf node of dict tree
if let value = any as? String {
pairs.append(LocalizationPair(prefix: prefix, value: value))
continue
}
// if not leaf node of dict tree
if let dict = any as? [String: Any] {
let innerPairs = traval(dictionary: dict, prefixKeys: prefix)
pairs.append(contentsOf: innerPairs)
}
}
return pairs
}
}

View File

@ -0,0 +1,74 @@
import os.log
import Foundation
let currentFileURL = URL(fileURLWithPath: "\(#file)", isDirectory: false)
let packageRootURL = currentFileURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
let inputDirectoryURL = packageRootURL.appendingPathComponent("input", isDirectory: true)
let outputDirectoryURL = packageRootURL.appendingPathComponent("output", isDirectory: true)
private func convert(from inputDirectory: URL, to outputDirectory: URL) {
do {
let inputLanguageDirectoryURLs = try FileManager.default.contentsOfDirectory(
at: inputDirectoryURL,
includingPropertiesForKeys: [.nameKey, .isDirectoryKey],
options: []
)
for inputLanguageDirectoryURL in inputLanguageDirectoryURLs {
let language = inputLanguageDirectoryURL.lastPathComponent
guard let mappedLanguage = map(language: language) else { continue }
let outputDirectoryURL = outputDirectory.appendingPathComponent(mappedLanguage + ".lproj", isDirectory: true)
os_log("%{public}s[%{public}ld], %{public}s: process %s -> %s", ((#file as NSString).lastPathComponent), #line, #function, language, mappedLanguage)
let fileURLs = try FileManager.default.contentsOfDirectory(
at: inputLanguageDirectoryURL,
includingPropertiesForKeys: [.nameKey, .isDirectoryKey],
options: []
)
for jsonURL in fileURLs where jsonURL.pathExtension == "json" {
os_log("%{public}s[%{public}ld], %{public}s: process %s", ((#file as NSString).lastPathComponent), #line, #function, jsonURL.debugDescription)
let filename = jsonURL.deletingPathExtension().lastPathComponent
guard let (mappedFilename, keyStyle) = map(filename: filename) else { continue }
let outputFileURL = outputDirectoryURL.appendingPathComponent(mappedFilename).appendingPathExtension("strings")
let strings = try process(url: jsonURL, keyStyle: keyStyle)
try? FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try strings.write(to: outputFileURL, atomically: true, encoding: .utf8)
}
}
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
exit(1)
}
}
private func map(language: String) -> String? {
switch language {
case "en_US": return "en"
case "zh_CN": return "zh-Hans"
case "ja_JP": return "ja"
case "de_DE": return "de"
case "pt_BR": return "pt-BR"
default: return nil
}
}
private func map(filename: String) -> (filename: String, keyStyle: Parser.KeyStyle)? {
switch filename {
case "app": return ("Localizable", .swiftgen)
case "ios-infoPlist": return ("infoPlist", .infoPlist)
default: return nil
}
}
private func process(url: URL, keyStyle: Parser.KeyStyle) throws -> String {
do {
let data = try Data(contentsOf: url)
let parser = try Parser(data: data)
let strings = parser.generateStrings(keyStyle: keyStyle)
return strings
} catch {
os_log("%{public}s[%{public}ld], %{public}s: error: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
throw error
}
}
convert(from: inputDirectoryURL, to: outputDirectoryURL)

View File

@ -0,0 +1,7 @@
import XCTest
import StringsConvertorTests
var tests = [XCTestCaseEntry]()
tests += StringsConvertorTests.allTests()
XCTMain(tests)

View File

@ -0,0 +1,47 @@
import XCTest
import class Foundation.Bundle
final class StringsConvertorTests: XCTestCase {
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
// Some of the APIs that we use below are available in macOS 10.13 and above.
guard #available(macOS 10.13, *) else {
return
}
let fooBinary = productsDirectory.appendingPathComponent("StringsConvertor")
let process = Process()
process.executableURL = fooBinary
let pipe = Pipe()
process.standardOutput = pipe
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
XCTAssertEqual(output, "Hello, world!\n")
}
/// Returns path to the built products directory.
var productsDirectory: URL {
#if os(macOS)
for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
return bundle.bundleURL.deletingLastPathComponent()
}
fatalError("couldn't find the products directory")
#else
return Bundle.main.bundleURL
#endif
}
static var allTests = [
("testExample", testExample),
]
}

View File

@ -0,0 +1,9 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(StringsConvertorTests.allTests),
]
}
#endif

View File

@ -0,0 +1,78 @@
{
"common": {
"alerts": {},
"controls": {
"actions": {
"add": "Add",
"remove": "Remove",
"edit": "Edit",
"save": "Save",
"ok": "OK",
"confirm": "Confirm",
"continue": "Continue",
"cancel": "Cancel",
"take_photo": "Take photo",
"save_photo": "Save photo",
"sign_in": "Sign in",
"sign_up": "Sign up",
"see_more": "See More",
"preview": "Preview",
"open_in_safari": "Open in Safari"
},
"timeline": {
"load_more": "Load More"
}
},
"countable": {
"photo": {
"single": "photo",
"multiple": "photos"
}
}
},
"scene": {
"welcome": {
"slogan": "Social networking\nback in your hands."
},
"server_picker": {
"title": "Pick a Server,\nany server.",
"input": {
"placeholder": "Find a server or join your own..."
}
},
"register": {
"title": "Tell us about you.",
"input": {
"username": {
"placeholder": "username",
"duplicate_prompt": "This username is taken."
},
"display_name": {
"placeholder": "display name"
},
"email": {
"placeholder": "email"
},
"password": {
"placeholder": "password",
"prompt": "Your password needs at least:",
"prompt_eight_characters": "Eight characters"
}
}
},
"server_rules": {
"title": "Some ground rules.",
"subtitle": "These rules are set by the admins of %s.",
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
"button": {
"confirm": "I Agree"
}
},
"home_timeline": {
"title": "Home"
},
"public_timeline": {
"title": "Public"
}
}
}

View File

@ -0,0 +1,4 @@
{
"NSCameraUsageDescription": "Used to take photo for toot",
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
}

View File

@ -0,0 +1,37 @@
"Common.Controls.Actions.Add" = "Add";
"Common.Controls.Actions.Cancel" = "Cancel";
"Common.Controls.Actions.Confirm" = "Confirm";
"Common.Controls.Actions.Continue" = "Continue";
"Common.Controls.Actions.Edit" = "Edit";
"Common.Controls.Actions.Ok" = "OK";
"Common.Controls.Actions.OpenInSafari" = "Open in Safari";
"Common.Controls.Actions.Preview" = "Preview";
"Common.Controls.Actions.Remove" = "Remove";
"Common.Controls.Actions.Save" = "Save";
"Common.Controls.Actions.SavePhoto" = "Save photo";
"Common.Controls.Actions.SeeMore" = "See More";
"Common.Controls.Actions.SignIn" = "Sign in";
"Common.Controls.Actions.SignUp" = "Sign up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Timeline.LoadMore" = "Load More";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.HomeTimeline.Title" = "Home";
"Scene.PublicTimeline.Title" = "Public";
"Scene.Register.Input.DisplayName.Placeholder" = "display name";
"Scene.Register.Input.Email.Placeholder" = "email";
"Scene.Register.Input.Password.Placeholder" = "password";
"Scene.Register.Input.Password.Prompt" = "Your password needs at least:";
"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters";
"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken.";
"Scene.Register.Input.Username.Placeholder" = "username";
"Scene.Register.Title" = "Tell us about you.";
"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own...";
"Scene.ServerPicker.Title" = "Pick a Server,
any server.";
"Scene.ServerRules.Button.Confirm" = "I Agree";
"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@.";
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
"Scene.ServerRules.Title" = "Some ground rules.";
"Scene.Welcome.Slogan" = "Social networking
back in your hands.";

View File

@ -0,0 +1,2 @@
"NSCameraUsageDescription" = "Used to take photo for toot";
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";

View File

@ -0,0 +1,28 @@
#!/bin/zsh
set -ev
# Crowin_Latest_Build="https://crowdin.com/backend/download/project/<TBD>.zip"
if [[ -d input ]]; then
rm -rf input
fi
if [[ -d output ]]; then
rm -rf output
fi
mkdir output
# FIXME: temporary use local json for i18n
# replace by the Crowdin remote template later
mkdir -p input/en_US
cp ../app.json ./input/en_US
cp ../ios-infoPlist.json ./input/en_US
# curl -o <TBD>.zip -L ${Crowin_Latest_Build}
# unzip -o -q <TBD>.zip -d input
# rm -rf <TBD>.zip
swift run

78
Localization/app.json Normal file
View File

@ -0,0 +1,78 @@
{
"common": {
"alerts": {},
"controls": {
"actions": {
"add": "Add",
"remove": "Remove",
"edit": "Edit",
"save": "Save",
"ok": "OK",
"confirm": "Confirm",
"continue": "Continue",
"cancel": "Cancel",
"take_photo": "Take photo",
"save_photo": "Save photo",
"sign_in": "Sign in",
"sign_up": "Sign up",
"see_more": "See More",
"preview": "Preview",
"open_in_safari": "Open in Safari"
},
"timeline": {
"load_more": "Load More"
}
},
"countable": {
"photo": {
"single": "photo",
"multiple": "photos"
}
}
},
"scene": {
"welcome": {
"slogan": "Social networking\nback in your hands."
},
"server_picker": {
"title": "Pick a Server,\nany server.",
"input": {
"placeholder": "Find a server or join your own..."
}
},
"register": {
"title": "Tell us about you.",
"input": {
"username": {
"placeholder": "username",
"duplicate_prompt": "This username is taken."
},
"display_name": {
"placeholder": "display name"
},
"email": {
"placeholder": "email"
},
"password": {
"placeholder": "password",
"prompt": "Your password needs at least:",
"prompt_eight_characters": "Eight characters"
}
}
},
"server_rules": {
"title": "Some ground rules.",
"subtitle": "These rules are set by the admins of %s.",
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
"button": {
"confirm": "I Agree"
}
},
"home_timeline": {
"title": "Home"
},
"public_timeline": {
"title": "Public"
}
}
}

View File

@ -0,0 +1,4 @@
{
"NSCameraUsageDescription": "Used to take photo for toot",
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
}

View File

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>