Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

## master

### 1.0.1

### Added

- Add support for plural keys ([#16](https://github.com/AckeeCZ/ACKLocalization/pull/16), kudos to @LukasHromadnik)

### 1.0.1

- Add support for specifiying number of decimals for float ([#15](https://github.com/AckeeCZ/ACKLocalization/pull/15), kudos to @fortmarek)

## 1.0.0
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ This is example folder structure of the project
|------ServiceAccount.json
|------en.lproj
|----------Localizable.strings
|----------Localizable.stringsDict
|------cs.lproj
|----------Localizable.strings
|----------Localizable.stringsDict
```

#### Spreadsheet structure
Expand Down Expand Up @@ -147,6 +149,18 @@ This is the example config file:
}
```

### Plural keys

To add plurals to the spreadsheet you need to specify the translation key and the plural type in the following convention

```
translation.key##{zero}
translation.key##{one}
translation.key##{two}
```

This will be automatically generated into `Localizable.stringsDict` and the key won't be presented in `Localizable.strings`.

## Author

[Ackee](https://ackee.cz) team
Expand Down
82 changes: 75 additions & 7 deletions Sources/ACKLocalizationCore/ACKLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,80 @@ public final class ACKLocalization {
transformValuesPublisher(valueRange, with: config.languageMapping, keyColumnName: config.keyColumnName)
}

/// Builds plurals from `rows` of each language
///
/// - Parameter `rows`: All translations of the selected language
/// - Returns: Plural keys that are specified in `rows`
func buildPlurals(from rows: [LocRow]) throws -> [String: PluralRuleWrapper] {
var plurals: [String: PluralRuleWrapper] = [:]

let regular = try NSRegularExpression(pattern: Constants.pluralPattern, options: [])

try rows.forEach {
// Try to split the translation key into the actual key and the plural rule key
// based on the predefined regular expression
let matches = regular.matches(in: $0.key, options: [], range: NSRange(location: 0, length: $0.key.utf16.count))

// Skip key which doesn't contain the translation rule
// There should be always exactly one match
guard let match = matches.first else { return }

// Index 0 – range of the whole string
// Index 1 – range of the translation key
let translationKeyRange = match.range(at: 1)

// Check if the actual translation key is presented
guard translationKeyRange.location != NSNotFound else { throw PluralError.missingTranslationKey($0.key) }

// Get the actual translation key from the `translationKeyRange`
let translationKey = ($0.key as NSString).substring(with: translationKeyRange)

// Index 2 – range of the plural rule
let pluralRuleRange = match.range(at: 2)

// Check if the plural rule is presented
guard pluralRuleRange.location != NSNotFound else { throw PluralError.missingPluralRule($0.key) }

// Get the plural rule from the `pluralRuleRange`
let pluralRuleString = ($0.key as NSString).substring(with: pluralRuleRange)

// Check if the plural rule is valid
guard let pluralRuleKey = PluralRuleKey(rawValue: pluralRuleString) else { throw PluralError.invalidPluralRule($0.key) }

// Load all translations for the given key
var currentTranslations = plurals[translationKey]?.translations ?? []

// Create new rule and add it to the other rules
let translation = PluralRule(key: pluralRuleKey, value: $0.value)
currentTranslations.append(translation)
plurals[translationKey] = PluralRuleWrapper(translations: currentTranslations)
}

return plurals
}

/// Saves given `mappedValues` to correct directory file
public func saveMappedValues(_ mappedValues: MappedValues, directory: String, stringsFileName: String) throws {
public func saveMappedValues(_ mappedValues: MappedValues, directory: String, stringsFileName: String, stringsDictFileName: String) throws {
try mappedValues.forEach { langCode, rows in
let dirPath = directory + "/" + langCode + ".lproj"
let filePath = dirPath + "/" + stringsFileName
let pluralsPath = dirPath + "/" + stringsDictFileName

try? FileManager.default.removeItem(atPath: filePath)
try? FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true)

do {
// we filter out entries with `plist.` prefix as they will be written into different file
try writeRows(rows.filter { !$0.key.hasPrefix(Constants.plistKeyPrefix + ".") }, to: filePath)
// Collection of plural rules for a given translation key.
// Translation key is the base without the suffix ##{plural-rule}
let plurals = try buildPlurals(from: rows)

let finalRows = rows
// we filter out entries with `plist.` prefix as they will be written into different file
.filter { !$0.key.hasPrefix(Constants.plistKeyPrefix + ".") }
// Filter out plurals
.filter { $0.key.range(of: Constants.pluralPattern, options: .regularExpression) == nil }

try writeRows(finalRows, to: filePath)

// write plist values to appropriate files
var plistOutputs = [String: [LocRow]]()
Expand All @@ -169,22 +231,28 @@ public final class ACKLocalization {
}

try plistOutputs.forEach { try writeRows($1, to: dirPath + "/" + $0 + ".strings") }

// Create stringDict from data and save it
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
let data = try encoder.encode(plurals)
try data.write(to: URL(fileURLWithPath: pluralsPath))
} catch {
throw LocalizationError(message: "Unable to save mapped values - " + error.localizedDescription)
}
}
}

/// Saves given `mappedValues` to correct directory file
public func saveMappedValuesPublisher(_ mappedValues: MappedValues, directory: String, stringsFileName: String) -> AnyPublisher<Void, LocalizationError> {
public func saveMappedValuesPublisher(_ mappedValues: MappedValues, directory: String, stringsFileName: String, stringsDictFileName: String) -> AnyPublisher<Void, LocalizationError> {
Future { [weak self] promise in
guard let self = self else {
promise(.failure(LocalizationError(message: "Unable to save mapped values")))
return
}

do {
try self.saveMappedValues(mappedValues, directory: directory, stringsFileName: stringsFileName)
try self.saveMappedValues(mappedValues, directory: directory, stringsFileName: stringsFileName, stringsDictFileName: stringsDictFileName)
promise(.success(()))
} catch {
switch error {
Expand All @@ -197,7 +265,7 @@ public final class ACKLocalization {

/// Saves given `mappedValues` to correct directory file
public func saveMappedValuesPublisher(_ mappedValues: MappedValues, config: Configuration) -> AnyPublisher<Void, LocalizationError> {
saveMappedValuesPublisher(mappedValues, directory: config.destinationDir, stringsFileName: config.stringsFileName ?? "Localizable.strings")
saveMappedValuesPublisher(mappedValues, directory: config.destinationDir, stringsFileName: config.stringsFileName ?? "Localizable.strings", stringsDictFileName: config.stringsDictFileName ?? "Localizable.stringsDict")
}

/// Fetches sheet values from given `config`
Expand Down
5 changes: 4 additions & 1 deletion Sources/ACKLocalizationCore/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
import Foundation

/// Struct holding important constants used throughout the tool
public struct Constants {
public enum Constants {
/// Prefix that defines that localization key holds plist item
public static let plistKeyPrefix = "plist"

/// Regex pattern for suffix of the plural translations
public static let pluralPattern = #"^([\w.]+)?##\{([\w]+)?\}$"#
}
6 changes: 5 additions & 1 deletion Sources/ACKLocalizationCore/Model/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ public struct Configuration: Decodable {
/// Name of strings file that should be generated
public let stringsFileName: String?

/// Name of stringsDict file that should be generated
public let stringsDictFileName: String?

public init(apiKey: APIKey?, destinationDir: String, keyColumnName: String, languageMapping: LanguageMapping, serviceAccount: String?,
spreadsheetID: String, spreadsheetTabName: String?, stringsFileName: String?) {
spreadsheetID: String, spreadsheetTabName: String?, stringsFileName: String?, stringsDictFileName: String?) {
self.apiKey = apiKey
self.destinationDir = destinationDir
self.keyColumnName = keyColumnName
Expand All @@ -51,5 +54,6 @@ public struct Configuration: Decodable {
self.spreadsheetID = spreadsheetID
self.spreadsheetTabName = spreadsheetTabName
self.stringsFileName = stringsFileName
self.stringsDictFileName = stringsDictFileName
}
}
19 changes: 19 additions & 0 deletions Sources/ACKLocalizationCore/Model/CustomKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// CustomKey.swift
//
//
// Created by Lukáš Hromadník on 07/07/2020.
//

import Foundation

struct CustomKey: CodingKey {
var stringValue: String
var intValue: Int? { nil }

init(stringValue: String) {
self.stringValue = stringValue
}

init?(intValue: Int) { nil }
}
14 changes: 14 additions & 0 deletions Sources/ACKLocalizationCore/Model/PluralError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// File.swift
//
//
// Created by Lukáš Hromadník on 24/08/2020.
//

import Foundation

public enum PluralError: Error, Equatable {
case missingTranslationKey(String)
case missingPluralRule(String)
case invalidPluralRule(String)
}
14 changes: 14 additions & 0 deletions Sources/ACKLocalizationCore/Model/PluralRule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// PluralRule.swift
//
//
// Created by Lukáš Hromadník on 07/07/2020.
//

import Foundation

/// Encapsulates pair of plural rule key and the given translation for the given rule key
struct PluralRule: Codable {
let key: PluralRuleKey
let value: String
}
20 changes: 20 additions & 0 deletions Sources/ACKLocalizationCore/Model/PluralRuleKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// PluralRuleKey.swift
//
//
// Created by Lukáš Hromadník on 07/07/2020.
//

import Foundation

/// Enumeration of all possible plural rule keys
///
/// We can check during the generation if translations in the sheet are correct
enum PluralRuleKey: String, Codable {
case zero
case one
case two
case few
case many
case other
}
41 changes: 41 additions & 0 deletions Sources/ACKLocalizationCore/Model/PluralRuleWrapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// PluralRuleWrapper.swift
//
//
// Created by Lukáš Hromadník on 07/07/2020.
//

import Foundation

/// Custom wrapper around plural rule to have nice way how to create the stringsDict
/// without a need to create dynamic dictionaries
struct PluralRuleWrapper {
/// Array of plural rules (plural rule = one | zero | two ...)
let translations: [PluralRule]
}

extension PluralRuleWrapper: Codable {
func encode(to encoder: Encoder) throws {
// Since the stringDict is completely dynamic
// we cannot use some predefined `struct` to encapsulate the data.
// We need to use `CustomKey` that can take any string as a key.
var container = encoder.container(keyedBy: CustomKey.self)

var items = [
// Mandatory key and the only option
"NSStringFormatSpecTypeKey": "NSStringPluralRuleType",
"NSStringFormatValueTypeKey": "d"
]

translations.forEach {
items[$0.key.rawValue] = $0.value
}

// StringsDicts use variables by default.
// We need to specify the variable that is then
// replaced by the plural rule.
// In this case the variable is `inner` and it's hardcoded.
try container.encode("%#@inner@", forKey: .init(stringValue: "NSStringLocalizedFormatKey"))
try container.encode(items, forKey: .init(stringValue: "inner"))
}
}
Loading