diff --git a/README.md b/README.md index bab28f4..b23db9d 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,7 @@ Support levels:
  • init(data: Data, encoding: StringEncoding)
  • init(bytes: [UInt8], encoding: StringEncoding)
  • init(contentsOf: URL)
  • +
  • static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String
  • var capitalized: String
  • var deletingLastPathComponent: String
  • func replacingOccurrences(of search: String, with replacement: String) -> String
  • diff --git a/Sources/SkipFoundation/Bundle.swift b/Sources/SkipFoundation/Bundle.swift index 78606ee..d6312a4 100644 --- a/Sources/SkipFoundation/Bundle.swift +++ b/Sources/SkipFoundation/Bundle.swift @@ -264,10 +264,23 @@ public class Bundle : Hashable, SwiftCustomBridged { let table = tableName ?? "Localizable" var locTable = localizedTables[table] if locTable == nil { - let resURL = url(forResource: table, withExtension: "strings") - let resTable = resURL == nil ? nil : try? PropertyListSerialization.propertyList(from: Data(contentsOf: resURL!), format: nil) - locTable = Self.stringFormatsTable(from: resTable) - localizedTables[table] = locTable! + let newTable = mutableMapOf>() + let resTypes: [(extension: String, format: PropertyListSerialization.PropertyListFormat)] = [ + (extension: "strings", format: .openStep), + (extension: "stringsdict", format: .xml) + ] + for resType in resTypes { + if let resURL = url(forResource: table, withExtension: resType.extension), + let resData = try? Data(contentsOf: resURL), + let resTable = try? PropertyListSerialization.propertyList(from: resData, format: resType.format) { + for (sKey, sValue) in resTable where newTable[sKey] == nil { + newTable[sKey] = Triple(sValue, sValue.kotlinFormatString, MarkdownNode.from(string: sValue)) + } + } + } + + locTable = newTable + localizedTables[table] = newTable } if let formats = locTable?[key] { return formats diff --git a/Sources/SkipFoundation/PropertyListSerialization.swift b/Sources/SkipFoundation/PropertyListSerialization.swift index 94aa18b..1e8db82 100644 --- a/Sources/SkipFoundation/PropertyListSerialization.swift +++ b/Sources/SkipFoundation/PropertyListSerialization.swift @@ -1,24 +1,149 @@ // Copyright 2023–2025 Skip // SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception #if SKIP +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.ByteArrayInputStream public class PropertyListSerialization { + @available(*, unavailable) public static func propertyList(_ propertyList: Any, isValidFor: PropertyListSerialization.PropertyListFormat) -> Bool { fatalError() } + public static func propertyList(from data: Data, options: PropertyListSerialization.ReadOptions = [], format: Any?) throws -> [String: String]? { + let text = data.utf8String + guard let text = text else { + // should this throw an error? + return nil + } - public static func propertyList(from: Data, options: PropertyListSerialization.ReadOptions = [], format: Any?) throws -> [String: String]? { // TODO: auto-detect format from data content if the format argument is unset - return try openStepPropertyList(from: from, options: options) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if (format as? PropertyListFormat) == .xml || trimmed.hasPrefix(" [String: String]? { - var dict: Dictionary = [:] + private static func convertStringsDictToICUDict(from data: Data) throws -> [String: String]? { + let parser = Xml.newPullParser() + parser.setInput(ByteArrayInputStream(data.platformValue), "UTF-8") + + var result: [String: String] = [:] + var dictStack: [[String: Any]] = [] + var keyStack: [String] = [] + var textAccumulator = "" + + var eventType = parser.getEventType() + while eventType != XmlPullParser.END_DOCUMENT { + + let tagName = parser.getName() + switch eventType { + case XmlPullParser.START_TAG: + textAccumulator = "" + if tagName == "dict" { + dictStack.append([:]) + } + + case XmlPullParser.TEXT: + textAccumulator += parser.getText() ?? "" + + case XmlPullParser.END_TAG: + let content = textAccumulator.trimmingCharacters(in: .whitespacesAndNewlines) + switch tagName { + case "key": + keyStack.append(content) + case "string": + if let key = keyStack.popLast(), !dictStack.isEmpty { + dictStack[dictStack.count - 1][key] = content + } + case "dict": + if let finishedDict = dictStack.popLast() { + if let parentKey = keyStack.popLast(), !dictStack.isEmpty { + dictStack[dictStack.count - 1][parentKey] = finishedDict + } else { + for (key, value) in finishedDict { + /* SKIP NOWARN */ + if let stringsDict = value as? [String: Any], + let icuString = convertStringsDictToICUString(stringsDict) { + result[key] = icuString + } + } + } + } + default: + break + } + + textAccumulator = "" + default: + break + } + + eventType = parser.next() + } + + return result + } + + private static func convertStringsDictToICUString(_ stringsDict: [String: Any]) -> String? { + guard let formatKey = stringsDict["NSStringLocalizedFormatKey"] as? String else { + return nil + } + + var result: String = formatKey + + /* SKIP NOWARN */ + for (key, varDict) in stringsDict.compactMapValues({ $0 as? [String: Any] }) { + guard let specType = varDict["NSStringFormatSpecTypeKey"] as? String, specType == "NSStringPluralRuleType" + else { + continue + } + + let categories = ["zero", "one", "two", "few", "many", "other"] + let rules = categories.compactMap { (category: String) -> String? in + guard let pluralString = varDict[category] as? String else { return nil } + var cleaned = pluralString! + for index in 1...10 { + cleaned = cleaned + .replacingOccurrences(of: "%\(index)$@", with: "{\(index-1)}") + .replacingOccurrences(of: "%\(index)$d", with: "{\(index-1)}") + } + let icuCategory = category == "zero" ? "=0" : category + return "\(icuCategory){\(cleaned)}" + }.joined(separator: " ") - let text = from.utf8String + if !rules.isEmpty { + let pattern = "%#@\(key)@" + let replacement = "{0, plural, \(rules)}" + + if result.contains(pattern) { + result = result.replacingOccurrences(of: pattern, with: replacement) + } else if result.contains("%#@") { + let parts = result.components(separatedBy: "%#@") + if parts.count > 1 { + let prefix = parts[0] + let remaining = parts[1] + let subParts = remaining.components(separatedBy: "@") + if subParts.count > 1 { + let suffix = subParts[1...].joined(separator: "@") + result = prefix + replacement + suffix + } + } + } + } + } + + return result + } + + private static func openStepPropertyList(from data: Data, options: PropertyListSerialization.ReadOptions = []) throws -> [String: String]? { + var dict: Dictionary = [:] + let text = data.utf8String guard let text = text else { // should this throw an error? return nil diff --git a/Sources/SkipFoundation/String.swift b/Sources/SkipFoundation/String.swift index b1ba9bb..41108cd 100644 --- a/Sources/SkipFoundation/String.swift +++ b/Sources/SkipFoundation/String.swift @@ -15,6 +15,8 @@ //===----------------------------------------------------------------------===// #if SKIP +import android.icu.text.MessageFormat +import java.util.ArrayList public typealias NSString = kotlin.String public func NSString(string: String) -> NSString { string } @@ -108,6 +110,23 @@ extension String { fatalError("unsupported") } + public static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String { + let list = ArrayList() + for argument in arguments { list.add(argument) } + let platformArguments = list.toArray() + + let locale = Locale.current.platformValue + if format.contains("{0, plural,") { + return MessageFormat(format, locale).format(platformArguments) + } + + if !arguments.isEmpty { + return java.lang.String.format(locale, format, platformArguments) + } + + return format + } + public func trimmingCharacters(in set: CharacterSet) -> String { return trim { set.platformValue.contains(UInt32($0.code)) } } diff --git a/Tests/SkipFoundationTests/Foundation/LocaleTests.swift b/Tests/SkipFoundationTests/Foundation/LocaleTests.swift index 890a96b..b1ea50c 100644 --- a/Tests/SkipFoundationTests/Foundation/LocaleTests.swift +++ b/Tests/SkipFoundationTests/Foundation/LocaleTests.swift @@ -127,6 +127,44 @@ final class LocaleTests: XCTestCase { ]) } + func testLocalizableStringsDictParsing() throws { + let locstr = #""" + + + + + repeats_every_day + + NSStringLocalizedFormatKey + %#@num_days@ + num_days + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Ne se répète jamais + one + Se répète quotidiennement + other + Se répète tous les %d jours + + + + + """# + + let data = try XCTUnwrap(locstr.data(using: .utf8)) + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + + // SKIP NOWARN + let dict = try XCTUnwrap(plist as? [String: Any]) + + XCTAssertEqual(1, dict.count) + XCTAssertNotNil(dict["repeats_every_day"]) + } + func testLocaleFormats() throws { #if !SKIP // TODO @@ -804,6 +842,29 @@ final class LocaleTests: XCTestCase { // "sk" is not localized at all, so the expected behavior is to fall back to base.lproj, which is en, and so "lower-case" will be translated as "UPPER-CASE" XCTAssertEqual("UPPER-CASE", String(localized: LocalizedStringResource("lower-case", locale: Locale(identifier: "sk"), bundle: moduleBundle))) } + + func testLocalizedPluralStringFormatting() throws { + #if os(macOS) + if !isJava && Bundle.module.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "en") == nil { + throw XCTSkip("Localizable.xcstrings not compiled to strings/stringsdict") + } + #endif + + let bundleDesc = LocalizedStringResource.BundleDescription.atURL(Bundle.module.bundleURL) + var resource = LocalizedStringResource("repeats_every_day", bundle: bundleDesc) + + // English + resource.locale = Locale(identifier: "en") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 0), "Repeats never") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 1), "Repeats daily") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 5), "Repeats every 5 days") + + // French + resource.locale = Locale(identifier: "fr") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 0), "Ne se répète jamais") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 1), "Se répète quotidiennement") + XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 5), "Se répète tous les 5 jours") + } } #if !SKIP diff --git a/Tests/SkipFoundationTests/Resources/Localizable.xcstrings b/Tests/SkipFoundationTests/Resources/Localizable.xcstrings index 51cad1b..ea412a5 100644 --- a/Tests/SkipFoundationTests/Resources/Localizable.xcstrings +++ b/Tests/SkipFoundationTests/Resources/Localizable.xcstrings @@ -128,6 +128,239 @@ } } }, + "repeats_every_day" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتكرر كل %1$d أيام" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتكرر كل %1$d يوماً" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتكرر يومياً" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتكرر كل %1$d يوم" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتكرر كل يومين" + } + }, + "zero" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يتكرر أبداً" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repeats daily" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repeats every %1$d days" + } + }, + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repeats never" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se répète quotidiennement" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se répète tous les %1$d jours" + } + }, + "zero" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ne se répète jamais" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "חוזר כל יום" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "חוזר כל %1$d ימים" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d日ごとに繰り返し" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repete diariamente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repete a cada %1$d dias" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторяется каждые %1$d дня" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторяется каждые %1$d дней" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторяется каждый %1$d день" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторяется каждые %1$d дней" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upprepas varje dag" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upprepas var %1$d dag" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторюється кожні %1$d дні" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторюється кожні %1$d днів" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторюється щодня" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторюється кожні %1$d днів" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "每 %1$d 天重复一次" + } + } + } + } + } + } + }, "Settings" : { }