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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ POEditor-Parser is available through [Mint](https://github.com/yonaskolb/Mint)

To install it, simply add the following line to your `Mintfile`:
```ruby
hyperdevs-team/poeditor-parser-swift@2.1.0
hyperdevs-team/poeditor-parser-swift@2.2.0
```

## Usage
Expand All @@ -32,6 +32,8 @@ hyperdevs-team/poeditor-parser-swift@2.1.0
* `--tablename` - The tableName value for NSLocalizedString
* `--outputformat` [default: Struct] - The output format for swift file (enum or struct)
* `--keysformat` [default: UpperCamelCase] - The format for the localized key
* `--format` [default: strings] - The translation file format to download and generate (`strings` or `xcstrings`). With `xcstrings` the file at `--stringsfile` is written with a `.xcstrings` extension, containing the full downloaded String Catalog (all languages). The generated `.swift` file keeps the same format regardless of this option.
* `--exportall` [flag, default: off] - Download all languages at once (POEditor `options=[{"export_all": 1}]`). Pass it without a value (e.g. `--exportall`) to enable it; omit it to keep the default (single language). Combine it with `--format xcstrings` to get every language in a single `.xcstrings` file. Does not have any effect when `--format` is `strings`.

Run poe help for more info

Expand All @@ -42,6 +44,7 @@ Run poe help for more info
* **[Jorge Revuelta](https://github.com/minuscorp)**
* **[Sebastián Varela](https://github.com/sebastianvarela)**
* **[David Martínez García](https://github.com/daviwiki)**
* **[Adrián Ruiz Lafuente](https://github.com/adrianrl)**

## License

Expand Down
2 changes: 1 addition & 1 deletion Sources/PoEditorParser/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ enum POEConstants {

"""

static let version = "2.1.0"
static let version = "2.2.0"

static func literalsStructHeader(name: String) -> String { "public struct \(name) {\n" }
static func literalsStructStaticTableName(name: String?) -> String {
Expand Down
66 changes: 51 additions & 15 deletions Sources/PoEditorParser/Program.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,23 @@ public class Program {
tableName: String?,
outputFormat: OutputFormat,
keysFormat: KeysFormat,
format: TranslationFormat,
exportAll: Bool,
poEditorApiUrl: String
) throws {
do {
// For xcstrings the translations file lives at a `.xcstrings` path
// (defaulting to the stringsFile path with the extension swapped).
let translationsFile = format == .xcstrings
? (stringsFile as NSString).deletingPathExtension + ".xcstrings"
: stringsFile

print("🚀 Starting PoEditor Parser v\(POEConstants.version)".blue)
print("- Only Generate: \(onlyGenerate)".white)
print("- Translation format: \(format)".white)
print("- Export all languages: \(exportAll)".white)
print("- Generating Swift: \(swiftFile)".white)
print("- Generating .strings: \(stringsFile)".white)
print("- Generating translations: \(translationsFile)".white)
print("- Type name: \(typeName)".white)
print("- Table name: \(tableName ?? "NOT SET")".white)
print("- Output format: \(outputFormat)".white)
Expand All @@ -38,11 +48,17 @@ public class Program {
print("🔄 Querying POEditor for the latest strings file...".magenta)
var request = URLRequest(url: URL(string: "\(poEditorApiUrl)/projects/export")!)
request.httpMethod = "POST"
let parameters = ""
var parameters = ""
+ "api_token=\(token)&"
+ "id=\(id)&"
+ "language=\(language)&"
+ "type=apple_strings"
+ "type=\(format.apiType)"

if exportAll {
let options = "[{\"export_all\": 1}]"
.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
parameters += "&options=\(options)"
}
request.httpBody = parameters.data(using: .utf8)
let data = try URLSession.shared.syncDataTask(with: request)
guard
Expand All @@ -66,21 +82,31 @@ public class Program {
translationStringContent = translationString
} else {
print("✅ Fetching content from passed strings path".green)
print("ℹ️ Reading content from: \(stringsFile)".green)
guard let data = FileManager.default.contents(atPath: stringsFile) else {
throw AppError.fileNotFound(file: stringsFile)
print("ℹ️ Reading content from: \(translationsFile)".green)
guard let data = FileManager.default.contents(atPath: translationsFile) else {
throw AppError.fileNotFound(file: translationsFile)
}
guard let content = String(data: data, encoding: .utf8) else {
throw AppError.fileOpenError(file: stringsFile)
throw AppError.fileOpenError(file: translationsFile)
}

translationStringContent = content
}

print("ℹ️ Parsing strings file...".blue)
let parser = StringTranslationParser(typeName: typeName,
print("ℹ️ Parsing translations file...".blue)
let parser: TranslationParser
switch format {
case .strings:
parser = StringTranslationParser(typeName: typeName,
translation: translationStringContent,
keysFormat: keysFormat)

case .xcstrings:
parser = XCStringsTranslationParser(typeName: typeName,
translation: translationStringContent,
keysFormat: keysFormat,
preferredLanguage: language)
}
let translations = try parser.parse().sorted()

FileManager.default.createFile(atPath: swiftFile, contents: nil, attributes: nil)
Expand All @@ -94,13 +120,23 @@ public class Program {
fileCodeGenerator.generateCode(translations: translations)
print("✅ Success! Literals generated at \(swiftFile)".green)

FileManager.default.createFile(atPath: stringsFile, contents: nil, attributes: nil)
guard let stringsHandle = FileHandle(forWritingAtPath: stringsFile) else {
throw AppError.writeFileError(file: stringsFile)
switch format {
case .strings:
FileManager.default.createFile(atPath: translationsFile, contents: nil, attributes: nil)
guard let stringsHandle = FileHandle(forWritingAtPath: translationsFile) else {
throw AppError.writeFileError(file: translationsFile)
}
let stringsFileGenerator = StringsFileGenerator(fileHandle: stringsHandle)
stringsFileGenerator.generateCode(translations: translations)
print("✅ Success! Strings generated at \(translationsFile)".green)

case .xcstrings:
if !onlyGenerate {
let normalized = XCStringsTranslationParser.normalizingPlaceholders(in: translationStringContent)
try normalized.write(toFile: translationsFile, atomically: true, encoding: .utf8)
}
print("✅ Success! String catalog written at \(translationsFile)".green)
}
let stringsFileGenerator = StringsFileGenerator(fileHandle: stringsHandle)
stringsFileGenerator.generateCode(translations: translations)
print("✅ Success! Strings generated at \(stringsFile)".green)
} catch let error {
print("❌ [ERROR] \(error.localizedDescription)".red)
throw error
Expand Down
33 changes: 33 additions & 0 deletions Sources/PoEditorParser/TranslationFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Commander
import Foundation

public enum TranslationFormat: String, ArgumentConvertible {
case strings
case xcstrings

public var apiType: String {
switch self {
case .strings:
return "apple_strings"

case .xcstrings:
return "xcstrings"
}
}

public init(parser: Commander.ArgumentParser) throws {
guard let value = parser.shift() else {
throw ArgumentError.missingValue(argument: nil)
}

guard let format = TranslationFormat(rawValue: value) else {
throw ArgumentError.invalidType(value: value, type: "translation format", argument: nil)
}

self = format
}

public var description: String {
rawValue
}
}
86 changes: 86 additions & 0 deletions Sources/PoEditorParser/XCStringsTranslationParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

/// Parses an Apple String Catalog (`.xcstrings`) file into `[Translation]`.
///
/// The `.swift` output only needs the key and one value per term (placeholders
/// like `{{var}}` are identical across languages), so we take the value from the
/// preferred language, falling back to the source language, then any available.
public class XCStringsTranslationParser: TranslationParser {
let typeName: String
let translation: String
let keysFormat: KeysFormat
let preferredLanguage: String?

public init(typeName: String, translation: String, keysFormat: KeysFormat, preferredLanguage: String?) {
self.typeName = typeName
self.translation = translation
self.keysFormat = keysFormat
self.preferredLanguage = preferredLanguage
}

public func parse() throws -> [Translation] {
guard
let data = translation.data(using: .utf8),
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let strings = json["strings"] as? [String: Any]
else {
throw AppError.apiDownloadTermsError
}

let sourceLanguage = json["sourceLanguage"] as? String

return try strings.compactMap { key, entry -> Translation? in
guard
let entry = entry as? [String: Any],
let localizations = entry["localizations"] as? [String: Any]
else {
// Key with no translations yet: emit an empty value so it still
// appears in the generated Swift literals.
return try Translation(typeName: typeName, key: key, rawValue: "", keysFormat: keysFormat)
}

let value = value(from: localizations, sourceLanguage: sourceLanguage) ?? ""
return try Translation(typeName: typeName, key: key, rawValue: value, keysFormat: keysFormat)
}
}

/// Converts placeholder-marked variables like `{1{variable}}` (used by other
/// platforms that share this POEditor project) into the plain `{{variable}}`
/// format iOS expects, mirroring what `TranslationValueParser` does for the
/// `.strings` output. Runs over the whole catalog so every language is
/// normalized in a single pass, without re-serializing the JSON.
public static func normalizingPlaceholders(in catalog: String) -> String {
// {optional-order-number{ name }}
let pattern = "\\{[0-9]*\\{([^{}]+)\\}\\}"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return catalog }
let ns = catalog as NSString
let matches = regex.matches(in: catalog, range: NSRange(location: 0, length: ns.length))
guard !matches.isEmpty else { return catalog }

// Single forward pass: append the text between matches plus the rewritten
// variable. O(n) overall, versus the quadratic cost of per-match index
// conversions + replaceSubrange (which is what made --exportall slow).
var result = ""
result.reserveCapacity(ns.length)
var cursor = 0
for match in matches {
result += ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor))
let parameterKey = Variable(rawKey: ns.substring(with: match.range(at: 1))).parameterKey
result += "{{\(parameterKey)}}"
cursor = match.range.location + match.range.length
}
result += ns.substring(from: cursor)
return result
}

private func value(from localizations: [String: Any], sourceLanguage: String?) -> String? {
let ordered = [preferredLanguage, sourceLanguage].compactMap { $0 } + Array(localizations.keys)
for language in ordered {
if let stringUnit = (localizations[language] as? [String: Any])?["stringUnit"] as? [String: Any],
let value = stringUnit["value"] as? String {
return value
}
}
return nil
}
}
8 changes: 7 additions & 1 deletion Sources/PoEditorParserCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ private func processingClosure(
typeName: String,
tableName: String?,
outputFormat: OutputFormat,
keysFormat: KeysFormat
keysFormat: KeysFormat,
format: TranslationFormat,
exportAll: Bool
) throws {
let program = Program()
try program.run(
Expand All @@ -29,6 +31,8 @@ private func processingClosure(
tableName: tableName,
outputFormat: outputFormat,
keysFormat: keysFormat,
format: format,
exportAll: exportAll,
poEditorApiUrl: POEditorAPIURL
)
}
Expand All @@ -44,5 +48,7 @@ command(
Option<String?>("tablename", default: nil, description: "The tableName value for NSLocalizedString"),
Option<OutputFormat>("outputformat", default: .struct, description: "The output format for swift file (enum or struct)"),
Option<KeysFormat>("keysformat", default: .upperCamelCase, description: "The format for the localized key"),
Option<TranslationFormat>("format", default: .strings, description: "The translation file format to download and generate (strings or xcstrings)"),
Flag("exportall", default: false, description: "Download all languages at once (POEditor options=[{\"export_all\": 1}])"),
processingClosure
).run()
78 changes: 78 additions & 0 deletions Tests/PoEditorParserTests/XCStringsTranslationParserTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
@testable import PoEditorParser
import Testing

private let sampleCatalog = """
{
"sourceLanguage" : "en",
"strings" : {
"welcome_message" : {
"localizations" : {
"es" : { "stringUnit" : { "state" : "translated", "value" : "Hola {{name}}!" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Hello {{name}}!" } }
}
},
"plain_key" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Just text" } }
}
},
"no_translations" : {}
},
"version" : "1.0"
}
"""

@Test
func testXCStringsParsesKeysAndPrefersLanguage() throws {
let parser = XCStringsTranslationParser(
typeName: "Literals",
translation: sampleCatalog,
keysFormat: .upperCamelCase,
preferredLanguage: "es"
)

let translations = try parser.parse().sorted()

#expect(translations.map { $0.key } == ["no_translations", "plain_key", "welcome_message"])
let welcome = try #require(translations.first { $0.key == "welcome_message" })
#expect(welcome.value == "Hola {{name}}!")
#expect(welcome.hasVariables)
}

@Test
func testXCStringsFallsBackToSourceLanguage() throws {
let parser = XCStringsTranslationParser(
typeName: "Literals",
translation: sampleCatalog,
keysFormat: .upperCamelCase,
preferredLanguage: "fr" // not present -> falls back to sourceLanguage "en"
)

let translations = try parser.parse()
let welcome = try #require(translations.first { $0.key == "welcome_message" })
#expect(welcome.value == "Hello {{name}}!")
}

@Test
func testXCStringsNormalizesPlaceholderVariables() throws {
let catalog = """
{ "value" : "Hola {1{name}}, tienes {2{item_count}} y {{plain}}" }
"""
let normalized = XCStringsTranslationParser.normalizingPlaceholders(in: catalog)
#expect(normalized == "{ \"value\" : \"Hola {{name}}, tienes {{item_count}} y {{plain}}\" }")
}

@Test
func testXCStringsInvalidJSONThrows() throws {
let parser = XCStringsTranslationParser(
typeName: "Literals",
translation: "not json",
keysFormat: .upperCamelCase,
preferredLanguage: nil
)

#expect(throws: AppError.self) {
_ = try parser.parse()
}
}
Binary file modified bin/poe
Binary file not shown.