Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/Recipe.swift
2025-05-31 11:12:14 +02:00

492 lines
18 KiB
Swift

//
// 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<String.Index>]? = nil) -> [(String, Range<String.Index>)] {
var foundMatches: [(String, Range<String.Index>)] = []
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"
}
}
}
*/