Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to define location for all generated files #34

Merged
merged 7 commits into from
Oct 6, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
podspec:
name: Podspec
runs-on: macos-latest
runs-on: macos-12
steps:
- uses: actions/checkout@v2
- name: Install Bundler dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
jobs:
deploy:
name: Deploy
runs-on: macos-latest
runs-on: macos-12
steps:
- uses: actions/checkout@v2
- uses: AckeeCZ/load-xcode-version@1.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on: [pull_request, push]
jobs:
tests:
name: Run tests
runs-on: macos-latest
runs-on: macos-12
steps:
- uses: actions/checkout@v2
- uses: AckeeCZ/load-xcode-version@1.0
Expand Down
2 changes: 1 addition & 1 deletion .github/xcode-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
12.4
14.0.1
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

## master

## Added
- Add support to define destination for all files that are generated ([#34](https://github.com/AckeeCZ/ACKLocalization/pull/33), kudos to @leinhauplk)

## 1.4.0

### Added
Expand Down
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,30 +62,32 @@ The file is named `localization.json`. This is how example file looks like, you

```json
{
"destinationDir": "Resources",
"defaultFileName": "Localizable",
"destinations": {
"Localizable": "Framework/Resources",
"InfoPlist": "App/Resources"
},
"keyColumnName": "iOS",
"languageMapping": {
"CZ": "cs"
},
"serviceAccount": "Resources/ServiceAccount.json",
"spreadsheetID": "<GOOGLE_SPREADSHEET_ID>",
"stringsFileName": "Localizable.strings",
"spreadsheetTabName": "Localizations"
}
```

Attributes documentation:

| Name | Required | Note |
| ---- | -------- | ---- |
| `destinationDir` | ✅ | Path to destination directory where generated strings files should be saved |
| `defaultFileName` | ✅ | Name of default strings(dict) file |
| `destinations` | ✅ | Dictionary of destinations for all generated files (at least entry with `defaultFileName` is required), if you use `plist.<filename>.` prefix in your sheet, you might wanna add those entries as well, otherwise they will be generated alongside the default file |
| `keyColumnName` | ✅ | Name of column that contains keys to be localized |
| `languageMapping` | ✅ | Mapping of language column names to app languages, you specify how columns in spreasheet should be mapped to app languages |
| `apiKey` | ❌ | API key that will be used to communicate with Google Sheets API, `apiKey` or `serviceAccount` has to be provided |
| `serviceAccount` | ❌ | Path to service account file that will be used to access spreadsheet, `apiKey` or `serviceAccount` has to be provided |
| `spreadsheetID` | ✅ | Identifier of spreadsheet that should be downloaded |
| `spreadsheetTabName` | ❌ | Name of spreadsheet tab to be fetched, if nothing is specified, we will use the first tab in spreadsheet |
| `stringsFileName` | ❌ | Name of strings file that should be generated |

The file has to be in the same directory where you call ACKLocalization.

Expand Down Expand Up @@ -120,9 +122,15 @@ This is example folder structure of the project
|-localization.json
|-Podfile
|-Project.xcodeproj
|-Project
|-ServiceAccount.json
|-App
|---Resources
|------en.lproj
|----------InfoPlist.strings
|------cs.lproj
|----------InfoPlist.strings
|-Framework
|---Resources
|------ServiceAccount.json
|------en.lproj
|----------Localizable.strings
|----------Localizable.stringsDict
Expand All @@ -138,21 +146,25 @@ This is example structure of the spreadsheet with translations
| key_ios | EN | CS |
|---------|-------|------|
| hello | Hello | Ahoj |
| plist.InfoPlist.NSCameraUsageDescription | Cammera usage description | Popis využití kamery |

#### Example config file for this case would be

This is the example config file:
```json
{
"destinationDir": "Resources",
"defaultFileName": "Localizable",
"destinations": {
"Localizable": "Framework/Resources",
"InfoPlist": "App/Resources"
},
"keyColumnName": "key_ios",
"languageMapping": {
"CS": "cs",
"EN": "en"
},
"serviceAccount": "Resources/ServiceAccount.json",
"spreadsheetID": "<GOOGLE_SPREADSHEET_ID>",
"stringsFileName": "Localizable.strings",
"spreadsheetTabName": "Localizations"
}
```
Expand Down
178 changes: 127 additions & 51 deletions Sources/ACKLocalizationCore/ACKLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,68 +192,113 @@ public final class ACKLocalization {
}

/// Saves given `mappedValues` to correct directory file
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 {
// 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]]()
public func saveMappedValues(
_ mappedValues: MappedValues,
defaultFileName: String,
destinations: [String: String]
) throws {
struct RowsPerFile {
let language: String
let fileName: String
let rows: [LocRow]
}

let defaultFileName = defaultFileName.removingSuffix(".strings")
.removingSuffix(".stringsdict")
let rowsPerFile = mappedValues.flatMap { langCode, rows in
let fileGroups = [String: [LocRow]](grouping: rows) { row in
let keyComponents = row.key.components(separatedBy: ".")

rows.filter { $0.key.hasPrefix(Constants.plistKeyPrefix + ".") }.forEach { row in
// key format for this type of entry is `plist.<plist_file_name>.<key>`
let components = row.key.components(separatedBy: ".")

guard components.count > 2 else { return }

let plistName = components[1]
var rows = plistOutputs[plistName] ?? []
rows.append(LocRow(key: components[2...].joined(separator: "."), value: row.value))
plistOutputs[plistName] = rows
guard
row.key.hasPrefix(Constants.plistKeyPrefix + "."),
keyComponents.count > 2
else {
return defaultFileName
}

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

if plurals.count > 0 {
// 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)
return keyComponents[1]
}

return fileGroups.map { fileName, rows in
RowsPerFile(
language: langCode,
fileName: fileName,
rows: rows.map { row in
let keyComponents = row.key.components(separatedBy: ".")

if row.key.hasPrefix(Constants.plistKeyPrefix + "."),
keyComponents.count > 2 {
return LocRow(
key: keyComponents[2...].joined(separator: "."),
value: row.value
)
}

return row
}
)
}
}

let defaultDestination = destinations[defaultFileName]

if defaultDestination == nil {
warn("No destination for default strings file '\(defaultFileName)'")
warn("This means that all keys in localization sheet need to have file specified (using `plist.<filename>.` prefix) an all such files need to have its path defined in `destinations` dictionary")
olejnjak marked this conversation as resolved.
Show resolved Hide resolved
}

try rowsPerFile.forEach { fileRows in
guard let path = destinations[fileRows.fileName] ?? defaultDestination else {
warn("No destination path found for '\(fileRows.fileName)' strings file")
return
}

let dirPath = ((path as NSString).expandingTildeInPath as NSString)
.appendingPathComponent(fileRows.language + ".lproj")

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

// Collection of plural rules for a given translation key.
// Translation key is the base without the suffix ##{plural-rule}
let plurals = try buildPlurals(from: fileRows.rows)
let nonPlurals = fileRows.rows.filter { !$0.isPlural }

if !nonPlurals.isEmpty {
let stringsPath = (dirPath as NSString)
.appendingPathComponent(fileRows.fileName + ".strings")
try writeRows(nonPlurals, to: stringsPath)
}

if !plurals.isEmpty {
let stringsDictPath = (dirPath as NSString)
.appendingPathComponent(fileRows.fileName + ".stringsdict")
// 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: stringsDictPath))
}
}
}

/// Saves given `mappedValues` to correct directory file
public func saveMappedValuesPublisher(_ mappedValues: MappedValues, directory: String, stringsFileName: String, stringsDictFileName: String) -> AnyPublisher<Void, LocalizationError> {
public func saveMappedValuesPublisher(
_ mappedValues: MappedValues,
defaultFileName: String,
destinations: [String: 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, stringsDictFileName: stringsDictFileName)
try self.saveMappedValues(
mappedValues,
defaultFileName: defaultFileName,
destinations: destinations
)
promise(.success(()))
} catch {
switch error {
Expand All @@ -265,8 +310,15 @@ 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", stringsDictFileName: config.stringsDictFileName ?? "Localizable.stringsdict")
public func saveMappedValuesPublisher(
_ mappedValues: MappedValues,
config: Configuration
) -> AnyPublisher<Void, LocalizationError> {
saveMappedValuesPublisher(
mappedValues,
defaultFileName: config.defaultFileName,
destinations: config.destinations
)
}

/// Fetches sheet values from given `config`
Expand Down Expand Up @@ -316,10 +368,19 @@ public final class ACKLocalization {
throw LocalizationError(message: "Unable to find `localization.json` config file. Does it exist in current directory?")
}

let decoder = JSONDecoder()

do {
return try JSONDecoder().decode(Configuration.self, from: configData)
return try decoder.decode(Configuration.self, from: configData)
} catch {
throw LocalizationError(message: "Unable to read `localization.json` - " + error.localizedDescription)
// For backwards compatibility we will try to decode old version of Configuration
// if that fails we will throw error with original message as we wanna encourage
// usage of latest Configuration version
if let v1Config = try? decoder.decode(ConfigurationV1.self, from: configData) {
return Configuration(v1Config: v1Config)
} else {
throw LocalizationError(message: "Unable to read `localization.json` - " + error.localizedDescription)
}
}
}

Expand Down Expand Up @@ -371,3 +432,18 @@ public final class ACKLocalization {
}
}
}

extension String {
func removingSuffix(_ suffix: String) -> String {
guard hasSuffix(suffix) else { return self }

return String(self[...index(endIndex, offsetBy: -suffix.count)])
}
}

/// Prints warning
///
/// Simple solution for now, later on we might wanna use something bit more robust and testable
func warn(_ message: String) {
print("⚠️", message)
}
Loading