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,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
@@ -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)
}
}
}
}
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.hanold.bookstax</string>
</array>
</dict>
</plist>
@@ -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)
}
}
}
}
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -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<ShelfSummary>.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<T: Decodable>(_ 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<T: Decodable>: 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]?
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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 ""
}
}
+149
View File
@@ -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
}
}
}
@@ -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)
}
}
}
}
}
}
}
@@ -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.";
@@ -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.";
@@ -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.";
@@ -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.";