From 7c824b492eecd888900896cd81ca709d171f69b8 Mon Sep 17 00:00:00 2001 From: Hendrik Hogertz Date: Sun, 15 Feb 2026 00:47:28 +0100 Subject: [PATCH] Modernize networking layer and fix category navigation and recipe list bugs Network layer: - Replace static CookbookApi protocol with instance-based CookbookApiProtocol using async/throws instead of tuple returns - Refactor ApiRequest to use URLComponents for proper URL encoding, replace print statements with OSLog, and return typed NetworkError cases - Add structured NetworkError variants (httpError, connectionError, etc.) - Remove global cookbookApi constant in favor of injected dependency on AppState - Delete unused RecipeEditViewModel, RecipeScraper, and Scraper playground Data & model fixes: - Add custom Decodable for RecipeDetail with safe fallbacks for malformed JSON - Make Category Hashable/Equatable use only `name` so NavigationSplitView selection survives category refreshes with updated recipe_count - Return server-assigned ID from uploadRecipe so new recipes get their ID before the post-upload refresh block executes View updates: - Refresh both old and new category recipe lists after upload when category changes, mapping empty recipeCategory to "*" for uncategorized recipes - Raise deployment target to iOS 18, adopt new SwiftUI API conventions - Clean up alerts, onboarding views, and settings Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 4 + .../project.pbxproj | 26 +- .../xcshareddata/swiftpm/Package.resolved | 12 +- Nextcloud Cookbook iOS Client/AppState.swift | 375 ++++++------------ .../Data/DataModels.swift | 8 + .../Data/DataStore.swift | 29 +- .../Data/ObservableRecipeDetail.swift | 3 +- .../Data/RecipeModels.swift | 62 ++- .../Extensions/JSONCoderExtension.swift | 7 +- .../Extensions/LoggerExtension.swift | 3 + .../Localizable.xcstrings | 6 + .../Models/RecipeEditViewModel.swift | 137 ------- .../Network/ApiRequest.swift | 66 ++- .../Network/CookbookApi/CookbookApi.swift | 183 ++------- .../Network/CookbookApi/CookbookApiV1.swift | 317 ++++++--------- .../Network/NetworkError.swift | 54 ++- .../Network/NetworkUtils.swift | 4 - .../Network/NextcloudApi/NextcloudApi.swift | 15 +- .../RecipeImport/RecipeScraper.swift | 128 ------ .../Scraper.playground/Contents.swift | 9 - .../Scraper.playground/contents.xcplayground | 4 - .../Util/Alerts.swift | 27 -- .../Views/Onboarding/OnboardingView.swift | 3 +- .../Views/Onboarding/TokenLoginView.swift | 20 +- .../Views/Onboarding/V2LoginView.swift | 9 +- .../Views/Recipes/RecipeListView.swift | 2 +- .../Views/Recipes/RecipeView.swift | 90 +++-- .../Views/Recipes/TimerView.swift | 11 +- .../Views/SettingsView.swift | 5 +- .../Views/Tabs/GroceryListTabView.swift | 13 +- .../Views/Tabs/RecipeTabView.swift | 5 +- 31 files changed, 534 insertions(+), 1103 deletions(-) delete mode 100644 Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift delete mode 100644 Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift delete mode 100644 Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/Contents.swift delete mode 100644 Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/contents.xcplayground diff --git a/CLAUDE.md b/CLAUDE.md index 7bbe226..a5b5dce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,7 @@ Four languages supported via `Localizable.xcstrings`: English, German, Spanish, - Server credentials (username, token, authString) are stored in `UserDefaults` via `UserSettings`, not in Keychain. - No `.gitignore` file exists in the repository. - No CI/CD, no linting tools, and no meaningful test coverage. + +## Workflow + +- Do not run `xcodebuild` directly. Ask the user to build manually in Xcode and report the results back. diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 3098f8b..3dcc0b2 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -26,13 +26,10 @@ A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; }; - A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; }; - A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.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 */; }; @@ -110,12 +107,10 @@ A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = ""; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; - A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = ""; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = ""; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.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 = ""; }; @@ -157,7 +152,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -263,7 +257,6 @@ A70171B72AB2445700064C43 /* Models */ = { isa = PBXGroup; children = ( - A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, ); path = Models; sourceTree = ""; @@ -314,7 +307,6 @@ A781E75F2AF8228100452F6F /* RecipeImport */ = { isa = PBXGroup; children = ( - A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */, ); path = RecipeImport; sourceTree = ""; @@ -441,7 +433,6 @@ ); name = "Nextcloud Cookbook iOS Client"; packageProductDependencies = ( - A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */, ); productName = "Nextcloud Cookbook iOS Client"; @@ -520,7 +511,6 @@ ); mainGroup = A70171752AA8E71900064C43; packageReferences = ( - A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, ); productRefGroup = A701717F2AA8E71900064C43 /* Products */; @@ -575,7 +565,7 @@ A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, - A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, + A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, @@ -613,7 +603,6 @@ A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, - A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, @@ -993,14 +982,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.6.1; - }; - }; A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/techprimate/TPPDF.git"; @@ -1012,11 +993,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = { - isa = XCSwiftPackageProductDependency; - package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; - productName = SwiftSoup; - }; A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { isa = XCSwiftPackageProductDependency; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d488c1a..3243c95 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,6 @@ { + "originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a", "pins" : [ - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", - "state" : { - "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", - "version" : "2.6.1" - } - }, { "identity" : "tppdf", "kind" : "remoteSourceControl", @@ -19,5 +11,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Nextcloud Cookbook iOS Client/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift index 1206c5c..d28d9fc 100644 --- a/Nextcloud Cookbook iOS Client/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI import UIKit @@ -19,97 +20,75 @@ import UIKit var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] var allKeywords: [RecipeKeyword] = [] - + private let dataStore: DataStore - - init() { - print("Created MainViewModel") + private let api: CookbookApiProtocol + + init(api: CookbookApiProtocol? = nil) { + Logger.network.debug("Created AppState") self.dataStore = DataStore() - + self.api = api ?? CookbookApiFactory.makeClient() + if UserSettings.shared.authString == "" { let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginData = loginString.data(using: String.Encoding.utf8)! UserSettings.shared.authString = loginData.base64EncodedString() } } - + enum FetchMode { case preferLocal, preferServer, onlyLocal, onlyServer } - - - /** - 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. - */ + // MARK: - Categories + func getCategories() async { - let (categories, _) = await cookbookApi.getCategories( - auth: UserSettings.shared.authString - ) - if let categories = categories { - print("Successfully loaded categories") + do { + let categories = try await api.getCategories() + Logger.data.debug("Successfully loaded categories") self.categories = categories await saveLocal(self.categories, path: "categories.data") - } else { - // If there's no server connection, try loading categories from local storage - print("Loading categories from store ...") + } catch { + Logger.data.debug("Loading categories from store ...") if let categories: [Category] = await loadLocal(path: "categories.data") { self.categories = categories - print("Success!") + Logger.data.debug("Loaded categories from local store") } else { - print("Failure!") + Logger.data.error("Failed to load categories from local store") } } - - // Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories + for category in self.categories { lastUpdates[category.name] = Date.distantPast } } - - /** - 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 { - print("getCategory(\(name), fetchMode: \(fetchMode))") + Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))") func getLocal() async -> Bool { + let categoryString = name == "*" ? "_" : name if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { self.recipes[name] = recipes return true } return false } - + func getServer(store: Bool = false) async -> Bool { - let (recipes, _) = await cookbookApi.getCategory( - auth: UserSettings.shared.authString, - named: categoryString - ) - if let recipes = recipes { + let categoryString = name == "*" ? "_" : name + do { + let recipes = try await api.getCategory(named: categoryString) self.recipes[name] = recipes if store { await saveLocal(recipes, path: "category_\(categoryString).data") } - //userSettings.lastUpdate = Date() return true + } catch { + return false } - return false } - - let categoryString = name == "*" ? "_" : name + switch fetchMode { case .preferLocal: if await getLocal() { return } @@ -123,50 +102,38 @@ import UIKit if await getServer() { return } } } - + + // MARK: - Recipe details + func updateAllRecipeDetails() async { for category in self.categories { await updateRecipeDetails(in: category.name) } UserSettings.shared.lastUpdate = Date() } - + func updateRecipeDetails(in category: String) async { guard UserSettings.shared.storeRecipes else { return } guard let recipes = self.recipes[category] else { return } for recipe in recipes { if let dateModified = recipe.dateModified { if needsUpdate(category: category, lastModified: dateModified) { - print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown")") + Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))") await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } else { - print("\(recipe.name) is up to date.") + Logger.data.debug("\(recipe.name) is up to date.") } } else { await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) } } } - - /** - 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 cookbookApi.getRecipes( - auth: UserSettings.shared.authString - ) - if let recipes = recipes { - return recipes - } else if let error = error { - print(error) + do { + return try await api.getRecipes() + } catch { + Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)") } var allRecipes: [Recipe] = [] for category in categories { @@ -174,48 +141,29 @@ import UIKit allRecipes.append(contentsOf: recipeArray) } } - return allRecipes.sorted(by: { - $0.name < $1.name - }) + return allRecipes.sorted(by: { $0.name < $1.name }) } - - /** - 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, save: Bool = false) async -> RecipeDetail? { func getLocal() async -> RecipeDetail? { if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } return nil } - + func getServer() async -> RecipeDetail? { - let (recipe, error) = await cookbookApi.getRecipe( - auth: UserSettings.shared.authString, - id: id - ) - if let recipe = recipe { + do { + let recipe = try await api.getRecipe(id: id) if save { self.recipeDetails[id] = recipe await self.saveLocal(recipe, path: "recipe\(id).data") } return recipe - } else if let error = error { - print(error) + } catch { + Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)") + return nil } - return nil } - + switch fetchMode { case .preferLocal: if let recipe = await getLocal() { return recipe } @@ -230,31 +178,19 @@ import UIKit } return nil } - - - /** - 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 updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async { if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) { await saveLocal(recipeDetail, path: "recipe\(id).data") } - + if withThumb { let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer) guard let thumbnail = thumbnail else { return } guard let thumbnailData = thumbnail.pngData() else { return } await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb") } - + if withImage { let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer) guard let image = image else { return } @@ -262,50 +198,26 @@ import UIKit await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full") } } - - - /// Check if recipeDetail is stored locally, either in cache or on disk - /// - Parameters - /// - recipeId: The id of a recipe. - /// - Returns: True if the recipeDetail is stored, otherwise false + func recipeDetailExists(recipeId: Int) -> Bool { - if (dataStore.recipeDetailExists(recipeId: recipeId)) { - return true - } - return false + return dataStore.recipeDetailExists(recipeId: recipeId) } - - /** - 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. + // MARK: - Images - - 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) } - + func getServer() async -> UIImage? { - let (image, _) = await cookbookApi.getImage( - auth: UserSettings.shared.authString, - id: id, - size: size - ) - if let image = image { return image } - return nil + do { + return try await api.getImage(id: id, size: size) + } catch { + return nil + } } - + switch fetchMode { case .preferLocal: if let image = imageFromCache(id: id, size: size) { @@ -355,28 +267,20 @@ import UIKit imagesNeedUpdate[id] = [size.rawValue: false] return nil } - - /** - 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. + // MARK: - Keywords - - 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 -> [RecipeKeyword] { func getLocal() async -> [RecipeKeyword]? { return await loadLocal(path: "keywords.data") } - + func getServer() async -> [RecipeKeyword]? { - let (tags, _) = await cookbookApi.getTags( - auth: UserSettings.shared.authString - ) - return tags + do { + return try await api.getTags() + } catch { + return nil + } } switch fetchMode { @@ -399,7 +303,9 @@ import UIKit } return [] } - + + // MARK: - Data management + func deleteAllData() { if dataStore.clearAll() { self.categories = [] @@ -409,31 +315,14 @@ import UIKit self.imagesNeedUpdate = [:] } } - - /** - 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 cookbookApi.deleteRecipe( - auth: UserSettings.shared.authString, - id: id - ) - - if let error = error { + do { + try await api.deleteRecipe(id: id) + } catch { return .REQUEST_DROPPED } + let path = "recipe\(id).data" dataStore.delete(path: path) if recipes[categoryName] != nil { @@ -444,95 +333,59 @@ import UIKit } return nil } - - /** - 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 { - let (categories, _) = await cookbookApi.getCategories( - auth: UserSettings.shared.authString - ) - if let categories = categories { + do { + let categories = try await api.getCategories() self.categories = categories await saveLocal(categories, path: "categories.data") return true + } catch { + return false } - 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 cookbookApi.createRecipe( - auth: UserSettings.shared.authString, - recipe: recipeDetail - ) - } else { - error = await cookbookApi.updateRecipe( - auth: UserSettings.shared.authString, - recipe: recipeDetail - ) - } - if error != nil { - return .REQUEST_DROPPED - } - return nil - } - - func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { - guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) } - let (recipeDetail, error) = await cookbookApi.importRecipe( - auth: UserSettings.shared.authString, - data: data - ) - if error != nil { + func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) { + do { + if createNew { + let id = try await api.createRecipe(recipeDetail) + return (id, nil) + } else { + let id = try await api.updateRecipe(recipeDetail) + return (id, nil) + } + } catch { + return (nil, .REQUEST_DROPPED) + } + } + + func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { + do { + let recipeDetail = try await api.importRecipe(url: url) + return (recipeDetail, nil) + } catch { return (nil, .REQUEST_DROPPED) } - return (recipeDetail, nil) } - } - +// MARK: - Local storage helpers extension AppState { func loadLocal(path: String) async -> T? { do { return try await dataStore.load(fromPath: path) - } catch (let error) { - print(error) + } catch { + Logger.data.debug("Failed to load local data: \(error.localizedDescription)") return nil } } - + func saveLocal(_ object: T, path: String) async { await dataStore.save(data: object, toPath: path) } - + private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { do { let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")" @@ -542,18 +395,18 @@ extension AppState { return image } } catch { - print("Could not find image in local storage.") + Logger.data.debug("Could not find image in local storage.") return nil } return nil } - + private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async { if let data = image.pngData() { await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)") } } - + private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) { if recipeImages[id] != nil { recipeImages[id]![size.rawValue] = image @@ -566,14 +419,14 @@ extension AppState { imagesNeedUpdate[id] = [size.rawValue: false] } } - + private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? { if recipeImages[id] != nil { return recipeImages[id]![size.rawValue] } return nil } - + private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool { if imagesNeedUpdate[id] != nil { if imagesNeedUpdate[id]![size.rawValue] != nil { @@ -582,26 +435,22 @@ extension AppState { } return true } - + private func needsUpdate(category: String, lastModified: String) -> Bool { - print("=======================") - print("original date string: \(lastModified)") - // Create a DateFormatter let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - - // Convert the string to a Date object + if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] { if date < lastUpdate { - print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))") + Logger.data.debug("No update needed for \(category)") return false } else { - print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))") + Logger.data.debug("Update needed for \(category)") return true } } - print("String is not a date. Update needed.") + Logger.data.debug("Date parse failed, update needed for \(category)") return true } } @@ -624,11 +473,11 @@ extension AppState { timers[recipeId] = timer return timer } - + func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration) } - + func deleteTimer(forRecipe recipeId: String) { timers.removeValue(forKey: recipeId) } diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 2a20a1f..437619a 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -20,6 +20,14 @@ struct Category: Codable { extension Category: Identifiable, Hashable { var id: String { name } + + static func == (lhs: Category, rhs: Category) -> Bool { + lhs.name == rhs.name + } + + func hash(into hasher: inout Hasher) { + hasher.combine(name) + } } diff --git a/Nextcloud Cookbook iOS Client/Data/DataStore.swift b/Nextcloud Cookbook iOS Client/Data/DataStore.swift index 4ef9bd0..b0f3b59 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataStore.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataStore.swift @@ -6,12 +6,13 @@ // import Foundation +import OSLog import SwiftUI class DataStore { let fileManager = FileManager.default static let shared = DataStore() - + private static func fileURL(appending: String) throws -> URL { try FileManager.default.url( for: .documentDirectory, @@ -21,7 +22,7 @@ class DataStore { ) .appendingPathComponent(appending) } - + private static func fileURL() throws -> URL { try FileManager.default.url( for: .documentDirectory, @@ -30,7 +31,7 @@ class DataStore { create: false ) } - + func load(fromPath path: String) async throws -> D? { let task = Task { let fileURL = try Self.fileURL(appending: path) @@ -42,7 +43,7 @@ class DataStore { } return try await task.value } - + func save(data: D, toPath path: String) async { let task = Task { let data = try JSONEncoder().encode(data) @@ -52,42 +53,36 @@ class DataStore { do { _ = try await task.value } catch { - print("Could not save data (path: \(path)") + Logger.data.error("Could not save data (path: \(path))") } } - + func delete(path: String) { Task { let fileURL = try Self.fileURL(appending: path) try fileManager.removeItem(at: fileURL) } } - + func recipeDetailExists(recipeId: Int) -> Bool { let filePath = "recipe\(recipeId).data" guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } return fileManager.fileExists(atPath: folderPath + filePath) } - + func clearAll() -> Bool { - print("Attempting to delete all data ...") + Logger.data.debug("Attempting to delete all data ...") guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } - print("Folder path: ", folderPath) do { let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath) for filePath in filePaths { - print("File path: ", filePath) try fileManager.removeItem(atPath: folderPath + filePath) } } catch { - print("Could not delete documents folder contents: \(error)") + Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)") return false } - print("Done.") + Logger.data.debug("All data deleted successfully.") return true - } } - - - diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift index 7149abb..e74128b 100644 --- a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI class ObservableRecipeDetail: ObservableObject { @@ -180,7 +181,7 @@ class ObservableRecipeDetail: ObservableObject { } return foundMatches } catch { - print("Regex error: \(error.localizedDescription)") + Logger.data.error("Regex error: \(error.localizedDescription)") } return [] } diff --git a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift index f04478c..081ebb8 100644 --- a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift @@ -28,7 +28,7 @@ struct Recipe: Codable { extension Recipe: Identifiable, Hashable { - var id: String { name } + var id: Int { recipe_id } } @@ -93,29 +93,67 @@ struct RecipeDetail: Codable { // Custom decoder to handle value type ambiguity private enum CodingKeys: String, CodingKey { - case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition + case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) - keywords = try container.decode(String.self, forKey: .keywords) + keywords = (try? container.decode(String.self, forKey: .keywords)) ?? "" dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated) dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified) - imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) + // Server import returns "image"; show/index responses and local storage use "imageUrl" + imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image)) + ?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl)) id = try container.decode(String.self, forKey: .id) prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime) cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime) totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime) - description = try container.decode(String.self, forKey: .description) - url = try container.decode(String.self, forKey: .url) - recipeYield = try container.decode(Int.self, forKey: .recipeYield) - recipeCategory = try container.decode(String.self, forKey: .recipeCategory) - tool = try container.decode([String].self, forKey: .tool) - recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient) - recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions) + description = (try? container.decode(String.self, forKey: .description)) ?? "" + url = try? container.decode(String.self, forKey: .url) + recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? "" - nutrition = try container.decode(Dictionary.self, forKey: .nutrition).mapValues { String(describing: $0.value) } + // recipeYield: try Int first, then parse leading digits from String + if let yieldInt = try? container.decode(Int.self, forKey: .recipeYield) { + recipeYield = yieldInt + } else if let yieldString = try? container.decode(String.self, forKey: .recipeYield) { + let digits = yieldString.prefix(while: { $0.isNumber }) + recipeYield = Int(digits) ?? 0 + } else { + recipeYield = 0 + } + + tool = (try? container.decode([String].self, forKey: .tool)) ?? [] + recipeIngredient = (try? container.decode([String].self, forKey: .recipeIngredient)) ?? [] + recipeInstructions = (try? container.decode([String].self, forKey: .recipeInstructions)) ?? [] + + if let nutritionDict = try? container.decode(Dictionary.self, forKey: .nutrition) { + nutrition = nutritionDict.mapValues { String(describing: $0.value) } + } else { + nutrition = [:] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(keywords, forKey: .keywords) + try container.encodeIfPresent(dateCreated, forKey: .dateCreated) + try container.encodeIfPresent(dateModified, forKey: .dateModified) + // Encode under "image" — the key the server expects for create/update + try container.encodeIfPresent(imageUrl, forKey: .image) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(prepTime, forKey: .prepTime) + try container.encodeIfPresent(cookTime, forKey: .cookTime) + try container.encodeIfPresent(totalTime, forKey: .totalTime) + try container.encode(description, forKey: .description) + try container.encodeIfPresent(url, forKey: .url) + try container.encode(recipeYield, forKey: .recipeYield) + try container.encode(recipeCategory, forKey: .recipeCategory) + try container.encode(tool, forKey: .tool) + try container.encode(recipeIngredient, forKey: .recipeIngredient) + try container.encode(recipeInstructions, forKey: .recipeInstructions) + try container.encode(nutrition, forKey: .nutrition) } } diff --git a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift index 6218642..fa8a0f0 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/JSONCoderExtension.swift @@ -6,14 +6,15 @@ // import Foundation +import OSLog extension JSONDecoder { static func safeDecode(_ data: Data) -> T? { let decoder = JSONDecoder() do { return try decoder.decode(T.self, from: data) - } catch (let error) { - print(error) + } catch { + Logger.data.error("JSONDecoder - safeDecode(): \(error.localizedDescription)") return nil } } @@ -24,7 +25,7 @@ extension JSONEncoder { do { return try JSONEncoder().encode(object) } catch { - print("JSONDecoder - safeEncode(): Could not encode object \(T.self)") + Logger.data.error("JSONEncoder - safeEncode(): Could not encode \(String(describing: T.self))") } return nil } diff --git a/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift index 65c3999..c10e07e 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/LoggerExtension.swift @@ -16,4 +16,7 @@ extension Logger { /// Network related logging static let network = Logger(subsystem: subsystem, category: "network") + + /// Data/persistence related logging + static let data = Logger(subsystem: subsystem, category: "data") } diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 9df0669..f20b300 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -663,6 +663,7 @@ } }, "Bad URL" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -931,6 +932,7 @@ } }, "Connection error" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2656,6 +2658,7 @@ } }, "Parsing error" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2722,6 +2725,7 @@ } }, "Please check the entered URL." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3788,6 +3792,7 @@ } }, "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4011,6 +4016,7 @@ } }, "Unable to load website content. Please check your internet connection." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift b/Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift deleted file mode 100644 index a91be4d..0000000 --- a/Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// RecipeEditViewModel.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 11.11.23. -// - -import Foundation -import SwiftUI - -@MainActor class RecipeEditViewModel: ObservableObject { - @ObservedObject var mainViewModel: AppState - @Published var recipe: RecipeDetail = RecipeDetail() - - @Published var prepDuration: DurationComponents = DurationComponents() - @Published var cookDuration: DurationComponents = DurationComponents() - @Published var totalDuration: DurationComponents = DurationComponents() - - @Published var searchText: String = "" - @Published var keywords: [String] = [] - @Published var keywordSuggestions: [RecipeKeyword] = [] - - @Published var showImportSection: Bool = false - @Published var importURL: String = "" - - - - var uploadNew: Bool = true - var waitingForUpload: Bool = false - - - init(mainViewModel: AppState, uploadNew: Bool) { - self.mainViewModel = mainViewModel - self.uploadNew = uploadNew - } - - init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) { - self.mainViewModel = mainViewModel - self.recipe = recipeDetail - self.uploadNew = uploadNew - } - - - func createRecipe() { - self.recipe.prepTime = prepDuration.toPTString() - self.recipe.cookTime = cookDuration.toPTString() - self.recipe.totalTime = totalDuration.toPTString() - self.recipe.setKeywordsFromArray(keywords) - } - - func recipeValid() -> RecipeAlert? { - // Check if the recipe has a name - if recipe.name.replacingOccurrences(of: " ", with: "") == "" { - return RecipeAlert.NO_TITLE - } - // Check if the recipe has a unique name - for recipeList in mainViewModel.recipes.values { - for r in recipeList { - if r.name - .replacingOccurrences(of: " ", with: "") - .lowercased() == - recipe.name - .replacingOccurrences(of: " ", with: "") - .lowercased() - { - return RecipeAlert.DUPLICATE - } - } - } - - return nil - } - - func uploadNewRecipe() async -> UserAlert? { - print("Uploading new recipe.") - waitingForUpload = true - createRecipe() - if let recipeValidationError = recipeValid() { - return recipeValidationError - } - - return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true) - } - - func uploadEditedRecipe() async -> UserAlert? { - waitingForUpload = true - print("Uploading changed recipe.") - guard let recipeId = Int(recipe.id) else { return RequestAlert.REQUEST_DROPPED } - createRecipe() - - return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false) - } - - func deleteRecipe() async -> RequestAlert? { - guard let id = Int(recipe.id) else { - return .REQUEST_DROPPED - } - return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory) - } - - func prepareView() { - if let prepTime = recipe.prepTime { - prepDuration.fromPTString(prepTime) - } - if let cookTime = recipe.cookTime { - cookDuration.fromPTString(cookTime) - } - if let totalTime = recipe.totalTime { - totalDuration.fromPTString(totalTime) - } - self.keywords = recipe.getKeywordsArray() - } - - func importRecipe() async -> UserAlert? { - let (scrapedRecipe, error) = await mainViewModel.importRecipe(url: importURL) - if let scrapedRecipe = scrapedRecipe { - self.recipe = scrapedRecipe - prepareView() - return nil - } - - do { - let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL) - if let scrapedRecipe = scrapedRecipe { - self.recipe = scrapedRecipe - prepareView() - } - if let error = error { - return error - } - } catch { - print("Error") - } - return nil - - } -} diff --git a/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift index 4971767..f915f78 100644 --- a/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift @@ -14,7 +14,7 @@ struct ApiRequest { let authString: String? let headerFields: [HeaderField] let body: Data? - + init( path: String, method: RequestMethod, @@ -28,21 +28,21 @@ struct ApiRequest { self.authString = authString self.body = body } - + func send(pathCompletion: Bool = true) async -> (Data?, NetworkError?) { Logger.network.debug("\(method.rawValue) \(path) sending ...") - + // Prepare URL let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : 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) } - + guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) } + // Ensure path percent encoding is applied correctly + components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path + guard let url = components.url else { return (nil, .missingUrl) } + // Create URL request var request = URLRequest(url: url) request.httpMethod = method.rawValue - + // Set authentication string, if needed if let authString = authString { request.setValue( @@ -50,7 +50,7 @@ struct ApiRequest { forHTTPHeaderField: "Authorization" ) } - + // Set other header fields for headerField in headerFields { request.setValue( @@ -58,46 +58,38 @@ struct ApiRequest { 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 error = decodeURLResponse(response: response as? HTTPURLResponse) { - print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") + let (data, response) = try await URLSession.shared.data(for: request) + if let error = decodeURLResponse(response: response as? HTTPURLResponse, data: data) { + Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") return (nil, error) } - if let data = data { - print(data, String(data: data, encoding: .utf8) as Any) - return (data, nil) - } - return (nil, .unknownError) + 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) + Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") + return (nil, .connectionError(underlying: error)) } } - - private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { + + private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? { guard let response = response else { - return NetworkError.unknownError + return .unknownError(detail: "No HTTP response") } - print("Status code: ", response.statusCode) - 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) + let statusCode = response.statusCode + switch statusCode { + case 200...299: + return nil + default: + let body = data.flatMap { String(data: $0, encoding: .utf8) } + return .httpError(statusCode: statusCode, body: body) } } } diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift index 467d5a6..92d062b 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApi.swift @@ -10,169 +10,38 @@ import OSLog import UIKit -/// The Cookbook API class used for requests to the Nextcloud Cookbook service. -let cookbookApi: CookbookApi.Type = { - switch UserSettings.shared.cookbookApiVersion { - case .v1: - return CookbookApiV1.self - } -}() - /// The Cookbook API version. enum CookbookApiVersion: String { case v1 = "v1" } -/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions. -protocol CookbookApi { - static var basePath: String { get } - - /// Not implemented yet. - static func importRecipe( - auth: String, - data: Data - ) async -> (RecipeDetail?, NetworkError?) - - /// Get either the full image or a thumbnail sized version. - /// - Parameters: - /// - 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( - auth: String, - id: Int, - size: RecipeImage.RecipeImageSize - ) async -> (UIImage?, NetworkError?) - - /// Get all recipes. - /// - Parameters: - /// - auth: Server authentication string. - /// - Returns: A list of all recipes. - static func getRecipes( - auth: String - ) async -> ([Recipe]?, NetworkError?) - - /// Create a new recipe. - /// - Parameters: - /// - auth: Server authentication string. - /// - Returns: A NetworkError if the request fails. Nil otherwise. - static func createRecipe( - auth: String, - recipe: RecipeDetail - ) async -> (NetworkError?) - - /// Get the recipe with the specified id. - /// - Parameters: - /// - auth: Server authentication string. - /// - id: The recipe id. - /// - Returns: The recipe if it exists. A NetworkError if the request fails. - static func getRecipe( - auth: String, id: Int - ) async -> (RecipeDetail?, NetworkError?) - - /// Update an existing recipe with new entries. - /// - Parameters: - /// - auth: Server authentication string. - /// - id: The recipe id. - /// - Returns: A NetworkError if the request fails. Nil otherwise. - static func updateRecipe( - auth: String, - recipe: RecipeDetail - ) async -> (NetworkError?) - - /// Delete the recipe with the specified id. - /// - Parameters: - /// - auth: Server authentication string. - /// - id: The recipe id. - /// - Returns: A NetworkError if the request fails. Nil otherwise. - static func deleteRecipe( - auth: String, - id: Int - ) async -> (NetworkError?) - - /// Get all categories. - /// - Parameters: - /// - auth: Server authentication string. - /// - Returns: A list of categories. A NetworkError if the request fails. - static func getCategories( - auth: String - ) async -> ([Category]?, NetworkError?) - - /// Get all recipes of a specified category. - /// - Parameters: - /// - auth: Server authentication string. - /// - categoryName: The category name. - /// - Returns: A list of recipes. A NetworkError if the request fails. - static func getCategory( - auth: String, - named categoryName: String - ) async -> ([Recipe]?, NetworkError?) - - /// Rename an existing category. - /// - Parameters: - /// - 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( - auth: String, - named categoryName: String, - newName: String - ) async -> (NetworkError?) - - /// Get all keywords/tags. - /// - Parameters: - /// - auth: Server authentication string. - /// - Returns: A list of tag strings. A NetworkError if the request fails. - static func getTags( - auth: String - ) async -> ([RecipeKeyword]?, NetworkError?) - - /// Get all recipes tagged with the specified keyword. - /// - Parameters: - /// - 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( - auth: String, - keyword: String - ) async -> ([Recipe]?, NetworkError?) - - /// Get the servers api version. - /// - Parameters: - /// - auth: Server authentication string. - /// - Returns: A NetworkError if the request fails. - static func getApiVersion( - auth: String - ) async -> (NetworkError?) - - /// Trigger a reindexing action on the server. - /// - Parameters: - /// - auth: Server authentication string - /// - Returns: A NetworkError if the request fails. - static func postReindex( - auth: String - ) async -> (NetworkError?) - - /// Get the current configuration of the Cookbook server application. - /// - Parameters: - /// - auth: Server authentication string - /// - Returns: A NetworkError if the request fails. - static func getConfig( - auth: String - ) async -> (NetworkError?) - - /// Set the current configuration of the Cookbook server application. - /// - Parameters: - /// - auth: Server authentication string - /// - Returns: A NetworkError if the request fails. - static func postConfig( - auth: String - ) async -> (NetworkError?) +/// A protocol defining common API endpoints for the Cookbook API. +protocol CookbookApiProtocol { + func importRecipe(url: String) async throws -> RecipeDetail + func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage? + func getRecipes() async throws -> [Recipe] + func createRecipe(_ recipe: RecipeDetail) async throws -> Int + func getRecipe(id: Int) async throws -> RecipeDetail + func updateRecipe(_ recipe: RecipeDetail) async throws -> Int + func deleteRecipe(id: Int) async throws + func getCategories() async throws -> [Category] + func getCategory(named: String) async throws -> [Recipe] + func renameCategory(named: String, to newName: String) async throws + func getTags() async throws -> [RecipeKeyword] + func getRecipesTagged(keyword: String) async throws -> [Recipe] + func searchRecipes(query: String) async throws -> [Recipe] } - - +enum CookbookApiFactory { + static func makeClient( + version: CookbookApiVersion = UserSettings.shared.cookbookApiVersion, + settings: UserSettings = .shared + ) -> CookbookApiProtocol { + switch version { + case .v1: + return CookbookApiClient(settings: settings) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index facab74..9179483 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -6,212 +6,155 @@ // import Foundation +import OSLog import UIKit -class CookbookApiV1: CookbookApi { - static let basePath: String = "/index.php/apps/cookbook/api/v1" - - static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/import", - method: .POST, - authString: auth, - headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)] - ) - - let (data, error) = await request.send() - guard let data = data else { return (nil, error) } - return (JSONDecoder.safeDecode(data), nil) +final class CookbookApiClient: CookbookApiProtocol { + private let basePath = "/index.php/apps/cookbook/api/v1" + private let settings: UserSettings + + private struct RecipeImportRequest: Codable { + let url: String } - - static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) { + + init(settings: UserSettings = .shared) { + self.settings = settings + } + + // MARK: - Private helpers + + private var auth: String { settings.authString } + + private func makeRequest( + path: String, + method: RequestMethod, + accept: ContentType = .JSON, + contentType: ContentType? = nil, + body: Data? = nil + ) -> ApiRequest { + var headers = [ + HeaderField.ocsRequest(value: true), + HeaderField.accept(value: accept) + ] + if let contentType = contentType { + headers.append(HeaderField.contentType(value: contentType)) + } + return ApiRequest( + path: basePath + path, + method: method, + authString: auth, + headerFields: headers, + body: body + ) + } + + private func sendAndDecode(_ request: ApiRequest) async throws -> T { + let (data, error) = await request.send() + if let error = error { throw error } + guard let data = data else { throw NetworkError.unknownError(detail: "No data received") } + guard let decoded: T = JSONDecoder.safeDecode(data) else { + throw NetworkError.decodingFailed(detail: "Failed to decode \(T.self)") + } + return decoded + } + + private func sendRaw(_ request: ApiRequest) async throws -> Data { + let (data, error) = await request.send() + if let error = error { throw error } + guard let data = data else { throw NetworkError.unknownError(detail: "No data received") } + return data + } + + // MARK: - Protocol implementation + + func importRecipe(url: String) async throws -> RecipeDetail { + let importRequest = RecipeImportRequest(url: url) + guard let body = JSONEncoder.safeEncode(importRequest) else { + throw NetworkError.encodingFailed(detail: "Failed to encode import request") + } + let request = makeRequest(path: "/import", method: .POST, contentType: .JSON, body: body) + return try await sendAndDecode(request) + } + + func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage? { let imageSize = (size == .FULL ? "full" : "thumb") - let request = ApiRequest( - path: basePath + "/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) + let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE) + let data = try await sendRaw(request) + return UIImage(data: data) } - - static func getRecipes(auth: String) async -> ([Recipe]?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) } - print("\n\nRECIPE: ", String(data: data, encoding: .utf8)) - return (JSONDecoder.safeDecode(data), nil) + + func getRecipes() async throws -> [Recipe] { + let request = makeRequest(path: "/recipes", method: .GET) + return try await sendAndDecode(request) } - - static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) { - guard let recipeData = JSONEncoder.safeEncode(recipe) else { - return .dataError + + func createRecipe(_ recipe: RecipeDetail) async throws -> Int { + guard let body = JSONEncoder.safeEncode(recipe) else { + throw NetworkError.encodingFailed(detail: "Failed to encode recipe") } - - let request = ApiRequest( - path: basePath + "/recipes", - method: .POST, - authString: auth, - headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(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 + let request = makeRequest(path: "/recipes", method: .POST, contentType: .JSON, body: body) + let data = try await sendRaw(request) + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return id } - return nil + throw NetworkError.decodingFailed(detail: "Expected recipe ID in response") } - - static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) + + func getRecipe(id: Int) async throws -> RecipeDetail { + let request = makeRequest(path: "/recipes/\(id)", method: .GET) + return try await sendAndDecode(request) } - - static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) { - guard let recipeData = JSONEncoder.safeEncode(recipe) else { - return .dataError + + func updateRecipe(_ recipe: RecipeDetail) async throws -> Int { + guard let body = JSONEncoder.safeEncode(recipe) else { + throw NetworkError.encodingFailed(detail: "Failed to encode recipe") } - let request = ApiRequest( - path: basePath + "/recipes/\(recipe.id)", - method: .PUT, - authString: auth, - headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(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 + let request = makeRequest(path: "/recipes/\(recipe.id)", method: .PUT, contentType: .JSON, body: body) + let data = try await sendRaw(request) + let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) + if let id = json as? Int { + return id } - return nil + throw NetworkError.decodingFailed(detail: "Expected recipe ID in response") } - - static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) { - let request = ApiRequest( - path: basePath + "/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 + + func deleteRecipe(id: Int) async throws { + let request = makeRequest(path: "/recipes/\(id)", method: .DELETE) + let _ = try await sendRaw(request) } - - static func getCategories(auth: String) async -> ([Category]?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) + + func getCategories() async throws -> [Category] { + let request = makeRequest(path: "/categories", method: .GET) + return try await sendAndDecode(request) } - - static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) + + func getCategory(named categoryName: String) async throws -> [Recipe] { + let request = makeRequest(path: "/category/\(categoryName)", method: .GET) + return try await sendAndDecode(request) } - - static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) { - let request = ApiRequest( - path: basePath + "/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 + + func renameCategory(named categoryName: String, to newName: String) async throws { + guard let body = JSONEncoder.safeEncode(["name": newName]) else { + throw NetworkError.encodingFailed(detail: "Failed to encode category name") + } + let request = makeRequest(path: "/category/\(categoryName)", method: .PUT, contentType: .JSON, body: body) + let _ = try await sendRaw(request) } - - static func getTags(auth: String) async -> ([RecipeKeyword]?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) + + func getTags() async throws -> [RecipeKeyword] { + let request = makeRequest(path: "/keywords", method: .GET) + return try await sendAndDecode(request) } - - static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) { - let request = ApiRequest( - path: basePath + "/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) + + func getRecipesTagged(keyword: String) async throws -> [Recipe] { + let request = makeRequest(path: "/tags/\(keyword)", method: .GET) + return try await sendAndDecode(request) } - - static func getApiVersion(auth: String) async -> (NetworkError?) { - return .none - } - - static func postReindex(auth: String) async -> (NetworkError?) { - return .none - } - - static func getConfig(auth: String) async -> (NetworkError?) { - return .none - } - - static func postConfig(auth: String) async -> (NetworkError?) { - return .none + + func searchRecipes(query: String) async throws -> [Recipe] { + let request = makeRequest(path: "/search/\(query)", method: .GET) + return try await sendAndDecode(request) } } diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkError.swift b/Nextcloud Cookbook iOS Client/Network/NetworkError.swift index cf6b6e1..01a751e 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkError.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkError.swift @@ -7,17 +7,45 @@ import Foundation -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" - case invalidRequest = "Invalid request" - case unknownError = "Unknown error" - case dataError = "Invalid data error." +public enum NetworkError: Error, LocalizedError { + case missingUrl + case encodingFailed(detail: String? = nil) + case decodingFailed(detail: String? = nil) + case httpError(statusCode: Int, body: String? = nil) + case connectionError(underlying: Error? = nil) + case invalidRequest + case unknownError(detail: String? = nil) + + public var errorDescription: String? { + switch self { + case .missingUrl: + return "Missing URL." + case .encodingFailed(let detail): + return "Parameter encoding failed." + (detail.map { " \($0)" } ?? "") + case .decodingFailed(let detail): + return "Data decoding failed." + (detail.map { " \($0)" } ?? "") + case .httpError(let statusCode, let body): + return "HTTP error \(statusCode)." + (body.map { " \($0)" } ?? "") + case .connectionError(let underlying): + return "Connection error." + (underlying.map { " \($0.localizedDescription)" } ?? "") + case .invalidRequest: + return "Invalid request." + case .unknownError(let detail): + return "Unknown error." + (detail.map { " \($0)" } ?? "") + } + } + + var isClientError: Bool { + if case .httpError(let statusCode, _) = self { + return (400...499).contains(statusCode) + } + return false + } + + var isServerError: Bool { + if case .httpError(let statusCode, _) = self { + return (500...599).contains(statusCode) + } + return false + } } - - diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift b/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift index 4d0258f..978aead 100644 --- a/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift +++ b/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift @@ -44,7 +44,3 @@ struct HeaderField { return HeaderField(_field: "Content-Type", _value: value.rawValue) } } - -struct RecipeImportRequest: Codable { - let url: String -} diff --git a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift index e4326cb..5555ef0 100644 --- a/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift +++ b/Nextcloud Cookbook iOS Client/Network/NextcloudApi/NextcloudApi.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI /// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication. @@ -33,10 +34,10 @@ class NextcloudApi { return (nil, error) } guard let data = data else { - return (nil, NetworkError.dataError) + return (nil, NetworkError.encodingFailed()) } guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else { - return (nil, NetworkError.decodingFailed) + return (nil, NetworkError.decodingFailed()) } return (loginRequest, nil) } @@ -69,10 +70,10 @@ class NextcloudApi { return (nil, error) } guard let data = data else { - return (nil, NetworkError.dataError) + return (nil, NetworkError.encodingFailed()) } guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else { - return (nil, NetworkError.decodingFailed) + return (nil, NetworkError.decodingFailed()) } return (loginResponse, nil) } @@ -107,11 +108,11 @@ class NextcloudApi { userId: data?["userId"] as? String ?? "", userDisplayName: data?["displayName"] as? String ?? "" ) - print(userData) + Logger.network.debug("Loaded hover card for user \(userData.userId)") return (userData, nil) } catch { - print(error.localizedDescription) - return (nil, NetworkError.decodingFailed) + Logger.network.error("Failed to decode hover card: \(error.localizedDescription)") + return (nil, NetworkError.decodingFailed()) } } } diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift b/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift deleted file mode 100644 index ac9f79f..0000000 --- a/Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// RecipeScraper.swift -// Nextcloud Cookbook iOS Client -// -// Created by Vincent Meilinger on 09.11.23. -// - -import Foundation -import SwiftSoup -import SwiftUI - - -class RecipeScraper { - func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportAlert?) { - var contents: String? = nil - if let url = URL(string: url) { - do { - contents = try String(contentsOf: url) - } catch { - print("ERROR: Could not load url content.") - return (nil, .CHECK_CONNECTION) - } - - } else { - print("ERROR: Bad url.") - return (nil, .BAD_URL) - } - - guard let html = contents else { - print("ERROR: no contents") - return (nil, .WEBSITE_NOT_SUPPORTED) - } - let doc = try SwiftSoup.parse(html) - - let elements: Elements = try doc.select("script") - for elem in elements.array() { - for attr in elem.getAttributes()!.asList() { - if attr.getValue() == "application/ld+json" { - guard let dict = toDict(elem) else { continue } - if let recipe = getRecipe(fromDict: dict) { - return (recipe, nil) - } - } - } - } - return (nil, .WEBSITE_NOT_SUPPORTED) - } - - - private func toDict(_ elem: Element) -> [String: Any]? { - var recipeDict: [String: Any]? = nil - do { - let jsonString = try elem.html() - let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: .fragmentsAllowed) - if let recipe = json as? [String : Any] { - recipeDict = recipe - } else if let recipe = (json as! [Any])[0] as? [String : Any] { - recipeDict = recipe - } - } catch { - print("Unable to decode json") - return nil - } - - guard let recipeDict = recipeDict else { - print("Json is not a dict") - return nil - } - - if recipeDict["@type"] as? String ?? "" == "Recipe" { - return recipeDict - } else if (recipeDict["@type"] as? [String] ?? []).contains("Recipe") { - return recipeDict - } else { - print("Json dict is not a recipe ...") - return nil - } - } - - private func getRecipe(fromDict recipe: Dictionary) -> RecipeDetail? { - - var recipeDetail = RecipeDetail() - recipeDetail.name = recipe["name"] as? String ?? "New Recipe" - recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? "" - recipeDetail.keywords = joinedStringForKey("keywords", dict: recipe) - recipeDetail.description = recipe["description"] as? String ?? "" - recipeDetail.dateCreated = recipe["dateCreated"] as? String ?? "" - recipeDetail.dateModified = recipe["dateModified"] as? String ?? "" - recipeDetail.imageUrl = recipe["imageUrl"] as? String ?? "" - recipeDetail.url = recipe["url"] as? String ?? "" - recipeDetail.cookTime = recipe["cookTime"] as? String ?? "" - recipeDetail.prepTime = recipe["prepTime"] as? String ?? "" - recipeDetail.totalTime = recipe["totalTime"] as? String ?? "" - recipeDetail.recipeInstructions = stringArrayForKey("recipeInstructions", dict: recipe) - recipeDetail.recipeYield = recipe["recipeYield"] as? Int ?? 0 - recipeDetail.recipeIngredient = recipe["recipeIngredient"] as? [String] ?? [] - recipeDetail.tool = stringArrayForKey("tool", dict: recipe) - recipeDetail.nutrition = recipe["nutrition"] as? [String:String] ?? [:] - print(recipeDetail) - return recipeDetail - } - - private func stringArrayForKey(_ key: String, dict: Dictionary) -> [String] { - if let text = dict[key] as? String { - return [text] - } else if let value = dict[key] as? [String] { - return value - } else if let orderedList = dict[key] as? [Any] { - var entries: [String] = [] - for dict in orderedList { - guard let dict = dict as? [String: Any] else { continue } - guard let text = dict["text"] as? String else { continue } - entries.append(text) - } - return entries - } - return [] - } - - private func joinedStringForKey(_ key: String, dict: Dictionary) -> String { - if let value = dict[key] as? [String] { - return value.joined(separator: ",") - } else if let value = dict[key] as? String { - return value - } - return "" - } -} diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/Contents.swift b/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/Contents.swift deleted file mode 100644 index e418b58..0000000 --- a/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/Contents.swift +++ /dev/null @@ -1,9 +0,0 @@ -import SwiftSoup -import Foundation - - - -//let url = "https://www.chefkoch.de/rezepte/1385981243676608/Knusprige-Entenbrust.html" -let url = "https://www.allrecipes.com/recipe/234620/mascarpone-mashed-potatoes/" - -let scraper = RecipeScaper() diff --git a/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/contents.xcplayground b/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/contents.xcplayground deleted file mode 100644 index cf026f2..0000000 --- a/Nextcloud Cookbook iOS Client/RecipeImport/Scraper.playground/contents.xcplayground +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Nextcloud Cookbook iOS Client/Util/Alerts.swift b/Nextcloud Cookbook iOS Client/Util/Alerts.swift index f435240..e1c069b 100644 --- a/Nextcloud Cookbook iOS Client/Util/Alerts.swift +++ b/Nextcloud Cookbook iOS Client/Util/Alerts.swift @@ -94,33 +94,6 @@ enum RecipeAlert: UserAlert { } -enum RecipeImportAlert: UserAlert { - case BAD_URL, - CHECK_CONNECTION, - WEBSITE_NOT_SUPPORTED - - var localizedDescription: LocalizedStringKey { - switch self { - case .BAD_URL: return "Please check the entered URL." - case .CHECK_CONNECTION: return "Unable to load website content. Please check your internet connection." - case .WEBSITE_NOT_SUPPORTED: return "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." - } - } - - var localizedTitle: LocalizedStringKey { - switch self { - case .BAD_URL: return "Bad URL" - case .CHECK_CONNECTION: return "Connection error" - case .WEBSITE_NOT_SUPPORTED: return "Parsing error" - } - } - - var alertButtons: [AlertButton] { - return [.OK] - } -} - - enum RequestAlert: UserAlert { case REQUEST_DELAYED, REQUEST_DROPPED, diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift index 808a1c2..fc5a193 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/OnboardingView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI struct OnboardingView: View { @@ -203,7 +204,7 @@ struct ServerAddressField: View { .tint(.white) .font(.headline) .onChange(of: serverProtocol) { value in - print(value) + Logger.view.debug("\(value.rawValue)") userSettings.serverProtocol = value.rawValue } diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift index c47c625..3efccbc 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/TokenLoginView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI @@ -72,7 +73,7 @@ struct TokenLoginView: View { case .username: focusedField = .token default: - print("Attempting to log in ...") + Logger.view.debug("Attempting to log in ...") } } } @@ -87,21 +88,16 @@ struct TokenLoginView: View { showAlert = true return false } - + UserSettings.shared.setAuthString() - let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString) - - if let error = error { + let client = CookbookApiFactory.makeClient() + do { + let _ = try await client.getCategories() + return true + } catch { alertMessage = "Login failed. Please check your inputs and internet connection." showAlert = true return false } - - guard let data = data else { - alertMessage = "Login failed. Please check your inputs." - showAlert = true - return false - } - return true } } diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift index 3bd7ea2..b4c0f5e 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI import WebKit @@ -82,7 +83,7 @@ struct V2LoginView: View { Task { let error = await sendLoginV2Request() if let error = error { - alertMessage = "A network error occured (\(error.rawValue))." + alertMessage = "A network error occured (\(error.localizedDescription))." showAlert = true } if let loginRequest = loginRequest { @@ -151,13 +152,13 @@ struct V2LoginView: View { } func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { - guard let loginRequest = loginRequest else { return (nil, .parametersNil) } + guard let loginRequest = loginRequest else { return (nil, .invalidRequest) } return await NextcloudApi.loginV2Response(req: loginRequest) } func checkLogin(response: LoginV2Response?, error: NetworkError?) { if let error = error { - alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))" + alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))" showAlert = true return } @@ -166,7 +167,7 @@ struct V2LoginView: View { showAlert = true return } - print("Login successful for user \(response.loginName)!") + Logger.network.debug("Login successful for user \(response.loginName)!") UserSettings.shared.username = response.loginName UserSettings.shared.token = response.appPassword let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 304d0a2..6299a35 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -10,6 +10,7 @@ import SwiftUI + struct RecipeListView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var groceryList: GroceryList @@ -64,7 +65,6 @@ struct RecipeListView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { - print("Add new recipe") showEditView = true } label: { Image(systemName: "plus.circle.fill") diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 99a2edc..6668e8f 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI @@ -291,22 +292,17 @@ extension RecipeView { let (scrapedRecipe, error) = await appState.importRecipe(url: url) if let scrapedRecipe = scrapedRecipe { viewModel.setupView(recipeDetail: scrapedRecipe) + // Fetch the image from the server if the import created a recipe with a valid id + if let recipeId = Int(scrapedRecipe.id), recipeId > 0 { + viewModel.recipeImage = await appState.getImage( + id: recipeId, + size: .FULL, + fetchMode: .onlyServer + ) + } return nil } - - do { - let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url) - if let scrapedRecipe = scrapedRecipe { - viewModel.setupView(recipeDetail: scrapedRecipe) - } - if let error = error { - return error - } - } catch { - print("Error") - - } - return nil + return error } } @@ -371,7 +367,7 @@ struct RecipeViewToolBar: ToolbarContent { } Button { - print("Sharing recipe ...") + Logger.view.debug("Sharing recipe ...") viewModel.presentShareSheet = true } label: { Label("Share Recipe", systemImage: "square.and.arrow.up") @@ -386,34 +382,70 @@ struct RecipeViewToolBar: ToolbarContent { func handleUpload() async { if viewModel.newRecipe { - print("Uploading new recipe.") - if let recipeValidationError = recipeValid() { - viewModel.presentAlert(recipeValidationError) - return - } - - if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) { - viewModel.presentAlert(alert) - return + // Check if the recipe was already created on the server by import + let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0 + let alreadyCreatedByImport = importedId > 0 + + if alreadyCreatedByImport { + Logger.view.debug("Uploading changes to imported recipe (id: \(importedId)).") + let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) + if let alert { + viewModel.presentAlert(alert) + return + } + } else { + Logger.view.debug("Uploading new recipe.") + if let recipeValidationError = recipeValid() { + viewModel.presentAlert(recipeValidationError) + return + } + + let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) + if let alert { + viewModel.presentAlert(alert) + return + } + if let newId { + viewModel.observableRecipeDetail.id = String(newId) + } } } else { - print("Uploading changed recipe.") - + Logger.view.debug("Uploading changed recipe.") + guard let _ = Int(viewModel.observableRecipeDetail.id) else { viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) return } - - if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) { + + let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) + if let alert { viewModel.presentAlert(alert) return } } await appState.getCategories() - await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer) + let newCategory = viewModel.observableRecipeDetail.recipeCategory.isEmpty ? "*" : viewModel.observableRecipeDetail.recipeCategory + let oldCategory = viewModel.recipeDetail.recipeCategory.isEmpty ? "*" : viewModel.recipeDetail.recipeCategory + await appState.getCategory(named: newCategory, fetchMode: .preferServer) + if oldCategory != newCategory { + await appState.getCategory(named: oldCategory, fetchMode: .preferServer) + } if let id = Int(viewModel.observableRecipeDetail.id) { let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true) + // Fetch the image after upload so it displays in view mode + viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer) + // Update recipe reference so the view tracks the server-assigned id + viewModel.recipe = Recipe( + name: viewModel.observableRecipeDetail.name, + keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","), + dateCreated: "", + dateModified: "", + imageUrl: viewModel.observableRecipeDetail.imageUrl, + imagePlaceholderUrl: "", + recipe_id: id + ) } + viewModel.newRecipe = false viewModel.editMode = false viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS) } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift index 7eb87fc..1e1a6b3 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI import Combine import AVFoundation @@ -152,7 +153,7 @@ extension RecipeTimer { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) } catch { - print("Failed to set audio session category. Error: \(error)") + Logger.view.error("Failed to set audio session category. Error: \(error)") } } @@ -163,7 +164,7 @@ extension RecipeTimer { audioPlayer?.prepareToPlay() audioPlayer?.numberOfLoops = -1 // Loop indefinitely } catch { - print("Error loading sound file: \(error)") + Logger.view.error("Error loading sound file: \(error)") } } } @@ -185,9 +186,9 @@ extension RecipeTimer { func requestNotificationPermissions() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if granted { - print("Notification permission granted.") + Logger.view.debug("Notification permission granted.") } else if let error = error { - print("Notification permission denied because: \(error.localizedDescription).") + Logger.view.error("Notification permission denied because: \(error.localizedDescription).") } } } @@ -204,7 +205,7 @@ extension RecipeTimer { UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error scheduling notification: \(error)") + Logger.view.error("Error scheduling notification: \(error)") } } } diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 71a4167..cc72afc 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI @@ -135,7 +136,7 @@ struct SettingsView: View { Section { Button("Log out") { - print("Log out.") + Logger.view.debug("Log out.") viewModel.alertType = .LOG_OUT viewModel.showAlert = true @@ -143,7 +144,7 @@ struct SettingsView: View { .tint(.red) Button("Delete local data") { - print("Clear cache.") + Logger.view.debug("Clear cache.") viewModel.alertType = .DELETE_CACHE viewModel.showAlert = true } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift index b3ece9a..889ddf3 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI @@ -150,7 +151,7 @@ class GroceryRecipeItem: Identifiable, Codable { func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) { - print("Adding item of recipe \(String(describing: recipeName))") + Logger.view.debug("Adding item of recipe \(String(describing: recipeName))") DispatchQueue.main.async { if self.groceryDict[recipeId] != nil { self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName)) @@ -174,7 +175,7 @@ class GroceryRecipeItem: Identifiable, Codable { } func deleteItem(_ itemName: String, fromRecipe recipeId: String) { - print("Deleting item \(itemName)") + Logger.view.debug("Deleting item \(itemName)") guard let recipe = groceryDict[recipeId] else { return } guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return } groceryDict[recipeId]?.items.remove(at: itemIndex) @@ -186,20 +187,20 @@ class GroceryRecipeItem: Identifiable, Codable { } func deleteGroceryRecipe(_ recipeId: String) { - print("Deleting grocery recipe with id \(recipeId)") + Logger.view.debug("Deleting grocery recipe with id \(recipeId)") groceryDict.removeValue(forKey: recipeId) save() objectWillChange.send() } func deleteAll() { - print("Deleting all grocery items") + Logger.view.debug("Deleting all grocery items") groceryDict = [:] save() } func toggleItemChecked(_ groceryItem: GroceryRecipeItem) { - print("Item checked: \(groceryItem.name)") + Logger.view.debug("Item checked: \(groceryItem.name)") groceryItem.isChecked.toggle() save() } @@ -229,7 +230,7 @@ class GroceryRecipeItem: Identifiable, Codable { ) else { return } self.groceryDict = groceryDict } catch { - print("Unable to load grocery list") + Logger.view.error("Unable to load grocery list") } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index d0c1df5..382b68f 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog import SwiftUI @@ -143,7 +144,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { // Server connection indicator ToolbarItem(placement: .topBarTrailing) { Button { - print("Check server connection") + Logger.view.debug("Check server connection") viewModel.presentConnectionPopover = true } label: { if viewModel.presentLoadingIndicator { @@ -170,7 +171,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent { // Create new recipes ToolbarItem(placement: .topBarTrailing) { Button { - print("Add new recipe") + Logger.view.debug("Add new recipe") viewModel.presentEditView = true } label: { Image(systemName: "plus.circle.fill")