Files
2025-12-06 19:31:18 +01:00

325 lines
12 KiB
Swift

import Foundation
import Units
@dynamicMemberLookup
public struct Nutriments: Codable, Sendable, Sequence {
// 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 v = [String: Double]()
var u = [String: String]()
for key in container.allKeys {
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)
{
if let doubleFromStr = Double(stringVal) {
v[keyStr] = doubleFromStr
} else {
u[keyStr] = stringVal // It's likely a unit like "kcal", "g", "kj"
}
}
}
self.values = v
self.units = u
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: AnyCodingKey.self)
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))
}
}
/// 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)
}
// MARK: - Sequence Conformance
// Allows: nutriments.forEach { nutrient in ... }
public func makeIterator() -> AnyIterator<Nutrient> {
// 1. Collect all keys from values and units
let allKeys = Set(values.keys).union(units.keys)
// 2. Extract unique base names (e.g., "energy-kcal_100g" -> "energy-kcal")
let baseNames = allKeys.compactMap { key -> String? in
// Filter out keys that are not nutrient properties
guard !key.isEmpty else { return nil }
// Remove common suffixes to find the "root" nutrient name
let suffixes = [
"_100g", "_serving", "_unit", "_value", "_prepared", "_label",
]
var name = key
// Sort suffixes by length (descending) to avoid partial matches
for suffix in suffixes {
if let range = name.range(of: suffix) {
name.removeSubrange(range)
}
}
return name
}
// 3. Create a unique sorted list
let uniqueNames = Set(baseNames).sorted()
var iterator = uniqueNames.makeIterator()
// 4. Return an iterator that produces `Nutrient` views
return AnyIterator {
guard let name = iterator.next() else { return nil }
return Nutrient(name: name, values: self.values, units: self.units)
}
}
}
// 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"] ?? _unit?.rawValue }
// 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)
}
private var _unit: Units.Unit? {
switch name {
case "energy-kj": .init(rawValue: "kJ")
case "energy-kcal": .init(rawValue: "kcal")
case "energy": .init(rawValue: "kj")
case "energy-from-fat": .init(rawValue: "kJ")
case "fat": .init(rawValue: "g")
case "saturated-fat": .init(rawValue: "g")
case "butyric-acid": .init(rawValue: "g")
case "caproic-acid": .init(rawValue: "g")
case "caprylic-acid": .init(rawValue: "g")
case "capric-acid": .init(rawValue: "g")
case "lauric-acid": .init(rawValue: "g")
case "myristic-acid": .init(rawValue: "g")
case "palmitic-acid": .init(rawValue: "g")
case "Psicose": .init(rawValue: "g")
case "stearic-acid": .init(rawValue: "g")
case "arachidic-acid": .init(rawValue: "g")
case "behenic-acid": .init(rawValue: "g")
case "lignoceric-acid": .init(rawValue: "g")
case "cerotic-acid": .init(rawValue: "g")
case "montanic-acid": .init(rawValue: "g")
case "melissic-acid": .init(rawValue: "g")
case "unsaturated-fat": .init(rawValue: "g")
case "monounsaturated-fat": .init(rawValue: "g")
case "polyunsaturated-fat": .init(rawValue: "g")
case "omega-3-fat": .init(rawValue: "mg")
case "alpha-linolenic-acid": .init(rawValue: "g")
case "eicosapentaenoic-acid": .init(rawValue: "g")
case "docosahexaenoic-acid": .init(rawValue: "g")
case "omega-6-fat": .init(rawValue: "mg")
case "linoleic-acid": .init(rawValue: "g")
case "arachidonic-acid": .init(rawValue: "g")
case "gamma-linolenic-acid": .init(rawValue: "g")
case "dihomo-gamma-linolenic-acid": .init(rawValue: "g")
case "omega-9-fat": .init(rawValue: "mg")
case "oleic-acid": .init(rawValue: "g")
case "elaidic-acid": .init(rawValue: "g")
case "gondoic-acid": .init(rawValue: "g")
case "mead-acid": .init(rawValue: "g")
case "erucic-acid": .init(rawValue: "g")
case "nervonic-acid": .init(rawValue: "g")
case "trans-fat": .init(rawValue: "g")
case "cholesterol": .init(rawValue: "mg")
case "gamma-oryzanol": .init(rawValue: "mg")
case "carbohydrates-total": .init(rawValue: "g")
case "carbohydrates": .init(rawValue: "g")
case "sugars": .init(rawValue: "g")
case "added-sugars": .init(rawValue: "g")
case "sucrose": .init(rawValue: "g")
case "glucose": .init(rawValue: "g")
case "fructose": .init(rawValue: "g")
case "oligosaccharide": .init(rawValue: "g")
case "lactose": .init(rawValue: "g")
case "galactose": .init(rawValue: "g")
case "maltose": .init(rawValue: "g")
case "maltodextrins": .init(rawValue: "g")
case "starch": .init(rawValue: "g")
case "polyols": .init(rawValue: "g")
case "Erythritol": .init(rawValue: "g")
case "Isomalt": .init(rawValue: "g")
case "Maltitol": .init(rawValue: "g")
case "Sorbitol": .init(rawValue: "g")
case "fiber": .init(rawValue: "g")
case "soluble-fiber": .init(rawValue: "g")
case "insoluble-fiber": .init(rawValue: "g")
case "proteins": .init(rawValue: "g")
case "casein": .init(rawValue: "g")
case "serum-proteins": .init(rawValue: "g")
case "nucleotides": .init(rawValue: "g")
case "salt": .init(rawValue: "g")
case "added-salt": .init(rawValue: "g")
case "sodium": .init(rawValue: "g")
case "alcohol": .init(rawValue: "% vol")
case "vitamin-a": .init(rawValue: "µg")
case "beta-carotene": .init(rawValue: "g")
case "vitamin-d": .init(rawValue: "µg")
case "vitamin-e": .init(rawValue: "mg")
case "vitamin-k": .init(rawValue: "µg")
case "vitamin-c": .init(rawValue: "mg")
case "vitamin-b1": .init(rawValue: "mg")
case "vitamin-b2": .init(rawValue: "mg")
case "vitamin-pp": .init(rawValue: "mg")
case "vitamin-b6": .init(rawValue: "mg")
case "vitamin-b9": .init(rawValue: "µg")
case "folates": .init(rawValue: "µg")
case "vitamin-b12": .init(rawValue: "µg")
case "biotin": .init(rawValue: "µg")
case "pantothenic-acid": .init(rawValue: "mg")
case "silica": .init(rawValue: "mg")
case "bicarbonate": .init(rawValue: "mg")
case "Sulphate": .init(rawValue: "mg")
case "Nitrate": .init(rawValue: "mg")
case "potassium": .init(rawValue: "mg")
case "chloride": .init(rawValue: "mg")
case "calcium": .init(rawValue: "mg")
case "phosphorus": .init(rawValue: "mg")
case "iron": .init(rawValue: "mg")
case "magnesium": .init(rawValue: "mg")
case "zinc": .init(rawValue: "mg")
case "copper": .init(rawValue: "mg")
case "manganese": .init(rawValue: "mg")
case "fluoride": .init(rawValue: "mg")
case "selenium": .init(rawValue: "µg")
case "chromium": .init(rawValue: "µg")
case "molybdenum": .init(rawValue: "µg")
case "iodine": .init(rawValue: "µg")
case "caffeine": .init(rawValue: "mg")
case "taurine": .init(rawValue: "g")
case "chlorophyl": .init(rawValue: "g")
case "choline": .init(rawValue: "g")
case "phylloquinone": .init(rawValue: "g")
case "beta-glucan": .init(rawValue: "g")
case "inositol": .init(rawValue: "g")
case "carnitine": .init(rawValue: "g")
case "melatonin": .init(rawValue: "µg")
case "methylsulfonylmethane": .init(rawValue: "mg")
case "creatine": .init(rawValue: "g")
case "l-citrulline": .init(rawValue: "mg")
case "l-glutamine": .init(rawValue: "mg")
case "bcaa": .init(rawValue: "g")
case "l-valine": .init(rawValue: "mg")
case "l-leucine": .init(rawValue: "mg")
case "l-isoleucine": .init(rawValue: "mg")
case "l-arginine": .init(rawValue: "mg")
case "l-cysteine": .init(rawValue: "mg")
case "l-Glutathione": .init(rawValue: "mg")
case "iron II sulphate monohydrate": .init(rawValue: "mg")
case "potassium iodide": .init(rawValue: "mg")
case "copper II sulphate pentahydrate": .init(rawValue: "mg")
case "manganous sulphate monohydrate": .init(rawValue: "mg")
case "zinc sulphate monohydrate": .init(rawValue: "mg")
case "sodium selenite": .init(rawValue: "mg")
case "calcium iodate anhydrous": .init(rawValue: "mg")
case "cassia gum": .init(rawValue: "mg")
case "ammonium chloride": .init(rawValue: "mg")
case "choline chloride": .init(rawValue: "mg")
default: nil
}
}
}
// 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()
}
}