Skip to content

Commit 270e02d

Browse files
authored
Merge pull request #1 from duyduong/support-plural
Support plural form in stringsdict
2 parents 36f28e2 + 555e9c4 commit 270e02d

File tree

6 files changed

+226
-44
lines changed

6 files changed

+226
-44
lines changed
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
enum LocalizationValue: Equatable {
2+
case simple(String)
3+
case plural([String: String])
4+
}
5+
16
struct StringsData: Equatable {
27
var tableName: String
38
var language: String
4-
var values: [String: String]
9+
var values: [String: LocalizationValue]
510
}

Sources/XCStringsMigrator/XCStrings.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,29 @@ struct Strings: Codable, Equatable {
1111
}
1212

1313
struct Localization: Codable, Equatable {
14+
var stringUnit: StringUnit?
15+
var variations: Variations?
16+
17+
init(stringUnit: StringUnit? = nil, variations: Variations? = nil) {
18+
self.stringUnit = stringUnit
19+
self.variations = variations
20+
}
21+
}
22+
23+
struct Variations: Codable, Equatable {
24+
var plural: [String: PluralVariation]?
25+
26+
init(plural: [String: PluralVariation]? = nil) {
27+
self.plural = plural
28+
}
29+
}
30+
31+
struct PluralVariation: Codable, Equatable {
1432
var stringUnit: StringUnit
33+
34+
init(stringUnit: StringUnit) {
35+
self.stringUnit = stringUnit
36+
}
1537
}
1638

1739
struct StringUnit: Codable, Equatable {

Sources/XCStringsMigrator/XMMigrator.swift

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,53 @@ public struct XMMigrator {
4545
}
4646
return dictionary
4747
}
48+
49+
func extractStringsDictValue(from url: URL) -> [String: [String: String]]? {
50+
guard let data = try? Data(contentsOf: url),
51+
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
52+
let dictionary = plist as? [String: [String: Any]] else {
53+
return nil
54+
}
55+
56+
return dictionary.compactMapValues { topLevelDict -> [String: String]? in
57+
// Extract the format key (e.g., "%#@format@")
58+
guard let formatKey = topLevelDict["NSStringLocalizedFormatKey"] as? String else {
59+
return nil
60+
}
61+
62+
// Extract variable name from formatKey (e.g., "%#@format@" -> "format")
63+
let variableName = extractVariableName(from: formatKey)
64+
guard !variableName.isEmpty else { return nil }
65+
66+
// Get the variable dictionary
67+
guard let variableDict = topLevelDict[variableName] as? [String: Any],
68+
let specType = variableDict["NSStringFormatSpecTypeKey"] as? String,
69+
specType == "NSStringPluralRuleType" else {
70+
return nil
71+
}
72+
73+
// Extract plural variations (zero, one, two, few, many, other)
74+
var pluralRules: [String: String] = [:]
75+
for key in ["zero", "one", "two", "few", "many", "other"] {
76+
if let value = variableDict[key] as? String {
77+
pluralRules[key] = value
78+
}
79+
}
80+
81+
return pluralRules.isEmpty ? nil : pluralRules
82+
}
83+
}
84+
85+
private func extractVariableName(from formatKey: String) -> String {
86+
// Extract variable from "%#@format@" -> "format"
87+
let pattern = "%#@([^@]+)@"
88+
guard let regex = try? NSRegularExpression(pattern: pattern),
89+
let match = regex.firstMatch(in: formatKey, range: NSRange(formatKey.startIndex..., in: formatKey)),
90+
let range = Range(match.range(at: 1), in: formatKey) else {
91+
return ""
92+
}
93+
return String(formatKey[range])
94+
}
4895

4996
func extractStringsData() throws -> [StringsData] {
5097
let fileManager = FileManager.default
@@ -60,16 +107,25 @@ public struct XMMigrator {
60107
let language = url.deletingPathExtension().lastPathComponent
61108
return contents
62109
.map { url.appending(component: $0) }
63-
.filter { $0.pathExtension == "strings" }
110+
.filter { $0.pathExtension == "strings" || $0.pathExtension == "stringsdict" }
64111
.map { StringsFile(language: language, url: $0) }
65112
}
66113
guard !stringsFiles.isEmpty else {
67114
throw XMError.stringsFilesNotFound
68115
}
69116
return stringsFiles.compactMap { stringsFile in
70-
guard let values = extractKeyValue(from: stringsFile.url) else { return nil }
71117
let tableName = stringsFile.url.deletingPathExtension().lastPathComponent
72-
return StringsData(tableName: tableName, language: stringsFile.language, values: values)
118+
let isStringsDict = stringsFile.url.pathExtension == "stringsdict"
119+
120+
if isStringsDict {
121+
guard let pluralValues = extractStringsDictValue(from: stringsFile.url) else { return nil }
122+
let values = pluralValues.mapValues { LocalizationValue.plural($0) }
123+
return StringsData(tableName: tableName, language: stringsFile.language, values: values)
124+
} else {
125+
guard let simpleValues = extractKeyValue(from: stringsFile.url) else { return nil }
126+
let values = simpleValues.mapValues { LocalizationValue.simple($0) }
127+
return StringsData(tableName: tableName, language: stringsFile.language, values: values)
128+
}
73129
}
74130
}
75131

@@ -86,8 +142,26 @@ public struct XMMigrator {
86142

87143
func convertToXCStrings(from array: [StringsData]) -> XCStrings {
88144
let strings = array.reduce(into: [String: Strings]()) { partialResult, stringsData in
89-
stringsData.values.forEach { stringKey, localizedValue in
90-
let localization = Localization(stringUnit: StringUnit(value: localizedValue))
145+
stringsData.values.forEach { stringKey, localizationValue in
146+
let localization: Localization
147+
148+
switch localizationValue {
149+
case .simple(let value):
150+
localization = Localization(
151+
stringUnit: StringUnit(value: value),
152+
variations: nil
153+
)
154+
155+
case .plural(let pluralRules):
156+
let pluralVariations = pluralRules.mapValues { value in
157+
PluralVariation(stringUnit: StringUnit(value: value))
158+
}
159+
localization = Localization(
160+
stringUnit: nil,
161+
variations: Variations(plural: pluralVariations)
162+
)
163+
}
164+
91165
if partialResult.keys.contains(stringKey) {
92166
partialResult[stringKey]?.localizations[stringsData.language] = localization
93167
} else {
@@ -120,6 +194,7 @@ public struct XMMigrator {
120194
try writeData(data, outputURL)
121195
standardOutput("Succeeded to export xcstrings files.")
122196
} catch {
197+
Swift.print("error:", error)
123198
throw XMError.failedToExportXCStringsFile
124199
}
125200
}

Sources/XCStringsMigrator/XMReverter.swift

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ public struct XMReverter {
2323

2424
public func run() throws {
2525
let xcstrings = try extractXCStrings()
26-
let array = convertToStringsData(from: xcstrings)
27-
try array.forEach { stringsData in
26+
let (stringsArray, stringsDictArray) = convertToStringsData(from: xcstrings)
27+
try stringsArray.forEach { stringsData in
2828
try exportStringsFile(stringsData)
2929
}
30+
try stringsDictArray.forEach { stringsData in
31+
try exportStringsDictFile(stringsData)
32+
}
3033
standardOutput("Completed.")
3134
}
3235

@@ -44,20 +47,42 @@ public struct XMReverter {
4447
}
4548
}
4649

47-
func convertToStringsData(from xcstrings: XCStrings) -> [StringsData] {
48-
xcstrings.strings.reduce(into: [StringsData]()) { partialResult, item in
49-
item.value.localizations.forEach { language, localization in
50-
if let index = partialResult.firstIndex(where: { $0.language == language }) {
51-
partialResult[index].values[item.key] = localization.stringUnit.value
52-
} else {
53-
partialResult.append(StringsData(
54-
tableName: "Localizable",
55-
language: language,
56-
values: [item.key : localization.stringUnit.value]
57-
))
50+
func convertToStringsData(from xcstrings: XCStrings) -> (strings: [StringsData], stringsdict: [StringsData]) {
51+
var stringsArray: [StringsData] = []
52+
var stringsDictArray: [StringsData] = []
53+
54+
xcstrings.strings.forEach { stringKey, strings in
55+
strings.localizations.forEach { language, localization in
56+
// Check if this is a plural variation or simple string
57+
if let variations = localization.variations,
58+
let pluralVariations = variations.plural {
59+
// Handle plural - goes to stringsdict
60+
let pluralValues = pluralVariations.mapValues { $0.stringUnit.value }
61+
if let index = stringsDictArray.firstIndex(where: { $0.language == language }) {
62+
stringsDictArray[index].values[stringKey] = .plural(pluralValues)
63+
} else {
64+
stringsDictArray.append(StringsData(
65+
tableName: "Localizable",
66+
language: language,
67+
values: [stringKey: .plural(pluralValues)]
68+
))
69+
}
70+
} else if let stringUnit = localization.stringUnit {
71+
// Handle simple string - goes to strings
72+
if let index = stringsArray.firstIndex(where: { $0.language == language }) {
73+
stringsArray[index].values[stringKey] = .simple(stringUnit.value)
74+
} else {
75+
stringsArray.append(StringsData(
76+
tableName: "Localizable",
77+
language: language,
78+
values: [stringKey: .simple(stringUnit.value)]
79+
))
80+
}
5881
}
5982
}
6083
}
84+
85+
return (stringsArray, stringsDictArray)
6186
}
6287

6388
func exportStringsFile(_ stringsData: StringsData) throws {
@@ -71,12 +96,66 @@ public struct XMReverter {
7196
.appendingPathExtension("strings")
7297
let text = stringsData.values
7398
.sorted(by: { $0.key < $1.key })
74-
.map { "\($0.key.debugDescription) = \($0.value.debugDescription);" }
99+
.compactMap { key, value -> String? in
100+
guard case .simple(let stringValue) = value else { return nil }
101+
return "\(key.debugDescription) = \(stringValue.debugDescription);"
102+
}
75103
.joined(separator: "\n")
76104
try writeString(text, outputFileURL)
77105
standardOutput("Succeeded to export strings file.")
78106
} catch {
79107
throw XMError.failedToExportStringsFile
80108
}
81109
}
110+
111+
func exportStringsDictFile(_ stringsData: StringsData) throws {
112+
do {
113+
let outputFolderURL = URL(filePath: outputPath)
114+
.appending(path: stringsData.language)
115+
.appendingPathExtension("lproj")
116+
try createDirectory(outputFolderURL)
117+
let outputFileURL = outputFolderURL
118+
.appending(path: stringsData.tableName)
119+
.appendingPathExtension("stringsdict")
120+
121+
// Build the plist dictionary
122+
var plistDict: [String: [String: Any]] = [:]
123+
124+
for (key, value) in stringsData.values.sorted(by: { $0.key < $1.key }) {
125+
guard case .plural(let pluralRules) = value else { continue }
126+
127+
// Create the variable name (use "format" as default)
128+
let variableName = "format"
129+
130+
// Build the variable dictionary
131+
var variableDict: [String: Any] = [
132+
"NSStringFormatSpecTypeKey": "NSStringPluralRuleType",
133+
"NSStringFormatValueTypeKey": "li"
134+
]
135+
136+
// Add plural rules
137+
for (pluralKey, pluralValue) in pluralRules {
138+
variableDict[pluralKey] = pluralValue
139+
}
140+
141+
// Build the top-level dictionary for this key
142+
plistDict[key] = [
143+
"NSStringLocalizedFormatKey": "%#@\(variableName)@",
144+
variableName: variableDict
145+
]
146+
}
147+
148+
// Convert to XML plist
149+
let plistData = try PropertyListSerialization.data(
150+
fromPropertyList: plistDict,
151+
format: .xml,
152+
options: 0
153+
)
154+
155+
try plistData.write(to: outputFileURL)
156+
standardOutput("Succeeded to export stringsdict file.")
157+
} catch {
158+
throw XMError.failedToExportStringsFile
159+
}
160+
}
82161
}

Tests/XCStringsMigratorTests/XMMigratorTests.swift

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ final class XMMigratorTests: XCTestCase {
6161
tableName: "Localizable",
6262
language: "full",
6363
values: [
64-
"\"Hello %@\"": "\"Hello %@\"",
65-
"Count = %lld": "Count = %lld",
66-
"key": "value",
67-
"path": "/",
64+
"\"Hello %@\"": .simple("\"Hello %@\""),
65+
"Count = %lld": .simple("Count = %lld"),
66+
"key": .simple("value"),
67+
"path": .simple("/"),
6868
]
6969
)
7070
]
@@ -104,20 +104,20 @@ final class XMMigratorTests: XCTestCase {
104104
tableName: "Module1",
105105
language: "en",
106106
values: [
107-
"\"Hello %@\"": "\"Hello %@\"",
108-
"Count = %lld": "Count = %lld",
109-
"language": "English",
110-
"path": "/",
107+
"\"Hello %@\"": .simple("\"Hello %@\""),
108+
"Count = %lld": .simple("Count = %lld"),
109+
"language": .simple("English"),
110+
"path": .simple("/"),
111111
]
112112
),
113113
StringsData(
114114
tableName: "Module1",
115115
language: "ja",
116116
values: [
117-
"\"Hello %@\"": "「こんにちは%@」",
118-
"Count = %lld": "カウント=%lld",
119-
"language": "日本語",
120-
"path": "/",
117+
"\"Hello %@\"": .simple("「こんにちは%@」"),
118+
"Count = %lld": .simple("カウント=%lld"),
119+
"language": .simple("日本語"),
120+
"path": .simple("/"),
121121
]
122122
),
123123
]

Tests/XCStringsMigratorTests/XMReverterTests.swift

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,29 @@ final class XMReverterTests: XCTestCase {
7676
],
7777
version: "1.0"
7878
)
79-
let actual = sut.convertToStringsData(from: input)
79+
let (actualStrings, actualStringsDict) = sut.convertToStringsData(from: input)
8080
let expect = [
8181
StringsData(
8282
tableName: "Localizable",
8383
language: "en",
8484
values: [
85-
"key1": "en_value_1",
86-
"key2": "en_value_2",
87-
"key3": "en_value_3",
85+
"key1": .simple("en_value_1"),
86+
"key2": .simple("en_value_2"),
87+
"key3": .simple("en_value_3"),
8888
]
8989
),
9090
StringsData(
9191
tableName: "Localizable",
9292
language: "ja",
9393
values: [
94-
"key1": "ja_value_1",
95-
"key2": "ja_value_2",
96-
"key3": "ja_value_3",
94+
"key1": .simple("ja_value_1"),
95+
"key2": .simple("ja_value_2"),
96+
"key3": .simple("ja_value_3"),
9797
]
9898
),
9999
]
100-
XCTAssertEqual(actual.sorted(by: { $0.language < $1.language }), expect)
100+
XCTAssertEqual(actualStrings.sorted(by: { $0.language < $1.language }), expect)
101+
XCTAssertTrue(actualStringsDict.isEmpty)
101102
}
102103
}
103104

@@ -118,10 +119,10 @@ final class XMReverterTests: XCTestCase {
118119
tableName: "Localizable",
119120
language: "test",
120121
values: [
121-
"\"Hello %@\"": "\"Hello %@\"",
122-
"Count = %lld": "Count = %lld",
123-
"key": "value",
124-
"path": "/",
122+
"\"Hello %@\"": .simple("\"Hello %@\""),
123+
"Count = %lld": .simple("Count = %lld"),
124+
"key": .simple("value"),
125+
"path": .simple("/"),
125126
]
126127
)
127128
try sut.exportStringsFile(input)

0 commit comments

Comments
 (0)