From 34191c921a5201f3cc5c07fce6c2fa9238615bf0 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 22 Feb 2021 16:20:23 +0800 Subject: [PATCH] feat: add localization helper --- Localization/README.md | 8 ++ Localization/StringsConvertor/Package.swift | 25 +++++ Localization/StringsConvertor/README.md | 12 +++ .../Sources/StringsConvertor/Parser.swift | 100 ++++++++++++++++++ .../Sources/StringsConvertor/main.swift | 74 +++++++++++++ .../StringsConvertor/Tests/LinuxMain.swift | 7 ++ .../StringsConvertorTests.swift | 47 ++++++++ .../XCTestManifests.swift | 9 ++ .../StringsConvertor/input/en_US/app.json | 78 ++++++++++++++ .../input/en_US/ios-infoPlist.json | 4 + .../output/en.lproj/Localizable.strings | 37 +++++++ .../output/en.lproj/infoPlist.strings | 2 + .../StringsConvertor/scripts/build.sh | 28 +++++ Localization/app.json | 78 ++++++++++++++ Localization/ios-infoPlist.json | 4 + Mastodon/Info.plist | 2 - 16 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 Localization/README.md create mode 100644 Localization/StringsConvertor/Package.swift create mode 100644 Localization/StringsConvertor/README.md create mode 100644 Localization/StringsConvertor/Sources/StringsConvertor/Parser.swift create mode 100644 Localization/StringsConvertor/Sources/StringsConvertor/main.swift create mode 100644 Localization/StringsConvertor/Tests/LinuxMain.swift create mode 100644 Localization/StringsConvertor/Tests/StringsConvertorTests/StringsConvertorTests.swift create mode 100644 Localization/StringsConvertor/Tests/StringsConvertorTests/XCTestManifests.swift create mode 100644 Localization/StringsConvertor/input/en_US/app.json create mode 100644 Localization/StringsConvertor/input/en_US/ios-infoPlist.json create mode 100644 Localization/StringsConvertor/output/en.lproj/Localizable.strings create mode 100644 Localization/StringsConvertor/output/en.lproj/infoPlist.strings create mode 100755 Localization/StringsConvertor/scripts/build.sh create mode 100644 Localization/app.json create mode 100644 Localization/ios-infoPlist.json diff --git a/Localization/README.md b/Localization/README.md new file mode 100644 index 00000000..1e6975f8 --- /dev/null +++ b/Localization/README.md @@ -0,0 +1,8 @@ +# Localization + +Mastodon localization template file + + +## How to contribute? + +TBD \ No newline at end of file diff --git a/Localization/StringsConvertor/Package.swift b/Localization/StringsConvertor/Package.swift new file mode 100644 index 00000000..c4192456 --- /dev/null +++ b/Localization/StringsConvertor/Package.swift @@ -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"]), + ] +) diff --git a/Localization/StringsConvertor/README.md b/Localization/StringsConvertor/README.md new file mode 100644 index 00000000..62df25d9 --- /dev/null +++ b/Localization/StringsConvertor/README.md @@ -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 +``` diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/Parser.swift b/Localization/StringsConvertor/Sources/StringsConvertor/Parser.swift new file mode 100644 index 00000000..ba9750cd --- /dev/null +++ b/Localization/StringsConvertor/Sources/StringsConvertor/Parser.swift @@ -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 + } + +} diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift new file mode 100644 index 00000000..4ccbb307 --- /dev/null +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -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) diff --git a/Localization/StringsConvertor/Tests/LinuxMain.swift b/Localization/StringsConvertor/Tests/LinuxMain.swift new file mode 100644 index 00000000..7087778c --- /dev/null +++ b/Localization/StringsConvertor/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import StringsConvertorTests + +var tests = [XCTestCaseEntry]() +tests += StringsConvertorTests.allTests() +XCTMain(tests) diff --git a/Localization/StringsConvertor/Tests/StringsConvertorTests/StringsConvertorTests.swift b/Localization/StringsConvertor/Tests/StringsConvertorTests/StringsConvertorTests.swift new file mode 100644 index 00000000..cb59f3bb --- /dev/null +++ b/Localization/StringsConvertor/Tests/StringsConvertorTests/StringsConvertorTests.swift @@ -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), + ] +} diff --git a/Localization/StringsConvertor/Tests/StringsConvertorTests/XCTestManifests.swift b/Localization/StringsConvertor/Tests/StringsConvertorTests/XCTestManifests.swift new file mode 100644 index 00000000..81a65399 --- /dev/null +++ b/Localization/StringsConvertor/Tests/StringsConvertorTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(StringsConvertorTests.allTests), + ] +} +#endif diff --git a/Localization/StringsConvertor/input/en_US/app.json b/Localization/StringsConvertor/input/en_US/app.json new file mode 100644 index 00000000..0c3f16c7 --- /dev/null +++ b/Localization/StringsConvertor/input/en_US/app.json @@ -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" + } + } +} \ No newline at end of file diff --git a/Localization/StringsConvertor/input/en_US/ios-infoPlist.json b/Localization/StringsConvertor/input/en_US/ios-infoPlist.json new file mode 100644 index 00000000..0a260c27 --- /dev/null +++ b/Localization/StringsConvertor/input/en_US/ios-infoPlist.json @@ -0,0 +1,4 @@ +{ + "NSCameraUsageDescription": "Used to take photo for toot", + "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library" +} diff --git a/Localization/StringsConvertor/output/en.lproj/Localizable.strings b/Localization/StringsConvertor/output/en.lproj/Localizable.strings new file mode 100644 index 00000000..707ef3cc --- /dev/null +++ b/Localization/StringsConvertor/output/en.lproj/Localizable.strings @@ -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."; \ No newline at end of file diff --git a/Localization/StringsConvertor/output/en.lproj/infoPlist.strings b/Localization/StringsConvertor/output/en.lproj/infoPlist.strings new file mode 100644 index 00000000..972e1a7a --- /dev/null +++ b/Localization/StringsConvertor/output/en.lproj/infoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Used to take photo for toot"; +"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Localization/StringsConvertor/scripts/build.sh b/Localization/StringsConvertor/scripts/build.sh new file mode 100755 index 00000000..81e17745 --- /dev/null +++ b/Localization/StringsConvertor/scripts/build.sh @@ -0,0 +1,28 @@ +#!/bin/zsh + +set -ev + +# Crowin_Latest_Build="https://crowdin.com/backend/download/project/.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 .zip -L ${Crowin_Latest_Build} +# unzip -o -q .zip -d input +# rm -rf .zip + +swift run diff --git a/Localization/app.json b/Localization/app.json new file mode 100644 index 00000000..0c3f16c7 --- /dev/null +++ b/Localization/app.json @@ -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" + } + } +} \ No newline at end of file diff --git a/Localization/ios-infoPlist.json b/Localization/ios-infoPlist.json new file mode 100644 index 00000000..0a260c27 --- /dev/null +++ b/Localization/ios-infoPlist.json @@ -0,0 +1,4 @@ +{ + "NSCameraUsageDescription": "Used to take photo for toot", + "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library" +} diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 47f90b99..7450b654 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -2,8 +2,6 @@ - UIUserInterfaceStyle - Dark CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable