// // MainViewModel.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 06.09.23. // import Foundation import OSLog import SwiftUI import UIKit @MainActor class AppState: ObservableObject { @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:] @Published var timers: [String: RecipeTimer] = [:] var recipeImages: [Int: [String: UIImage]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] var allKeywords: [RecipeKeyword] = [] private let dataStore: DataStore private let api: CookbookApiProtocol init(api: CookbookApiProtocol? = nil) { Logger.network.debug("Created AppState") self.dataStore = DataStore() self.api = api ?? CookbookApiFactory.makeClient() if UserSettings.shared.authString == "" { let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginData = loginString.data(using: String.Encoding.utf8)! UserSettings.shared.authString = loginData.base64EncodedString() } } enum FetchMode { case preferLocal, preferServer, onlyLocal, onlyServer } // MARK: - Categories func getCategories() async { do { let categories = try await api.getCategories() Logger.data.debug("Successfully loaded categories") self.categories = categories await saveLocal(self.categories, path: "categories.data") } catch { Logger.data.debug("Loading categories from store ...") if let categories: [Category] = await loadLocal(path: "categories.data") { self.categories = categories Logger.data.debug("Loaded categories from local store") } else { Logger.data.error("Failed to load categories from local store") } } for category in self.categories { lastUpdates[category.name] = Date.distantPast } } func getCategory(named name: String, fetchMode: FetchMode) async { Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))") func getLocal() async -> Bool { let categoryString = name == "*" ? "_" : name if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { self.recipes[name] = recipes return true } return false } func getServer(store: Bool = false) async -> Bool { let categoryString = name == "*" ? "_" : name do { let recipes = try await api.getCategory(named: categoryString) self.recipes[name] = recipes if store { await saveLocal(recipes, path: "category_\(categoryString).data") } return true } catch { return false } } switch fetchMode { case .preferLocal: if await getLocal() { return } if await getServer(store: true) { return } case .preferServer: if await getServer(store: true) { return } if await getLocal() { return } case .onlyLocal: if await getLocal() { return } case .onlyServer: if await getServer() { return } } } // MARK: - Recipe details func updateAllRecipeDetails() async { for category in self.categories { await updateRecipeDetails(in: category.name) } UserSettings.shared.lastUpdate = Date() } func updateRecipeDetails(in category: String) async { guard UserSettings.shared.storeRecipes else { return } guard let recipes = self.recipes[category] else { return } for recipe in recipes { if let dateModified = recipe.dateModified { if needsUpdate(category: category, lastModified: dateModified) { Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))") await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } else { Logger.data.debug("\(recipe.name) is up to date.") } } else { await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } } } func getRecipes() async -> [Recipe] { do { return try await api.getRecipes() } catch { Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)") } var allRecipes: [Recipe] = [] for category in categories { if let recipeArray = self.recipes[category.name] { allRecipes.append(contentsOf: recipeArray) } } return allRecipes.sorted(by: { $0.name < $1.name }) } func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? { func getLocal() async -> RecipeDetail? { if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } return nil } func getServer() async -> RecipeDetail? { do { let recipe = try await api.getRecipe(id: id) if save { self.recipeDetails[id] = recipe await self.saveLocal(recipe, path: "recipe\(id).data") } return recipe } catch { Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)") return nil } } switch fetchMode { case .preferLocal: if let recipe = await getLocal() { return recipe } if let recipe = await getServer() { return recipe } case .preferServer: if let recipe = await getServer() { return recipe } if let recipe = await getLocal() { return recipe } case .onlyLocal: if let recipe = await getLocal() { return recipe } case .onlyServer: if let recipe = await getServer() { return recipe } } return nil } func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async { if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) { await saveLocal(recipeDetail, path: "recipe\(id).data") } if withThumb { let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer) guard let thumbnail = thumbnail else { return } guard let thumbnailData = thumbnail.pngData() else { return } await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb") } if withImage { let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer) guard let image = image else { return } guard let imageData = image.pngData() else { return } await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full") } } func recipeDetailExists(recipeId: Int) -> Bool { return dataStore.recipeDetailExists(recipeId: recipeId) } // MARK: - Images func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? { func getLocal() async -> UIImage? { return await imageFromStore(id: id, size: size) } func getServer() async -> UIImage? { do { return try await api.getImage(id: id, size: size) } catch { return nil } } switch fetchMode { case .preferLocal: if let image = imageFromCache(id: id, size: size) { return image } if !imageUpdateNeeded(id: id, size: size) { return nil } if let image = await getLocal() { imageToCache(id: id, size: size, image: image) return image } if let image = await getServer() { await imageToStore(id: id, size: size, image: image) imageToCache(id: id, size: size, image: image) return image } case .preferServer: if let image = await getServer() { await imageToStore(id: id, size: size, image: image) imageToCache(id: id, size: size, image: image) return image } if let image = imageFromCache(id: id, size: size) { return image } if let image = await getLocal() { imageToCache(id: id, size: size, image: image) return image } case .onlyLocal: if let image = imageFromCache(id: id, size: size) { return image } if !imageUpdateNeeded(id: id, size: size) { return nil } if let image = await getLocal() { imageToCache(id: id, size: size, image: image) return image } case .onlyServer: if let image = imageFromCache(id: id, size: size) { return image } if let image = await getServer() { imageToCache(id: id, size: size, image: image) return image } } imagesNeedUpdate[id] = [size.rawValue: false] return nil } // MARK: - Keywords func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] { func getLocal() async -> [RecipeKeyword]? { return await loadLocal(path: "keywords.data") } func getServer() async -> [RecipeKeyword]? { do { return try await api.getTags() } catch { return nil } } switch fetchMode { case .preferLocal: if let keywords = await getLocal() { return keywords } if let keywords = await getServer() { await saveLocal(keywords, path: "keywords.data") return keywords } case .preferServer: if let keywords = await getServer() { await saveLocal(keywords, path: "keywords.data") return keywords } if let keywords = await getLocal() { return keywords } case .onlyLocal: if let keywords = await getLocal() { return keywords } case .onlyServer: if let keywords = await getServer() { return keywords } } return [] } // MARK: - Data management func deleteAllData() { if dataStore.clearAll() { self.categories = [] self.recipes = [:] self.recipeDetails = [:] self.recipeImages = [:] self.imagesNeedUpdate = [:] } } func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { do { try await api.deleteRecipe(id: id) } catch { return .REQUEST_DROPPED } let path = "recipe\(id).data" dataStore.delete(path: path) if recipes[categoryName] != nil { recipes[categoryName]!.removeAll(where: { recipe in recipe.recipe_id == id }) recipeDetails.removeValue(forKey: id) } return nil } func checkServerConnection() async -> Bool { do { let categories = try await api.getCategories() self.categories = categories await saveLocal(categories, path: "categories.data") return true } catch { return false } } func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) { do { if createNew { let id = try await api.createRecipe(recipeDetail) return (id, nil) } else { let id = try await api.updateRecipe(recipeDetail) return (id, nil) } } catch { return (nil, .REQUEST_DROPPED) } } func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { do { let recipeDetail = try await api.importRecipe(url: url) return (recipeDetail, nil) } catch { return (nil, .REQUEST_DROPPED) } } } // MARK: - Local storage helpers extension AppState { func loadLocal(path: String) async -> T? { do { return try await dataStore.load(fromPath: path) } catch { Logger.data.debug("Failed to load local data: \(error.localizedDescription)") return nil } } func saveLocal(_ object: T, path: String) async { await dataStore.save(data: object, toPath: path) } private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { do { let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")" if let data: String = try await dataStore.load(fromPath: localPath) { guard let dataDecoded = Data(base64Encoded: data) else { return nil } let image = UIImage(data: dataDecoded) return image } } catch { Logger.data.debug("Could not find image in local storage.") return nil } return nil } private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async { if let data = image.pngData() { await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)") } } private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) { if recipeImages[id] != nil { recipeImages[id]![size.rawValue] = image } else { recipeImages[id] = [size.rawValue : image] } if imagesNeedUpdate[id] != nil { imagesNeedUpdate[id]![size.rawValue] = false } else { imagesNeedUpdate[id] = [size.rawValue: false] } } private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? { if recipeImages[id] != nil { return recipeImages[id]![size.rawValue] } return nil } private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool { if imagesNeedUpdate[id] != nil { if imagesNeedUpdate[id]![size.rawValue] != nil { return imagesNeedUpdate[id]![size.rawValue]! } } return true } private func needsUpdate(category: String, lastModified: String) -> Bool { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] { if date < lastUpdate { Logger.data.debug("No update needed for \(category)") return false } else { Logger.data.debug("Update needed for \(category)") return true } } Logger.data.debug("Date parse failed, update needed for \(category)") return true } } extension DateFormatter { static func utcToString(date: Date) -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) return dateFormatter.string(from: date) } } // Timer logic extension AppState { func createTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { let timer = RecipeTimer(duration: duration) timers[recipeId] = timer return timer } func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration) } func deleteTimer(forRecipe recipeId: String) { timers.removeValue(forKey: recipeId) } }