diff --git a/CHANGELOG.md b/CHANGELOG.md index b59187b..98ed607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,9 @@ *March 22, 2021* - Fixes a minor version issue + +## Transifex iOS SDK 0.1.4 + +*March 24, 2021* + +- Exposes `TXStandardCache.getCache` method in Objective-C. diff --git a/Sources/Transifex/CDSHandler.swift b/Sources/Transifex/CDSHandler.swift index bad6dff..c636b9c 100644 --- a/Sources/Transifex/CDSHandler.swift +++ b/Sources/Transifex/CDSHandler.swift @@ -117,6 +117,7 @@ class CDSHandler { private static let CONTENT_ENDPOINT = "content" private static let INVALIDATE_ENDPOINT = "invalidate" + private static let FILTER_TAGS_PARAM = "filter[tags]" fileprivate static let HTTP_STATUS_CODE_OK = 200 fileprivate static let HTTP_STATUS_CODE_ACCEPTED = 202 @@ -212,8 +213,10 @@ class CDSHandler { /// - Parameters: /// - localeCode: an optional locale to fetch translations from; if none provided it will fetch /// translations for all locales defined in the configuration + /// - tags: An optional list of tags so that only strings that have all of the given tags are fetched. /// - completionHandler: a callback function to call when the operation is complete public func fetchTranslations(localeCode: String? = nil, + tags: [String]? = nil, completionHandler: @escaping TXPullCompletionHandler) { guard let cdsHostURL = URL(string: cdsHost) else { Logger.error("Error: Invalid CDS host URL: \(cdsHost)") @@ -244,7 +247,8 @@ class CDSHandler { for code in fetchLocaleCodes { let url = baseURL.appendingPathComponent(code) - var request = URLRequest(url: url) + var request = buildURLRequest(url: url, + tags: tags) request.allHTTPHeaderFields = getHeaders(withSecret: false) requestsByLocale[code] = request } @@ -482,4 +486,31 @@ Source: } return headers } + + /// Builds the URL request that is going to be used to query CDS using the optional tags list + /// + /// - Parameters: + /// - url: The initial URL + /// - tags: The optional tag list + /// - Returns: The final URL request to be used to query CDS + private func buildURLRequest(url: URL, + tags: [String]?) -> URLRequest { + guard let tags = tags, + tags.count > 0, + var components = URLComponents(url: url, + resolvingAgainstBaseURL: false) else { + return URLRequest(url: url) + } + + let tagList = tags.joined(separator: ",") + let queryItem = URLQueryItem(name: CDSHandler.FILTER_TAGS_PARAM, + value: tagList) + components.queryItems = [ queryItem ] + + guard let tagRequestURL = components.url else { + return URLRequest(url: url) + } + + return URLRequest(url: tagRequestURL) + } } diff --git a/Sources/Transifex/Cache.swift b/Sources/Transifex/Cache.swift index e2a08a4..43eece9 100644 --- a/Sources/Transifex/Cache.swift +++ b/Sources/Transifex/Cache.swift @@ -405,6 +405,7 @@ public final class TXStandardCache: NSObject { /// memory cache with the stored contents from disk. Defaults to .replaceAll. /// - groupIdentifier: The group identifier of the app, if the app makes use of the app groups /// entitlement. Defaults to nil. + @objc public static func getCache(updatePolicy: TXCacheUpdatePolicy = .replaceAll, groupIdentifier: String? = nil) -> TXCache { var providers: [TXCacheProvider] = [] diff --git a/Sources/Transifex/Core.swift b/Sources/Transifex/Core.swift index d1b952d..33062c9 100644 --- a/Sources/Transifex/Core.swift +++ b/Sources/Transifex/Core.swift @@ -28,10 +28,8 @@ public enum TXRenderingStategy : Int { /// If the string is not found the logic returns the value of the `params` dictionary and if this is /// also not found, it returns the `sourceString`. /// -/// This logic is used when the app needs to access its source localization or when a string for the -/// requested localization is not found and the missing policy is about to be called. Due to the fact -/// that CDS doesn't provide a way to download the source localizations, the SDK needs to look into -/// the bundled translations for the source locale and use those translations instead. +/// This logic is used when a string for the requested localization is not found and the missing policy +/// is about to be called. final class BypassLocalizer { let bundle : Bundle? @@ -123,7 +121,7 @@ class NativeCore : TranslationProvider { ) { self.locales = locales self.cdsHandler = CDSHandler( - localeCodes: self.locales.translatedLocales, + localeCodes: self.locales.appLocales, token: token, secret: secret, cdsHost: cdsHost, @@ -142,9 +140,11 @@ class NativeCore : TranslationProvider { /// /// - Parameter localeCode: an optional locale to fetch translations from; if none provided, it /// will fetch translations for all locales defined in the configuration + /// - Parameter tags: An optional list of tags so that only strings that have all of the given tags are fetched. /// - Parameter completionHandler: The completion handler that informs the caller with the /// new translations and a list of possible errors that might have occured func fetchTranslations(_ localeCode: String? = nil, + tags: [String]? = nil, completionHandler: TXPullCompletionHandler? = nil) { cdsHandler.fetchTranslations(localeCode: localeCode) { (translations, errors) in if errors.count > 0 { @@ -224,36 +224,45 @@ class NativeCore : TranslationProvider { params: [String: Any], context: String?) -> String { var translationTemplate: String? - let localeToRender = localeCode ?? self.locales.currentLocale - let isSource = self.locales.isSource(localeToRender) + let localeToRender = localeCode ?? locales.currentLocale + let key = txGenerateKey(sourceString: sourceString, + context: context) + + translationTemplate = cache.get(key: key, + localeCode: localeToRender) - /// If the source locale is requested, or if the source string is missing from cache, - /// the bypass localizer is used, to look up on the application bundle and fetch the - /// localized content for the source locale (if found) by bypassing swizzling. + var applyMissingPolicy = false - if isSource { - translationTemplate = self.bypassLocalizer.get(sourceString: sourceString, - params: params) - } - else { - let key = txGenerateKey(sourceString: sourceString, - context: context) - translationTemplate = cache.get(key: key, - localeCode: localeToRender) - if !String.containsTranslation(translationTemplate) { - let bypassedString = self.bypassLocalizer.get(sourceString: sourceString, - params: params) - - return missingPolicy.get(sourceString: bypassedString) + /// If the string is not found in the cache, use the bypass localizer to look it up on the + /// application bundle, which returns either the bundled translation if found, or the provided + /// source string. + if !String.containsTranslation(translationTemplate) { + translationTemplate = bypassLocalizer.get(sourceString: sourceString, + params: params) + + /// For source locale, we treat the return value of the bypass localizer as the ground truth + /// and we use the value to render the final string. + /// + /// For target locales, we do the same, with the exception that we pass the final rendered + /// string from the missing policy to inform the user that this string is missing. + if !locales.isSource(localeToRender) { + applyMissingPolicy = true } } - return render( + let renderedString = render( sourceString: sourceString, stringToRender: translationTemplate, localeCode: localeToRender, params: params ) + + if applyMissingPolicy { + return missingPolicy.get(sourceString: renderedString) + } + else { + return renderedString + } } /// Renders the translation to the current format, taking into account any variable placeholders. @@ -300,7 +309,7 @@ Error rendering source string '\(sourceString)' with string to render '\(stringT /// A static class that is the main point of entry for all the functionality of Transifex Native throughout the SDK. public final class TXNative : NSObject { /// The SDK version - internal static let version = "0.1.3" + internal static let version = "0.1.4" /// The filename of the file that holds the translated strings and it's bundled inside the app. public static let STRINGS_FILENAME = "txstrings.json" @@ -318,6 +327,9 @@ public final class TXNative : NSObject { /// Designated initializer of the TXNative SDK. /// + /// Do not call initialize() twice without calling dispose() first to deconstruct the previous singleton + /// instance. + /// /// - Parameters: /// - locales: keeps track of the available and current locales /// - token: the Transifex token that can be used for retrieving translations from CDS @@ -428,10 +440,12 @@ token: \(token) /// /// - Parameter localeCode: if not provided, it will fetch translations for all locales defined in the /// app configuration. + /// - Parameter tags: An optional list of tags so that only strings that have all of the given tags are fetched. /// - Parameter completionHandler: The completion handler that informs the caller with the /// new translations and a list of possible errors that might have occured @objc public static func fetchTranslations(_ localeCode: String? = nil, + tags: [String]? = nil, completionHandler: TXPullCompletionHandler? = nil) { tx?.fetchTranslations(localeCode, completionHandler: completionHandler) @@ -462,4 +476,10 @@ token: \(token) public static func forceCacheInvalidation(completionHandler: @escaping (Bool) -> Void) { tx?.forceCacheInvalidation(completionHandler: completionHandler) } + + /// Destructs the TXNative singleton instance so that another one can be used. + @objc + public static func dispose() { + tx = nil + } } diff --git a/Sources/Transifex/SourceString.swift b/Sources/Transifex/SourceString.swift index 21656cf..fea5864 100644 --- a/Sources/Transifex/SourceString.swift +++ b/Sources/Transifex/SourceString.swift @@ -14,20 +14,20 @@ import Foundation /// properties of the struct while also being able to be used both by Swift and Objective-C applications. public final class TXSourceString: NSObject { /// The key that was generated by the `txGenerateKey()` method - let key: String + public let key: String /// The source string - let sourceString: String + public let sourceString: String /// An optional developer comment - let developerComment: String? + public let developerComment: String? /// A list of relative file paths where this string is located. - let occurrences: [String] + public let occurrences: [String] /// A list of tags accompanying this source string - let tags: [String]? + public let tags: [String]? /// A limit provided by the developer that should be respected by translators when translating the /// source string, 0 means no limit. - let characterLimit: Int + public let characterLimit: Int /// A list of strings providing more context to the source string - let context: [String]? + public let context: [String]? /// Public and designated constructor. /// diff --git a/Tests/TransifexTests/TransifexTests.swift b/Tests/TransifexTests/TransifexTests.swift index 89d1af7..b303382 100644 --- a/Tests/TransifexTests/TransifexTests.swift +++ b/Tests/TransifexTests/TransifexTests.swift @@ -19,6 +19,7 @@ struct MockResponse { var data : Data? var statusCode : Int? var error : Error? + var url : URL? } class URLSessionMock: URLSession { @@ -60,13 +61,24 @@ class URLSessionMock: URLSession { else if let mockResponse = mockResponses?[mockResponseIndex] { mockResponseIndex += 1 + let requestURL = request.url! let data = mockResponse.data let error = mockResponse.error let statusCode = mockResponse.statusCode + let url = mockResponse.url + + var finalStatusCode = 200 + + if let statusCode = statusCode { + finalStatusCode = statusCode + } + else if let url = url { + finalStatusCode = (url == requestURL ? 200 : 403) + } return URLSessionDataTaskMock { - let response = HTTPURLResponse(url: request.url!, - statusCode: statusCode ?? 200, + let response = HTTPURLResponse(url: requestURL, + statusCode: finalStatusCode, httpVersion: nil, headerFields: nil) completionHandler(data, response, error) @@ -114,6 +126,10 @@ class MockErrorPolicy : TXErrorPolicy { } final class TransifexTests: XCTestCase { + override func tearDown() { + TXNative.dispose() + } + func testDuplicateLocaleFiltering() { let duplicateLocales = [ "en", "fr", "en" ] @@ -198,6 +214,34 @@ final class TransifexTests: XCTestCase { XCTAssertEqual("{something}".extractICUPlurals(), nil) } + func testFetchTranslationsWithTags() { + let expectation = self.expectation(description: "Waiting for translations to be fetched") + var translationErrors : [Error]? = nil + let mockResponseData = "{\"data\":{\"testkey1\":{\"string\":\"test string 1\"},\"testkey2\":{\"string\":\"test string 2\"}}}".data(using: .utf8) + + let expectedURL = URL(string: "https://cds.svc.transifex.net/content/en?filter%5Btags%5D=ios") + let mockResponse = MockResponse(data: mockResponseData, + url: expectedURL) + + let urlSession = URLSessionMock() + urlSession.mockResponses = [mockResponse] + + let cdsHandler = CDSHandler(localeCodes: [ "en" ], + token: "test_token", + session: urlSession) + + cdsHandler.fetchTranslations(tags: ["ios"]) { translations, errors in + translationErrors = errors + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) { (error) in + XCTAssertNil(error) + XCTAssertNotNil(translationErrors) + XCTAssertTrue(translationErrors?.count == 0) + } + } + func testFetchTranslations() { let expectation = self.expectation(description: "Waiting for translations to be fetched") var translationsResult : TXTranslations? = nil @@ -475,6 +519,61 @@ final class TransifexTests: XCTestCase { forKey: appleLanguagesKey) } + func testTranslateWithSourceStringsInCache() { + let sourceLocale = "en" + let localeState = TXLocaleState(sourceLocale: sourceLocale, + appLocales: [ + sourceLocale, + "el"]) + + + let sourceStringTest = "tx_test_key" + let translatedStringTest = "test updated" + + let sourceStringPlural = "tx_plural_test_key" + let translatedStringPluralOne = "car updated" + let translatedStringPluralOther = "cars updated" + let translatedStringPluralRule = "{cnt, plural, one {\(translatedStringPluralOne)} other {\(translatedStringPluralOther)}}" + + let keyTest = txGenerateKey(sourceString: sourceStringTest, context: nil) + let keyPlural = txGenerateKey(sourceString: sourceStringPlural, context: nil) + + let existingTranslations: TXTranslations = [ + sourceLocale: [ + keyTest: [ "string": translatedStringTest ], + keyPlural : [ "string": translatedStringPluralRule ] + ] + ] + + let memoryCache = TXMemoryCache() + memoryCache.update(translations: existingTranslations) + + TXNative.initialize(locales: localeState, + token: "", + secret: "", + cache: memoryCache) + + let result = TXNative.translate(sourceString: sourceStringTest, + params: [:], + context: nil) + + XCTAssertEqual(result, translatedStringTest) + + let pluralsResultOne = TXNative.translate(sourceString: sourceStringPlural, + params: [ + Swizzler.PARAM_ARGUMENTS_KEY: [1 as CVarArg]], + context: nil) + + XCTAssertEqual(pluralsResultOne, translatedStringPluralOne) + + let pluralsResultOther = TXNative.translate(sourceString: sourceStringPlural, + params: [ + Swizzler.PARAM_ARGUMENTS_KEY: [3 as CVarArg]], + context: nil) + + XCTAssertEqual(pluralsResultOther, translatedStringPluralOther) + } + static var allTests = [ ("testDuplicateLocaleFiltering", testDuplicateLocaleFiltering), ("testCurrentLocaleProvider", testCurrentLocaleProvider), @@ -491,5 +590,6 @@ final class TransifexTests: XCTestCase { ("testPlatformStrategyWithInvalidSourceString", testPlatformStrategyWithInvalidSourceString), ("testErrorPolicy", testErrorPolicy), ("testCurrentLocale", testCurrentLocale), + ("testTranslateWithSourceStringsInCache", testTranslateWithSourceStringsInCache), ] }