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 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 00:47:28 +01:00
parent 527acd2967
commit 7c824b492e
31 changed files with 534 additions and 1103 deletions

View File

@@ -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)
}
}
}

View File

@@ -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<T: Decodable>(_ 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)
}
}