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