From c94088c1f02518fe92edb9288a1d3b60c46cb12e Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:28:17 +0100 Subject: [PATCH] Should work fine --- .../Extensions/String+Cases.swift | 12 -- .../OpenFoodFactsSDK/Schemas/Nutriments.swift | 146 ++++++++++--- .../Schemas/NutriscoreData.swift | 26 +++ .../OpenFoodFactsSDK/Schemas/Product.swift | 194 +++++++++++++++--- .../OpenFoodFactsTests.swift | 1 - 5 files changed, 312 insertions(+), 67 deletions(-) delete mode 100644 Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift create mode 100644 Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift diff --git a/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift b/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift deleted file mode 100644 index 84870e0..0000000 --- a/Sources/OpenFoodFactsSDK/Extensions/String+Cases.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -extension String { - public func camelCaseToSnakeCase() -> String { - let pattern = "([a-z0-9])([A-Z])" - let regex = try! NSRegularExpression(pattern: pattern, options: []) - let range = NSRange(location: 0, length: self.count) - return regex.stringByReplacingMatches( - in: self, options: [], range: range, withTemplate: "$1_$2" - ).lowercased() - } -} diff --git a/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift b/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift index 5e26320..30f6f88 100644 --- a/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/Nutriments.swift @@ -1,52 +1,146 @@ import Foundation +import Units @dynamicMemberLookup public struct Nutriments: Codable, Sendable { - private var storage: [String: Double] + // We separate values (Doubles) and units (Strings) for efficient, thread-safe storage + private let values: [String: Double] + private let units: [String: String] public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: AnyCodingKey.self) - var dict = [String: Double]() + var v = [String: Double]() + var u = [String: String]() + for key in container.allKeys { - if let val = try? container.decode(Double.self, forKey: key) { - dict[key.stringValue] = val - } else if let valStr = try? container.decode( - String.self, forKey: key), let val = Double(valStr) + let keyStr = key.stringValue + + // Try decoding as Double first (most common for _100g, _serving) + if let doubleVal = try? container.decode(Double.self, forKey: key) { + v[keyStr] = doubleVal + } + // Try decoding as String (could be a unit OR a number in string format) + else if let stringVal = try? container.decode( + String.self, forKey: key) { - dict[key.stringValue] = val + if let doubleFromStr = Double(stringVal) { + v[keyStr] = doubleFromStr + } else { + u[keyStr] = stringVal // It's likely a unit like "kcal", "g", "kj" + } } } - self.storage = dict + self.values = v + self.units = u } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: AnyCodingKey.self) - for (key, value) in storage { - try container.encode(value, forKey: AnyCodingKey(stringValue: key)) + for (key, val) in values { + try container.encode(val, forKey: AnyCodingKey(stringValue: key)) + } + for (key, unit) in units { + try container.encode(unit, forKey: AnyCodingKey(stringValue: key)) } } - /// Access any nutrient dynamically (e.g. `nutriments.energyKcal`) - public subscript(dynamicMember member: String) -> Double? { - // Convert camelCase "energyKcal" to snake_case "energy-kcal" or "energy_kcal" logic if needed - // For V2, OFF often returns "energy-kcal_100g" - let snake = member.camelCaseToSnakeCase() - return storage[snake] ?? storage["\(snake)_100g"] - ?? storage["\(snake)_value"] + /// Dynamic lookup: converts `nutriments.energyKcal` -> `Nutrient(name: "energy-kcal")` + public subscript(dynamicMember member: String) -> Nutrient { + let kebabName = member.camelCaseToKebabCase() + return Nutrient(name: kebabName, values: values, units: units) } - - // Specific standard getters - public var energyKcal: Double? { self.storage["energy-kcal_100g"] } - public var carbohydrates: Double? { self.storage["carbohydrates_100g"] } - public var fat: Double? { self.storage["fat_100g"] } - public var proteins: Double? { self.storage["proteins_100g"] } - public var salt: Double? { self.storage["salt_100g"] } } -// Helper for String extension used above -struct AnyCodingKey: CodingKey { +// MARK: - Nutrient View +public struct Nutrient: Sendable { + public let name: String + private let values: [String: Double] + private let units: [String: String] + + internal init( + name: String, values: [String: Double], units: [String: String] + ) { + self.name = name + self.values = values + self.units = units + } + + // MARK: - Raw Properties (Mapped to V2 JSON keys) + public var per100g: Double? { values["\(name)_100g"] } + public var perServing: Double? { values["\(name)_serving"] } + public var value: Double? { values["\(name)_value"] ?? values[name] } + public var unit: String? { units["\(name)_unit"] } + + // Computed / Legacy + public var valueComputed: Double? { values["\(name)_value"] } + + // Prepared + public var preparedPer100g: Double? { values["\(name)_prepared_100g"] } + public var preparedPerServing: Double? { + values["\(name)_prepared_serving"] + } + public var preparedValue: Double? { + values["\(name)_prepared_value"] ?? values["\(name)_prepared"] + } + public var preparedUnit: String? { units["\(name)_prepared_unit"] } + public var preparedValueComputed: Double? { + values["\(name)_prepared_value"] + } + + // MARK: - UnitValue Helpers (Restored) + + public var per100gUnitValue: UnitValue? { + guard let rawValue = per100g ?? value ?? valueComputed, + let unitString = unit, + let unitEnum = Unit(rawValue: unitString) + else { return nil } + return UnitValue(value: rawValue, unit: unitEnum) + } + + public var perServingUnitValue: UnitValue? { + guard let rawValue = perServing, + let unitString = unit, + let unitEnum = Unit(rawValue: unitString) + else { return nil } + return UnitValue(value: rawValue, unit: unitEnum) + } + + public var preparedPer100gUnitValue: UnitValue? { + guard + let rawValue = preparedPer100g ?? preparedValue + ?? preparedValueComputed, + let unitString = preparedUnit ?? unit, // Fallback to main unit + let unitEnum = Unit(rawValue: unitString) + else { return nil } + return UnitValue(value: rawValue, unit: unitEnum) + } + + public var preparedPerServingUnitValue: UnitValue? { + guard let rawValue = preparedPerServing, + let unitString = preparedUnit ?? unit, + let unitEnum = Unit(rawValue: unitString) + else { return nil } + return UnitValue(value: rawValue, unit: unitEnum) + } +} + +// MARK: - Helpers + +private struct AnyCodingKey: CodingKey { var stringValue: String var intValue: Int? init(stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { return nil } } + +extension String { + // Converts "energyKcal" -> "energy-kcal" + fileprivate func camelCaseToKebabCase() -> String { + let pattern = "([a-z0-9])([A-Z])" + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(location: 0, length: self.count) + return regex.stringByReplacingMatches( + in: self, options: [], range: range, withTemplate: "$1-$2" + ).lowercased() + } +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift b/Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift new file mode 100644 index 0000000..4c40f16 --- /dev/null +++ b/Sources/OpenFoodFactsSDK/Schemas/NutriscoreData.swift @@ -0,0 +1,26 @@ +public struct NutriscoreData: Sendable, Codable { + + public let saturatedFatRatio: Float? + public let saturatedFatRatioPoints: Int? + public let saturatedFatRatioValue: Float? + + public let isBeverage: Int? + public let isCheese: Int? + public let isWater: Int? + public let isFat: Int? + public let energy: Int? + + // ... + + public enum CodingKeys: String, CodingKey { + case saturatedFatRatio = "saturated_fat_ratio" + case saturatedFatRatioPoints = "saturated_fat_ratio_points" + case saturatedFatRatioValue = "saturated_fat_ratio_value" + case isBeverage = "is_beverage" + case isCheese = "is_cheese" + case isWater = "is_water" + case isFat = "is_fat" + case energy + } + +} diff --git a/Sources/OpenFoodFactsSDK/Schemas/Product.swift b/Sources/OpenFoodFactsSDK/Schemas/Product.swift index ccb0dd3..83ccac7 100644 --- a/Sources/OpenFoodFactsSDK/Schemas/Product.swift +++ b/Sources/OpenFoodFactsSDK/Schemas/Product.swift @@ -1,98 +1,236 @@ import Foundation +import Units public struct Product: Codable, Sendable, Identifiable { public let code: String? public let productName: String? + public let genericName: String? public let brands: String? + public let brandsTags: [String]? public let quantity: String? + + // MARK: - Images public let imageFrontUrl: String? public let imageSmallUrl: String? - public let ingredientsText: String? + public let imageUrl: String? + public let imageNutritionSmallUrl: String? + public let imageNutritionThumbUrl: String? + public let imageNutritionUrl: String? + public let imageIngredientsSmallUrl: String? + public let imageIngredientsThumbUrl: String? + public let imageIngredientsUrl: String? - // Grades + // MARK: - Quantities + public let productQuantity: Float? + public let productQuantityUnit: String? + public let servingQuantity: String? + public let servingQuantityUnit: String? + public let servingSize: String? + + // MARK: - Grades & Scores public let nutriscoreGrade: String? + public let nutriscoreData: NutriscoreData? public let novaGroup: Int? + public let ecoscoreGrade: String? - // Nutriments + public let nutritionDataPer: String? public let nutriments: Nutriments? - public let ingredients: [Ingredient]? + public let nutrientLevels: NutrientLevels? + + // MARK: - Ingredients & Allergens + public let ingredientsText: String? + public let ingredients: [Ingredient]? + public let ingredientsHierarchy: [String]? + public let allergens: String? + public let allergensHierarchy: [String]? - // MARK: - Product-Misc public let additivesN: Int? + + // MARK: - Metadata public let checked: String? public let complete: Int? public let completeness: Float? - public let ecoscoreGrade: String? - // ... - public let nutrientLevels: NutrientLevels? + public var categories: String? + public var categoriesHierarchy: [String]? public var id: String { guard let code = code else { return UUID().uuidString } - return code } + // MARK: - Computed Properties (Units) + public var waterQuantity: Units.UnitValue? { + guard let ingredient = ingredients?.first(where: { $0.isWater }) else { + return nil + } + + guard let quantity = productQuantity, + let estimate = ingredient.percentEstimate, + let rawUnit = productQuantityUnit, + let unit = Units.Unit(rawValue: rawUnit) + else { + return nil + } + + return Double(quantity * (estimate / 100))[unit] + } + + public var isLiquid: Bool { + guard let rawUnit = productQuantityUnit, + let unit = Units.Unit(rawValue: rawUnit) + else { + return false + } + + return unit.category == .volume + } + + // MARK: - CodingKeys private enum CodingKeys: String, CodingKey { case code case productName = "product_name" + case genericName = "generic_name" case brands + case brandsTags = "brands_tags" case quantity + + // Images case imageFrontUrl = "image_front_url" case imageSmallUrl = "image_small_url" + case imageUrl = "image_url" + case imageNutritionSmallUrl = "image_nutrition_small_url" + case imageNutritionThumbUrl = "image_nutrition_thumb_url" + case imageNutritionUrl = "image_nutrition_url" + case imageIngredientsSmallUrl = "image_ingredients_small_url" + case imageIngredientsThumbUrl = "image_ingredients_thumb_url" + case imageIngredientsUrl = "image_ingredients_url" + + // Quantities + case productQuantity = "product_quantity" + case productQuantityUnit = "product_quantity_unit" + case servingQuantity = "serving_quantity" + case servingQuantityUnit = "serving_quantity_unit" + case servingSize = "serving_size" + case ingredientsText = "ingredients_text" + case ingredients + case ingredientsHierarchy = "ingredients_hierarchy" + + // Grades & Data case nutriscoreGrade = "nutriscore_grade" + case nutriscoreData = "nutriscore_data" case ecoscoreGrade = "ecoscore_grade" case novaGroup = "nova_group" case nutriments - case ingredients + case nutrientLevels = "nutrient_levels" + case nutritionDataPer = "nutrition_data_per" + case complete case completeness case additivesN = "additives_n" case checked - case nutrientLevels = "nutrient_levels" + + case allergens + case allergensHierarchy = "allergens_hierarchy" + case categories + case categoriesHierarchy = "categories_hierarchy" } + // MARK: - Initializer public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) code = try container.decodeIfPresent(String.self, forKey: .code) productName = try container.decodeIfPresent( String.self, forKey: .productName) + genericName = try container.decodeIfPresent( + String.self, forKey: .genericName) brands = try container.decodeIfPresent(String.self, forKey: .brands) + brandsTags = try container.decodeIfPresent( + [String].self, forKey: .brandsTags) quantity = try container.decodeIfPresent(String.self, forKey: .quantity) + + // Images imageFrontUrl = try container.decodeIfPresent( String.self, forKey: .imageFrontUrl) imageSmallUrl = try container.decodeIfPresent( String.self, forKey: .imageSmallUrl) + imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) + imageNutritionSmallUrl = try container.decodeIfPresent( + String.self, forKey: .imageNutritionSmallUrl) + imageNutritionThumbUrl = try container.decodeIfPresent( + String.self, forKey: .imageNutritionThumbUrl) + imageNutritionUrl = try container.decodeIfPresent( + String.self, forKey: .imageNutritionUrl) + imageIngredientsSmallUrl = try container.decodeIfPresent( + String.self, forKey: .imageIngredientsSmallUrl) + imageIngredientsThumbUrl = try container.decodeIfPresent( + String.self, forKey: .imageIngredientsThumbUrl) + imageIngredientsUrl = try container.decodeIfPresent( + String.self, forKey: .imageIngredientsUrl) + + // Quantities (Robust decoding) + productQuantity = try container.decodeFloatOrString( + forKey: .productQuantity) + productQuantityUnit = try container.decodeIfPresent( + String.self, forKey: .productQuantityUnit) + + // servingQuantity is String?, but API might return a number + if let floatVal = try? container.decode( + Float.self, forKey: .servingQuantity) + { + servingQuantity = String(floatVal) + } else { + servingQuantity = try container.decodeIfPresent( + String.self, forKey: .servingQuantity) + } + + servingQuantityUnit = try container.decodeIfPresent( + String.self, forKey: .servingQuantityUnit) + servingSize = try container.decodeIfPresent( + String.self, forKey: .servingSize) + + // Ingredients & Categories ingredientsText = try container.decodeIfPresent( String.self, forKey: .ingredientsText) - - // Grades - nutriscoreGrade = try container.decodeIfPresent( - String.self, forKey: .nutriscoreGrade) - novaGroup = try container.decodeIfPresent( - Int.self, forKey: .novaGroup) - - // Nutriments - nutriments = try container.decodeIfPresent( - Nutriments.self, forKey: .nutriments) ingredients = try container.decodeIfPresent( [Ingredient].self, forKey: .ingredients) + ingredientsHierarchy = try container.decodeIfPresent( + [String].self, forKey: .ingredientsHierarchy) - // MARK: - Product-Misc + categories = try container.decodeIfPresent( + String.self, forKey: .categories) + categoriesHierarchy = try container.decodeIfPresent( + [String].self, forKey: .categoriesHierarchy) + + // Grades & Nutriments + nutriscoreGrade = try container.decodeIfPresent( + String.self, forKey: .nutriscoreGrade) + nutriscoreData = try container.decodeIfPresent( + NutriscoreData.self, forKey: .nutriscoreData) + novaGroup = try container.decodeIfPresent(Int.self, forKey: .novaGroup) + ecoscoreGrade = try container.decodeIfPresent( + String.self, forKey: .ecoscoreGrade) + + nutriments = try container.decodeIfPresent( + Nutriments.self, forKey: .nutriments) + nutrientLevels = try container.decodeIfPresent( + NutrientLevels.self, forKey: .nutrientLevels) + nutritionDataPer = try container.decodeIfPresent( + String.self, forKey: .nutritionDataPer) + + // Misc additivesN = try container.decodeIfPresent( Int.self, forKey: .additivesN) checked = try container.decodeIfPresent(String.self, forKey: .checked) complete = try container.decodeIfPresent(Int.self, forKey: .complete) completeness = try container.decodeFloatOrString(forKey: .completeness) - ecoscoreGrade = try container.decodeIfPresent( - String.self, forKey: .ecoscoreGrade) - // ... - nutrientLevels = try container.decodeIfPresent( - NutrientLevels.self, forKey: .nutrientLevels) + allergens = try container.decodeIfPresent( + String.self, forKey: .allergens) + allergensHierarchy = try container.decodeIfPresent( + [String].self, forKey: .allergensHierarchy) } - } diff --git a/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift b/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift index 28376a6..7bbf558 100644 --- a/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift +++ b/Tests/OpenFoodFactsTests/OpenFoodFactsTests.swift @@ -36,7 +36,6 @@ final class OpenFoodFactsTests: XCTestCase { .tag(tag: .brands, value: "milka"), .pageSize(5), .sort(.popularity), - fields: [.nutrientLevels] ) let jsonResults = try JSONEncoder().encode(results)