From 4549dbcd73671b0739fa8de010ac564a65347dd6 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 11:22:58 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Prepare=20new=20version=20of=20?= =?UTF-8?q?`Configuration`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/Configuration.swift | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/Sources/ACKLocalizationCore/Model/Configuration.swift b/Sources/ACKLocalizationCore/Model/Configuration.swift index b025050..919b9f7 100644 --- a/Sources/ACKLocalizationCore/Model/Configuration.swift +++ b/Sources/ACKLocalizationCore/Model/Configuration.swift @@ -10,7 +10,9 @@ import Foundation public typealias LanguageMapping = [String: String] /// Object representing app parameters -public struct Configuration: Decodable { +/// +/// Old variant, will be removed in future versions +public struct ConfigurationV1: Decodable { /// API key that will be used to comunicate with Google Sheets API /// /// Either `apiKey` or `serviceAccount` must be provided, if both are provided, then `serviceAccount` will be used @@ -57,3 +59,75 @@ public struct Configuration: Decodable { self.stringsDictFileName = stringsDictFileName } } + +public typealias Configuration = ConfigurationV2 + +/// Object representing app parameters +public struct ConfigurationV2: Decodable { + /// API key that will be used to comunicate with Google Sheets API + /// + /// Either `apiKey` or `serviceAccount` must be provided, if both are provided, then `serviceAccount` will be used + public let apiKey: APIKey? + + /// Path to destination directory where generated strings files should be saved + public let destinations: [String: String] + + /// Name of column that contains keys to be localized + public let keyColumnName: String + + /// Mapping of language column names to app languages + public let languageMapping: LanguageMapping + + /// Path to service account file that will be used to access spreadsheet + /// + /// Either `apiKey` or `serviceAccount` must be provided, if both are provided, then `serviceAccount` will be used + public let serviceAccount: String? + + /// Identifier of spreadsheet that should be downloaded + public let spreadsheetID: String + + /// Name of spreadsheet tab to be fetched + /// + /// If nothing is specified, we will use the first tab in spreadsheet + public let spreadsheetTabName: String? + + /// Name of default strings file that should be generated + public let defaultFileName: String + + public init( + apiKey: APIKey?, + destinations: [String: String], + keyColumnName: String, + languageMapping: LanguageMapping, + serviceAccount: String?, + spreadsheetID: String, + spreadsheetTabName: String?, + defaultFileName: String + ) { + self.apiKey = apiKey + self.destinations = destinations + self.keyColumnName = keyColumnName + self.languageMapping = languageMapping + self.serviceAccount = serviceAccount + self.spreadsheetID = spreadsheetID + self.spreadsheetTabName = spreadsheetTabName + self.defaultFileName = defaultFileName + } + + init(v1Config configuration: ConfigurationV1) { + apiKey = configuration.apiKey + keyColumnName = configuration.keyColumnName + languageMapping = configuration.languageMapping + serviceAccount = configuration.serviceAccount + spreadsheetID = configuration.spreadsheetID + spreadsheetTabName = configuration.spreadsheetTabName + defaultFileName = configuration.stringsFileName?.removingSuffix(".strings") ?? "Localizable" + destinations = [ + defaultFileName: configuration.destinationDir + ] + + if configuration.stringsDictFileName != nil { + warn("Usage of `stringsDictFileName` has no further effect now") + } + } +} From b7a55a2ae717318e8fed2bdee665b316eda4f463 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 11:23:31 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Update=20generation=20process?= =?UTF-8?q?=20for=20new=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ACKLocalizationCore/ACKLocalization.swift | 177 +++++++++++++----- .../ACKLocalizationCore/Model/LocRow.swift | 5 + 2 files changed, 131 insertions(+), 51 deletions(-) diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index e158d42..3397ef2 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -192,60 +192,100 @@ 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..` - 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..` prefix) an all such files need to have its path defined in `destinations` dictionary") + } + + 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) + + let stringsPath = (dirPath as NSString) + .appendingPathComponent(fileRows.fileName + ".strings") + + // 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 } + + 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 { + public func saveMappedValuesPublisher( + _ mappedValues: MappedValues, + defaultFileName: String, + destinations: [String: String] + ) -> AnyPublisher { Future { [weak self] promise in guard let self = self else { promise(.failure(LocalizationError(message: "Unable to save mapped values"))) @@ -253,7 +293,11 @@ public final class ACKLocalization { } do { - try self.saveMappedValues(mappedValues, directory: directory, stringsFileName: stringsFileName, stringsDictFileName: stringsDictFileName) + try self.saveMappedValues( + mappedValues, + defaultFileName: defaultFileName, + destinations: destinations + ) promise(.success(())) } catch { switch error { @@ -265,8 +309,15 @@ public final class ACKLocalization { } /// Saves given `mappedValues` to correct directory file - public func saveMappedValuesPublisher(_ mappedValues: MappedValues, config: Configuration) -> AnyPublisher { - saveMappedValuesPublisher(mappedValues, directory: config.destinationDir, stringsFileName: config.stringsFileName ?? "Localizable.strings", stringsDictFileName: config.stringsDictFileName ?? "Localizable.stringsdict") + public func saveMappedValuesPublisher( + _ mappedValues: MappedValues, + config: Configuration + ) -> AnyPublisher { + saveMappedValuesPublisher( + mappedValues, + defaultFileName: config.defaultFileName, + destinations: config.destinations + ) } /// Fetches sheet values from given `config` @@ -316,10 +367,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) + } } } @@ -371,3 +431,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) +} diff --git a/Sources/ACKLocalizationCore/Model/LocRow.swift b/Sources/ACKLocalizationCore/Model/LocRow.swift index 988a253..1418cbc 100644 --- a/Sources/ACKLocalizationCore/Model/LocRow.swift +++ b/Sources/ACKLocalizationCore/Model/LocRow.swift @@ -18,6 +18,11 @@ public struct LocRow: Equatable { /// Representation that can be used as row in strings file public var localizableRow: String { return "\"" + normalizedKey + "\" = \"" + normalizedValue + "\";" } + /// Checks if key contains plural pattern + public var isPlural: Bool { + key.range(of: Constants.pluralPattern, options: .regularExpression) != nil + } + // MARK: - Initializers public init(key: String, value: String) { From 54c31b9cac215789b4105fb13f35fce095fb2e48 Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 11:58:34 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=93=9D=20Add=20new=20config=20structu?= =?UTF-8?q?re=20to=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1f459a6..27dfce4 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,17 @@ 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": "", - "stringsFileName": "Localizable.strings", - "spreadsheetTabName": "Localizations" } ``` @@ -78,14 +80,14 @@ 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..` 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. @@ -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 @@ -138,13 +146,18 @@ 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", @@ -152,7 +165,6 @@ This is the example config file: }, "serviceAccount": "Resources/ServiceAccount.json", "spreadsheetID": "", - "stringsFileName": "Localizable.strings", "spreadsheetTabName": "Localizations" } ``` From acc27fc1a983407ee96dbab22b140fd3b15fd49c Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 12:02:43 +0100 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=93=9D=20Update=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0daabf..c338458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From a214ad100478cc7c1517d4d4b26096908d20ffdb Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 12:17:08 +0100 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20Do=20not=20generate=20strings?= =?UTF-8?q?=20file=20if=20there=20are=20only=20plurals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ACKLocalizationCore/ACKLocalization.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 3397ef2..656437e 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -258,15 +258,16 @@ public final class ACKLocalization { try? FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true) - let stringsPath = (dirPath as NSString) - .appendingPathComponent(fileRows.fileName + ".strings") - // 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 } - try writeRows(nonPlurals, to: stringsPath) + 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) From fccd0fbb524b137ae49c39ae92ad2e2df2e80aeb Mon Sep 17 00:00:00 2001 From: Jakub Olejnik Date: Wed, 5 Oct 2022 12:23:38 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=A7=20Use=20Xcode=2014=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 2 +- .github/workflows/deploy.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/xcode-version | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c2cb2be..b4f2d90 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d039bfc..8baefc7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9f3949..d3d2048 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.github/xcode-version b/.github/xcode-version index 800fd35..8d2e58b 100644 --- a/.github/xcode-version +++ b/.github/xcode-version @@ -1 +1 @@ -12.4 +14.0.1 \ No newline at end of file From 3913a86c804826533f9be10bf3a5b2140f422418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Olejn=C3=ADk?= Date: Thu, 6 Oct 2022 10:19:31 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=91=8C=20Fix=20warning=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Igor Rosocha --- Sources/ACKLocalizationCore/ACKLocalization.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 656437e..0f6b391 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -244,7 +244,7 @@ public final class ACKLocalization { 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..` prefix) an all such files need to have its path defined in `destinations` dictionary") + warn("This means that all keys in localization sheet need to have file specified (using `plist..` prefix) and all such files need to have its path defined in `destinations` dictionary") } try rowsPerFile.forEach { fileRows in