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 "" } }