// // ObservableRecipeDetail.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 01.03.24. // import Foundation import SwiftUI import SwiftData // MARK: - Recipe Model @Model class RecipeImage { enum RecipeImageSize { case THUMB, FULL } var imageData: Data? @Transient var image: UIImage? { guard let imageData else { return nil } return UIImage(data: imageData) } init(imageData: Data? = nil) { self.imageData = imageData } } @Model class RecipeThumbnail { var thumbnailData: Data? @Transient var thumbnail: UIImage? { guard let thumbnailData else { return nil } return UIImage(data: thumbnailData) } init(thumbnailData: Data? = nil) { self.thumbnailData = thumbnailData } } @Model class Recipe { var id: String var name: String var keywords: [String] @Attribute(.externalStorage) var image: RecipeImage? @Attribute(.externalStorage) var thumbnail: RecipeThumbnail? var dateCreated: String? = nil var dateModified: String? = nil var prepTime: String var cookTime: String var totalTime: String var recipeDescription: String var url: String? var yield: Int var category: String var tools: [String] var ingredients: [String] var instructions: [String] var nutrition: [String:String] // Additional functionality @Transient var ingredientMultiplier: Double = 1.0 var prepTimeDurationComponent: DurationComponents { DurationComponents.fromPTString(prepTime) } var cookTimeDurationComponent: DurationComponents { DurationComponents.fromPTString(cookTime) } var totalTimeDurationComponent: DurationComponents { DurationComponents.fromPTString(totalTime) } init( id: String, name: String, keywords: [String], dateCreated: String? = nil, dateModified: String? = nil, prepTime: String, cookTime: String, totalTime: String, recipeDescription: String, url: String? = nil, yield: Int, category: String, tools: [String], ingredients: [String], instructions: [String], nutrition: [String : String], ingredientMultiplier: Double ) { self.id = id self.name = name self.keywords = keywords self.dateCreated = dateCreated self.dateModified = dateModified self.prepTime = prepTime self.cookTime = cookTime self.totalTime = totalTime self.recipeDescription = recipeDescription self.url = url self.yield = yield self.category = category self.tools = tools self.ingredients = ingredients self.instructions = instructions self.nutrition = nutrition self.ingredientMultiplier = ingredientMultiplier } init() { self.id = UUID().uuidString self.name = String(localized: "New Recipe") self.keywords = [] self.dateCreated = nil self.dateModified = nil self.prepTime = "0" self.cookTime = "0" self.totalTime = "0" self.recipeDescription = "" self.url = "" self.yield = 1 self.category = "" self.tools = [] self.ingredients = [] self.instructions = [] self.nutrition = [:] self.ingredientMultiplier = 1 } } // MARK: - Recipe Stub struct RecipeStub: Codable, Hashable, Identifiable { let id: String let name: String let keywords: String? let dateCreated: String? let dateModified: String? let thumbnailPath: String? var storedLocally: Bool = false var lastUpdated: String? } // MARK: - Recipe /* class Recipe: ObservableObject { // Cookbook recipe detail fields var id: String @Published var name: String @Published var keywords: [String] @Published var imageUrl: String? var dateCreated: String? = nil var dateModified: String? = nil @Published var prepTime: DurationComponents @Published var cookTime: DurationComponents @Published var totalTime: DurationComponents @Published var description: String @Published var url: String? @Published var recipeYield: Int @Published var recipeCategory: String @Published var tool: [String] @Published var recipeIngredient: [String] @Published var recipeInstructions: [String] @Published var nutrition: [String:String] // Additional functionality @Published var ingredientMultiplier: Double init() { id = "" name = String(localized: "New Recipe") keywords = [] imageUrl = "" prepTime = DurationComponents() cookTime = DurationComponents() totalTime = DurationComponents() description = "" url = "" recipeYield = 1 recipeCategory = "" tool = [] recipeIngredient = [] recipeInstructions = [] nutrition = [:] ingredientMultiplier = 1 } init(_ recipeDetail: CookbookApiRecipeDetailV1) { id = recipeDetail.id name = recipeDetail.name keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",") imageUrl = recipeDetail.imageUrl ?? "" prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "") cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "") totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "") description = recipeDetail.description url = recipeDetail.url ?? "" recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero recipeCategory = recipeDetail.recipeCategory tool = recipeDetail.tool recipeIngredient = recipeDetail.recipeIngredient recipeInstructions = recipeDetail.recipeInstructions nutrition = recipeDetail.nutrition ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) } init( name: String, keywords: [String], dateCreated: String?, dateModified: String?, imageUrl: String?, id: String, prepTime: DurationComponents? = nil, cookTime: DurationComponents? = nil, totalTime: DurationComponents? = nil, description: String, url: String?, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String] ) { self.name = name self.keywords = keywords self.dateCreated = dateCreated self.dateModified = dateModified self.imageUrl = imageUrl self.id = id self.prepTime = prepTime ?? DurationComponents() self.cookTime = cookTime ?? DurationComponents() self.totalTime = totalTime ?? DurationComponents() self.description = description self.url = url self.recipeYield = recipeYield self.recipeCategory = recipeCategory self.tool = tool self.recipeIngredient = recipeIngredient self.recipeInstructions = recipeInstructions self.nutrition = nutrition ingredientMultiplier = Double(recipeYield == 0 ? 1 : recipeYield) } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) _name = Published(initialValue: try container.decode(String.self, forKey: .name)) _keywords = Published(initialValue: try container.decode([String].self, forKey: .keywords)) _imageUrl = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .imageUrl)) dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated) dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified) _prepTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .prepTime)) _cookTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .cookTime)) _totalTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .totalTime)) _description = Published(initialValue: try container.decode(String.self, forKey: .description)) _url = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .url)) _recipeYield = Published(initialValue: try container.decode(Int.self, forKey: .recipeYield)) _recipeCategory = Published(initialValue: try container.decode(String.self, forKey: .recipeCategory)) _tool = Published(initialValue: try container.decode([String].self, forKey: .tool)) _recipeIngredient = Published(initialValue: try container.decode([String].self, forKey: .recipeIngredient)) _recipeInstructions = Published(initialValue: try container.decode([String].self, forKey: .recipeInstructions)) _nutrition = Published(initialValue: try container.decode([String: String].self, forKey: .nutrition)) _ingredientMultiplier = Published(initialValue: try container.decode(Double.self, forKey: .ingredientMultiplier)) } func toRecipeDetail() -> CookbookApiRecipeDetailV1 { return CookbookApiRecipeDetailV1( name: self.name, keywords: self.keywords.joined(separator: ","), dateCreated: "", dateModified: "", imageUrl: self.imageUrl, id: self.id, prepTime: self.prepTime.toPTString(), cookTime: self.cookTime.toPTString(), totalTime: self.totalTime.toPTString(), description: self.description, url: self.url, recipeYield: self.recipeYield, recipeCategory: self.recipeCategory, tool: self.tool, recipeIngredient: self.recipeIngredient, recipeInstructions: self.recipeInstructions, nutrition: self.nutrition ) } static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString { if factor == 0 { return AttributedString(ingredient) } // Match mixed fractions first var matches = Recipe.matchPatternAndMultiply( .mixedFraction, in: ingredient, multFactor: factor ) // Then match fractions, exclude mixed fraction ranges matches.append(contentsOf: Recipe.matchPatternAndMultiply( .fraction, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 }) ) ) // Match numbers at last, exclude all prior matches matches.append(contentsOf: Recipe.matchPatternAndMultiply( .number, in: ingredient, multFactor: factor, excludedRanges: matches.map({ tuple in tuple.1 }) ) ) // Sort matches by match range lower bound, descending. matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound}) var attributedString = AttributedString(ingredient) for (newSubstring, matchRange) in matches { guard let range = Range(matchRange, in: attributedString) else { continue } var attributedSubString = AttributedString(newSubstring) //attributedSubString.foregroundColor = .ncTextHighlight attributedSubString.font = .system(.body, weight: .bold) attributedString.replaceSubrange(range, with: attributedSubString) } return attributedString } static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range]? = nil) -> [(String, Range)] { var foundMatches: [(String, Range)] = [] do { let regex = try NSRegularExpression(pattern: expr.pattern) let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str)) for match in matches { guard let matchRange = Range(match.range, in: str) else { continue } if let excludedRanges = excludedRanges, excludedRanges.contains(where: { $0.overlaps(matchRange) }) { // If there's an overlap, skip this match. continue } let matchedString = String(str[matchRange]) // Process each match based on its type var adjustedValue: Double = 0 switch expr { case .number: guard let number = numberFormatter.number(from: matchedString) else { continue } adjustedValue = number.doubleValue case .fraction: let fracComponents = matchedString.split(separator: "/") guard fracComponents.count == 2 else { continue } guard let nominator = Double(fracComponents[0]) else { continue } guard let denominator = Double(fracComponents[1]), denominator > 0 else { continue } adjustedValue = nominator/denominator case .mixedFraction: guard match.numberOfRanges == 4 else { continue } guard let intRange = Range(match.range(at: 1), in: str) else { continue } guard let nomRange = Range(match.range(at: 2), in: str) else { continue } guard let denomRange = Range(match.range(at: 3), in: str) else { continue } guard let number = Double(str[intRange]), let nominator = Double(str[nomRange]), let denominator = Double(str[denomRange]), denominator > 0 else { continue } adjustedValue = number + nominator/denominator } let formattedAdjustedValue = formatNumber(adjustedValue * multFactor) foundMatches.append((formattedAdjustedValue, matchRange)) } return foundMatches } catch { print("Regex error: \(error.localizedDescription)") } return [] } static func formatNumber(_ value: Double) -> String { if value <= 0.0001 { return "0" } let integerPart = value >= 1 ? Int(value) : 0 let decimalPart = value - Double(integerPart) if integerPart >= 1 && decimalPart < 0.0001 { return String(format: "%.0f", value) } // Define known fractions and their decimal equivalents let knownFractions: [(fraction: String, value: Double)] = [ ("1/8", 0.125), ("1/6", 0.167), ("1/4", 0.25), ("1/3", 0.33), ("1/2", 0.5), ("2/3", 0.66), ("3/4", 0.75) ] // Find the known fraction closest to the given value let closest = knownFractions.min(by: { abs($0.value - decimalPart) < abs($1.value - decimalPart) })! // Check if the value is close enough to a known fraction to be considered a match let threshold = 0.05 if abs(closest.value - decimalPart) <= threshold && integerPart == 0 { return closest.fraction } else if abs(closest.value - decimalPart) <= threshold && integerPart > 0 { return "\(String(integerPart)) \(closest.fraction)" } else { // If no close match is found, return the original value as a string return numberFormatter.string(from: NSNumber(value: value)) ?? "0"//String(format: "%.2f", value) } } func ingredientUnitsToMetric() { // TODO: Convert imperial units in recipes to metric units } } extension Recipe: Codable { enum CodingKeys: String, CodingKey { case id, name, keywords, imageUrl, dateCreated, dateModified, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition, ingredientMultiplier } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(keywords, forKey: .keywords) try container.encode(imageUrl, forKey: .imageUrl) try container.encode(dateCreated, forKey: .dateCreated) try container.encode(dateModified, forKey: .dateModified) try container.encode(prepTime, forKey: .prepTime) try container.encode(cookTime, forKey: .cookTime) try container.encode(totalTime, forKey: .totalTime) try container.encode(description, forKey: .description) try container.encode(url, forKey: .url) try container.encode(recipeYield, forKey: .recipeYield) try container.encode(recipeCategory, forKey: .recipeCategory) try container.encode(tool, forKey: .tool) try container.encode(recipeIngredient, forKey: .recipeIngredient) try container.encode(recipeInstructions, forKey: .recipeInstructions) try container.encode(nutrition, forKey: .nutrition) try container.encode(ingredientMultiplier, forKey: .ingredientMultiplier) } } enum RegexPattern: String, CaseIterable, Identifiable { case mixedFraction, fraction, number var id: String { self.rawValue } var pattern: String { switch self { case .mixedFraction: #"(\d+)\s+(\d+)/(\d+)"# case .fraction: #"(?:[1-9][0-9]*|0)\/([1-9][0-9]*)"# case .number: #"(\d+([.,]\d+)?)"# } } var localizedDescription: LocalizedStringKey { switch self { case .mixedFraction: "Mixed fraction" case .fraction: "Fraction" case .number: "Number" } } } */