mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2024-12-18 11:49:00 +01:00
feat: add localization helper
This commit is contained in:
parent
8a48eb5847
commit
34191c921a
8
Localization/README.md
Normal file
8
Localization/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Localization
|
||||
|
||||
Mastodon localization template file
|
||||
|
||||
|
||||
## How to contribute?
|
||||
|
||||
TBD
|
25
Localization/StringsConvertor/Package.swift
Normal file
25
Localization/StringsConvertor/Package.swift
Normal 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"]),
|
||||
]
|
||||
)
|
12
Localization/StringsConvertor/README.md
Normal file
12
Localization/StringsConvertor/README.md
Normal 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
|
||||
```
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
7
Localization/StringsConvertor/Tests/LinuxMain.swift
Normal file
7
Localization/StringsConvertor/Tests/LinuxMain.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import XCTest
|
||||
|
||||
import StringsConvertorTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += StringsConvertorTests.allTests()
|
||||
XCTMain(tests)
|
@ -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),
|
||||
]
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import XCTest
|
||||
|
||||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(StringsConvertorTests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
78
Localization/StringsConvertor/input/en_US/app.json
Normal file
78
Localization/StringsConvertor/input/en_US/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"NSCameraUsageDescription": "Used to take photo for toot",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
|
||||
}
|
@ -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.";
|
@ -0,0 +1,2 @@
|
||||
"NSCameraUsageDescription" = "Used to take photo for toot";
|
||||
"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library";
|
28
Localization/StringsConvertor/scripts/build.sh
Executable file
28
Localization/StringsConvertor/scripts/build.sh
Executable 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
78
Localization/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
4
Localization/ios-infoPlist.json
Normal file
4
Localization/ios-infoPlist.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"NSCameraUsageDescription": "Used to take photo for toot",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user