From 34bccfe51f7c37b2a7b8f70369b1b67a66f90f7c Mon Sep 17 00:00:00 2001 From: Alex Beaty Date: Sun, 23 Aug 2020 18:44:03 -0500 Subject: [PATCH] Add Labels to TaxonomiesService and Product view Labels were partially implemented but still needed to be incorporated into the API Models and Network Services. In doing so, it was discovered that the Taxonomies weren't being downloaded to Realm on first launch of the app as intended. After fixing that and adding the Label taxonomy to Models, they were successfully retrieved and stored. Then, to allow Labels to be displayed by ProductDetailViewController, they were included in the OFFJson mapping. The downloaded taxonomies were then used to translate Labels and code was added to fix translation of Categories. Lastly, the functionality to link link Labels to their OFF urls was added. Closes #719 --- Sources/Helpers/OFFUrlsHelper.swift | 4 ++ Sources/Models/API/OFFReadAPIkeysJSON.swift | 4 +- Sources/Models/API/Product.swift | 3 +- Sources/Models/API/Taxonomies/Label.swift | 49 +++++++++++++++++++ Sources/Models/DataManager.swift | 6 +++ Sources/Models/PersistenceManager.swift | 18 +++++++ Sources/Models/Transforms/TagTransform.swift | 2 +- Sources/Network/TaxonomiesParser.swift | 13 +++++ Sources/Network/TaxonomiesRequest.swift | 2 + Sources/Network/TaxonomiesService.swift | 32 +++++++++++- .../Protocols/TaxonomiesParserProtocol.swift | 1 + .../Detail/ProductDetailViewController.swift | 10 +++- Tests/Models/PersistenceManagerMock.swift | 7 +++ 13 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 Sources/Models/API/Taxonomies/Label.swift diff --git a/Sources/Helpers/OFFUrlsHelper.swift b/Sources/Helpers/OFFUrlsHelper.swift index 380ca613a97..aff2bcf7d47 100644 --- a/Sources/Helpers/OFFUrlsHelper.swift +++ b/Sources/Helpers/OFFUrlsHelper.swift @@ -46,6 +46,10 @@ class OFFUrlsHelper: NSObject { return URL(string: "\(baseUrl())/nucleotide/\(nucleotide.code)")! } + static func url(forLabel label: Label) -> URL { + return URL(string: "\(baseUrl())/label/\(label.code)")! + } + // Not sure if there is a taxonomy for this /* static func url(forOther other: OtherNutritionalSubstance) -> URL { diff --git a/Sources/Models/API/OFFReadAPIkeysJSON.swift b/Sources/Models/API/OFFReadAPIkeysJSON.swift index 3e1a232e789..1f7fc430b88 100644 --- a/Sources/Models/API/OFFReadAPIkeysJSON.swift +++ b/Sources/Models/API/OFFReadAPIkeysJSON.swift @@ -299,6 +299,7 @@ struct OFFJson { //static let IngredientsIdsDebugKey = "ingredients_ids_debug" //static let IngredientsThatMayBeFromPalmOilNKey = "ingredients_that_may_be_from_palm_oil_n" static let LabelsKey = "labels" + static let LabelsTagsKey = "labels_tags" //static let LabelsPrevHierarchyKey = "labels_prev_hierarchy" //static let LcKey = "lc" static let MineralsTagsKey = "minerals_tags" @@ -375,7 +376,8 @@ struct OFFJson { OFFJson.IngredientsTextKey, OFFJson.IngredientsThatMayBeFromPalmOilTagsKey, // OFFJson.LabelsHierarchyKey, - // OFFJson.LabelsTagsKey, + OFFJson.LabelsKey, + OFFJson.LabelsTagsKey, OFFJson.LangKey, OFFJson.ProductNameLanguagesKey, OFFJson.GenericNameLanguagesKey, diff --git a/Sources/Models/API/Product.swift b/Sources/Models/API/Product.swift index 978eb4f35b1..5bebcf90a09 100644 --- a/Sources/Models/API/Product.swift +++ b/Sources/Models/API/Product.swift @@ -122,6 +122,7 @@ struct Product: Mappable { var manufacturingPlaces: String? var origins: String? var labels: [String]? + var labelsTags: [String]? var citiesTags: [String]? var embCodesTags: [String]? var stores: [String]? @@ -303,6 +304,7 @@ struct Product: Mappable { manufacturingPlaces <- map[OFFJson.ManufacturingPlacesKey] origins <- map[OFFJson.OriginsKey] labels <- (map[OFFJson.LabelsKey], ArrayTransform()) + labelsTags <- map[OFFJson.LabelsTagsKey] citiesTags <- map[OFFJson.CitiesTagsKey] // countries <- (map[OFFJson.CountriesKey], ArrayTransform()) countriesTags <- map[OFFJson.CountriesTagsKey] @@ -316,7 +318,6 @@ struct Product: Mappable { imageUrl <- map[OFFJson.ImageUrlKey] ingredientsImageUrlDecoded <- map[OFFJson.ImageIngredientsUrlKey] ingredientsListDecoded <- map[OFFJson.IngredientsKey] - labels <- (map[OFFJson.LabelsKey], ArrayTransform()) lang <- map[OFFJson.LangKey] languageCodes <- map[OFFJson.LanguageCodesKey] manufacturingPlaces <- map[OFFJson.ManufacturingPlacesKey] diff --git a/Sources/Models/API/Taxonomies/Label.swift b/Sources/Models/API/Taxonomies/Label.swift new file mode 100644 index 00000000000..416f99fa51b --- /dev/null +++ b/Sources/Models/API/Taxonomies/Label.swift @@ -0,0 +1,49 @@ +// +// Label.swift +// OpenFoodFacts +// +// Created by Alexander Scott Beaty on 8/23/20. +// + +import Foundation +import RealmSwift +import ObjectMapper + +class Label: Object { + + @objc dynamic var code = "" + + let parents = List() + let children = List() + let names = List() + + @objc dynamic var mainName = "" // name in the language of the app, for sorting + @objc dynamic var indexedNames = "" // all names concatenated, for search + + convenience init(code: String, parents: [String], children: [String], names: [Tag]) { + self.init() + self.code = code + + self.parents.removeAll() + self.parents.append(objectsIn: parents) + + self.children.removeAll() + self.children.append(objectsIn: children) + + self.names.removeAll() + self.names.append(objectsIn: names) + + self.mainName = names.chooseForCurrentLanguage()?.value ?? "" + self.indexedNames = names.map({ (tag) -> String in + return tag.languageCode.appending(":").appending(tag.value) + }).joined(separator: " ||| ") // group all names to be able to query on only one field, independently of language + } + + override static func primaryKey() -> String? { + return "code" + } + + override static func indexedProperties() -> [String] { + return ["mainName", "indexedNames"] + } +} diff --git a/Sources/Models/DataManager.swift b/Sources/Models/DataManager.swift index 9555b089bdf..e29299195ee 100644 --- a/Sources/Models/DataManager.swift +++ b/Sources/Models/DataManager.swift @@ -50,6 +50,7 @@ protocol DataManagerProtocol { func ingredientsAnalysis(forProduct product: Product) -> [IngredientsAnalysisDetail] func ingredientsAnalysis(forTag tag: String) -> IngredientsAnalysis? func ingredientsAnalysisConfig(forTag tag: String) -> IngredientsAnalysisConfig? + func label(forTag: String) -> Label? func getTagline(_ callback: @escaping (_: Tagline?) -> Void) @@ -257,6 +258,11 @@ class DataManager: DataManagerProtocol { taxonomiesApi.getTagline(callback) } + func label(forTag tag: String) -> Label? { + let myLabel = persistenceManager.label(forCode: tag) + return myLabel + } + // MARK: - Settings func addAllergy(toAllergen: Allergen) { persistenceManager.addAllergy(toAllergen: toAllergen) diff --git a/Sources/Models/PersistenceManager.swift b/Sources/Models/PersistenceManager.swift index 781cfc78f00..2c597ad750f 100644 --- a/Sources/Models/PersistenceManager.swift +++ b/Sources/Models/PersistenceManager.swift @@ -65,6 +65,10 @@ protocol PersistenceManagerProtocol { func tagLine() -> Tagline? var additivesIsEmpty: Bool { get } + func save(labels: [Label]) + func label(forCode: String) -> Label? + var labelsIsEmpty: Bool { get } + // Offline func save(offlineProducts: [RealmOfflineProduct]) func getOfflineProduct(forCode: String) -> RealmOfflineProduct? @@ -365,6 +369,20 @@ class PersistenceManager: PersistenceManagerProtocol { return getRealm().object(ofType: IngredientsAnalysisConfig.self, forPrimaryKey: code) } + func save(labels: [Label]) { + saveOrUpdate(objects: labels) + log.info("Saved \(labels.count) labels in taxonomy database") + } + + func label(forCode code: String) -> Label? { + return getRealm().object(ofType: Label.self, forPrimaryKey: code) + } + + var labelsIsEmpty: Bool { + getRealm().objects(Label.self).isEmpty + } + + // Offline Products func save(offlineProducts: [RealmOfflineProduct]) { saveOrUpdate(objects: offlineProducts) } diff --git a/Sources/Models/Transforms/TagTransform.swift b/Sources/Models/Transforms/TagTransform.swift index 46febb85e82..624fc505a15 100644 --- a/Sources/Models/Transforms/TagTransform.swift +++ b/Sources/Models/Transforms/TagTransform.swift @@ -22,7 +22,7 @@ public class Tag: Object { /// choose the most appropriate tags based on the language passed in parameters, default to english if not found static func choose(inTags tags: [Tag], forLanguageCode languageCode: String? = nil, defaultToFirst: Bool = false) -> Tag? { - let lang = languageCode ?? Bundle.main.preferredLocalizations.first ?? "en" + let lang = languageCode ?? Bundle.main.currentLocalization if let tag = tags.first(where: { (tag: Tag) -> Bool in return tag.languageCode == lang diff --git a/Sources/Network/TaxonomiesParser.swift b/Sources/Network/TaxonomiesParser.swift index a138ea168af..8036df1d203 100644 --- a/Sources/Network/TaxonomiesParser.swift +++ b/Sources/Network/TaxonomiesParser.swift @@ -106,6 +106,19 @@ struct TaxonomiesParser: TaxonomiesParserProtocol { return ingredientsAnalysisConfig } + func parseLabels(data: [String: Any]) -> [Label] { + let labels = data.compactMap({ (labelCode: String, value: Any) -> Label? in + let tags = parseTags(value: value) + let parents = parseParents(value: value) + let children = parseChildren(value: value) + return Label(code: labelCode, + parents: parents, + children: children, + names: tags) + }) + return labels + } + // MARK: - Private Helper Methods private func parseTags(value: Any) -> [Tag] { diff --git a/Sources/Network/TaxonomiesRequest.swift b/Sources/Network/TaxonomiesRequest.swift index 96b02f091b3..4b840556c4b 100644 --- a/Sources/Network/TaxonomiesRequest.swift +++ b/Sources/Network/TaxonomiesRequest.swift @@ -30,6 +30,8 @@ struct TaxonomiesRequest: URLRequestConvertible { return route.rawValue + "/data/" + Endpoint.get case (.post, _): return Endpoint.post + route.rawValue + case (.get, _): + return Endpoint.get + "/data/" + route.rawValue default: return "" } diff --git a/Sources/Network/TaxonomiesService.swift b/Sources/Network/TaxonomiesService.swift index eef34519ae0..da693ef5dc3 100644 --- a/Sources/Network/TaxonomiesService.swift +++ b/Sources/Network/TaxonomiesService.swift @@ -21,6 +21,7 @@ enum TaxonomiesRoute: String { case getMinerals = "taxonomies/minerals.json" case getNucleotides = "taxonomies/nucleotides.json" case getInvalidBarcodes = "invalid-barcodes.json" + case getLabels = "taxonomies/labels.json" } enum FilesRouter: URLRequestConvertible { @@ -357,6 +358,27 @@ class TaxonomiesService: TaxonomiesApi { } } + fileprivate func refreshLabels(_ callback: @escaping (_: Bool) -> Void) { + do { + let request = try TaxonomiesRequest(route: .getLabels, requestType: .get).asURLRequest() + Alamofire.request(request) + .responseJSON { (response) in + switch response.result { + case .success(let responseBody): + if let json = responseBody as? [String: Any] { + let labels = self.taxonomiesParser.parseLabels(data: json) + self.persistenceManager.save(labels: labels) + callback(true) + } + case .failure(let error): + AnalyticsManager.record(error: error) + callback(false) + } + } + } catch { + callback(false) + } + } // swiftlint:disable identifier_name /// increment last number each time you want to force a refresh. Useful if you add a new refresh method or a new field @@ -380,7 +402,8 @@ class TaxonomiesService: TaxonomiesApi { persistenceManager.nucleotidesIsEmpty || // persistenceManager.otherNutritionalSubstancesIsEmpty || persistenceManager.ingredientsAnalysisIsEmpty || - persistenceManager.ingredientsAnalysisConfigIsEmpty + persistenceManager.ingredientsAnalysisConfigIsEmpty || + persistenceManager.labelsIsEmpty if shouldDownload { downloadTaxonomies() } else { @@ -454,12 +477,17 @@ class TaxonomiesService: TaxonomiesApi { }) group.enter() - self.refreshInvalidBarcodes { (success) in allSuccess = allSuccess && success group.leave() } + group.enter() + self.refreshLabels({ (success) in + allSuccess = allSuccess && success + group.leave() + }) + group.wait() if allSuccess { diff --git a/Sources/Protocols/TaxonomiesParserProtocol.swift b/Sources/Protocols/TaxonomiesParserProtocol.swift index 1cee3e0c519..17640fcc6ae 100644 --- a/Sources/Protocols/TaxonomiesParserProtocol.swift +++ b/Sources/Protocols/TaxonomiesParserProtocol.swift @@ -17,4 +17,5 @@ protocol TaxonomiesParserProtocol { func parseAdditives(data: [String: Any]) -> [Additive] func parseIngredientsAnalysis(data: [String: Any]) -> [IngredientsAnalysis] func parseIngredientsAnalysisConfig(data: [String: Any]) -> [IngredientsAnalysisConfig] + func parseLabels(data: [String: Any]) -> [Label] } diff --git a/Sources/ViewControllers/Products/Detail/ProductDetailViewController.swift b/Sources/ViewControllers/Products/Detail/ProductDetailViewController.swift index 0fd810e05cb..7e4cd68df60 100644 --- a/Sources/ViewControllers/Products/Detail/ProductDetailViewController.swift +++ b/Sources/ViewControllers/Products/Detail/ProductDetailViewController.swift @@ -218,7 +218,15 @@ class ProductDetailViewController: ButtonBarPagerTabStripViewController, DataMan return NSAttributedString(string: categoryTag) }), label: InfoRowKey.categories.localizedString) - createFormRow(with: &rows, item: product.labels, label: InfoRowKey.labels.localizedString) + createFormRow(with: &rows, item: product.labelsTags?.map({ (labelTag: String) -> NSAttributedString in + if let label = dataManager.label(forTag: labelTag) { + if let name = Tag.choose(inTags: Array(label.names)) { + return NSAttributedString(string: name.value, attributes: [NSAttributedString.Key.link : OFFUrlsHelper.url(forLabel: label)]) + } + } + return NSAttributedString(string: labelTag) + }), label: InfoRowKey.labels.localizedString) + createFormRow(with: &rows, item: product.citiesTags, label: InfoRowKey.citiesTags.localizedString) createFormRow(with: &rows, item: product.embCodesTags?.map({ (tag: String) -> NSAttributedString in diff --git a/Tests/Models/PersistenceManagerMock.swift b/Tests/Models/PersistenceManagerMock.swift index e61d933a403..80244d94cbb 100644 --- a/Tests/Models/PersistenceManagerMock.swift +++ b/Tests/Models/PersistenceManagerMock.swift @@ -256,6 +256,13 @@ class PersistenceManagerMock: PersistenceManagerProtocol { return nil } + func save(labels: [Label]) { + } + + func label(forCode: String) -> Label? { + return nil + } + func save(offlineProducts: [RealmOfflineProduct]) { }