Should work fine

This commit is contained in:
cdricms
2025-12-06 17:28:17 +01:00
parent 401d268aa4
commit c94088c1f0
5 changed files with 312 additions and 67 deletions

View File

@@ -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()
}
}

View File

@@ -1,52 +1,146 @@
import Foundation import Foundation
import Units
@dynamicMemberLookup @dynamicMemberLookup
public struct Nutriments: Codable, Sendable { 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 { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: AnyCodingKey.self) 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 { for key in container.allKeys {
if let val = try? container.decode(Double.self, forKey: key) { let keyStr = key.stringValue
dict[key.stringValue] = val
} else if let valStr = try? container.decode( // Try decoding as Double first (most common for _100g, _serving)
String.self, forKey: key), let val = Double(valStr) 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 { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: AnyCodingKey.self) var container = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in storage { for (key, val) in values {
try container.encode(value, forKey: AnyCodingKey(stringValue: key)) 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`) /// Dynamic lookup: converts `nutriments.energyKcal` -> `Nutrient(name: "energy-kcal")`
public subscript(dynamicMember member: String) -> Double? { public subscript(dynamicMember member: String) -> Nutrient {
// Convert camelCase "energyKcal" to snake_case "energy-kcal" or "energy_kcal" logic if needed let kebabName = member.camelCaseToKebabCase()
// For V2, OFF often returns "energy-kcal_100g" return Nutrient(name: kebabName, values: values, units: units)
let snake = member.camelCaseToSnakeCase() }
return storage[snake] ?? storage["\(snake)_100g"]
?? storage["\(snake)_value"]
} }
// Specific standard getters // MARK: - Nutrient View
public var energyKcal: Double? { self.storage["energy-kcal_100g"] } public struct Nutrient: Sendable {
public var carbohydrates: Double? { self.storage["carbohydrates_100g"] } public let name: String
public var fat: Double? { self.storage["fat_100g"] } private let values: [String: Double]
public var proteins: Double? { self.storage["proteins_100g"] } private let units: [String: String]
public var salt: Double? { self.storage["salt_100g"] }
internal init(
name: String, values: [String: Double], units: [String: String]
) {
self.name = name
self.values = values
self.units = units
} }
// Helper for String extension used above // MARK: - Raw Properties (Mapped to V2 JSON keys)
struct AnyCodingKey: CodingKey { 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<Double>? {
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<Double>? {
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<Double>? {
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<Double>? {
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 stringValue: String
var intValue: Int? var intValue: Int?
init(stringValue: String) { self.stringValue = stringValue } init(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil } 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()
}
}

View File

@@ -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
}
}

View File

@@ -1,98 +1,236 @@
import Foundation import Foundation
import Units
public struct Product: Codable, Sendable, Identifiable { public struct Product: Codable, Sendable, Identifiable {
public let code: String? public let code: String?
public let productName: String? public let productName: String?
public let genericName: String?
public let brands: String? public let brands: String?
public let brandsTags: [String]?
public let quantity: String? public let quantity: String?
// MARK: - Images
public let imageFrontUrl: String? public let imageFrontUrl: String?
public let imageSmallUrl: 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 nutriscoreGrade: String?
public let nutriscoreData: NutriscoreData?
public let novaGroup: Int? public let novaGroup: Int?
public let ecoscoreGrade: String?
// Nutriments public let nutritionDataPer: String?
public let nutriments: Nutriments? 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? public let additivesN: Int?
// MARK: - Metadata
public let checked: String? public let checked: String?
public let complete: Int? public let complete: Int?
public let completeness: Float? public let completeness: Float?
public let ecoscoreGrade: String? public var categories: String?
// ... public var categoriesHierarchy: [String]?
public let nutrientLevels: NutrientLevels?
public var id: String { public var id: String {
guard let code = code else { guard let code = code else {
return UUID().uuidString return UUID().uuidString
} }
return code return code
} }
// MARK: - Computed Properties (Units)
public var waterQuantity: Units.UnitValue<Double>? {
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 { private enum CodingKeys: String, CodingKey {
case code case code
case productName = "product_name" case productName = "product_name"
case genericName = "generic_name"
case brands case brands
case brandsTags = "brands_tags"
case quantity case quantity
// Images
case imageFrontUrl = "image_front_url" case imageFrontUrl = "image_front_url"
case imageSmallUrl = "image_small_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 ingredientsText = "ingredients_text"
case ingredients
case ingredientsHierarchy = "ingredients_hierarchy"
// Grades & Data
case nutriscoreGrade = "nutriscore_grade" case nutriscoreGrade = "nutriscore_grade"
case nutriscoreData = "nutriscore_data"
case ecoscoreGrade = "ecoscore_grade" case ecoscoreGrade = "ecoscore_grade"
case novaGroup = "nova_group" case novaGroup = "nova_group"
case nutriments case nutriments
case ingredients case nutrientLevels = "nutrient_levels"
case nutritionDataPer = "nutrition_data_per"
case complete case complete
case completeness case completeness
case additivesN = "additives_n" case additivesN = "additives_n"
case checked 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 { public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code) code = try container.decodeIfPresent(String.self, forKey: .code)
productName = try container.decodeIfPresent( productName = try container.decodeIfPresent(
String.self, forKey: .productName) String.self, forKey: .productName)
genericName = try container.decodeIfPresent(
String.self, forKey: .genericName)
brands = try container.decodeIfPresent(String.self, forKey: .brands) brands = try container.decodeIfPresent(String.self, forKey: .brands)
brandsTags = try container.decodeIfPresent(
[String].self, forKey: .brandsTags)
quantity = try container.decodeIfPresent(String.self, forKey: .quantity) quantity = try container.decodeIfPresent(String.self, forKey: .quantity)
// Images
imageFrontUrl = try container.decodeIfPresent( imageFrontUrl = try container.decodeIfPresent(
String.self, forKey: .imageFrontUrl) String.self, forKey: .imageFrontUrl)
imageSmallUrl = try container.decodeIfPresent( imageSmallUrl = try container.decodeIfPresent(
String.self, forKey: .imageSmallUrl) 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( ingredientsText = try container.decodeIfPresent(
String.self, forKey: .ingredientsText) 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( ingredients = try container.decodeIfPresent(
[Ingredient].self, forKey: .ingredients) [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( additivesN = try container.decodeIfPresent(
Int.self, forKey: .additivesN) Int.self, forKey: .additivesN)
checked = try container.decodeIfPresent(String.self, forKey: .checked) checked = try container.decodeIfPresent(String.self, forKey: .checked)
complete = try container.decodeIfPresent(Int.self, forKey: .complete) complete = try container.decodeIfPresent(Int.self, forKey: .complete)
completeness = try container.decodeFloatOrString(forKey: .completeness) 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)
} }
} }

View File

@@ -36,7 +36,6 @@ final class OpenFoodFactsTests: XCTestCase {
.tag(tag: .brands, value: "milka"), .tag(tag: .brands, value: "milka"),
.pageSize(5), .pageSize(5),
.sort(.popularity), .sort(.popularity),
fields: [.nutrientLevels]
) )
let jsonResults = try JSONEncoder().encode(results) let jsonResults = try JSONEncoder().encode(results)