diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index c45f620..9ffd489 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -38,6 +38,11 @@ A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; }; A79AA8F12B0D0B74007D25F2 /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */; }; A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */; }; + A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; }; + A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; }; + A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; }; + A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */; }; + A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; }; @@ -96,6 +101,11 @@ A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = ""; }; A79AA8F02B0D0B74007D25F2 /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; + A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = ""; }; + A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = ""; }; + A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = ""; }; + A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiRequest.swift; sourceTree = ""; }; + A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = ""; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = ""; }; A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = ""; }; @@ -195,6 +205,8 @@ A70171B22AB211F000064C43 /* Network */ = { isa = PBXGroup; children = ( + A79AA8EE2B063B33007D25F2 /* NextcloudApi */, + A79AA8E72B062DB6007D25F2 /* CookbookApi */, A703226C2ABAF90D00D7C4ED /* APIController.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, @@ -245,6 +257,7 @@ children = ( A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, + A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -273,6 +286,22 @@ A79AA8F22B0D0E1D007D25F2 /* TokenLoginView.swift */, ); path = Onboarding; + A79AA8E72B062DB6007D25F2 /* CookbookApi */ = { + isa = PBXGroup; + children = ( + A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, + A79AA8E32B02A961007D25F2 /* CookbookApi.swift */, + A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */, + ); + path = CookbookApi; + sourceTree = ""; + }; + A79AA8EE2B063B33007D25F2 /* NextcloudApi */ = { + isa = PBXGroup; + children = ( + A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */, + ); + path = NextcloudApi; sourceTree = ""; }; /* End PBXGroup section */ @@ -419,15 +448,19 @@ A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, + A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, + A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, + A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, + A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A79AA8F32B0D0E1E007D25F2 /* TokenLoginView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, @@ -435,6 +468,7 @@ A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226D2ABAF90D00D7C4ED /* APIController.swift in Sources */, + A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 819e556..23f0685 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -146,6 +146,9 @@ extension RecipeDetail { struct RecipeImage { + enum RecipeImageSize { + case THUMB, FULL + } var imageExists: Bool = true var thumb: UIImage? var full: UIImage? @@ -193,7 +196,4 @@ struct MetaData: Codable { } -// Networking -struct ServerMessage: Decodable { - let msg: String -} + diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift index 020338f..1b18fbb 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -22,6 +22,12 @@ class UserSettings: ObservableObject { } } + @Published var authString: String { + didSet { + UserDefaults.standard.set(authString, forKey: "authString") + } + } + @Published var serverAddress: String { didSet { UserDefaults.standard.set(serverAddress, forKey: "serverAddress") @@ -55,11 +61,30 @@ class UserSettings: ObservableObject { init() { self.username = UserDefaults.standard.object(forKey: "username") as? String ?? "" self.token = UserDefaults.standard.object(forKey: "token") as? String ?? "" + self.authString = UserDefaults.standard.object(forKey: "authString") as? String ?? "" self.serverAddress = UserDefaults.standard.object(forKey: "serverAddress") as? String ?? "" self.onboarding = UserDefaults.standard.object(forKey: "onboarding") as? Bool ?? true self.defaultCategory = UserDefaults.standard.object(forKey: "defaultCategory") as? String ?? "" self.language = UserDefaults.standard.object(forKey: "language") as? String ?? SupportedLanguage.DEVICE.rawValue self.downloadRecipes = UserDefaults.standard.object(forKey: "downloadRecipes") as? Bool ?? false + + if authString == "" { + if token != "" && username != "" { + let loginString = "\(self.username):\(self.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + authString = loginData.base64EncodedString() + } + } + } + + func setAuthString() -> String { + if token != "" && username != "" { + let loginString = "\(self.username):\(self.token)" + let loginData = loginString.data(using: String.Encoding.utf8)! + return loginData.base64EncodedString() + } else { + return "" + } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift index 89b8498..6218642 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift @@ -11,11 +11,9 @@ extension JSONDecoder { static func safeDecode(_ data: Data) -> T? { let decoder = JSONDecoder() do { - print("Decoding type ", T.self, " ...") return try decoder.decode(T.self, from: data) } catch (let error) { - print("JSONDecoder - safeDecode(): Failed to decode data.") - print("Error: ", error) + print(error) return nil } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift new file mode 100644 index 0000000..65c3999 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift @@ -0,0 +1,19 @@ +// +// Logger.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 13.11.23. +// + +import Foundation +import OSLog + +extension Logger { + private static var subsystem = Bundle.main.bundleIdentifier! + + /// UI related logging + static let view = Logger(subsystem: subsystem, category: "view") + + /// Network related logging + static let network = Logger(subsystem: subsystem, category: "network") +} diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index a0d535f..420696a 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -292,6 +292,12 @@ } } } + }, + "Action completed." : { + + }, + "Action delayed" : { + }, "Add" : { "localizations" : { @@ -622,6 +628,9 @@ } } } + }, + "Could not establish a connection to the server. The action will be retried upon reconnection." : { + }, "Delete" : { "localizations" : { @@ -865,6 +874,9 @@ } } } + }, + "Error" : { + }, "Error." : { "localizations" : { @@ -1877,6 +1889,9 @@ } } } + }, + "Success" : { + }, "Support" : { "localizations" : { @@ -2100,6 +2115,9 @@ } } } + }, + "Unable to complete action." : { + }, "Unable to connect to server." : { "localizations" : { diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift new file mode 100644 index 0000000..ea6f272 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift @@ -0,0 +1,104 @@ +// +// ApiRequest.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation +import OSLog + +struct ApiRequest { + /// The server address, e.g. https://example.com + let serverAddress: String + let path: String + let method: RequestMethod + let authString: String? + let headerFields: [HeaderField] + let body: Data? + + /// The path to the Cookbook application on the nextcloud server. + let cookbookPath = "/index.php/apps/cookbook" + + init( + serverAdress: String, + path: String, + method: RequestMethod, + authString: String? = nil, + headerFields: [HeaderField] = [], + body: Data? = nil + ) { + self.serverAddress = serverAdress + self.method = method + self.path = path + self.headerFields = headerFields + self.authString = authString + self.body = body + } + + func send() async -> (Data?, NetworkError?) { + Logger.network.debug("\(method.rawValue) \(path) sending ...") + + // Prepare URL + let urlString = "https://" + serverAddress + cookbookPath + path + print("Full path: \(urlString)") + //Logger.network.debug("Full path: \(urlString)") + guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) } + guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) } + + // Create URL request + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + // Set authentication string, if needed + if let authString = authString { + request.setValue( + "Basic \(authString)", + forHTTPHeaderField: "Authorization" + ) + } + + // Set other header fields + for headerField in headerFields { + request.setValue( + headerField.getValue(), + forHTTPHeaderField: headerField.getField() + ) + } + + // Set http body + if let body = body { + request.httpBody = body + } + + // Wait for and return data and (decoded) response + var data: Data? = nil + var response: URLResponse? = nil + do { + (data, response) = try await URLSession.shared.data(for: request) + Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") + if let data = data { + print(data, String(data: data, encoding: .utf8)) + } + return (data, nil) + } catch { + let error = decodeURLResponse(response: response as? HTTPURLResponse) + Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") + return (nil, error) + } + } + + private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { + guard let response = response else { + return NetworkError.unknownError + } + switch response.statusCode { + case 200...299: return (nil) + case 300...399: return (NetworkError.redirectionError) + case 400...499: return (NetworkError.clientError) + case 500...599: return (NetworkError.serverError) + case 600: return (NetworkError.invalidRequest) + default: return (NetworkError.unknownError) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift new file mode 100644 index 0000000..6bd6f24 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -0,0 +1,192 @@ +// +// CookbookApi.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 13.11.23. +// + +import Foundation +import OSLog +import UIKit + + +protocol CookbookApi { + /// Not implemented yet. + static func importRecipe( + from serverAdress: String, + auth: String, + data: Data + ) async -> (NetworkError?) + + /// Get either the full image or a thumbnail sized version. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The according recipe id. + /// - size: The size of the image. + /// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil. + static func getImage( + from serverAdress: String, + auth: String, + id: Int, + size: RecipeImage.RecipeImageSize + ) async -> (UIImage?, NetworkError?) + + /// Get all recipes. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of all recipes. + static func getRecipes( + from serverAdress: String, + auth: String + ) async -> ([Recipe]?, NetworkError?) + + /// Create a new recipe. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func createRecipe( + from serverAdress: String, + auth: String, + recipe: RecipeDetail + ) async -> (NetworkError?) + + /// Get the recipe with the specified id. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: The recipe if it exists. A NetworkError if the request fails. + static func getRecipe( + from serverAdress: String, + auth: String, id: Int + ) async -> (RecipeDetail?, NetworkError?) + + /// Update an existing recipe with new entries. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func updateRecipe( + from serverAdress: String, + auth: String, + recipe: RecipeDetail + ) async -> (NetworkError?) + + /// Delete the recipe with the specified id. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - id: The recipe id. + /// - Returns: A NetworkError if the request fails. Nil otherwise. + static func deleteRecipe( + from serverAdress: String, + auth: String, + id: Int + ) async -> (NetworkError?) + + /// Get all categories. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of categories. A NetworkError if the request fails. + static func getCategories( + from serverAdress: String, + auth: String + ) async -> ([Category]?, NetworkError?) + + /// Get all recipes of a specified category. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - categoryName: The category name. + /// - Returns: A list of recipes. A NetworkError if the request fails. + static func getCategory( + from serverAdress: String, + auth: String, + named categoryName: String + ) async -> ([Recipe]?, NetworkError?) + + /// Rename an existing category. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - categoryName: The name of the category to be renamed. + /// - newName: The new category name. + /// - Returns: A NetworkError if the request fails. + static func renameCategory( + from serverAdress: String, + auth: String, + named categoryName: String, + newName: String + ) async -> (NetworkError?) + + /// Get all keywords/tags. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A list of tag strings. A NetworkError if the request fails. + static func getTags( + from serverAdress: String, + auth: String + ) async -> ([String]?, NetworkError?) + + /// Get all recipes tagged with the specified keyword. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - keyword: The keyword. + /// - Returns: A list of recipes tagged with the specified keyword. A NetworkError if the request fails. + static func getRecipesTagged( + from serverAdress: String, + auth: String, + keyword: String + ) async -> ([Recipe]?, NetworkError?) + + /// Get the servers api version. + /// - Parameters: + /// - serverAdress: Server address in the format https://example.com. + /// - auth: Server authentication string. + /// - Returns: A NetworkError if the request fails. + static func getApiVersion( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Trigger a reindexing action on the server. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func postReindex( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Get the current configuration of the Cookbook server application. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func getConfig( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) + + /// Set the current configuration of the Cookbook server application. + /// - Parameters: + /// - serverAdress: Server address in the format. https://example.com + /// - auth: Server authentication string + /// - Returns: A NetworkError if the request fails. + static func postConfig( + from serverAdress: String, + auth: String + ) async -> (NetworkError?) +} + + + + diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift new file mode 100644 index 0000000..e47e421 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -0,0 +1,216 @@ +// +// CookbookApiV1.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation +import UIKit + + +class CookbookApiV1: CookbookApi { + static func importRecipe(from serverAdress: String, auth: String, data: Data) async -> (NetworkError?) { + return .unknownError + } + + static func getImage(from serverAdress: String, auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) { + let imageSize = (size == .FULL ? "full" : "thumb") + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)/image?size=\(imageSize)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (UIImage(data: data), error) + } + + static func getRecipes(from serverAdress: String, auth: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func createRecipe(from serverAdress: String, auth: String, recipe: RecipeDetail) async -> (NetworkError?) { + guard let recipeData = JSONEncoder.safeEncode(recipe) else { + return .dataError + } + + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes", + method: .POST, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)], + body: recipeData + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return nil + } else if let dict = json as? [String: Any] { + return .serverError + } + } catch { + return .decodingFailed + } + return nil + } + + static func getRecipe(from serverAdress: String, auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func updateRecipe(from serverAdress: String, auth: String, recipe: RecipeDetail) async -> (NetworkError?) { + guard let recipeData = JSONEncoder.safeEncode(recipe) else { + return .dataError + } + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(recipe.id)", + method: .PUT, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)], + body: recipeData + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return nil + } else if let dict = json as? [String: Any] { + return .serverError + } + } catch { + return .decodingFailed + } + return nil + } + + static func deleteRecipe(from serverAdress: String, auth: String, id: Int) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + method: .DELETE, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + return nil + } + + static func getCategories(from serverAdress: String, auth: String) async -> ([Category]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/categories", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getCategory(from serverAdress: String, auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/category/\(categoryName)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func renameCategory(from serverAdress: String, auth: String, named categoryName: String, newName: String) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/category/\(categoryName)", + method: .PUT, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (error) } + return nil + } + + static func getTags(from serverAdress: String, auth: String) async -> ([String]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/keywords", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getRecipesTagged(from serverAdress: String, auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/tags/\(keyword)", + method: .GET, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + let (data, error) = await request.send() + guard let data = data else { return (nil, error) } + return (JSONDecoder.safeDecode(data), nil) + } + + static func getApiVersion(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func postReindex(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func getConfig(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } + + static func postConfig(from serverAdress: String, auth: String) async -> (NetworkError?) { + return .none + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CustomError.swift b/Nextcloud Cookbook iOS Client/Network/CustomError.swift index c3f8867..e9522fc 100644 --- a/Nextcloud Cookbook iOS Client/Network/CustomError.swift +++ b/Nextcloud Cookbook iOS Client/Network/CustomError.swift @@ -18,6 +18,7 @@ public enum NetworkError: String, Error { case missingUrl = "Missing URL." case parametersNil = "Parameters are nil." case encodingFailed = "Parameter encoding failed." + case decodingFailed = "Data decoding failed." case redirectionError = "Redirection error" case clientError = "Client error" case serverError = "Server error" @@ -25,3 +26,4 @@ public enum NetworkError: String, Error { case unknownError = "Unknown error" case dataError = "Invalid data error." } + diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift index 690b435..f782a7f 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift @@ -14,35 +14,7 @@ enum RequestMethod: String { DELETE = "DELETE" } -enum RequestPath { - case CATEGORIES, - RECIPE_LIST(categoryName: String), - RECIPE_DETAIL(recipeId: Int), - NEW_RECIPE, - IMAGE(recipeId: Int, thumb: Bool), - CONFIG, - KEYWORDS - - case LOGINV2REQ, - CUSTOM(path: String), - NONE - - var stringValue: String { - switch self { - case .CATEGORIES: return "categories" - case .RECIPE_LIST(categoryName: let name): return "category/\(name)" - case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" - case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" - case .NEW_RECIPE: return "recipes" - case .CONFIG: return "config" - case .KEYWORDS: return "keywords" - - case .LOGINV2REQ: return "/index.php/login/v2" - case .CUSTOM(path: let path): return path - case .NONE: return "" - } - } -} + enum ContentType: String { case JSON = "application/json", @@ -75,6 +47,37 @@ struct HeaderField { } } + +enum RequestPath { + case CATEGORIES, + RECIPE_LIST(categoryName: String), + RECIPE_DETAIL(recipeId: Int), + NEW_RECIPE, + IMAGE(recipeId: Int, thumb: Bool), + CONFIG, + KEYWORDS + + case LOGINV2REQ, + CUSTOM(path: String), + NONE + + var stringValue: String { + switch self { + case .CATEGORIES: return "categories" + case .RECIPE_LIST(categoryName: let name): return "category/\(name)" + case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)" + case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" + case .NEW_RECIPE: return "recipes" + case .CONFIG: return "config" + case .KEYWORDS: return "keywords" + + case .LOGINV2REQ: return "/index.php/login/v2" + case .CUSTOM(path: let path): return path + case .NONE: return "" + } + } +} + struct RequestWrapper { private let _method: RequestMethod private let _path: RequestPath diff --git a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift new file mode 100644 index 0000000..d5caf9d --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift @@ -0,0 +1,12 @@ +// +// NextcloudApi.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 16.11.23. +// + +import Foundation + +class NextcloudApi { + +} diff --git a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift index d165993..8abe2e7 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -11,65 +11,127 @@ import UIKit @MainActor class MainViewModel: ObservableObject { + @AppStorage("authString") var authString = "" + @AppStorage("serverAddress") var serverAdress = "" + + @Published var categories: [Category] = [] @Published var recipes: [String: [Recipe]] = [:] - private var recipeDetails: [Int: RecipeDetail] = [:] - private var imageCache: [Int: RecipeImage] = [:] + @Published var recipeDetails: [Int: RecipeDetail] = [:] + private var requestQueue: [RequestWrapper] = [] - let dataStore: DataStore - var apiController: APIController? = nil + private let api: CookbookApi.Type + private let dataStore: DataStore - /// The path of an image in storage - private var localImagePath: (Int, Bool) -> (String) = { recipeId, thumb in - return "image\(recipeId)_\(thumb ? "thumb" : "full")" - } - - /// The path of an image on the server - private var networkImagePath: (Int, Bool) -> (String) = { recipeId, thumb in - return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")" - } - - init() { + init(apiVersion api: CookbookApi.Type = CookbookApiV1.self) { + print("Created MainViewModel") + self.api = api self.dataStore = DataStore() } - /// Try to load the category list from store or the server. - /// - Parameters - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - func loadCategoryList(needsUpdate: Bool = false) async { - if let categoryList: [Category] = await loadObject( - localPath: "categories.data", - networkPath: .CATEGORIES, - needsUpdate: needsUpdate - ) { - self.categories = categoryList - } - print(self.categories) + enum FetchMode { + case preferLocal, preferServer, onlyLocal, onlyServer } - /// Try to load the recipe list from store or the server. - /// - Warning: The category named '\*' is translated into '\_' for network calls and storage requests in this function. This is necessary for the nextcloud cookbook api. - /// - Parameters - /// - categoryName: The name of the category containing the requested list of recipes. - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from store first. - func loadRecipeList(categoryName: String, needsUpdate: Bool = false) async { - let categoryString = categoryName == "*" ? "_" : categoryName - if let recipeList: [Recipe] = await loadObject( - localPath: "category_\(categoryString).data", - networkPath: .RECIPE_LIST(categoryName: categoryString), - needsUpdate: needsUpdate - ) { - recipes[categoryName] = recipeList - print(recipeList) + + /** + Asynchronously loads and updates the list of categories. + + This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + */ + func getCategories() async { + let (categories, _) = await api.getCategories( + from: serverAdress, + auth: authString + ) + if let categories = categories { + self.categories = categories + await saveLocal(categories, path: "categories.data") + } else { + // If there's no server connection, try loading categories from local storage + if let categories: [Category] = await loadLocal(path: "categories.data") { + self.categories = categories + } + } + } + + + /** + Fetches recipes for a specified category from either the server or local storage. + + - Parameters: + - name: The name of the category. Use "*" to fetch recipes without assigned categories. + - needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first. + + This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage. + + - Note: The category name "*" is used for all uncategorized recipes. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + */ + func getCategory(named name: String, fetchMode: FetchMode) async { + func getLocal() async -> Bool { + if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { + self.recipes[name] = recipes + return true + } + return false } + func getServer() async -> Bool { + let (recipes, _) = await api.getCategory( + from: serverAdress, + auth: authString, + named: name + ) + if let recipes = recipes { + self.recipes[name] = recipes + return true + } + return false + } + + let categoryString = name == "*" ? "_" : name + switch fetchMode { + case .preferLocal: + if await getLocal() { return } + if await getServer() { return } + case .preferServer: + if await getServer() { return } + if await getLocal() { return } + case .onlyLocal: + if await getLocal() { return } + case .onlyServer: + if await getServer() { return } + } } - func getAllRecipes() async -> [Recipe] { + /** + Asynchronously retrieves all recipes either from the server or the locally cached data. + + This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded. + + Example usage: + ```swift + let recipes = await mainViewModel.getRecipes() + */ + func getRecipes() async -> [Recipe] { + let (recipes, error) = await api.getRecipes( + from: serverAdress, + auth: authString + ) + if let recipes = recipes { + return recipes + } else if let error = error { + print(error) + } var allRecipes: [Recipe] = [] for category in categories { - await loadRecipeList(categoryName: category.name) - if let recipeArray = recipes[category.name] { + if let recipeArray = self.recipes[category.name] { allRecipes.append(contentsOf: recipeArray) } } @@ -78,39 +140,93 @@ import UIKit }) } - /// Try to load the recipe details from cache. If not found, try to load from store or the server. - /// - Parameters - /// - recipeId: The id of the recipe. - /// - needsUpdate: If true, the recipe will be loaded from the server directly, otherwise it will be loaded from cache/store first. - /// - Returns: RecipeDetail struct. If not found locally, and unable to load from server, a RecipeDetail struct containing an error message. - func loadRecipeDetail(recipeId: Int, needsUpdate: Bool = false) async -> RecipeDetail { - if !needsUpdate { - if let recipeDetail = recipeDetails[recipeId] { - return recipeDetail + /** + Asynchronously retrieves a recipe detail either from the server or locally cached data. + + This function attempts to fetch a recipe detail with the specified `id` from the server using the provided `api`. If the server connection is successful, it returns the fetched recipe detail. If the server connection fails, it falls back to loading the recipe detail from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe to retrieve. + + Example usage: + ```swift + let recipeDetail = await mainViewModel.getRecipe(id: 123) + */ + func getRecipe(id: Int, fetchMode: FetchMode) async -> RecipeDetail { + func getLocal() async -> RecipeDetail? { + if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { + return recipe } + return nil } - if let recipeDetail: RecipeDetail = await loadObject( - localPath: "recipe\(recipeId).data", - networkPath: .RECIPE_DETAIL(recipeId: recipeId), - needsUpdate: needsUpdate - ) { - recipeDetails[recipeId] = recipeDetail - return recipeDetail + + func getServer() async -> RecipeDetail? { + let (recipe, error) = await api.getRecipe( + from: serverAdress, + auth: authString, + id: id + ) + if let recipe = recipe { + return recipe + } else if let error = error { + print(error) + } + return nil } - return RecipeDetail.error + + 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 .error } + + + + /** + Asynchronously downloads and saves details, thumbnails, and full images for all recipes. + + This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + await mainViewModel.downloadAllRecipes() + */ func downloadAllRecipes() async { for category in categories { - await loadRecipeList(categoryName: category.name, needsUpdate: true) + await getCategory(named: category.name, fetchMode: .onlyServer) guard let recipeList = recipes[category.name] else { continue } for recipe in recipeList { - let _ = await loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - let _ = await loadImage(recipeId: recipe.recipe_id, thumb: true) + let recipeDetail = await getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer) + await saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") + + let thumbnail = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .onlyServer) + guard let thumbnail = thumbnail else { continue } + guard let thumbnailData = thumbnail.pngData() else { continue } + await saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") + + let image = await getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .onlyServer) + guard let image = image else { continue } + guard let imageData = image.pngData() else { continue } + await saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") } } } + /// Check if recipeDetail is stored locally, either in cache or on disk /// - Parameters /// - recipeId: The id of a recipe. @@ -124,78 +240,88 @@ import UIKit return false } - - /// Try to load the recipe image from cache. If not found, try to load from store or the server. - /// - Parameters - /// - recipeId: The id of a recipe. - /// - full: If true, load the full resolution image. Otherwise, load a thumbnail-sized image. - /// - needsUpdate: Determines wether the image should be loaded directly from the server, or if it should be loaded from cache/store first. - /// - Returns: The image if found locally or on the server, otherwise nil. - func loadImage(recipeId: Int, thumb: Bool, needsUpdate: Bool = false) async -> UIImage? { - print("loadImage(recipeId: \(recipeId), thumb: \(thumb), needsUpdate: \(needsUpdate))") - // If the image needs an update, request it from the server and overwrite the stored image - if needsUpdate { - guard let apiController = apiController else { return nil } - if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) { - guard let image = UIImage(data: data) else { - imageCache[recipeId] = RecipeImage(imageExists: false) - return nil - } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } else { - imageCache[recipeId] = RecipeImage(imageExists: false) - return nil - } + /** + Asynchronously retrieves and returns an image for a recipe with the specified ID and size. + + This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe associated with the image. + - size: The size of the desired image (thumbnail or full). + - needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage. + + Example usage: + ```swift + let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true) + */ + func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? { + func getLocal() async -> UIImage? { + return await imageFromStore(id: id, size: size) } - // Check imageExists flag to detect if we attempted to load a non-existing image before. - // This allows us to avoid sending requests to the server if we already know the recipe has no image. - if imageCache[recipeId] != nil { - guard imageCache[recipeId]!.imageExists else { return nil } + func getServer() async -> UIImage? { + let (image, _) = await api.getImage( + from: serverAdress, + auth: authString, + id: id, + size: size + ) + if let image = image { return image } + return nil } - // Try to load image from cache - print("Attempting to load image from cache ...") - if let image = imageFromCache(recipeId: recipeId, thumb: thumb) { - print("Image found in cache.") - return image + switch fetchMode { + case .preferLocal: + if let image = await getLocal() { return image } + if let image = await getServer() { return image } + case .preferServer: + if let image = await getServer() { return image } + if let image = await getLocal() { return image } + case .onlyLocal: + if let image = await getLocal() { return image } + case .onlyServer: + if let image = await getServer() { return image } } - - // Try to load from store - print("Attempting to load image from local storage ...") - if let image = await imageFromStore(recipeId: recipeId, thumb: thumb) { - print("Image found in local storage.") - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } - - // Try to load from the server. Store if successfull. - print("Attempting to load image from server ...") - guard let apiController = apiController else { return nil } - if let data = await apiController.imageDataFromServer(recipeId: recipeId, thumb: thumb) { - print("Image data received.") - // Create empty RecipeImage for each recipe even if no image found, so that further server requests are only sent if explicitly requested. - guard let image = UIImage(data: data) else { - imageCache[recipeId] = RecipeImage(imageExists: false) - return nil - } - await dataStore.save(data: data.base64EncodedString(), toPath: localImagePath(recipeId, thumb)) - imageToCache(image: image, recipeId: recipeId, thumb: thumb) - return image - } - imageCache[recipeId] = RecipeImage(imageExists: false) return nil } - func getKeywords() async -> [String] { - if let keywords: [RecipeKeyword] = await self.loadObject( - localPath: "keywords.data", - networkPath: .KEYWORDS, - needsUpdate: true - ) { - return keywords.map { $0.name } + /** + Asynchronously retrieves and returns a list of keywords (tags). + + This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + let keywords = await mainViewModel.getKeywords() + */ + func getKeywords(fetchMode: FetchMode) async -> [String] { + func getLocal() async -> [String]? { + return await loadLocal(path: "keywords.data") + } + + func getServer() async -> [String]? { + let (tags, _) = await api.getTags( + from: serverAdress, + auth: authString + ) + return tags + } + + switch fetchMode { + case .preferLocal: + if let keywords = await getLocal() { return keywords } + if let keywords = await getServer() { return keywords } + case .preferServer: + if let keywords = await getServer() { 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 [] } @@ -204,35 +330,105 @@ import UIKit if dataStore.clearAll() { self.categories = [] self.recipes = [:] - self.imageCache = [:] self.recipeDetails = [:] + self.requestQueue = [] } } - func deleteRecipe(withId id: Int, categoryName: String) { + /** + Asynchronously deletes a recipe with the specified ID from the server and local storage. + + This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - id: The identifier of the recipe to delete. + - categoryName: The name of the category to which the recipe belongs. + + Example usage: + ```swift + let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts") + */ + func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + let (error) = await api.deleteRecipe( + from: serverAdress, + auth: authString, + id: id + ) + + if let error = error { + return .REQUEST_DROPPED + } let path = "recipe\(id).data" dataStore.delete(path: path) - guard recipes[categoryName] != nil else { return } - recipes[categoryName]!.removeAll(where: { recipe in - recipe.recipe_id == id ? true : false - }) - recipeDetails.removeValue(forKey: id) + if recipes[categoryName] != nil { + recipes[categoryName]!.removeAll(where: { recipe in + recipe.recipe_id == id ? true : false + }) + recipeDetails.removeValue(forKey: id) + } + return .REQUEST_SUCCESS } + /** + Asynchronously checks the server connection by attempting to fetch categories. + + This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + Example usage: + ```swift + let isConnected = await mainViewModel.checkServerConnection() + */ func checkServerConnection() async -> Bool { - guard let apiController = apiController else { return false } - let req = RequestWrapper.customRequest( - method: .GET, - path: .CONFIG, - headerFields: [ - .ocsRequest(value: true), - .accept(value: .JSON) - ] + let (categories, _) = await api.getCategories( + from: serverAdress, + auth: authString ) - if let error = await apiController.sendRequest(req) { - return false + if let categories = categories { + self.categories = categories + await saveLocal(categories, path: "categories.data") + return true } - return true + return false + } + + /** + Asynchronously uploads a recipe to the server. + + This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`. + + - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance. + + - Parameters: + - recipeDetail: The detailed information of the recipe to upload. + - createNew: If true, creates a new recipe on the server; otherwise, updates an existing one. + + Example usage: + ```swift + let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true) + */ + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + var error: NetworkError? = nil + if createNew { + error = await api.createRecipe( + from: serverAdress, + auth: authString, + recipe: recipeDetail + ) + } else { + error = await api.updateRecipe( + from: serverAdress, + auth: authString, + recipe: recipeDetail + ) + } + if let error = error { + return .REQUEST_DROPPED + } + return .REQUEST_SUCCESS } } @@ -240,54 +436,23 @@ import UIKit extension MainViewModel { - private func loadObject(localPath: String, networkPath: RequestPath, needsUpdate: Bool = false) async -> T? { + func loadLocal(path: String) async -> T? { do { - if !needsUpdate, let data: T = try await dataStore.load(fromPath: localPath) { - print("Data found locally.") - return data - } else { - guard let apiController = apiController else { return nil } - let request = RequestWrapper.jsonGetRequest(path: networkPath) - let (data, error): (T?, Error?) = await apiController.sendDataRequest(request) - print(error as Any) - if let data = data { - await dataStore.save(data: data, toPath: localPath) - } - return data - } - } catch { - print("An unknown error occurred.") - } - return nil - } - - private func imageToCache(image: UIImage, recipeId: Int, thumb: Bool) { - if imageCache[recipeId] == nil { - imageCache[recipeId] = RecipeImage(imageExists: true) - } - if thumb { - imageCache[recipeId]!.imageExists = true - imageCache[recipeId]!.thumb = image - } else { - imageCache[recipeId]!.imageExists = true - imageCache[recipeId]!.full = image + return try await dataStore.load(fromPath: path) + } catch (let error) { + print(error) + return nil } } - private func imageFromCache(recipeId: Int, thumb: Bool) -> UIImage? { - if imageCache[recipeId] != nil { - if thumb { - return imageCache[recipeId]!.thumb - } else { - return imageCache[recipeId]!.full - } - } - return nil + func saveLocal(_ object: T, path: String) async { + guard let data = JSONEncoder.safeEncode(object) else { return } + await dataStore.save(data: data, toPath: path) } - private func imageFromStore(recipeId: Int, thumb: Bool) async -> UIImage? { + private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { do { - let localPath = localImagePath(recipeId, thumb) + 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) diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index 56b159f..aced57b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -26,7 +26,7 @@ import SwiftUI @Published var presentAlert = false var alertType: UserAlert = RecipeCreationError.GENERIC - var alertAction: @MainActor () -> () = {} + var alertAction: @MainActor () async -> (RequestAlert) = { return .REQUEST_DROPPED } var uploadNew: Bool = true var waitingForUpload: Bool = false @@ -57,7 +57,7 @@ import SwiftUI // Check if the recipe has a name if recipe.name.replacingOccurrences(of: " ", with: "") == "" { alertType = RecipeCreationError.NO_TITLE - alertAction = {} + alertAction = {return .REQUEST_DROPPED} presentAlert = true return false } @@ -72,7 +72,7 @@ import SwiftUI .lowercased() { alertType = RecipeCreationError.DUPLICATE - alertAction = {} + alertAction = {return .REQUEST_DROPPED} presentAlert = true return false } @@ -82,79 +82,37 @@ import SwiftUI return true } - func uploadNewRecipe() { + func uploadNewRecipe() async -> RequestAlert { print("Uploading new recipe.") waitingForUpload = true createRecipe() - guard recipeValid() else { return } - let request = RequestWrapper.customRequest( - method: .POST, - path: .NEW_RECIPE, - headerFields: [ - HeaderField.accept(value: .JSON), - HeaderField.ocsRequest(value: true), - HeaderField.contentType(value: .JSON) - ], - body: JSONEncoder.safeEncode(self.recipe) - ) - sendRequest(request) - dismissEditView() + guard recipeValid() else { return .REQUEST_DROPPED } + + return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true) } - func uploadEditedRecipe() { + func uploadEditedRecipe() async -> RequestAlert { waitingForUpload = true print("Uploading changed recipe.") - guard let recipeId = Int(recipe.id) else { return } + guard let recipeId = Int(recipe.id) else { return .REQUEST_DROPPED } createRecipe() - let request = RequestWrapper.customRequest( - method: .PUT, - path: .RECIPE_DETAIL(recipeId: recipeId), - headerFields: [ - HeaderField.accept(value: .JSON), - HeaderField.ocsRequest(value: true), - HeaderField.contentType(value: .JSON) - ], - body: JSONEncoder.safeEncode(self.recipe) - ) - sendRequest(request) - dismissEditView() + + return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false) } - func deleteRecipe() { - guard let recipeId = Int(recipe.id) else { return } - let request = RequestWrapper.customRequest( - method: .DELETE, - path: .RECIPE_DETAIL(recipeId: recipeId), - headerFields: [ - HeaderField.accept(value: .JSON), - HeaderField.ocsRequest(value: true) - ] - ) - sendRequest(request) - if let recipeIdInt = Int(recipe.id) { - mainViewModel.deleteRecipe(withId: recipeIdInt, categoryName: recipe.recipeCategory) + func deleteRecipe() async -> RequestAlert { + guard let id = Int(recipe.id) else { + return .REQUEST_DROPPED } - dismissEditView() + return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory) } - func sendRequest(_ request: RequestWrapper) { - Task { - guard let apiController = mainViewModel.apiController else { return } - let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) - guard let data = data else { return } - do { - let error = try JSONDecoder().decode(ServerMessage.self, from: data) - // TODO: Better error handling (Show error to user!) - } catch { - - } - } - } + func dismissEditView() { Task { - await mainViewModel.loadCategoryList(needsUpdate: true) - await mainViewModel.loadRecipeList(categoryName: recipe.recipeCategory, needsUpdate: true) + await mainViewModel.getCategories() + await mainViewModel.getCategory(named: recipe.recipeCategory, fetchMode: .preferServer) } isPresented.wrappedValue = false } @@ -182,7 +140,7 @@ import SwiftUI } if let error = error { self.alertType = error - self.alertAction = {} + self.alertAction = {return .REQUEST_DROPPED} self.presentAlert = true } } catch { diff --git a/Nextcloud Cookbook iOS Client/Views/Alerts.swift b/Nextcloud Cookbook iOS Client/Views/Alerts.swift index 90c9ea2..41f7873 100644 --- a/Nextcloud Cookbook iOS Client/Views/Alerts.swift +++ b/Nextcloud Cookbook iOS Client/Views/Alerts.swift @@ -109,3 +109,30 @@ enum RecipeImportError: UserAlert { return [.OK] } } + + +enum RequestAlert: UserAlert { + case REQUEST_DELAYED, + REQUEST_DROPPED, + REQUEST_SUCCESS + + var localizedDescription: LocalizedStringKey { + switch self { + case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection." + case .REQUEST_DROPPED: return "Unable to complete action." + case .REQUEST_SUCCESS: return "Action completed." + } + } + + var localizedTitle: LocalizedStringKey { + switch self { + case .REQUEST_DELAYED: return "Action delayed" + case .REQUEST_DROPPED: return "Error" + case .REQUEST_SUCCESS: return "Success" + } + } + + var alertButtons: [AlertButton] { + return [.OK] + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift index 94d5ede..384b05d 100644 --- a/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/CategoryDetailView.swift @@ -61,10 +61,10 @@ struct CategoryDetailView: View { } .searchable(text: $searchText, prompt: "Search recipes") .task { - await viewModel.loadRecipeList(categoryName: categoryName) + await viewModel.getCategory(named: categoryName, fetchMode: .preferLocal) } .refreshable { - await viewModel.loadRecipeList(categoryName: categoryName, needsUpdate: true) + await viewModel.getCategory(named: categoryName, fetchMode: .preferServer) } } @@ -79,13 +79,20 @@ struct CategoryDetailView: View { func downloadRecipes() { if let recipes = viewModel.recipes[categoryName] { - let dispatchQueue = DispatchQueue(label: "RecipeDownload", qos: .background) - dispatchQueue.async { + Task { for recipe in recipes { - Task { - let _ = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - let _ = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) - } + let recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .onlyServer) + await viewModel.saveLocal(recipeDetail, path: "recipe\(recipe.recipe_id).data") + + let thumbnail = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .onlyServer) + guard let thumbnail = thumbnail else { continue } + guard let thumbnailData = thumbnail.pngData() else { continue } + await viewModel.saveLocal(thumbnailData.base64EncodedString(), path: "image\(recipe.recipe_id)_thumb") + + let image = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .onlyServer) + guard let image = image else { continue } + guard let imageData = image.pngData() else { continue } + await viewModel.saveLocal(imageData.base64EncodedString(), path: "image\(recipe.recipe_id)_full") } } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 6cdba2e..e772155 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -94,7 +94,7 @@ struct MainView: View { } .task { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategoryList() + await viewModel.getCategories()//viewModel.loadCategoryList() // Open detail view for default category if userSettings.defaultCategory != "" { if let cat = viewModel.categories.first(where: { c in @@ -109,7 +109,7 @@ struct MainView: View { } .refreshable { self.serverConnection = await viewModel.checkServerConnection() - await viewModel.loadCategoryList(needsUpdate: true) + await viewModel.getCategories()//loadCategoryList(needsUpdate: true) } } @@ -212,7 +212,7 @@ struct RecipeSearchView: View { .navigationTitle("Search recipe") } .task { - allRecipes = await viewModel.getAllRecipes() + allRecipes = await viewModel.getRecipes()//.getAllRecipes() } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift index f5a7fd6..9db1e67 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeCardView.swift @@ -51,11 +51,11 @@ struct RecipeCardView: View { .clipShape(RoundedRectangle(cornerRadius: 17)) .padding(.horizontal) .task { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeThumb = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: true, needsUpdate: true) + recipeThumb = await viewModel.getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferServer) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift index 0f6f2d0..cd70f58 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeDetailView.swift @@ -106,13 +106,13 @@ struct RecipeDetailView: View { } } .task { - recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferLocal)//loadRecipeDetail(recipeId: recipe.recipe_id) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferLocal)//.loadImage(recipeId: recipe.recipe_id, thumb: false) self.isDownloaded = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) } .refreshable { - recipeDetail = await viewModel.loadRecipeDetail(recipeId: recipe.recipe_id, needsUpdate: true) - recipeImage = await viewModel.loadImage(recipeId: recipe.recipe_id, thumb: false, needsUpdate: true) + recipeDetail = await viewModel.getRecipe(id: recipe.recipe_id, fetchMode: .preferServer) + recipeImage = await viewModel.getImage(id: recipe.recipe_id, size: .FULL, fetchMode: .preferServer) } } } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift index 323d081..463d453 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift +++ b/Nextcloud Cookbook iOS Client/Views/RecipeEditView.swift @@ -45,10 +45,12 @@ struct RecipeEditView: View { } Spacer() Button() { - if viewModel.uploadNew { - viewModel.uploadNewRecipe() - } else { - viewModel.uploadEditedRecipe() + Task { + if viewModel.uploadNew { + await viewModel.uploadNewRecipe() + } else { + await viewModel.uploadEditedRecipe() + } } } label: { Text("Upload") @@ -141,7 +143,7 @@ struct RecipeEditView: View { } } .task { - viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords() + viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords(fetchMode: .preferServer) } .onAppear { viewModel.prepareView() @@ -150,13 +152,17 @@ struct RecipeEditView: View { ForEach(viewModel.alertType.alertButtons) { buttonType in if buttonType == .OK { Button(AlertButton.OK.rawValue, role: .cancel) { - viewModel.alertAction() + Task { + await viewModel.alertAction() + } } } else if buttonType == .CANCEL { Button(AlertButton.CANCEL.rawValue, role: .cancel) { } } else if buttonType == .DELETE { Button(AlertButton.DELETE.rawValue, role: .destructive) { - viewModel.alertAction() + Task { + await viewModel.alertAction() + } } } }