Unit tests, Lokalisierung, ShareExtension
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; };
|
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; };
|
||||||
23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.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 */; };
|
26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26FD17072F8A9643006E87F3 /* Donations.storekit */; };
|
||||||
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; };
|
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; };
|
||||||
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; };
|
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; };
|
||||||
@@ -20,6 +22,13 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 26F69D862F964C1700A6C5E6;
|
||||||
|
remoteInfo = BookStaxShareExtension;
|
||||||
|
};
|
||||||
992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = {
|
992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
|
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||||
@@ -29,13 +38,29 @@
|
|||||||
};
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = "<group>"; };
|
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = "<group>"; };
|
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = "<group>"; };
|
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
2480561934949230710825EA /* StringHTMLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StringHTMLTests.swift; path = bookstaxTests/StringHTMLTests.swift; sourceTree = "<group>"; };
|
||||||
261299D62F6C686D00EC1C97 /* bookstax.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bookstax.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
||||||
26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = "<group>"; };
|
26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = "<group>"; };
|
||||||
26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
|
26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
|
||||||
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = "<group>"; };
|
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -45,12 +70,40 @@
|
|||||||
E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = "<group>"; };
|
E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
261299D82F6C686D00EC1C97 /* bookstax */ = {
|
261299D82F6C686D00EC1C97 /* bookstax */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = bookstax;
|
path = bookstax;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */,
|
||||||
|
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */,
|
||||||
|
);
|
||||||
|
path = BookStaxShareExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -69,6 +122,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
26F69D842F964C1700A6C5E6 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -85,6 +145,7 @@
|
|||||||
children = (
|
children = (
|
||||||
26FD17062F8A95E1006E87F3 /* Tips.storekit */,
|
26FD17062F8A95E1006E87F3 /* Tips.storekit */,
|
||||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||||
|
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
|
||||||
261299D72F6C686D00EC1C97 /* Products */,
|
261299D72F6C686D00EC1C97 /* Products */,
|
||||||
26FD17072F8A9643006E87F3 /* Donations.storekit */,
|
26FD17072F8A9643006E87F3 /* Donations.storekit */,
|
||||||
EB2578937899373803DA341A /* Frameworks */,
|
EB2578937899373803DA341A /* Frameworks */,
|
||||||
@@ -97,6 +158,7 @@
|
|||||||
children = (
|
children = (
|
||||||
261299D62F6C686D00EC1C97 /* bookstax.app */,
|
261299D62F6C686D00EC1C97 /* bookstax.app */,
|
||||||
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
|
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
|
||||||
|
26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -112,6 +174,7 @@
|
|||||||
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */,
|
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */,
|
||||||
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */,
|
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */,
|
||||||
E478C272640163A74D17B3DE /* DonationServiceTests.swift */,
|
E478C272640163A74D17B3DE /* DonationServiceTests.swift */,
|
||||||
|
26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */,
|
||||||
);
|
);
|
||||||
name = bookstaxTests;
|
name = bookstaxTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -134,10 +197,12 @@
|
|||||||
261299D22F6C686D00EC1C97 /* Sources */,
|
261299D22F6C686D00EC1C97 /* Sources */,
|
||||||
261299D32F6C686D00EC1C97 /* Frameworks */,
|
261299D32F6C686D00EC1C97 /* Frameworks */,
|
||||||
261299D42F6C686D00EC1C97 /* Resources */,
|
261299D42F6C686D00EC1C97 /* Resources */,
|
||||||
|
26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||||
@@ -147,6 +212,28 @@
|
|||||||
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */ = {
|
AD8774751A52779622D7AED5 /* bookstaxTests */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */;
|
buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */;
|
||||||
@@ -172,12 +259,15 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2630;
|
LastSwiftUpdateCheck = 2640;
|
||||||
LastUpgradeCheck = 2630;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
261299D52F6C686D00EC1C97 = {
|
261299D52F6C686D00EC1C97 = {
|
||||||
CreatedOnToolsVersion = 26.3;
|
CreatedOnToolsVersion = 26.3;
|
||||||
};
|
};
|
||||||
|
26F69D862F964C1700A6C5E6 = {
|
||||||
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */;
|
buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */;
|
||||||
@@ -199,6 +289,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
261299D52F6C686D00EC1C97 /* bookstax */,
|
261299D52F6C686D00EC1C97 /* bookstax */,
|
||||||
AD8774751A52779622D7AED5 /* bookstaxTests */,
|
AD8774751A52779622D7AED5 /* bookstaxTests */,
|
||||||
|
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -212,6 +303,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
26F69D852F964C1700A6C5E6 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
AA28FE166C71A3A60AC62034 /* Resources */ = {
|
AA28FE166C71A3A60AC62034 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -229,10 +327,18 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
26F69D832F964C1700A6C5E6 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
67E32E036FC96F91F25C740D /* Sources */ = {
|
67E32E036FC96F91F25C740D /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */,
|
||||||
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */,
|
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */,
|
||||||
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */,
|
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */,
|
||||||
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */,
|
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */,
|
||||||
@@ -247,6 +353,11 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
|
||||||
|
targetProxy = 26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = {
|
90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
name = bookstax;
|
name = bookstax;
|
||||||
@@ -265,6 +376,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
||||||
|
PRODUCT_NAME = bookstaxTests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
||||||
@@ -398,6 +510,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||||
@@ -409,7 +522,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -430,6 +543,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||||
@@ -441,7 +555,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -457,6 +571,66 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */ = {
|
C9DF29CF9FF31B97AC4E31E5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -466,6 +640,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
||||||
|
PRODUCT_NAME = bookstaxTests;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
||||||
@@ -493,6 +668,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
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" */ = {
|
29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2640"
|
LastUpgradeVersion = "2640"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
||||||
BuildableName = "bookstaxTests.xctest"
|
BuildableName = ".xctest"
|
||||||
BlueprintName = "bookstaxTests"
|
BlueprintName = "bookstaxTests"
|
||||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -41,15 +41,14 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "NO">
|
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
||||||
BuildableName = "bookstaxTests.xctest"
|
BuildableName = ".xctest"
|
||||||
BlueprintName = "bookstaxTests"
|
BlueprintName = "bookstaxTests"
|
||||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>BookStaxShareExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
<key>bookstax.xcscheme_^#shared#^_</key>
|
<key>bookstax.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ final class ServerProfileStore {
|
|||||||
tokenId: creds.tokenId,
|
tokenId: creds.tokenId,
|
||||||
tokenSecret: creds.tokenSecret
|
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
|
// MARK: - Remove
|
||||||
@@ -100,6 +107,12 @@ final class ServerProfileStore {
|
|||||||
let tokenId = newTokenId ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenId ?? ""
|
let tokenId = newTokenId ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenId ?? ""
|
||||||
let tokenSecret = newTokenSecret ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenSecret ?? ""
|
let tokenSecret = newTokenSecret ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenSecret ?? ""
|
||||||
CredentialStore.shared.update(serverURL: newURL, tokenId: tokenId, tokenSecret: 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,9 +101,17 @@ struct PageEditorView: View {
|
|||||||
@State private var textView: UITextView? = nil
|
@State private var textView: UITextView? = nil
|
||||||
@State private var imagePickerItem: PhotosPickerItem? = nil
|
@State private var imagePickerItem: PhotosPickerItem? = nil
|
||||||
@State private var showTagEditor = false
|
@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) {
|
init(mode: PageEditorViewModel.Mode) {
|
||||||
_viewModel = State(initialValue: PageEditorViewModel(mode: 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 {
|
var body: some View {
|
||||||
@@ -195,6 +203,17 @@ struct PageEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
|
.overlay {
|
||||||
|
if !isEditorReady {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemBackground).ignoresSafeArea()
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeOut(duration: 0.2), value: isEditorReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -214,7 +233,15 @@ struct PageEditorView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
MarkdownTextEditor(text: $viewModel.markdownContent,
|
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
|
onImagePaste: { image in
|
||||||
Task {
|
Task {
|
||||||
let data = image.jpegData(compressionQuality: 0.85) ?? Data()
|
let data = image.jpegData(compressionQuality: 0.85) ?? Data()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import bookstax
|
@testable import bookstax
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user