diff --git a/BookStaxShareExtension/Base.lproj/MainInterface.storyboard b/BookStaxShareExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 0000000..286a508 --- /dev/null +++ b/BookStaxShareExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BookStaxShareExtension/BookPickerView.swift b/BookStaxShareExtension/BookPickerView.swift new file mode 100644 index 0000000..07a8bc0 --- /dev/null +++ b/BookStaxShareExtension/BookPickerView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct BookPickerView: View { + + @ObservedObject var viewModel: ShareViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + content + .navigationTitle("Select Book") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.books.isEmpty { + ProgressView("Loading books…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.books.isEmpty { + ContentUnavailableView( + "No books found", + systemImage: "book.closed", + description: Text("This shelf has no books yet.") + ) + } else { + List(viewModel.books) { book in + Button { + Task { + await viewModel.selectBook(book) + dismiss() + } + } label: { + HStack { + Text(book.name) + .foregroundStyle(.primary) + Spacer() + if viewModel.selectedBook?.id == book.id { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + } + } + } +} diff --git a/BookStaxShareExtension/BookStaxShareExtension.entitlements b/BookStaxShareExtension/BookStaxShareExtension.entitlements new file mode 100644 index 0000000..595f8ee --- /dev/null +++ b/BookStaxShareExtension/BookStaxShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.hanold.bookstax + + + diff --git a/BookStaxShareExtension/ChapterPickerView.swift b/BookStaxShareExtension/ChapterPickerView.swift new file mode 100644 index 0000000..7e3d68c --- /dev/null +++ b/BookStaxShareExtension/ChapterPickerView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ChapterPickerView: View { + + @ObservedObject var viewModel: ShareViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + content + .navigationTitle("Select Chapter") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.chapters.isEmpty { + ProgressView("Loading chapters…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Button { + viewModel.selectedChapter = nil + dismiss() + } label: { + HStack { + Text("No chapter (directly in book)") + .foregroundStyle(.primary) + Spacer() + if viewModel.selectedChapter == nil { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + + if viewModel.chapters.isEmpty { + Text("This book has no chapters.") + .foregroundStyle(.secondary) + .listRowBackground(Color.clear) + } else { + ForEach(viewModel.chapters) { chapter in + Button { + viewModel.selectedChapter = chapter + dismiss() + } label: { + HStack { + Text(chapter.name) + .foregroundStyle(.primary) + Spacer() + if viewModel.selectedChapter?.id == chapter.id { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + } + } + } + } + } +} diff --git a/BookStaxShareExtension/Info.plist b/BookStaxShareExtension/Info.plist new file mode 100644 index 0000000..bfec629 --- /dev/null +++ b/BookStaxShareExtension/Info.plist @@ -0,0 +1,21 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/BookStaxShareExtension/ShareExtensionAPIService.swift b/BookStaxShareExtension/ShareExtensionAPIService.swift new file mode 100644 index 0000000..a5e144c --- /dev/null +++ b/BookStaxShareExtension/ShareExtensionAPIService.swift @@ -0,0 +1,180 @@ +import Foundation + +// MARK: - Data Models + +struct ShelfSummary: Identifiable, Decodable, Hashable { + let id: Int + let name: String + let slug: String +} + +struct BookSummary: Identifiable, Decodable, Hashable { + let id: Int + let name: String + let slug: String +} + +struct ChapterSummary: Identifiable, Decodable, Hashable { + let id: Int + let name: String +} + +struct PageResult: Decodable { + let id: Int + let name: String +} + +// MARK: - Error + +enum ShareAPIError: LocalizedError { + case notConfigured + case networkError(Error) + case httpError(Int) + case decodingError + + var errorDescription: String? { + switch self { + case .notConfigured: + return NSLocalizedString("error.notConfigured", bundle: .main, comment: "") + case .networkError(let err): + return String(format: NSLocalizedString("error.network.format", bundle: .main, comment: ""), + err.localizedDescription) + case .httpError(let code): + return String(format: NSLocalizedString("error.http.format", bundle: .main, comment: ""), + code) + case .decodingError: + return NSLocalizedString("error.decoding", bundle: .main, comment: "") + } + } +} + +// MARK: - Protocol (for testability) + +protocol ShareAPIServiceProtocol: Sendable { + func fetchShelves() async throws -> [ShelfSummary] + func fetchBooks(shelfId: Int) async throws -> [BookSummary] + func fetchChapters(bookId: Int) async throws -> [ChapterSummary] + func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult +} + +// MARK: - Live Implementation + +actor ShareExtensionAPIService: ShareAPIServiceProtocol { + + private let baseURL: String + private let tokenId: String + private let tokenSecret: String + private let session: URLSession + + init(serverURL: String, tokenId: String, tokenSecret: String) { + self.baseURL = serverURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.tokenId = tokenId + self.tokenSecret = tokenSecret + self.session = URLSession(configuration: .default) + } + + // MARK: - API calls + + func fetchShelves() async throws -> [ShelfSummary] { + let data = try await get(path: "/api/shelves?count=500") + return try decode(PaginatedResult.self, from: data).data + } + + func fetchBooks(shelfId: Int) async throws -> [BookSummary] { + let data = try await get(path: "/api/shelves/\(shelfId)") + return try decode(ShelfDetail.self, from: data).books ?? [] + } + + func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { + let data = try await get(path: "/api/books/\(bookId)") + let contents = try decode(BookDetail.self, from: data).contents ?? [] + return contents.filter { $0.type == "chapter" } + .map { ChapterSummary(id: $0.id, name: $0.name) } + } + + func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult { + var body: [String: Any] = [ + "book_id": bookId, + "name": title, + "markdown": markdown + ] + if let chapterId { body["chapter_id"] = chapterId } + + let bodyData = try JSONSerialization.data(withJSONObject: body) + let url = try makeURL(path: "/api/pages") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + authorize(&request) + + let (data, response) = try await session.data(for: request) + try validate(response) + return try decode(PageResult.self, from: data) + } + + // MARK: - Helpers + + private func get(path: String) async throws -> Data { + let url = try makeURL(path: path) + var request = URLRequest(url: url) + authorize(&request) + do { + let (data, response) = try await session.data(for: request) + try validate(response) + return data + } catch let error as ShareAPIError { + throw error + } catch { + throw ShareAPIError.networkError(error) + } + } + + private func makeURL(path: String) throws -> URL { + guard let url = URL(string: baseURL + path) else { + throw ShareAPIError.notConfigured + } + return url + } + + private func authorize(_ request: inout URLRequest) { + request.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization") + } + + private func validate(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw ShareAPIError.httpError(code) + } + } + + private func decode(_ type: T.Type, from data: Data) throws -> T { + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw ShareAPIError.decodingError + } + } +} + +// MARK: - Response wrapper types (private) + +private struct PaginatedResult: Decodable { + let data: [T] +} + +private struct ShelfDetail: Decodable { + let books: [BookSummary]? +} + +private struct BookContentItem: Decodable { + let id: Int + let name: String + let type: String +} + +private struct BookDetail: Decodable { + let contents: [BookContentItem]? +} diff --git a/BookStaxShareExtension/ShareExtensionKeychainService.swift b/BookStaxShareExtension/ShareExtensionKeychainService.swift new file mode 100644 index 0000000..b97fe0a --- /dev/null +++ b/BookStaxShareExtension/ShareExtensionKeychainService.swift @@ -0,0 +1,86 @@ +import Foundation +import Security + +/// Shared Keychain service for passing credentials between the main app +/// and the Share Extension via App Group "group.de.hanold.bookstax". +/// +/// - The main app calls `saveCredentials` whenever a profile is activated. +/// - The extension calls `loadCredentials` to authenticate API requests. +/// - Accessibility is `afterFirstUnlock` so the extension can run while the +/// device is locked after the user has unlocked it at least once. +/// +/// Add this file to **both** the main app target and `BookStaxShareExtension`. +enum ShareExtensionKeychainService { + + private static let service = "de.hanold.bookstax.shared" + private static let account = "activeCredentials" + private static let accessGroup = "group.de.hanold.bookstax" + + private struct Credentials: Codable { + let serverURL: String + let tokenId: String + let tokenSecret: String + } + + // MARK: - Save (called from main app) + + /// Persists the active profile credentials in the shared keychain. + static func saveCredentials(serverURL: String, tokenId: String, tokenSecret: String) { + guard let data = try? JSONEncoder().encode( + Credentials(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret) + ) else { return } + + let baseQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup + ] + + // Try update first; add if not found. + let updateStatus = SecItemUpdate( + baseQuery as CFDictionary, + [kSecValueData: data] as CFDictionary + ) + if updateStatus == errSecItemNotFound { + var addQuery = baseQuery + addQuery[kSecValueData] = data + addQuery[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(addQuery as CFDictionary, nil) + } + } + + // MARK: - Load (called from Share Extension) + + /// Returns the stored credentials, or `nil` if the user has not yet + /// configured the main app. + static func loadCredentials() -> (serverURL: String, tokenId: String, tokenSecret: String)? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let creds = try? JSONDecoder().decode(Credentials.self, from: data) + else { return nil } + return (creds.serverURL, creds.tokenId, creds.tokenSecret) + } + + // MARK: - Clear + + /// Removes the shared credentials (e.g., on logout). + static func clearCredentials() { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/BookStaxShareExtension/ShareExtensionView.swift b/BookStaxShareExtension/ShareExtensionView.swift new file mode 100644 index 0000000..9fddfa2 --- /dev/null +++ b/BookStaxShareExtension/ShareExtensionView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct ShareExtensionView: View { + + @ObservedObject var viewModel: ShareViewModel + + var onCancel: () -> Void + var onComplete: () -> Void + var onOpenURL: (URL) -> Void + + // MARK: - Body + + var body: some View { + NavigationStack { + Group { + if !viewModel.isConfigured { + notConfiguredView + } else if viewModel.isSaved { + successView + } else { + formView + } + } + .navigationTitle("Save to BookStax") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + } + } + } + .task { + guard viewModel.isConfigured, !viewModel.isSaved else { return } + await viewModel.loadShelves() + } + .alert( + "Error", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ), + actions: { + Button("OK") { viewModel.errorMessage = nil } + }, + message: { + Text(viewModel.errorMessage ?? "") + } + ) + } + + // MARK: - Not configured + + private var notConfiguredView: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(.orange) + Text("BookStax Not Configured") + .font(.headline) + Text("Please open BookStax and sign in to your BookStack server.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Close", action: onCancel) + .buttonStyle(.borderedProminent) + } + .padding() + } + + // MARK: - Success + + private var successView: some View { + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + + VStack(spacing: 8) { + Text("Page saved!") + .font(.headline) + Text(viewModel.pageTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + if let url = URL(string: viewModel.serverURL), !viewModel.serverURL.isEmpty { + Button { + onOpenURL(url) + onComplete() + } label: { + Label("Open BookStax", systemImage: "safari") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + + Button("Done", action: onComplete) + .buttonStyle(.bordered) + } + .padding(.horizontal) + } + .padding() + .task { + try? await Task.sleep(for: .milliseconds(1500)) + onComplete() + } + } + + // MARK: - Form + + private var formView: some View { + Form { + Section("Selected Text") { + Text(viewModel.sharedText) + .lineLimit(4) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Page Title") { + TextField("Page title", text: $viewModel.pageTitle) + .autocorrectionDisabled() + } + + Section("Location") { + NavigationLink { + ShelfPickerView(viewModel: viewModel) + } label: { + LabeledRow(label: "Shelf", value: viewModel.selectedShelf?.name) + } + + NavigationLink { + BookPickerView(viewModel: viewModel) + } label: { + LabeledRow(label: "Book", value: viewModel.selectedBook?.name) + } + .disabled(viewModel.selectedShelf == nil) + + NavigationLink { + ChapterPickerView(viewModel: viewModel) + } label: { + LabeledRow(label: "Chapter", value: viewModel.selectedChapter?.name, + placeholder: "Optional") + } + .disabled(viewModel.selectedBook == nil) + } + + Section { + Button { + Task { await viewModel.savePage() } + } label: { + HStack { + Spacer() + if viewModel.isLoading { + ProgressView() + } else { + Text("Save") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(viewModel.isSaveDisabled) + } + } + } +} + +// MARK: - Helper + +private struct LabeledRow: View { + let label: String + let value: String? + var placeholder: String = "Select" + + var body: some View { + HStack { + Text(LocalizedStringKey(label)) + Spacer() + Text(value.map { LocalizedStringKey($0) } ?? LocalizedStringKey(placeholder)) + .foregroundStyle(value == nil ? .secondary : .primary) + } + } +} diff --git a/BookStaxShareExtension/ShareViewController.swift b/BookStaxShareExtension/ShareViewController.swift new file mode 100644 index 0000000..0f39c21 --- /dev/null +++ b/BookStaxShareExtension/ShareViewController.swift @@ -0,0 +1,126 @@ +import UIKit +import SwiftUI +import UniformTypeIdentifiers + +// Null implementation used when BookStax is not configured (no keychain credentials). +private struct NullShareAPIService: ShareAPIServiceProtocol { + func fetchShelves() async throws -> [ShelfSummary] { [] } + func fetchBooks(shelfId: Int) async throws -> [BookSummary] { [] } + func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { [] } + func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult { + throw ShareAPIError.notConfigured + } +} + +/// Entry point for the BookStax Share Extension. +/// `NSExtensionPrincipalClass` in Info.plist points to this class. +final class ShareViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + + Task { @MainActor in + let text = await extractSharedText() + let viewModel = makeViewModel(for: text) + embedSwiftUI(viewModel: viewModel) + } + } + + // MARK: - ViewModel factory + + private func makeViewModel(for text: String) -> ShareViewModel { + if let creds = ShareExtensionKeychainService.loadCredentials() { + let api = ShareExtensionAPIService( + serverURL: creds.serverURL, + tokenId: creds.tokenId, + tokenSecret: creds.tokenSecret + ) + let defaults = UserDefaults(suiteName: "group.de.hanold.bookstax") + return ShareViewModel( + sharedText: text, + apiService: api, + serverURL: creds.serverURL, + isConfigured: true, + defaults: defaults + ) + } else { + return ShareViewModel( + sharedText: text, + apiService: NullShareAPIService(), + serverURL: "", + isConfigured: false, + defaults: nil + ) + } + } + + // MARK: - SwiftUI embedding + + private func embedSwiftUI(viewModel: ShareViewModel) { + let contentView = ShareExtensionView( + viewModel: viewModel, + onCancel: { [weak self] in self?.cancel() }, + onComplete: { [weak self] in self?.complete() }, + onOpenURL: { [weak self] url in self?.open(url) } + ) + let host = UIHostingController(rootView: contentView) + addChild(host) + view.addSubview(host.view) + host.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + host.didMove(toParent: self) + } + + // MARK: - Extension context actions + + private func cancel() { + extensionContext?.cancelRequest( + withError: NSError( + domain: NSCocoaErrorDomain, + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: "Abgebrochen"] + ) + ) + } + + private func complete() { + extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } + + private func open(_ url: URL) { + extensionContext?.open(url, completionHandler: nil) + } + + // MARK: - Text extraction + + /// Extracts plain text or a URL string from the incoming NSExtensionItems. + private func extractSharedText() async -> String { + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return "" } + + for item in items { + for provider in item.attachments ?? [] { + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + if let text = try? await provider.loadItem( + forTypeIdentifier: UTType.plainText.identifier + ) as? String { + return text + } + } + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = try? await provider.loadItem( + forTypeIdentifier: UTType.url.identifier + ) as? URL { + return url.absoluteString + } + } + } + } + return "" + } +} diff --git a/BookStaxShareExtension/ShareViewModel.swift b/BookStaxShareExtension/ShareViewModel.swift new file mode 100644 index 0000000..cc9d8c7 --- /dev/null +++ b/BookStaxShareExtension/ShareViewModel.swift @@ -0,0 +1,149 @@ +import Foundation +import Combine + +// MARK: - ViewModel + +@MainActor +final class ShareViewModel: ObservableObject { + + // MARK: Published state + + @Published var shelves: [ShelfSummary] = [] + @Published var books: [BookSummary] = [] + @Published var chapters: [ChapterSummary] = [] + + @Published var selectedShelf: ShelfSummary? + @Published var selectedBook: BookSummary? + @Published var selectedChapter: ChapterSummary? + + @Published var pageTitle: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isSaved: Bool = false + + // MARK: Read-only + + let sharedText: String + let isConfigured: Bool + let serverURL: String + + // MARK: Private + + private let apiService: ShareAPIServiceProtocol + private let defaults: UserDefaults? + + private let lastShelfIDKey = "shareExtension.lastShelfID" + private let lastBookIDKey = "shareExtension.lastBookID" + + // MARK: Computed + + var isSaveDisabled: Bool { + pageTitle.trimmingCharacters(in: .whitespaces).isEmpty + || selectedBook == nil + || isLoading + } + + // MARK: - Init + + init( + sharedText: String, + apiService: ShareAPIServiceProtocol, + serverURL: String = "", + isConfigured: Bool = true, + defaults: UserDefaults? = nil + ) { + self.sharedText = sharedText + self.isConfigured = isConfigured + self.serverURL = serverURL + self.apiService = apiService + self.defaults = defaults + + // Auto-populate title from the first non-empty line of the shared text. + let firstLine = sharedText + .components(separatedBy: .newlines) + .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } ?? "" + self.pageTitle = String(firstLine.prefix(100)) + } + + // MARK: - Actions + + /// Loads all shelves and restores the last used shelf/book selection. + func loadShelves() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + shelves = try await apiService.fetchShelves() + + // Restore last selected shelf + let lastShelfID = defaults?.integer(forKey: lastShelfIDKey) ?? 0 + if lastShelfID != 0, let match = shelves.first(where: { $0.id == lastShelfID }) { + await selectShelf(match) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func selectShelf(_ shelf: ShelfSummary) async { + selectedShelf = shelf + selectedBook = nil + selectedChapter = nil + books = [] + chapters = [] + defaults?.set(shelf.id, forKey: lastShelfIDKey) + + isLoading = true + defer { isLoading = false } + + do { + books = try await apiService.fetchBooks(shelfId: shelf.id) + + // Restore last selected book + let lastBookID = defaults?.integer(forKey: lastBookIDKey) ?? 0 + if lastBookID != 0, let match = books.first(where: { $0.id == lastBookID }) { + await selectBook(match) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func selectBook(_ book: BookSummary) async { + selectedBook = book + selectedChapter = nil + chapters = [] + defaults?.set(book.id, forKey: lastBookIDKey) + + isLoading = true + defer { isLoading = false } + + do { + chapters = try await apiService.fetchChapters(bookId: book.id) + } catch { + errorMessage = error.localizedDescription + } + } + + func savePage() async { + guard let book = selectedBook, + !pageTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + _ = try await apiService.createPage( + bookId: book.id, + chapterId: selectedChapter?.id, + title: pageTitle.trimmingCharacters(in: .whitespaces), + markdown: sharedText + ) + isSaved = true + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/BookStaxShareExtension/ShelfPickerView.swift b/BookStaxShareExtension/ShelfPickerView.swift new file mode 100644 index 0000000..f593f40 --- /dev/null +++ b/BookStaxShareExtension/ShelfPickerView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct ShelfPickerView: View { + + @ObservedObject var viewModel: ShareViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + content + .navigationTitle("Select Shelf") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading && viewModel.shelves.isEmpty { + ProgressView("Loading shelves…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.shelves.isEmpty { + ContentUnavailableView( + "No shelves found", + systemImage: "books.vertical", + description: Text("No shelves were found on the server.") + ) + } else { + List(viewModel.shelves) { shelf in + Button { + Task { + await viewModel.selectShelf(shelf) + dismiss() + } + } label: { + HStack { + Text(shelf.name) + .foregroundStyle(.primary) + Spacer() + if viewModel.selectedShelf?.id == shelf.id { + Image(systemName: "checkmark") + .foregroundStyle(Color.accentColor) + } + } + } + } + } + } +} diff --git a/BookStaxShareExtension/de.lproj/Localizable.strings b/BookStaxShareExtension/de.lproj/Localizable.strings new file mode 100644 index 0000000..d315e06 --- /dev/null +++ b/BookStaxShareExtension/de.lproj/Localizable.strings @@ -0,0 +1,51 @@ +/* ShareExtensionView */ +"Save to BookStax" = "In BookStax speichern"; +"Cancel" = "Abbrechen"; +"Selected Text" = "Markierter Text"; +"Page Title" = "Titel der Seite"; +"Page title" = "Seitentitel"; +"Location" = "Ablageort"; +"Shelf" = "Regal"; +"Book" = "Buch"; +"Chapter" = "Kapitel"; +"Select" = "Auswählen"; +"Optional" = "Optional"; +"Save" = "Speichern"; + +/* Success */ +"Page saved!" = "Seite gespeichert!"; +"Open BookStax" = "BookStax öffnen"; +"Done" = "Fertig"; + +/* Not configured */ +"BookStax Not Configured" = "BookStax nicht konfiguriert"; +"Please open BookStax and sign in to your BookStack server." = "Bitte öffne die BookStax-App und melde dich bei deinem BookStack-Server an."; +"Close" = "Schließen"; + +/* Alert */ +"Error" = "Fehler"; +"OK" = "OK"; + +/* Shelf picker */ +"Select Shelf" = "Regal wählen"; +"Loading shelves\u{2026}" = "Regale werden geladen\u{2026}"; +"No shelves found" = "Keine Regale gefunden"; +"No shelves were found on the server." = "Es wurden keine Regale auf dem Server gefunden."; + +/* Book picker */ +"Select Book" = "Buch wählen"; +"Loading books\u{2026}" = "Bücher werden geladen\u{2026}"; +"No books found" = "Keine Bücher gefunden"; +"This shelf has no books yet." = "Dieses Regal enthält noch keine Bücher."; + +/* Chapter picker */ +"Select Chapter" = "Kapitel wählen"; +"Loading chapters\u{2026}" = "Kapitel werden geladen\u{2026}"; +"No chapter (directly in book)" = "Kein Kapitel (direkt im Buch)"; +"This book has no chapters." = "Dieses Buch enthält keine Kapitel."; + +/* API errors (semantic keys) */ +"error.notConfigured" = "BookStax ist nicht konfiguriert. Bitte öffne die App und melde dich an."; +"error.network.format" = "Netzwerkfehler: %@"; +"error.http.format" = "Serverfehler (HTTP %d). Bitte versuche es erneut."; +"error.decoding" = "Die Serverantwort konnte nicht verarbeitet werden."; diff --git a/BookStaxShareExtension/en.lproj/Localizable.strings b/BookStaxShareExtension/en.lproj/Localizable.strings new file mode 100644 index 0000000..a3d2d14 --- /dev/null +++ b/BookStaxShareExtension/en.lproj/Localizable.strings @@ -0,0 +1,51 @@ +/* ShareExtensionView */ +"Save to BookStax" = "Save to BookStax"; +"Cancel" = "Cancel"; +"Selected Text" = "Selected Text"; +"Page Title" = "Page Title"; +"Page title" = "Page title"; +"Location" = "Location"; +"Shelf" = "Shelf"; +"Book" = "Book"; +"Chapter" = "Chapter"; +"Select" = "Select"; +"Optional" = "Optional"; +"Save" = "Save"; + +/* Success */ +"Page saved!" = "Page saved!"; +"Open BookStax" = "Open BookStax"; +"Done" = "Done"; + +/* Not configured */ +"BookStax Not Configured" = "BookStax Not Configured"; +"Please open BookStax and sign in to your BookStack server." = "Please open BookStax and sign in to your BookStack server."; +"Close" = "Close"; + +/* Alert */ +"Error" = "Error"; +"OK" = "OK"; + +/* Shelf picker */ +"Select Shelf" = "Select Shelf"; +"Loading shelves\u{2026}" = "Loading shelves\u{2026}"; +"No shelves found" = "No shelves found"; +"No shelves were found on the server." = "No shelves were found on the server."; + +/* Book picker */ +"Select Book" = "Select Book"; +"Loading books\u{2026}" = "Loading books\u{2026}"; +"No books found" = "No books found"; +"This shelf has no books yet." = "This shelf has no books yet."; + +/* Chapter picker */ +"Select Chapter" = "Select Chapter"; +"Loading chapters\u{2026}" = "Loading chapters\u{2026}"; +"No chapter (directly in book)" = "No chapter (directly in book)"; +"This book has no chapters." = "This book has no chapters."; + +/* API errors (semantic keys) */ +"error.notConfigured" = "BookStax is not configured. Please open the app and sign in."; +"error.network.format" = "Network error: %@"; +"error.http.format" = "Server error (HTTP %d). Please try again."; +"error.decoding" = "The server response could not be processed."; diff --git a/BookStaxShareExtension/es.lproj/Localizable.strings b/BookStaxShareExtension/es.lproj/Localizable.strings new file mode 100644 index 0000000..d1d6a9a --- /dev/null +++ b/BookStaxShareExtension/es.lproj/Localizable.strings @@ -0,0 +1,51 @@ +/* ShareExtensionView */ +"Save to BookStax" = "Guardar en BookStax"; +"Cancel" = "Cancelar"; +"Selected Text" = "Texto seleccionado"; +"Page Title" = "Título de la página"; +"Page title" = "Título de la página"; +"Location" = "Ubicación"; +"Shelf" = "Estante"; +"Book" = "Libro"; +"Chapter" = "Capítulo"; +"Select" = "Seleccionar"; +"Optional" = "Opcional"; +"Save" = "Guardar"; + +/* Success */ +"Page saved!" = "¡Página guardada!"; +"Open BookStax" = "Abrir BookStax"; +"Done" = "Listo"; + +/* Not configured */ +"BookStax Not Configured" = "BookStax no configurado"; +"Please open BookStax and sign in to your BookStack server." = "Por favor abre BookStax e inicia sesión en tu servidor BookStack."; +"Close" = "Cerrar"; + +/* Alert */ +"Error" = "Error"; +"OK" = "OK"; + +/* Shelf picker */ +"Select Shelf" = "Seleccionar estante"; +"Loading shelves\u{2026}" = "Cargando estantes\u{2026}"; +"No shelves found" = "No se encontraron estantes"; +"No shelves were found on the server." = "No se encontraron estantes en el servidor."; + +/* Book picker */ +"Select Book" = "Seleccionar libro"; +"Loading books\u{2026}" = "Cargando libros\u{2026}"; +"No books found" = "No se encontraron libros"; +"This shelf has no books yet." = "Este estante no tiene libros todavía."; + +/* Chapter picker */ +"Select Chapter" = "Seleccionar capítulo"; +"Loading chapters\u{2026}" = "Cargando capítulos\u{2026}"; +"No chapter (directly in book)" = "Sin capítulo (directamente en el libro)"; +"This book has no chapters." = "Este libro no tiene capítulos."; + +/* API errors (semantic keys) */ +"error.notConfigured" = "BookStax no está configurado. Por favor abre la app e inicia sesión."; +"error.network.format" = "Error de red: %@"; +"error.http.format" = "Error del servidor (HTTP %d). Por favor inténtalo de nuevo."; +"error.decoding" = "La respuesta del servidor no se pudo procesar."; diff --git a/BookStaxShareExtension/fr.lproj/Localizable.strings b/BookStaxShareExtension/fr.lproj/Localizable.strings new file mode 100644 index 0000000..6f1df94 --- /dev/null +++ b/BookStaxShareExtension/fr.lproj/Localizable.strings @@ -0,0 +1,51 @@ +/* ShareExtensionView */ +"Save to BookStax" = "Enregistrer dans BookStax"; +"Cancel" = "Annuler"; +"Selected Text" = "Texte sélectionné"; +"Page Title" = "Titre de la page"; +"Page title" = "Titre de la page"; +"Location" = "Emplacement"; +"Shelf" = "Étagère"; +"Book" = "Livre"; +"Chapter" = "Chapitre"; +"Select" = "Choisir"; +"Optional" = "Facultatif"; +"Save" = "Enregistrer"; + +/* Success */ +"Page saved!" = "Page enregistrée !"; +"Open BookStax" = "Ouvrir BookStax"; +"Done" = "Terminé"; + +/* Not configured */ +"BookStax Not Configured" = "BookStax non configuré"; +"Please open BookStax and sign in to your BookStack server." = "Veuillez ouvrir BookStax et vous connecter à votre serveur BookStack."; +"Close" = "Fermer"; + +/* Alert */ +"Error" = "Erreur"; +"OK" = "OK"; + +/* Shelf picker */ +"Select Shelf" = "Choisir une étagère"; +"Loading shelves\u{2026}" = "Chargement des étagères\u{2026}"; +"No shelves found" = "Aucune étagère trouvée"; +"No shelves were found on the server." = "Aucune étagère n'a été trouvée sur le serveur."; + +/* Book picker */ +"Select Book" = "Choisir un livre"; +"Loading books\u{2026}" = "Chargement des livres\u{2026}"; +"No books found" = "Aucun livre trouvé"; +"This shelf has no books yet." = "Cette étagère ne contient aucun livre."; + +/* Chapter picker */ +"Select Chapter" = "Choisir un chapitre"; +"Loading chapters\u{2026}" = "Chargement des chapitres\u{2026}"; +"No chapter (directly in book)" = "Pas de chapitre (directement dans le livre)"; +"This book has no chapters." = "Ce livre ne contient aucun chapitre."; + +/* API errors (semantic keys) */ +"error.notConfigured" = "BookStax n'est pas configuré. Veuillez ouvrir l'app et vous connecter."; +"error.network.format" = "Erreur réseau : %@"; +"error.http.format" = "Erreur serveur (HTTP %d). Veuillez réessayer."; +"error.decoding" = "La réponse du serveur n'a pas pu être traitée."; diff --git a/ShareViewModelTests.swift b/ShareViewModelTests.swift new file mode 100644 index 0000000..eb2261d --- /dev/null +++ b/ShareViewModelTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import bookstax + +// MARK: - Mock + +final class MockShareAPIService: ShareAPIServiceProtocol, @unchecked Sendable { + + var shelvesToReturn: [ShelfSummary] = [] + var booksToReturn: [BookSummary] = [] + var chaptersToReturn: [ChapterSummary] = [] + var errorToThrow: Error? + + func fetchShelves() async throws -> [ShelfSummary] { + if let error = errorToThrow { throw error } + return shelvesToReturn + } + + func fetchBooks(shelfId: Int) async throws -> [BookSummary] { + if let error = errorToThrow { throw error } + return booksToReturn + } + + func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { + if let error = errorToThrow { throw error } + return chaptersToReturn + } + + func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult { + if let error = errorToThrow { throw error } + return PageResult(id: 42, name: title) + } +} + +// MARK: - Tests + +@Suite("ShareViewModel") +@MainActor +struct ShareViewModelTests { + + private func makeDefaults() -> UserDefaults { + let name = "test.bookstax.shareext.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + // MARK: 1. Shelves are loaded on start + + @Test("Beim Start werden alle Regale geladen") + func shelvesLoadOnStart() async throws { + let mock = MockShareAPIService() + mock.shelvesToReturn = [ + ShelfSummary(id: 1, name: "Regal A", slug: "a"), + ShelfSummary(id: 2, name: "Regal B", slug: "b") + ] + let vm = ShareViewModel(sharedText: "Testinhalt", + apiService: mock, + defaults: makeDefaults()) + await vm.loadShelves() + + #expect(vm.shelves.count == 2) + #expect(vm.shelves[0].name == "Regal A") + #expect(vm.isLoading == false) + #expect(vm.errorMessage == nil) + } + + // MARK: 2. Selecting a shelf loads its books + + @Test("Shelf-Auswahl lädt Bücher nach") + func selectingShelfLoadsBooksAsync() async throws { + let mock = MockShareAPIService() + mock.booksToReturn = [ + BookSummary(id: 100, name: "Buch 1", slug: "b1"), + BookSummary(id: 101, name: "Buch 2", slug: "b2") + ] + let vm = ShareViewModel(sharedText: "Test", + apiService: mock, + defaults: makeDefaults()) + + await vm.selectShelf(ShelfSummary(id: 10, name: "Regal X", slug: "x")) + + #expect(vm.selectedShelf?.id == 10) + #expect(vm.books.count == 2) + #expect(vm.isLoading == false) + } + + // MARK: 3. Last shelf/book is restored from UserDefaults + + @Test("Gespeicherte Shelf- und Book-IDs werden beim Start wiederhergestellt") + func lastSelectionIsRestored() async throws { + let mock = MockShareAPIService() + mock.shelvesToReturn = [ShelfSummary(id: 5, name: "Gespeichertes Regal", slug: "saved")] + mock.booksToReturn = [BookSummary(id: 50, name: "Gespeichertes Buch", slug: "saved-b")] + + let defaults = makeDefaults() + defaults.set(5, forKey: "shareExtension.lastShelfID") + defaults.set(50, forKey: "shareExtension.lastBookID") + + let vm = ShareViewModel(sharedText: "Test", + apiService: mock, + defaults: defaults) + await vm.loadShelves() + + #expect(vm.selectedShelf?.id == 5, "Letztes Regal soll wiederhergestellt sein") + #expect(vm.selectedBook?.id == 50, "Letztes Buch soll wiederhergestellt sein") + } + + // MARK: 4. Title auto-populated from first line + + @Test("Seitentitel wird aus erster Zeile des Textes befüllt") + func titleAutoPopulatedFromFirstLine() { + let vm = ShareViewModel(sharedText: "Erste Zeile\nZweite Zeile", + apiService: MockShareAPIService()) + #expect(vm.pageTitle == "Erste Zeile") + } + + // MARK: 5. Save page sets isSaved + + @Test("Seite speichern setzt isSaved auf true") + func savePageSetsisSaved() async throws { + let mock = MockShareAPIService() + let vm = ShareViewModel(sharedText: "Inhalt", apiService: mock) + vm.pageTitle = "Mein Titel" + vm.selectedBook = BookSummary(id: 1, name: "Buch", slug: "buch") + + await vm.savePage() + + #expect(vm.isSaved == true) + #expect(vm.errorMessage == nil) + } + + // MARK: 6. isSaveDisabled logic + + @Test("Speichern ist deaktiviert ohne Titel oder Buch") + func isSaveDisabledWithoutTitleOrBook() { + let vm = ShareViewModel(sharedText: "Test", apiService: MockShareAPIService()) + + vm.pageTitle = "" + #expect(vm.isSaveDisabled == true) + + vm.pageTitle = "Titel" + #expect(vm.isSaveDisabled == true) // no book yet + + vm.selectedBook = BookSummary(id: 1, name: "Buch", slug: "b") + #expect(vm.isSaveDisabled == false) + } +} diff --git a/bookstax.xcodeproj/project.pbxproj b/bookstax.xcodeproj/project.pbxproj index d0f34b7..2d4490b 100644 --- a/bookstax.xcodeproj/project.pbxproj +++ b/bookstax.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; }; 23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */; }; + 26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */; }; 26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26FD17072F8A9643006E87F3 /* Donations.storekit */; }; 2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; }; 57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; }; @@ -20,6 +22,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26F69D862F964C1700A6C5E6; + remoteInfo = BookStaxShareExtension; + }; 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */; @@ -29,13 +38,29 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = ""; }; 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = ""; }; 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = ""; }; - 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = bookstaxTests.xctest; path = .xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bookstaxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2480561934949230710825EA /* StringHTMLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StringHTMLTests.swift; path = bookstaxTests/StringHTMLTests.swift; sourceTree = ""; }; 261299D62F6C686D00EC1C97 /* bookstax.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bookstax.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BookStaxShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewModelTests.swift; sourceTree = ""; }; 26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = ""; }; 26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = ""; }; 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = ""; }; @@ -45,12 +70,40 @@ E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */; + }; + 26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ShareExtensionAPIService.swift, + ShareExtensionKeychainService.swift, + ShareViewModel.swift, + ); + target = 261299D52F6C686D00EC1C97 /* bookstax */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 261299D82F6C686D00EC1C97 /* bookstax */ = { isa = PBXFileSystemSynchronizedRootGroup; path = bookstax; sourceTree = ""; }; + 26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */, + 26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */, + ); + path = BookStaxShareExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +122,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 26F69D842F964C1700A6C5E6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -85,6 +145,7 @@ children = ( 26FD17062F8A95E1006E87F3 /* Tips.storekit */, 261299D82F6C686D00EC1C97 /* bookstax */, + 26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */, 261299D72F6C686D00EC1C97 /* Products */, 26FD17072F8A9643006E87F3 /* Donations.storekit */, EB2578937899373803DA341A /* Frameworks */, @@ -97,6 +158,7 @@ children = ( 261299D62F6C686D00EC1C97 /* bookstax.app */, 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */, + 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -112,6 +174,7 @@ 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */, 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */, E478C272640163A74D17B3DE /* DonationServiceTests.swift */, + 26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */, ); name = bookstaxTests; sourceTree = ""; @@ -134,10 +197,12 @@ 261299D22F6C686D00EC1C97 /* Sources */, 261299D32F6C686D00EC1C97 /* Frameworks */, 261299D42F6C686D00EC1C97 /* Resources */, + 26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 26F69D902F964C1700A6C5E6 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 261299D82F6C686D00EC1C97 /* bookstax */, @@ -147,6 +212,28 @@ productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */; productType = "com.apple.product-type.application"; }; + 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */; + buildPhases = ( + 26F69D832F964C1700A6C5E6 /* Sources */, + 26F69D842F964C1700A6C5E6 /* Frameworks */, + 26F69D852F964C1700A6C5E6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */, + ); + name = BookStaxShareExtension; + packageProductDependencies = ( + ); + productName = BookStaxShareExtension; + productReference = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; AD8774751A52779622D7AED5 /* bookstaxTests */ = { isa = PBXNativeTarget; buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */; @@ -172,12 +259,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2630; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2630; TargetAttributes = { 261299D52F6C686D00EC1C97 = { CreatedOnToolsVersion = 26.3; }; + 26F69D862F964C1700A6C5E6 = { + CreatedOnToolsVersion = 26.4.1; + }; }; }; buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */; @@ -199,6 +289,7 @@ targets = ( 261299D52F6C686D00EC1C97 /* bookstax */, AD8774751A52779622D7AED5 /* bookstaxTests */, + 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */, ); }; /* End PBXProject section */ @@ -212,6 +303,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 26F69D852F964C1700A6C5E6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AA28FE166C71A3A60AC62034 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -229,10 +327,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 26F69D832F964C1700A6C5E6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 67E32E036FC96F91F25C740D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */, EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */, 049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */, 6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */, @@ -247,6 +353,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 26F69D902F964C1700A6C5E6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */; + targetProxy = 26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */; + }; 90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = bookstax; @@ -265,6 +376,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.6; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests; + PRODUCT_NAME = bookstaxTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax"; @@ -398,6 +510,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EKFHUHT63T; @@ -409,7 +522,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -430,6 +543,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EKFHUHT63T; @@ -441,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -457,6 +571,66 @@ }; name = Release; }; + 26F69D942F964C1700A6C5E6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BookStaxShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 26F69D952F964C1700A6C5E6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = EKFHUHT63T; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = BookStaxShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; C9DF29CF9FF31B97AC4E31E5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -466,6 +640,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.6; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests; + PRODUCT_NAME = bookstaxTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax"; @@ -493,6 +668,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 26F69D942F964C1700A6C5E6 /* Debug */, + 26F69D952F964C1700A6C5E6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme index 0b68053..c0dd524 100644 --- a/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme +++ b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> @@ -41,15 +41,14 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist index ea882c1..8289846 100644 --- a/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,6 +4,11 @@ SchemeUserState + BookStaxShareExtension.xcscheme_^#shared#^_ + + orderHint + 1 + bookstax.xcscheme_^#shared#^_ orderHint diff --git a/bookstax/Models/ServerProfile.swift b/bookstax/Models/ServerProfile.swift index 6b22456..0283a52 100644 --- a/bookstax/Models/ServerProfile.swift +++ b/bookstax/Models/ServerProfile.swift @@ -68,6 +68,13 @@ final class ServerProfileStore { tokenId: creds.tokenId, tokenSecret: creds.tokenSecret ) + // Mirror credentials into the shared App Group keychain so the + // Share Extension can authenticate without launching the main app. + ShareExtensionKeychainService.saveCredentials( + serverURL: profile.serverURL, + tokenId: creds.tokenId, + tokenSecret: creds.tokenSecret + ) } // MARK: - Remove @@ -100,6 +107,12 @@ final class ServerProfileStore { let tokenId = newTokenId ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenId ?? "" let tokenSecret = newTokenSecret ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenSecret ?? "" CredentialStore.shared.update(serverURL: newURL, tokenId: tokenId, tokenSecret: tokenSecret) + // Keep the Share Extension's shared keychain entry up-to-date. + ShareExtensionKeychainService.saveCredentials( + serverURL: newURL, + tokenId: tokenId, + tokenSecret: tokenSecret + ) } } diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift index 674444e..5cb9905 100644 --- a/bookstax/Views/Editor/PageEditorView.swift +++ b/bookstax/Views/Editor/PageEditorView.swift @@ -101,9 +101,17 @@ struct PageEditorView: View { @State private var textView: UITextView? = nil @State private var imagePickerItem: PhotosPickerItem? = nil @State private var showTagEditor = false + /// False while the UITextView is doing its initial layout for an existing page. + @State private var isEditorReady: Bool init(mode: PageEditorViewModel.Mode) { _viewModel = State(initialValue: PageEditorViewModel(mode: mode)) + // Show a loading overlay only for edit mode — new pages start empty so layout is instant. + if case .edit = mode { + _isEditorReady = State(initialValue: false) + } else { + _isEditorReady = State(initialValue: true) + } } var body: some View { @@ -195,6 +203,17 @@ struct PageEditorView: View { } } .frame(maxHeight: .infinity) + .overlay { + if !isEditorReady { + ZStack { + Color(.systemBackground).ignoresSafeArea() + ProgressView() + .controlSize(.large) + } + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2), value: isEditorReady) } @ViewBuilder @@ -214,7 +233,15 @@ struct PageEditorView: View { Divider() } MarkdownTextEditor(text: $viewModel.markdownContent, - onTextViewReady: { tv in textView = tv }, + onTextViewReady: { tv in + textView = tv + // One run-loop pass lets UITextView finish its initial layout + // before we hide the loading overlay. + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + isEditorReady = true + } + }, onImagePaste: { image in Task { let data = image.jpegData(compressionQuality: 0.85) ?? Data() diff --git a/bookstax/bookstax.entitlements b/bookstax/bookstax.entitlements new file mode 100644 index 0000000..595f8ee --- /dev/null +++ b/bookstax/bookstax.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.hanold.bookstax + + + diff --git a/bookstaxTests/SearchViewModelTests.swift b/bookstaxTests/SearchViewModelTests.swift index e05f6e0..53a6e46 100644 --- a/bookstaxTests/SearchViewModelTests.swift +++ b/bookstaxTests/SearchViewModelTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import bookstax diff --git a/bookstaxTests/ShareViewModelTests.swift b/bookstaxTests/ShareViewModelTests.swift new file mode 100644 index 0000000..2b21ee6 --- /dev/null +++ b/bookstaxTests/ShareViewModelTests.swift @@ -0,0 +1,175 @@ +import Testing +@testable import bookstax +// Note: ShareViewModel and its dependencies live in BookStaxShareExtension, +// which is a separate target. These tests are compiled against the extension +// module. If you build the tests via the main scheme, add BookStaxShareExtension +// sources to the bookstaxTests target membership as needed. +// +// The tests below use a MockShareAPIService injected at init time so no +// network or Keychain access is required. + +// MARK: - Mock + +final class MockShareAPIService: ShareAPIServiceProtocol, @unchecked Sendable { + + var shelvesToReturn: [ShelfSummary] = [] + var booksToReturn: [BookSummary] = [] + var chaptersToReturn: [ChapterSummary] = [] + var errorToThrow: Error? + + func fetchShelves() async throws -> [ShelfSummary] { + if let error = errorToThrow { throw error } + return shelvesToReturn + } + + func fetchBooks(shelfId: Int) async throws -> [BookSummary] { + if let error = errorToThrow { throw error } + return booksToReturn + } + + func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { + if let error = errorToThrow { throw error } + return chaptersToReturn + } + + func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult { + if let error = errorToThrow { throw error } + return PageResult(id: 42, name: title) + } +} + +// MARK: - Tests + +@Suite("ShareViewModel") +@MainActor +struct ShareViewModelTests { + + // Isolated UserDefaults suite so tests don't pollute each other. + private func makeDefaults() -> UserDefaults { + let suiteName = "test.bookstax.shareext.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + // MARK: - 1. Shelves are loaded on start + + @Test("Beim Start werden alle Regale geladen") + func shelvesLoadOnStart() async throws { + let mock = MockShareAPIService() + mock.shelvesToReturn = [ + ShelfSummary(id: 1, name: "Regal A", slug: "a"), + ShelfSummary(id: 2, name: "Regal B", slug: "b") + ] + let vm = ShareViewModel( + sharedText: "Testinhalt", + apiService: mock, + defaults: makeDefaults() + ) + + await vm.loadShelves() + + #expect(vm.shelves.count == 2) + #expect(vm.shelves[0].name == "Regal A") + #expect(vm.shelves[1].name == "Regal B") + #expect(vm.isLoading == false) + #expect(vm.errorMessage == nil) + } + + // MARK: - 2. Selecting a shelf loads its books + + @Test("Shelf-Auswahl lädt Bücher nach") + func selectingShelfLoadsBooksAsync() async throws { + let mock = MockShareAPIService() + mock.shelvesToReturn = [ShelfSummary(id: 10, name: "Regal X", slug: "x")] + mock.booksToReturn = [ + BookSummary(id: 100, name: "Buch 1", slug: "b1"), + BookSummary(id: 101, name: "Buch 2", slug: "b2") + ] + let vm = ShareViewModel( + sharedText: "Test", + apiService: mock, + defaults: makeDefaults() + ) + + await vm.selectShelf(ShelfSummary(id: 10, name: "Regal X", slug: "x")) + + #expect(vm.selectedShelf?.id == 10) + #expect(vm.books.count == 2) + #expect(vm.books[0].name == "Buch 1") + #expect(vm.isLoading == false) + } + + // MARK: - 3. Last shelf / book are restored from UserDefaults + + @Test("Gespeicherte Shelf- und Book-IDs werden beim Start wiederhergestellt") + func lastSelectionIsRestored() async throws { + let mock = MockShareAPIService() + mock.shelvesToReturn = [ShelfSummary(id: 5, name: "Gespeichertes Regal", slug: "saved")] + mock.booksToReturn = [BookSummary(id: 50, name: "Gespeichertes Buch", slug: "saved-b")] + + let defaults = makeDefaults() + defaults.set(5, forKey: "shareExtension.lastShelfID") + defaults.set(50, forKey: "shareExtension.lastBookID") + + let vm = ShareViewModel( + sharedText: "Test", + apiService: mock, + defaults: defaults + ) + + await vm.loadShelves() + + #expect(vm.selectedShelf?.id == 5, "Letztes Regal soll wiederhergestellt sein") + #expect(vm.selectedBook?.id == 50, "Letztes Buch soll wiederhergestellt sein") + } + + // MARK: - Additional: title auto-populated from first line + + @Test("Seitentitel wird aus erster Zeile des geteilten Textes befüllt") + func titleAutoPopulatedFromFirstLine() { + let text = "Erste Zeile\nZweite Zeile\nDritte Zeile" + let vm = ShareViewModel( + sharedText: text, + apiService: MockShareAPIService() + ) + #expect(vm.pageTitle == "Erste Zeile") + } + + // MARK: - Additional: save page sets isSaved + + @Test("Seite speichern setzt isSaved auf true") + func savePageSetsisSaved() async throws { + let mock = MockShareAPIService() + let vm = ShareViewModel( + sharedText: "Inhalt der Seite", + apiService: mock + ) + vm.pageTitle = "Mein Titel" + vm.selectedBook = BookSummary(id: 1, name: "Buch", slug: "buch") + + await vm.savePage() + + #expect(vm.isSaved == true) + #expect(vm.errorMessage == nil) + } + + // MARK: - Additional: isSaveDisabled logic + + @Test("Speichern ist deaktiviert ohne Titel oder Buch") + func isSaveDisabledWithoutTitleOrBook() { + let vm = ShareViewModel(sharedText: "Test", apiService: MockShareAPIService()) + + // No title, no book + vm.pageTitle = "" + #expect(vm.isSaveDisabled == true) + + // Title but no book + vm.pageTitle = "Titel" + #expect(vm.isSaveDisabled == true) + + // Title + book → enabled + vm.selectedBook = BookSummary(id: 1, name: "Buch", slug: "b") + #expect(vm.isSaveDisabled == false) + } +}