diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 8ceea9b..e03853f 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -36,6 +36,11 @@ A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; }; A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.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 */; }; @@ -92,6 +97,11 @@ A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = ""; }; A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.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 = ""; }; @@ -191,6 +201,8 @@ A70171B22AB211F000064C43 /* Network */ = { isa = PBXGroup; children = ( + A79AA8EE2B063B33007D25F2 /* NextcloudApi */, + A79AA8E72B062DB6007D25F2 /* CookbookApi */, A703226C2ABAF90D00D7C4ED /* APIController.swift */, A70171B32AB2122900064C43 /* NetworkRequests.swift */, A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, @@ -241,6 +253,7 @@ children = ( A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */, A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */, + A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -261,6 +274,24 @@ path = RecipeImport; sourceTree = ""; }; + 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 */ /* Begin PBXNativeTarget section */ @@ -405,21 +436,26 @@ 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 */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */, 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 eb98939..c721b28 100644 --- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift +++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift @@ -13,12 +13,20 @@ class UserSettings: ObservableObject { @Published var username: String { didSet { UserDefaults.standard.set(username, forKey: "username") + self.authString = setAuthString() } } @Published var token: String { didSet { UserDefaults.standard.set(token, forKey: "token") + self.authString = setAuthString() + } + } + + @Published var authString: String { + didSet { + UserDefaults.standard.set(authString, forKey: "authString") } } @@ -55,10 +63,21 @@ 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 } + + 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/Network/CookbookApi/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift new file mode 100644 index 0000000..bac5b58 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/ApiRequest.swift @@ -0,0 +1,100 @@ +// +// 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 = serverAddress + cookbookPath + path + 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!") + 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..37a070a --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -0,0 +1,187 @@ +// +// 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 + ) 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, id: Int + ) 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 -> ([String]?, 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..e1dadc6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -0,0 +1,207 @@ +// +// 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) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes", + method: .POST, + authString: auth, + headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)] + ) + + 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, id: Int) async -> (NetworkError?) { + let request = ApiRequest( + serverAdress: serverAdress, + path: "/api/v1/recipes/\(id)", + 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) } + 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 -> ([String]?, 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..6f0a03b 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/MainViewModel.swift @@ -15,6 +15,7 @@ import UIKit @Published var recipes: [String: [Recipe]] = [:] private var recipeDetails: [Int: RecipeDetail] = [:] private var imageCache: [Int: RecipeImage] = [:] + private var requestQueue: [RequestWrapper] = [] let dataStore: DataStore var apiController: APIController? = nil @@ -206,17 +207,34 @@ import UIKit self.recipes = [:] self.imageCache = [:] self.recipeDetails = [:] + self.requestQueue = [] } } - func deleteRecipe(withId id: Int, categoryName: String) { + func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert { + let request = RequestWrapper.customRequest( + method: .DELETE, + path: .RECIPE_DETAIL(recipeId: id), + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true) + ] + ) + 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) + } + if await sendRequest(request) { + return .REQUEST_SUCCESS + } else { + requestQueue.append(request) + return .REQUEST_DELAYED + } } func checkServerConnection() async -> Bool { @@ -234,6 +252,54 @@ import UIKit } return true } + + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert { + var path: RequestPath? = nil + if createNew { + path = .NEW_RECIPE + } else if let recipeId = Int(recipeDetail.id) { + path = .RECIPE_DETAIL(recipeId: recipeId) + } + + guard let path = path else { return .REQUEST_DROPPED } + + let request = RequestWrapper.customRequest( + method: createNew ? .POST : .PUT, + path: path, + headerFields: [ + HeaderField.accept(value: .JSON), + HeaderField.ocsRequest(value: true), + HeaderField.contentType(value: .JSON) + ], + body: JSONEncoder.safeEncode(recipeDetail) + ) + + if await sendRequest(request) { + return .REQUEST_SUCCESS + } else { + requestQueue.append(request) + return .REQUEST_DELAYED + } + } + + func sendRequest(_ request: RequestWrapper) async -> Bool { + guard let apiController = apiController else { return false } + let (data, _): (Data?, Error?) = await apiController.sendDataRequest(request) + guard let data = data else { return false } + do { + let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + if let recipeId = json as? Int { + return true + } else if let message = json as? [String : Any] { + print("Server message: ", message["msg"] ?? "-") + return false + } + // TODO: Better error handling (Show error to user!) + } catch { + print("Could not decode server response") + } + return false + } } diff --git a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift index 56b159f..c4685cb 100644 --- a/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift +++ b/Nextcloud Cookbook iOS Client/ViewModels/RecipeEditViewModel.swift @@ -82,74 +82,32 @@ 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 { 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] + } +}