Unit tests, Lokalisierung, ShareExtension

This commit is contained in:
2026-04-20 15:01:50 +02:00
parent 187c3e4fc6
commit 7bea01caaf
24 changed files with 1711 additions and 10 deletions
@@ -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 ""
}
}