492 lines
18 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|
|
|
|
*/
|