From bd3202b1d7c39b15675ee5dcb9b850bdfa04b0b3 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Mon, 12 Aug 2024 12:30:53 +0200 Subject: [PATCH] feat: 956 - added product owner fields (#961) * feat: 956 - added product owner fields New file: * `owner_field.dart`: Helper class to compute the Product.ownerFields tags. Impacted files: * `api_get_product_test.dart`: added a test for owner fields * `openfoodfacts.dart`: added new class `OwnerField` * `product.dart`: added field `ownerFields` and method `getOwnerFieldTimestamp` * `product.g.dart`: generated * `product_fields.dart`: refactored around localized product fields * Unrelated unit test minor fix --- lib/openfoodfacts.dart | 1 + lib/src/model/owner_field.dart | 25 +++ lib/src/model/product.dart | 30 +++- lib/src/model/product.g.dart | 4 + lib/src/utils/product_fields.dart | 252 +++++++++++++++++++++++++----- test/api_get_product_test.dart | 97 +++++++++++- 6 files changed, 364 insertions(+), 45 deletions(-) create mode 100644 lib/src/model/owner_field.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 1c5905debe..70a331db48 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -32,6 +32,7 @@ export 'src/model/old_product_result.dart'; export 'src/model/ordered_nutrient.dart'; export 'src/model/ordered_nutrients.dart'; export 'src/model/origins_of_ingredients.dart'; +export 'src/model/owner_field.dart'; export 'src/model/packaging.dart'; export 'src/model/parameter/allergens_parameter.dart'; export 'src/model/parameter/barcode_parameter.dart'; diff --git a/lib/src/model/owner_field.dart b/lib/src/model/owner_field.dart new file mode 100644 index 0000000000..09b35dc9cf --- /dev/null +++ b/lib/src/model/owner_field.dart @@ -0,0 +1,25 @@ +import '../utils/language_helper.dart'; +import '../utils/product_fields.dart'; +import 'nutrient.dart'; +import 'off_tagged.dart'; + +/// Helper class to compute the Product.ownerFields tags. +class OwnerField implements OffTagged { + const OwnerField.raw(this.offTag); + + OwnerField.nutrient(final Nutrient nutrient) : this.raw(nutrient.offTag); + + factory OwnerField.productField( + final ProductField productField, + final OpenFoodFactsLanguage language, + ) { + final ProductField? inLanguages = productField.inLanguages; + if (inLanguages == null) { + return OwnerField.raw(productField.offTag); + } + return OwnerField.raw('${inLanguages.offTag}${language.offTag}'); + } + + @override + final String offTag; +} diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index 07af16b43a..abc233bf02 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -10,6 +10,7 @@ import 'ingredients_analysis_tags.dart'; import 'knowledge_panels.dart'; import 'nutrient_levels.dart'; import 'nutriments.dart'; +import 'owner_field.dart'; import 'product_image.dart'; import 'product_packaging.dart'; import '../interface/json_object.dart'; @@ -562,10 +563,19 @@ class Product extends JsonObject { ) bool? obsolete; + /// Timestamps of owner_fields, in seconds. + /// + /// Typically used to say "Was that field set by the owner?". + /// Read-only. + /// See also [getOwnerFieldTimestamp]. + @JsonKey(name: 'owner_fields', includeIfNull: false) + Map? ownerFields; + /// Expiration date / best before. Just a string, no format control. @JsonKey(name: 'expiration_date', includeIfNull: false) String? expirationDate; + // TODO(monsieurtanuki): remove all the "this" constructor fields, except maybe "barcode" Product( {this.barcode, this.productName, @@ -766,7 +776,7 @@ class Product extends JsonObject { result.imagesFreshnessInLanguages![language] = values; return; default: - if (fieldsInLanguages.contains(productField)) { + if (productField.isInLanguages) { throw Exception('Unhandled in-languages case for $productField'); } } @@ -800,7 +810,10 @@ class Product extends JsonObject { // We store those values in a more structured maps like // [productNameInLanguages]. - ProductField? productField = extractProductField(key, fieldsAllLanguages); + ProductField? productField = extractProductField( + key, + ProductField.getAllLanguagesList(), + ); if (productField != null) { final Map? localized = _getLocalizedStrings(json[key]); @@ -813,7 +826,10 @@ class Product extends JsonObject { continue; } - productField = extractProductField(key, fieldsInLanguages); + productField = extractProductField( + key, + ProductField.getInLanguagesList(), + ); if (productField != null) { final OpenFoodFactsLanguage language = _langFrom(key, productField.offTag); @@ -1073,4 +1089,12 @@ class Product extends JsonObject { // quantity: put non breaking spaces between numbers and units return '$productNameBrand$separator${quantity!.replaceAll(' ', '\u{00A0}')}'; } + + /// Returns the timestamp (in seconds) of the owner field. + /// + /// Typically used to check if the field value was set by the product owner + /// (if the timestamp is not null), which means that the user cannot set a + /// value. + int? getOwnerFieldTimestamp(final OwnerField ownerField) => + ownerFields?[ownerField.offTag]; } diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index 11c285d991..32e0df2266 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -171,6 +171,9 @@ Product _$ProductFromJson(Map json) => Product( ..novaGroup = (json['nova_group'] as num?)?.toInt() ..website = json['link'] as String? ..obsolete = JsonHelper.checkboxFromJSON(json['obsolete']) + ..ownerFields = (json['owner_fields'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) ..expirationDate = json['expiration_date'] as String?; Map _$ProductToJson(Product instance) { @@ -306,6 +309,7 @@ Map _$ProductToJson(Product instance) { writeNotNull('nova_group', instance.novaGroup); writeNotNull('link', instance.website); val['obsolete'] = JsonHelper.checkboxToJSON(instance.obsolete); + writeNotNull('owner_fields', instance.ownerFields); writeNotNull('expiration_date', instance.expirationDate); val['no_nutrition_data'] = JsonHelper.checkboxToJSON(instance.noNutritionData); diff --git a/lib/src/utils/product_fields.dart b/lib/src/utils/product_fields.dart index 069ca9771d..03b35dd9b2 100644 --- a/lib/src/utils/product_fields.dart +++ b/lib/src/utils/product_fields.dart @@ -4,21 +4,63 @@ import '../model/off_tagged.dart'; /// Fields of a [Product] enum ProductField implements OffTagged { BARCODE(offTag: 'code'), - NAME(offTag: 'product_name'), - NAME_IN_LANGUAGES(offTag: 'product_name_'), - NAME_ALL_LANGUAGES(offTag: 'product_name_languages'), - GENERIC_NAME(offTag: 'generic_name'), - GENERIC_NAME_IN_LANGUAGES(offTag: 'generic_name_'), - GENERIC_NAME_ALL_LANGUAGES(offTag: 'generic_name_languages'), - ABBREVIATED_NAME(offTag: 'abbreviated_product_name'), - ABBREVIATED_NAME_IN_LANGUAGES(offTag: 'abbreviated_product_name_'), - ABBREVIATED_NAME_ALL_LANGUAGES(offTag: 'abbreviated_product_name_languages'), + NAME( + offTag: 'product_name', + inLanguagesProductField: ProductField.NAME_IN_LANGUAGES, + ), + NAME_IN_LANGUAGES( + offTag: 'product_name_', + isInLanguages: true, + ), + NAME_ALL_LANGUAGES( + offTag: 'product_name_languages', + inLanguagesProductField: ProductField.NAME_IN_LANGUAGES, + isAllLanguages: true, + ), + GENERIC_NAME( + offTag: 'generic_name', + inLanguagesProductField: ProductField.GENERIC_NAME_IN_LANGUAGES, + ), + GENERIC_NAME_IN_LANGUAGES( + offTag: 'generic_name_', + isInLanguages: true, + ), + GENERIC_NAME_ALL_LANGUAGES( + offTag: 'generic_name_languages', + inLanguagesProductField: ProductField.GENERIC_NAME_IN_LANGUAGES, + isAllLanguages: true, + ), + ABBREVIATED_NAME( + offTag: 'abbreviated_product_name', + inLanguagesProductField: ProductField.ABBREVIATED_NAME_IN_LANGUAGES, + ), + ABBREVIATED_NAME_IN_LANGUAGES( + offTag: 'abbreviated_product_name_', + isInLanguages: true, + ), + ABBREVIATED_NAME_ALL_LANGUAGES( + offTag: 'abbreviated_product_name_languages', + inLanguagesProductField: ProductField.ABBREVIATED_NAME_IN_LANGUAGES, + isAllLanguages: true, + ), BRANDS(offTag: 'brands'), - BRANDS_TAGS(offTag: 'brands_tags'), - BRANDS_TAGS_IN_LANGUAGES(offTag: 'brands_tags_'), + BRANDS_TAGS( + offTag: 'brands_tags', + inLanguagesProductField: ProductField.BRANDS_TAGS_IN_LANGUAGES, + ), + BRANDS_TAGS_IN_LANGUAGES( + offTag: 'brands_tags_', + isInLanguages: true, + ), COUNTRIES(offTag: 'countries'), - COUNTRIES_TAGS(offTag: 'countries_tags'), - COUNTRIES_TAGS_IN_LANGUAGES(offTag: 'countries_tags_'), + COUNTRIES_TAGS( + offTag: 'countries_tags', + inLanguagesProductField: ProductField.COUNTRIES_TAGS_IN_LANGUAGES, + ), + COUNTRIES_TAGS_IN_LANGUAGES( + offTag: 'countries_tags_', + isInLanguages: true, + ), LANGUAGE(offTag: 'lang'), QUANTITY(offTag: 'quantity'), SERVING_SIZE(offTag: 'serving_size'), @@ -36,47 +78,128 @@ enum ProductField implements OffTagged { IMAGE_PACKAGING_SMALL_URL(offTag: 'image_packaging_small_url'), IMAGES(offTag: 'images'), INGREDIENTS(offTag: 'ingredients'), - INGREDIENTS_TAGS(offTag: 'ingredients_tags'), - INGREDIENTS_TAGS_IN_LANGUAGES(offTag: 'ingredients_tags_'), - IMAGES_FRESHNESS_IN_LANGUAGES(offTag: 'images_to_update_'), + INGREDIENTS_TAGS( + offTag: 'ingredients_tags', + inLanguagesProductField: ProductField.INGREDIENTS_TAGS_IN_LANGUAGES, + ), + INGREDIENTS_TAGS_IN_LANGUAGES( + offTag: 'ingredients_tags_', + isInLanguages: true, + ), + IMAGES_FRESHNESS_IN_LANGUAGES( + offTag: 'images_to_update_', + isInLanguages: true, + ), NO_NUTRITION_DATA(offTag: 'no_nutrition_data'), NUTRIMENTS(offTag: 'nutriments'), - ADDITIVES(offTag: 'additives_tags'), - ADDITIVES_TAGS_IN_LANGUAGES(offTag: 'additives_tags_'), + ADDITIVES( + offTag: 'additives_tags', + inLanguagesProductField: ProductField.ADDITIVES_TAGS_IN_LANGUAGES, + ), + ADDITIVES_TAGS_IN_LANGUAGES( + offTag: 'additives_tags_', + isInLanguages: true, + ), NUTRIENT_LEVELS(offTag: 'nutrient_levels'), - INGREDIENTS_TEXT(offTag: 'ingredients_text'), - INGREDIENTS_TEXT_IN_LANGUAGES(offTag: 'ingredients_text_'), - INGREDIENTS_TEXT_ALL_LANGUAGES(offTag: 'ingredients_text_languages'), + INGREDIENTS_TEXT( + offTag: 'ingredients_text', + inLanguagesProductField: ProductField.INGREDIENTS_TEXT_IN_LANGUAGES, + ), + INGREDIENTS_TEXT_IN_LANGUAGES( + offTag: 'ingredients_text_', + isInLanguages: true, + ), + INGREDIENTS_TEXT_ALL_LANGUAGES( + offTag: 'ingredients_text_languages', + inLanguagesProductField: ProductField.INGREDIENTS_TEXT_IN_LANGUAGES, + isAllLanguages: true, + ), NUTRIMENT_ENERGY_UNIT(offTag: 'nutriment_energy_unit'), NUTRIMENT_DATA_PER(offTag: 'nutrition_data_per'), NUTRITION_DATA(offTag: 'nutrition_data'), NUTRISCORE(offTag: 'nutrition_grade_fr'), COMPARED_TO_CATEGORY(offTag: 'compared_to_category'), CATEGORIES(offTag: 'categories'), - CATEGORIES_TAGS(offTag: 'categories_tags'), - CATEGORIES_TAGS_IN_LANGUAGES(offTag: 'categories_tags_'), + CATEGORIES_TAGS( + offTag: 'categories_tags', + inLanguagesProductField: ProductField.CATEGORIES_TAGS_IN_LANGUAGES, + ), + CATEGORIES_TAGS_IN_LANGUAGES( + offTag: 'categories_tags_', + isInLanguages: true, + ), LABELS(offTag: 'labels'), - LABELS_TAGS(offTag: 'labels_tags'), - LABELS_TAGS_IN_LANGUAGES(offTag: 'labels_tags_'), + LABELS_TAGS( + offTag: 'labels_tags', + inLanguagesProductField: ProductField.LABELS_TAGS_IN_LANGUAGES, + ), + LABELS_TAGS_IN_LANGUAGES( + offTag: 'labels_tags_', + isInLanguages: true, + ), PACKAGING(offTag: 'packaging'), PACKAGINGS(offTag: 'packagings'), PACKAGINGS_COMPLETE(offTag: 'packagings_complete'), PACKAGING_TAGS(offTag: 'packaging_tags'), - PACKAGING_TEXT_IN_LANGUAGES(offTag: 'packaging_text_'), - PACKAGING_TEXT_ALL_LANGUAGES(offTag: 'packaging_text_languages'), - MISC_TAGS(offTag: 'misc_tags'), - MISC_TAGS_IN_LANGUAGES(offTag: 'misc_tags_'), - STATES_TAGS(offTag: 'states_tags'), - STATES_TAGS_IN_LANGUAGES(offTag: 'states_tags_'), - TRACES_TAGS(offTag: 'traces_tags'), - TRACES_TAGS_IN_LANGUAGES(offTag: 'traces_tags_'), - STORES_TAGS(offTag: 'stores_tags'), - STORES_TAGS_IN_LANGUAGES(offTag: 'stores_tags_'), + PACKAGING_TEXT_IN_LANGUAGES( + offTag: 'packaging_text_', + isInLanguages: true, + ), + PACKAGING_TEXT_ALL_LANGUAGES( + offTag: 'packaging_text_languages', + inLanguagesProductField: ProductField.PACKAGING_TEXT_IN_LANGUAGES, + isAllLanguages: true, + ), + MISC_TAGS( + offTag: 'misc_tags', + inLanguagesProductField: ProductField.MISC_TAGS_IN_LANGUAGES, + ), + MISC_TAGS_IN_LANGUAGES( + offTag: 'misc_tags_', + isInLanguages: true, + ), + STATES_TAGS( + offTag: 'states_tags', + inLanguagesProductField: ProductField.STATES_TAGS_IN_LANGUAGES, + ), + STATES_TAGS_IN_LANGUAGES( + offTag: 'states_tags_', + isInLanguages: true, + ), + TRACES_TAGS( + offTag: 'traces_tags', + inLanguagesProductField: ProductField.TRACES_TAGS_IN_LANGUAGES, + ), + TRACES_TAGS_IN_LANGUAGES( + offTag: 'traces_tags_', + isInLanguages: true, + ), + STORES_TAGS( + offTag: 'stores_tags', + inLanguagesProductField: ProductField.STORES_TAGS_IN_LANGUAGES, + ), + STORES_TAGS_IN_LANGUAGES( + offTag: 'stores_tags_', + isInLanguages: true, + ), STORES(offTag: 'stores'), - INGREDIENTS_ANALYSIS_TAGS(offTag: 'ingredients_analysis_tags'), - INGREDIENTS_ANALYSIS_TAGS_IN_LANGUAGES(offTag: 'ingredients_analysis_tags_'), - ALLERGENS(offTag: 'allergens_tags'), - ALLERGENS_TAGS_IN_LANGUAGES(offTag: 'allergens_tags_'), + INGREDIENTS_ANALYSIS_TAGS( + offTag: 'ingredients_analysis_tags', + inLanguagesProductField: + ProductField.INGREDIENTS_ANALYSIS_TAGS_IN_LANGUAGES, + ), + INGREDIENTS_ANALYSIS_TAGS_IN_LANGUAGES( + offTag: 'ingredients_analysis_tags_', + isInLanguages: true, + ), + ALLERGENS( + offTag: 'allergens_tags', + inLanguagesProductField: ProductField.ALLERGENS_TAGS_IN_LANGUAGES, + ), + ALLERGENS_TAGS_IN_LANGUAGES( + offTag: 'allergens_tags_', + isInLanguages: true, + ), ATTRIBUTE_GROUPS(offTag: 'attribute_groups'), LAST_MODIFIED(offTag: 'last_modified_t'), LAST_MODIFIER(offTag: 'last_modified_by'), @@ -102,6 +225,7 @@ enum ProductField implements OffTagged { WEBSITE(offTag: 'link'), EXPIRATION_DATE(offTag: 'expiration_date'), OBSOLETE(offTag: 'obsolete'), + OWNER_FIELDS(offTag: 'owner_fields'), /// All data as RAW from the server. E.g. packagings are only Strings there. RAW(offTag: 'raw'), @@ -109,12 +233,56 @@ enum ProductField implements OffTagged { const ProductField({ required this.offTag, - }); + final ProductField? inLanguagesProductField, + this.isInLanguages = false, + this.isAllLanguages = false, + }) : _inLanguagesProductField = inLanguagesProductField; @override final String offTag; + + final ProductField? _inLanguagesProductField; + + /// Is this field an "in languages" field? + final bool isInLanguages; + + /// Is this field an "all languages" field? + final bool isAllLanguages; + + /// Returns the corresponding "in languages" field, if relevant. + ProductField? get inLanguages => + isInLanguages ? this : _inLanguagesProductField; + + static List _inLanguagesList = []; + static List _allLanguagesList = []; + + /// Returns the list of all "in languages" fields. + static List getInLanguagesList() { + if (_inLanguagesList.isEmpty) { + for (final ProductField productField in ProductField.values) { + if (productField.isInLanguages) { + _inLanguagesList.add(productField); + } + } + } + return _inLanguagesList; + } + + /// Returns the list of all "all languages" fields. + static List getAllLanguagesList() { + if (_allLanguagesList.isEmpty) { + for (final ProductField productField in ProductField.values) { + if (productField.isAllLanguages) { + _allLanguagesList.add(productField); + } + } + } + return _allLanguagesList; + } } +// TODO: deprecated from 2024-08-03; remove when old enough +@Deprecated('Use ProductField.getInLanguagesList() instead') const Set fieldsInLanguages = { ProductField.NAME_IN_LANGUAGES, ProductField.GENERIC_NAME_IN_LANGUAGES, @@ -136,6 +304,8 @@ const Set fieldsInLanguages = { ProductField.IMAGES_FRESHNESS_IN_LANGUAGES, }; +// TODO: deprecated from 2024-08-03; remove when old enough +@Deprecated('Use ProductField.getAllLanguagesList() instead') const Set fieldsAllLanguages = { ProductField.NAME_ALL_LANGUAGES, ProductField.GENERIC_NAME_ALL_LANGUAGES, @@ -151,7 +321,7 @@ List convertFieldsToStrings( final fieldsStrings = []; for (final field in fields) { - if (fieldsInLanguages.contains(field)) { + if (field.isInLanguages) { if (languages.isEmpty) { throw ArgumentError( 'Cannot request in-lang field $field without language'); diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 6ebd4ea274..62338862bd 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -140,7 +140,7 @@ void main() { expect(result.product!.ingredientsText, isNotNull); expect(result.product!.ingredients, isNotNull); - expect(result.product!.ingredients!.length, 9); + expect(result.product!.ingredients!.length, 10); findExpectedIngredients( result.product!.ingredients!, @@ -1138,6 +1138,101 @@ void main() { expect(result.product!.lastCheckDates, hasLength(3)); expect(result.product!.entryDates, isNotNull); expect(result.product!.entryDates, hasLength(3)); + + configuration = ProductQueryConfiguration( + '3017620425035', + fields: [ + ProductField.OWNER_FIELDS, + ], + version: ProductQueryVersion.v3, + ); + result = await getProductV3InProd( + configuration, + ); + expect(result.status, ProductResultV3.statusSuccess); + expect(result.product, isNotNull); + expect(result.product!.ownerFields, isNotNull); + const List localizedFields = [ + ProductField.ABBREVIATED_NAME, + ProductField.GENERIC_NAME_ALL_LANGUAGES, + ProductField.INGREDIENTS_TEXT_IN_LANGUAGES, + ProductField.NAME, + ]; + const List notLocalizedFields = [ + ProductField.BRANDS, + ProductField.COUNTRIES, + ProductField.LANGUAGE, + ProductField.NO_NUTRITION_DATA, + ProductField.NUTRIMENT_DATA_PER, + ProductField.OBSOLETE, + ProductField.PACKAGING, + ProductField.QUANTITY, + ProductField.SERVING_SIZE, + ]; + const List nutrients = [ + Nutrient.carbohydrates, + Nutrient.energyKCal, + Nutrient.energyKJ, + Nutrient.fat, + Nutrient.proteins, + Nutrient.salt, + Nutrient.saturatedFat, + Nutrient.sugars, + ]; + const List raws = [ + 'allergens', + 'conservation_conditions_fr', + 'customer_service_fr', + 'data_sources', + 'energy', + 'lc', + 'owner', + 'producer_version_id', + ]; + for (final ProductField productField in localizedFields) { + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.productField( + productField, + OpenFoodFactsLanguage.FRENCH, + )), + isNotNull, + ); + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.productField( + productField, + OpenFoodFactsLanguage.GERMAN, + )), + isNull, + ); + } + for (final ProductField productField in notLocalizedFields) { + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.productField( + productField, + OpenFoodFactsLanguage.FRENCH, + )), + isNotNull, + ); + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.productField( + productField, + OpenFoodFactsLanguage.GERMAN, + )), + isNotNull, + ); + } + for (final Nutrient nutrient in nutrients) { + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.nutrient(nutrient)), + isNotNull, + ); + } + for (final String raw in raws) { + expect( + result.product!.getOwnerFieldTimestamp(OwnerField.raw(raw)), + isNotNull, + ); + } }); group('$OpenFoodAPIClient get new packagings field', () {