Compare commits

...

13 Commits

Author SHA1 Message Date
sven 7bea01caaf Unit tests, Lokalisierung, ShareExtension 2026-04-20 15:01:50 +02:00
sven 187c3e4fc6 Nudge-Screen 2026-04-20 09:41:18 +02:00
sven a48e857ada .gitignore eingefügt 2026-04-16 19:58:37 +02:00
sven fb33681f0f Fix IAP 2026-04-12 12:19:53 +02:00
sven 7f312ece18 Fix IAP 2026-04-11 17:09:52 +02:00
sven f5ea1ee23e Fix and adjust IAP 2026-04-11 16:56:31 +02:00
sven 67de78837f Rebuild the IAP part 2026-04-11 16:36:24 +02:00
sven 5590100990 Rebuild the IAP part 2026-04-07 17:28:54 +02:00
sven 0d8a998ddf Schnellnotiz und Editorfenster. 2026-03-25 09:36:36 +01:00
sven bcb6a93dd5 Theme-Wechsel, Reconnect-Fehler 2026-03-23 17:15:04 +01:00
sven c4a4833bec Multi-Server implementiert 2026-03-22 11:04:52 +01:00
sven 6b3b2db013 Toolbar, Editor Window 2026-03-21 18:43:46 +01:00
sven da22b50ae4 Kommentare, Toolbar, Editorfenster 2026-03-21 18:42:53 +01:00
67 changed files with 6936 additions and 702 deletions
+139
View File
@@ -0,0 +1,139 @@
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# Xcode / iOS
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcworkspace/contents.xcworkspacedata
DerivedData/
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
xcuserdata/
.build/
Pods/
*.xccheckout
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
# Swift Package Manager
.build/
.swiftpm/
Package.resolved
# Editors & IDEs
.vscode/
.idea/
*.swp
*.swo
*~
*.orig
.project
.classpath
.settings/
nbproject/
*.sublime-project
*.sublime-workspace
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
.yarn-integrity
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
.venv/
dist/
build/
*.egg-info/
.eggs/
*.egg
# Docker
.dockerignore
# Logs & temp files
*.log
*.tmp
*.temp
*.bak
*.cache
*.pid
logs/
tmp/
temp/
# Archives
*.zip
*.tar.gz
*.tgz
*.rar
*.7z
# Secrets & credentials
.env
.env.*
!.env.example
secrets/
*.pem
*.key
*.p12
*.pfx
@@ -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.";
+87
View File
@@ -0,0 +1,87 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "449B8AA7",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "6762048529",
"localizations" : [
{
"description" : "Donate the value of a book",
"displayName" : "Book",
"locale" : "en_US"
}
],
"productID" : "donatebook",
"referenceName" : "Book",
"type" : "Consumable"
},
{
"displayPrice" : "49.99",
"familyShareable" : false,
"internalID" : "6762048687",
"localizations" : [
{
"description" : "Donate the value of an Encyclopaedia",
"displayName" : "Encyclopaedia",
"locale" : "en_US"
}
],
"productID" : "donateencyclopaedia",
"referenceName" : "Enyclopaedia",
"type" : "Consumable"
},
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "6762048537",
"localizations" : [
{
"description" : "Donate the value of one page",
"displayName" : "Page",
"locale" : "en_US"
}
],
"productID" : "doneatepage",
"referenceName" : "Page",
"type" : "Consumable"
}
],
"settings" : {
"_applicationInternalID" : "6761068485",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_developerTeamID" : "EKFHUHT63T",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 797611590.06171799,
"_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA",
"_storeKitErrors" : [
],
"_timeRate" : 0
},
"subscriptionGroups" : [
],
"version" : {
"major" : 5,
"minor" : 0
}
}
+148
View File
@@ -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)
}
}
+369 -8
View File
@@ -6,19 +6,115 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; };
23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */; };
26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */; };
26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26FD17072F8A9643006E87F3 /* Donations.storekit */; };
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; };
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; };
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */; };
84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AC406884F8446C6F4FA215 /* DTOTests.swift */; };
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0841C05048CA5AE635439A8 /* Foundation.framework */; };
DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E478C272640163A74D17B3DE /* DonationServiceTests.swift */; };
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2480561934949230710825EA /* StringHTMLTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 26F69D862F964C1700A6C5E6;
remoteInfo = BookStaxShareExtension;
};
992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 261299D52F6C686D00EC1C97;
remoteInfo = bookstax;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = "<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>"; };
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>"; };
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>"; };
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>"; };
57AC406884F8446C6F4FA215 /* DTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DTOTests.swift; path = bookstaxTests/DTOTests.swift; sourceTree = "<group>"; };
944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIErrorTests.swift; path = bookstaxTests/APIErrorTests.swift; sourceTree = "<group>"; };
C0841C05048CA5AE635439A8 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
};
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareExtensionAPIService.swift,
ShareExtensionKeychainService.swift,
ShareViewModel.swift,
);
target = 261299D52F6C686D00EC1C97 /* bookstax */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
261299D82F6C686D00EC1C97 /* bookstax */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = bookstax;
sourceTree = "<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 */
/* Begin PBXFrameworksBuildPhase section */
1A1B96526910505E82E2CFDB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
261299D32F6C686D00EC1C97 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -26,14 +122,34 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
26F69D842F964C1700A6C5E6 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1BB5D3095A0460024F7BA321 /* iOS */ = {
isa = PBXGroup;
children = (
C0841C05048CA5AE635439A8 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
261299CD2F6C686D00EC1C97 = {
isa = PBXGroup;
children = (
26FD17062F8A95E1006E87F3 /* Tips.storekit */,
261299D82F6C686D00EC1C97 /* bookstax */,
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
261299D72F6C686D00EC1C97 /* Products */,
26FD17072F8A9643006E87F3 /* Donations.storekit */,
EB2578937899373803DA341A /* Frameworks */,
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */,
);
sourceTree = "<group>";
};
@@ -41,10 +157,36 @@
isa = PBXGroup;
children = (
261299D62F6C686D00EC1C97 /* bookstax.app */,
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */ = {
isa = PBXGroup;
children = (
2480561934949230710825EA /* StringHTMLTests.swift */,
944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */,
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */,
57AC406884F8446C6F4FA215 /* DTOTests.swift */,
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */,
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */,
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */,
E478C272640163A74D17B3DE /* DonationServiceTests.swift */,
26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */,
);
name = bookstaxTests;
sourceTree = "<group>";
};
EB2578937899373803DA341A /* Frameworks */ = {
isa = PBXGroup;
children = (
1BB5D3095A0460024F7BA321 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -55,20 +197,60 @@
261299D22F6C686D00EC1C97 /* Sources */,
261299D32F6C686D00EC1C97 /* Frameworks */,
261299D42F6C686D00EC1C97 /* Resources */,
26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
261299D82F6C686D00EC1C97 /* bookstax */,
);
name = bookstax;
productName = bookstax;
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
productType = "com.apple.product-type.application";
};
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */;
buildPhases = (
26F69D832F964C1700A6C5E6 /* Sources */,
26F69D842F964C1700A6C5E6 /* Frameworks */,
26F69D852F964C1700A6C5E6 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
261299D82F6C686D00EC1C97 /* bookstax */,
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
);
name = bookstax;
name = BookStaxShareExtension;
packageProductDependencies = (
);
productName = bookstax;
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
productType = "com.apple.product-type.application";
productName = BookStaxShareExtension;
productReference = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
AD8774751A52779622D7AED5 /* bookstaxTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */;
buildPhases = (
67E32E036FC96F91F25C740D /* Sources */,
1A1B96526910505E82E2CFDB /* Frameworks */,
AA28FE166C71A3A60AC62034 /* Resources */,
);
buildRules = (
);
dependencies = (
90647D0E4313E7A718C1C384 /* PBXTargetDependency */,
);
name = bookstaxTests;
productName = bookstaxTests;
productReference = 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
@@ -77,12 +259,15 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2630;
TargetAttributes = {
261299D52F6C686D00EC1C97 = {
CreatedOnToolsVersion = 26.3;
};
26F69D862F964C1700A6C5E6 = {
CreatedOnToolsVersion = 26.4.1;
};
};
};
buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */;
@@ -93,6 +278,7 @@
Base,
de,
es,
fr,
);
mainGroup = 261299CD2F6C686D00EC1C97;
minimizedProjectReferenceProxies = 1;
@@ -102,12 +288,29 @@
projectRoot = "";
targets = (
261299D52F6C686D00EC1C97 /* bookstax */,
AD8774751A52779622D7AED5 /* bookstaxTests */,
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
261299D42F6C686D00EC1C97 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
26F69D852F964C1700A6C5E6 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA28FE166C71A3A60AC62034 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -124,9 +327,63 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
26F69D832F964C1700A6C5E6 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
67E32E036FC96F91F25C740D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */,
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */,
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */,
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */,
84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */,
23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */,
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */,
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */,
DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
targetProxy = 26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */;
};
90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = bookstax;
target = 261299D52F6C686D00EC1C97 /* bookstax */;
targetProxy = 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
1C68E5D77B468BD3A7F1C349 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = NO;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
PRODUCT_NAME = bookstaxTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
261299DF2F6C686E00EC1C97 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -253,20 +510,25 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -281,20 +543,25 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -304,6 +571,82 @@
};
name = Release;
};
26F69D942F964C1700A6C5E6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
26F69D952F964C1700A6C5E6 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
C9DF29CF9FF31B97AC4E31E5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = NO;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
PRODUCT_NAME = bookstaxTests;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -325,6 +668,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
26F69D942F964C1700A6C5E6 /* Debug */,
26F69D952F964C1700A6C5E6 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1C68E5D77B468BD3A7F1C349 /* Release */,
C9DF29CF9FF31B97AC4E31E5 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 261299CE2F6C686D00EC1C97 /* Project object */;
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
BuildableName = "bookstax.app"
BlueprintName = "bookstax"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AD8774751A52779622D7AED5"
BuildableName = ".xctest"
BlueprintName = "bookstaxTests"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AD8774751A52779622D7AED5"
BuildableName = ".xctest"
BlueprintName = "bookstaxTests"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
BuildableName = "bookstax.app"
BlueprintName = "bookstax"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Donations.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
BuildableName = "bookstax.app"
BlueprintName = "bookstax"
ReferencedContainer = "container:bookstax.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -4,11 +4,24 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>BookStaxShareExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>bookstax.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>261299D52F6C686D00EC1C97</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
+29
View File
@@ -73,6 +73,35 @@ enum AccentTheme: String, CaseIterable, Identifiable {
var accentColor: Color { shelfColor }
}
// MARK: - Color Hex Helpers
extension Color {
/// Initialises a Color from a CSS-style hex string (#RRGGBB or #RGB).
init?(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h = String(h.dropFirst()) }
let len = h.count
guard len == 6 || len == 3 else { return nil }
if len == 3 {
h = h.map { "\($0)\($0)" }.joined()
}
guard let value = UInt64(h, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255
let g = Double((value >> 8) & 0xFF) / 255
let b = Double( value & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
/// Returns an #RRGGBB hex string for the color (resolved in the light trait environment).
func toHexString() -> String {
let ui = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
let ri = Int(r * 255), gi = Int(g * 255), bi = Int(b * 255)
return String(format: "#%02X%02X%02X", ri, gi, bi)
}
}
// MARK: - Environment Key
private struct AccentThemeKey: EnvironmentKey {
+21
View File
@@ -0,0 +1,21 @@
import Foundation
import UIKit
extension String {
/// Strips HTML tags and decodes common HTML entities for plain-text display.
var strippingHTML: String {
// Use NSAttributedString to parse HTML handles entities and nested tags correctly
guard let data = data(using: .utf8),
let attributed = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue],
documentAttributes: nil)
else {
// Fallback: remove tags with regex
return replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return attributed.string.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
-1
View File
@@ -96,7 +96,6 @@ extension SearchResultDTO {
extension CommentDTO {
static let mock = CommentDTO(
id: 1,
text: "Great documentation! Very helpful.",
html: "<p>Great documentation! Very helpful.</p>",
pageId: 1,
createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
+2 -2
View File
@@ -25,7 +25,7 @@ enum BookStackError: LocalizedError, Sendable {
case .unauthorized:
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
case .forbidden:
return "Access denied. Either your account lacks the \"Access System API\" role permission, or your reverse proxy (nginx/Caddy) is not forwarding the Authorization header. Add `proxy_set_header Authorization $http_authorization;` to your proxy config."
return "Access denied (403). Your account may lack permission for this action."
case .notFound(let resource):
return "\(resource) could not be found. It may have been deleted or moved."
case .httpError(let code, let message):
@@ -37,7 +37,7 @@ enum BookStackError: LocalizedError, Sendable {
case .keychainError(let status):
return "Credential storage failed (code \(status))."
case .sslError:
return "SSL certificate error. If your server uses a self-signed certificate, contact your admin to install a trusted certificate."
return "SSL/TLS connection failed. Possible causes: untrusted or expired certificate, mismatched TLS version, or a reverse-proxy configuration issue. Check your server's HTTPS setup."
case .timeout:
return "Request timed out. Make sure your device can reach the server."
case .notReachable(let host):
+10 -6
View File
@@ -130,11 +130,11 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
bookId = try c.decode(Int.self, forKey: .bookId)
chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId)
name = try c.decode(String.self, forKey: .name)
slug = try c.decode(String.self, forKey: .slug)
slug = (try? c.decode(String.self, forKey: .slug)) ?? ""
html = try c.decodeIfPresent(String.self, forKey: .html)
markdown = try c.decodeIfPresent(String.self, forKey: .markdown)
priority = try c.decode(Int.self, forKey: .priority)
draftStatus = try c.decode(Bool.self, forKey: .draftStatus)
priority = (try? c.decode(Int.self, forKey: .priority)) ?? 0
draftStatus = (try? c.decodeIfPresent(Bool.self, forKey: .draftStatus)) ?? false
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
createdAt = try c.decode(Date.self, forKey: .createdAt)
updatedAt = try c.decode(Date.self, forKey: .updatedAt)
@@ -207,7 +207,6 @@ nonisolated struct TagListResponseDTO: Codable, Sendable {
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let text: String
let html: String
let pageId: Int
let createdBy: UserSummaryDTO
@@ -215,14 +214,19 @@ nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, text, html
case pageId = "entity_id"
case id, html
case pageId = "commentable_id"
case createdBy = "created_by"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
/// Minimal shape returned by the list endpoint only used to get IDs for detail fetches.
nonisolated struct CommentSummaryDTO: Codable, Sendable {
let id: Int
}
nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
let id: Int
let name: String
+179
View File
@@ -0,0 +1,179 @@
import Foundation
import Observation
// MARK: - ServerProfile
struct ServerProfile: Codable, Identifiable, Hashable {
let id: UUID
var name: String
var serverURL: String
// Display options (per-profile)
var appTheme: String = "system"
var accentTheme: String = AccentTheme.ocean.rawValue
var editorFontSize: Double = 16
var readerFontSize: Double = 16
var appTextColor: String? = nil // nil = system default
var appBackgroundColor: String? = nil // nil = system default
}
// MARK: - ServerProfileStore
@Observable
final class ServerProfileStore {
static let shared = ServerProfileStore()
private(set) var profiles: [ServerProfile] = []
private(set) var activeProfileId: UUID?
var activeProfile: ServerProfile? {
profiles.first { $0.id == activeProfileId }
}
private let profilesKey = "serverProfiles"
private let activeIdKey = "activeProfileId"
private init() {
load()
migrate()
// Ensure CredentialStore is populated for the active profile on every launch.
// CredentialStore.init() bootstraps from UserDefaults/Keychain independently,
// but activate() is the authoritative path call it here to guarantee consistency.
if let profile = profiles.first(where: { $0.id == activeProfileId }) {
activate(profile)
}
}
// MARK: - Add
/// Adds a new profile and persists its credentials synchronously.
func addProfile(_ profile: ServerProfile, tokenId: String, tokenSecret: String) {
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
profiles.append(profile)
save()
activate(profile)
}
// MARK: - Activate
func activate(_ profile: ServerProfile) {
guard let creds = KeychainService.loadCredentialsSync(profileId: profile.id) else { return }
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
UserDefaults.standard.set(profile.serverURL, forKey: "serverURL")
UserDefaults.standard.set(profile.appTheme, forKey: "appTheme")
UserDefaults.standard.set(profile.accentTheme, forKey: "accentTheme")
CredentialStore.shared.update(
serverURL: profile.serverURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
// Mirror credentials into the shared App Group keychain so the
// Share Extension can authenticate without launching the main app.
ShareExtensionKeychainService.saveCredentials(
serverURL: profile.serverURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
}
// MARK: - Remove
func remove(_ profile: ServerProfile) {
profiles.removeAll { $0.id == profile.id }
KeychainService.deleteCredentialsSync(profileId: profile.id)
save()
if activeProfileId == profile.id {
activeProfileId = nil
UserDefaults.standard.removeObject(forKey: activeIdKey)
UserDefaults.standard.removeObject(forKey: "serverURL")
}
}
// MARK: - Update
/// Updates the name/URL of an existing profile. Optionally rotates credentials.
func updateProfile(_ profile: ServerProfile, newName: String, newURL: String,
newTokenId: String? = nil, newTokenSecret: String? = nil) {
guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
profiles[idx].name = newName
profiles[idx].serverURL = newURL
if let id = newTokenId, let secret = newTokenSecret {
KeychainService.saveCredentialsSync(tokenId: id, tokenSecret: secret, profileId: profile.id)
}
save()
if activeProfileId == profile.id {
UserDefaults.standard.set(newURL, forKey: "serverURL")
let tokenId = newTokenId ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenId ?? ""
let tokenSecret = newTokenSecret ?? KeychainService.loadCredentialsSync(profileId: profile.id)?.tokenSecret ?? ""
CredentialStore.shared.update(serverURL: newURL, tokenId: tokenId, tokenSecret: tokenSecret)
// Keep the Share Extension's shared keychain entry up-to-date.
ShareExtensionKeychainService.saveCredentials(
serverURL: newURL,
tokenId: tokenId,
tokenSecret: tokenSecret
)
}
}
// MARK: - Display Options
func updateDisplayOptions(for profile: ServerProfile,
editorFontSize: Double,
readerFontSize: Double,
appTextColor: String?,
appBackgroundColor: String?,
appTheme: String,
accentTheme: String) {
guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
profiles[idx].editorFontSize = editorFontSize
profiles[idx].readerFontSize = readerFontSize
profiles[idx].appTextColor = appTextColor
profiles[idx].appBackgroundColor = appBackgroundColor
profiles[idx].appTheme = appTheme
profiles[idx].accentTheme = accentTheme
save()
// Apply theme changes to app-wide UserDefaults immediately
if activeProfileId == profile.id {
UserDefaults.standard.set(appTheme, forKey: "appTheme")
UserDefaults.standard.set(accentTheme, forKey: "accentTheme")
}
}
// MARK: - Persistence
private func save() {
guard let data = try? JSONEncoder().encode(profiles) else { return }
UserDefaults.standard.set(data, forKey: profilesKey)
}
private func load() {
if let data = UserDefaults.standard.data(forKey: profilesKey),
let decoded = try? JSONDecoder().decode([ServerProfile].self, from: data) {
profiles = decoded
}
if let idString = UserDefaults.standard.string(forKey: activeIdKey),
let uuid = UUID(uuidString: idString) {
activeProfileId = uuid
}
}
// MARK: - Migration from legacy single-server config
private func migrate() {
guard profiles.isEmpty,
let url = UserDefaults.standard.string(forKey: "serverURL"),
!url.isEmpty,
let tokenId = KeychainService.loadSync(key: "tokenId"),
let tokenSecret = KeychainService.loadSync(key: "tokenSecret") else { return }
let profile = ServerProfile(id: UUID(), name: "BookStack", serverURL: url)
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
profiles.append(profile)
save()
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
// Leave legacy "serverURL" in place so CredentialStore continues working after migration.
AppLog(.info, "Migrated legacy server config to profile \(profile.id)", category: "ServerProfile")
}
}
-94
View File
@@ -1,95 +1 @@
import Foundation
import SwiftData
@Model
final class CachedShelf {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var shelfDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.shelfDescription = shelfDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: ShelfDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
shelfDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedBook {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var bookDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.bookDescription = bookDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: BookDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
bookDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedPage {
@Attribute(.unique) var id: Int
var bookId: Int
var chapterId: Int?
var name: String
var slug: String
var html: String?
var markdown: String?
var lastFetched: Date
init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
self.id = id
self.bookId = bookId
self.chapterId = chapterId
self.name = name
self.slug = slug
self.html = html
self.markdown = markdown
self.lastFetched = Date()
}
convenience init(from dto: PageDTO) {
self.init(
id: dto.id,
bookId: dto.bookId,
chapterId: dto.chapterId,
name: dto.name,
slug: dto.slug,
html: dto.html,
markdown: dto.markdown
)
}
}
@@ -0,0 +1,15 @@
import Foundation
import Observation
/// Shared navigation state for cross-tab navigation requests.
@Observable
final class AppNavigationState {
static let shared = AppNavigationState()
private init() {}
/// When set, MainTabView switches to the Library tab and LibraryView pushes this book.
var pendingBookNavigation: BookDTO? = nil
/// When set to true, MainTabView switches to the Settings tab (e.g. after a 401).
var navigateToSettings: Bool = false
}
+163 -50
View File
@@ -1,35 +1,98 @@
import Foundation
// MARK: - CredentialStore
/// Thread-safe, synchronously-bootstrapped credential store.
/// Populated from Keychain at app launch no async step required.
final class CredentialStore: @unchecked Sendable {
static let shared = CredentialStore()
private let lock = NSLock()
private var _serverURL: String
private var _tokenId: String
private var _tokenSecret: String
private init() {
if let idStr = UserDefaults.standard.string(forKey: "activeProfileId"),
let uuid = UUID(uuidString: idStr),
let creds = KeychainService.loadCredentialsSync(profileId: uuid),
let rawURL = UserDefaults.standard.string(forKey: "serverURL") {
_serverURL = Self.normalise(rawURL)
_tokenId = creds.tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = creds.tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
// Fall back to legacy single-profile keys
_serverURL = Self.normalise(UserDefaults.standard.string(forKey: "serverURL") ?? "")
_tokenId = (KeychainService.loadSync(key: "tokenId") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = (KeychainService.loadSync(key: "tokenSecret") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
func update(serverURL: String, tokenId: String, tokenSecret: String) {
lock.withLock {
_serverURL = Self.normalise(serverURL)
_tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
}
AppLog(.info, "Credentials updated for \(Self.normalise(serverURL))", category: "API")
}
func snapshot() -> (serverURL: String, tokenId: String, tokenSecret: String) {
lock.withLock { (_serverURL, _tokenId, _tokenSecret) }
}
var isConfigured: Bool { lock.withLock { !_serverURL.isEmpty && !_tokenId.isEmpty } }
static func normalise(_ url: String) -> String {
var s = url.trimmingCharacters(in: .whitespacesAndNewlines)
while s.hasSuffix("/") { s = String(s.dropLast()) }
return s
}
}
// MARK: - BookStackAPI
actor BookStackAPI {
static let shared = BookStackAPI()
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
// No actor-local credential state all reads go through CredentialStore.
private let decoder: JSONDecoder = {
let d = JSONDecoder()
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(abbreviation: "UTC")
d.dateDecodingStrategy = .formatted(formatter)
// BookStack returns ISO8601 with variable fractional seconds and timezone formats.
// Try formats in order: microseconds (6 digits), milliseconds (3 digits), no fractions.
let formats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", // 2024-01-15T10:30:00.000000Z
"yyyy-MM-dd'T'HH:mm:ss.SSSZ", // 2024-01-15T10:30:00.000Z
"yyyy-MM-dd'T'HH:mm:ssZ", // 2024-01-15T10:30:00Z
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // 2024-01-15T10:30:00.000000+00:00
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // 2024-01-15T10:30:00.000+00:00
"yyyy-MM-dd'T'HH:mm:ssXXXXX", // 2024-01-15T10:30:00+00:00
].map { fmt -> DateFormatter in
let f = DateFormatter()
f.dateFormat = fmt
f.locale = Locale(identifier: "en_US_POSIX")
return f
}
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
for formatter in formats {
if let date = formatter.date(from: string) { return date }
}
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Cannot decode date: \(string)")
}
return d
}()
// MARK: - Configuration
/// Kept for compatibility delegates to CredentialStore.
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
self.serverURL = clean
self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
AppLog(.info, "API configured for \(clean)", category: "API")
CredentialStore.shared.update(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret)
}
func getServerURL() -> String { serverURL }
func getServerURL() -> String { CredentialStore.shared.snapshot().serverURL }
// MARK: - Core Request (no body)
@@ -58,11 +121,12 @@ actor BookStackAPI {
method: String,
bodyData: Data?
) async throws -> T {
guard !serverURL.isEmpty else {
let creds = CredentialStore.shared.snapshot()
guard !creds.serverURL.isEmpty else {
AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
throw BookStackError.notAuthenticated
}
guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
guard let url = URL(string: "\(creds.serverURL)/api/\(endpoint)") else {
AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
throw BookStackError.invalidURL
}
@@ -71,7 +135,7 @@ actor BookStackAPI {
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
@@ -91,14 +155,26 @@ actor BookStackAPI {
case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable
case .cannotFindHost, .dnsLookupFailed:
mapped = .notReachable(host: serverURL)
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
mapped = .notReachable(host: creds.serverURL)
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
mapped = .sslError
case .cannotConnectToHost:
// Could be TLS rejection or TCP refused check underlying error
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
mapped = .sslError
} else {
mapped = .notReachable(host: creds.serverURL)
}
default:
AppLog(.warning, "\(method) /api/\(endpoint) — unhandled URLError \(urlError.code.rawValue): \(urlError.localizedDescription)", category: "API")
mapped = .unknown(urlError.localizedDescription)
}
AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
AppLog(.error, "\(method) /api/\(endpoint) — network error (\(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "API")
throw mapped
}
@@ -114,7 +190,7 @@ actor BookStackAPI {
let mapped: BookStackError
switch http.statusCode {
case 401: mapped = .unauthorized
case 403: mapped = .forbidden
case 403: mapped = .httpError(statusCode: 403, message: errorMessage ?? "Access denied. Your account may lack permission for this action.")
case 404: mapped = .notFound(resource: "Resource")
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
}
@@ -140,11 +216,13 @@ actor BookStackAPI {
}
private func parseErrorMessage(from data: Data) -> String? {
struct APIErrorEnvelope: Codable {
struct Inner: Codable { let message: String? }
let error: Inner?
}
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
// Shape 1: {"error": {"message": "..."}} (older BookStack)
if let errorObj = json["error"] as? [String: Any],
let msg = errorObj["message"] as? String { return msg }
// Shape 2: {"message": "...", "errors": {...}} (validation / newer BookStack)
if let msg = json["message"] as? String { return msg }
return nil
}
// MARK: - Shelves
@@ -317,25 +395,37 @@ actor BookStackAPI {
// MARK: - Comments
func fetchComments(pageId: Int) async throws -> [CommentDTO] {
let response: PaginatedResponse<CommentDTO> = try await request(
endpoint: "comments?entity_type=page&entity_id=\(pageId)"
let list: PaginatedResponse<CommentSummaryDTO> = try await request(
endpoint: "comments?filter[commentable_type]=page&filter[commentable_id]=\(pageId)&count=100"
)
return response.data
// Fetch full detail for each comment in parallel to get html + user info
return try await withThrowingTaskGroup(of: CommentDTO.self) { group in
for summary in list.data {
group.addTask {
try await self.request(endpoint: "comments/\(summary.id)")
}
}
var results: [CommentDTO] = []
for try await comment in group { results.append(comment) }
return results.sorted { $0.createdAt < $1.createdAt }
}
}
func postComment(pageId: Int, text: String) async throws -> CommentDTO {
func postComment(pageId: Int, text: String) async throws {
struct Body: Encodable, Sendable {
let text: String
let entityId: Int
let entityType: String
let pageId: Int
let html: String
enum CodingKeys: String, CodingKey {
case text
case entityId = "entity_id"
case entityType = "entity_type"
case pageId = "page_id"
case html
}
}
return try await request(endpoint: "comments", method: "POST",
body: Body(text: text, entityId: pageId, entityType: "page"))
// The POST response shape differs from CommentDTO use EmptyResponse and discard it
struct PostCommentResponse: Decodable, Sendable { let id: Int }
// BookStack expects HTML content wrap plain text in a paragraph tag
let html = "<p>\(text.replacingOccurrences(of: "\n", with: "<br>"))</p>"
let _: PostCommentResponse = try await request(endpoint: "comments", method: "POST",
body: Body(pageId: pageId, html: html))
}
func deleteComment(id: Int) async throws {
@@ -373,16 +463,29 @@ actor BookStackAPI {
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
AppLog(.error, "Network error reaching \(url) (URLError \(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "Auth")
switch urlError.code {
case .timedOut:
throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost:
throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError
case .cannotConnectToHost:
// TLS handshake abort arrives as cannotConnectToHost with an SSL underlying error
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
throw BookStackError.sslError
}
throw BookStackError.notReachable(host: url)
case .cannotFindHost, .dnsLookupFailed:
throw BookStackError.notReachable(host: url)
default:
AppLog(.warning, "Unhandled URLError \(urlError.code.rawValue) for \(url)", category: "Auth")
throw BookStackError.notReachable(host: url)
}
}
@@ -410,7 +513,7 @@ actor BookStackAPI {
case 403:
let msg = parseErrorMessage(from: data)
AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
throw BookStackError.forbidden
throw BookStackError.httpError(statusCode: 403, message: msg ?? "Access denied. Your account may lack the \"Access System API\" role permission.")
case 404:
// Old BookStack version without /api/system fall back to /api/books probe
@@ -443,9 +546,18 @@ actor BookStackAPI {
switch urlError.code {
case .timedOut: throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError
case .cannotConnectToHost:
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
throw BookStackError.sslError
}
throw BookStackError.notReachable(host: url)
default: throw BookStackError.notReachable(host: url)
}
}
@@ -490,8 +602,9 @@ actor BookStackAPI {
/// - mimeType: e.g. "image/jpeg" or "image/png"
/// - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
let creds = CredentialStore.shared.snapshot()
guard !creds.serverURL.isEmpty else { throw BookStackError.notAuthenticated }
guard let url = URL(string: "\(creds.serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
@@ -516,7 +629,7 @@ actor BookStackAPI {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
+229
View File
@@ -0,0 +1,229 @@
import Foundation
import StoreKit
import Observation
// MARK: - State Types
enum DonationLoadState {
case loading
case loaded([Product])
case empty
case failed(String)
}
enum DonationPurchaseState: Equatable {
case idle
case purchasing(productID: String)
case thankYou(productID: String)
case pending(productID: String)
case failed(productID: String, message: String)
var activePurchasingID: String? {
if case .purchasing(let id) = self { return id }
return nil
}
var thankYouID: String? {
if case .thankYou(let id) = self { return id }
return nil
}
var pendingID: String? {
if case .pending(let id) = self { return id }
return nil
}
func errorMessage(for productID: String) -> String? {
if case .failed(let id, let msg) = self, id == productID { return msg }
return nil
}
var isIdle: Bool {
if case .idle = self { return true }
return false
}
}
// MARK: - DonationService
/// Manages product loading, purchases, and donation history for the Support section.
/// Product IDs must exactly match App Store Connect configuration.
@Observable
@MainActor
final class DonationService {
static let shared = DonationService()
/// The exact product IDs as configured in App Store Connect.
static let productIDs: Set<String> = [
"doneatepage",
"donatebook",
"donateencyclopaedia"
]
private(set) var loadState: DonationLoadState = .loading
private(set) var purchaseState: DonationPurchaseState = .idle
/// Maps productID date of the most recent completed donation.
private(set) var donationHistory: [String: Date] = [:]
private var transactionListenerTask: Task<Void, Never>?
private static let historyDefaultsKey = "bookstax.donationHistory"
private static let nudgeDateKey = "bookstax.lastNudgeDate"
private static let installDateKey = "bookstax.installDate"
/// ~6 months in seconds
private static let nudgeIntervalSeconds: TimeInterval = 182 * 24 * 3600
/// 3-day grace period after install before first nudge
private static let gracePeriodSeconds: TimeInterval = 3 * 24 * 3600
private init() {
donationHistory = Self.loadPersistedHistory()
transactionListenerTask = startTransactionListener()
// Record install date on first launch
if UserDefaults.standard.object(forKey: Self.installDateKey) == nil {
UserDefaults.standard.set(Date(), forKey: Self.installDateKey)
}
}
// MARK: - Nudge & Supporter State
/// True if the user has completed at least one donation.
var hasEverDonated: Bool {
!donationHistory.isEmpty
}
/// True if the nudge sheet should be presented.
/// Returns false immediately once any donation has been made.
var shouldShowNudge: Bool {
guard !hasEverDonated else { return false }
if let last = UserDefaults.standard.object(forKey: Self.nudgeDateKey) as? Date {
return Date().timeIntervalSince(last) >= Self.nudgeIntervalSeconds
}
// Never shown before only show after 3-day grace period
guard let installDate = UserDefaults.standard.object(forKey: Self.installDateKey) as? Date else {
return false
}
return Date().timeIntervalSince(installDate) >= Self.gracePeriodSeconds
}
/// Call when the nudge sheet is dismissed so we know when to show it next.
func recordNudgeSeen() {
UserDefaults.standard.set(Date(), forKey: Self.nudgeDateKey)
}
// MARK: - Product Loading
func loadProducts() async {
loadState = .loading
do {
let fetched = try await Product.products(for: Self.productIDs)
loadState = fetched.isEmpty
? .empty
: .loaded(fetched.sorted { $0.price < $1.price })
} catch {
AppLog(.error, "Product load failed: \(error)", category: "IAP")
loadState = .failed(error.localizedDescription)
}
}
// MARK: - Purchase
func purchase(_ product: Product) async {
guard case .idle = purchaseState else { return }
purchaseState = .purchasing(productID: product.id)
do {
let result = try await product.purchase()
await handlePurchaseResult(result, productID: product.id)
} catch {
AppLog(.error, "Purchase failed for \(product.id): \(error)", category: "IAP")
purchaseState = .failed(productID: product.id, message: error.localizedDescription)
}
}
func dismissError(for productID: String) {
if case .failed(let id, _) = purchaseState, id == productID {
purchaseState = .idle
}
}
// MARK: - Private Purchase Handling
private func handlePurchaseResult(_ result: Product.PurchaseResult, productID: String) async {
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await completeTransaction(transaction)
await showThankYou(for: productID)
case .unverified(_, let error):
AppLog(.error, "Unverified transaction for \(productID): \(error)", category: "IAP")
purchaseState = .failed(productID: productID, message: error.localizedDescription)
}
case .userCancelled:
purchaseState = .idle
case .pending:
// Deferred purchase (e.g. Ask to Buy). Resolved via transaction listener.
purchaseState = .pending(productID: productID)
@unknown default:
purchaseState = .idle
}
}
private func completeTransaction(_ transaction: Transaction) async {
recordDonation(productID: transaction.productID)
await transaction.finish()
AppLog(.info, "Transaction \(transaction.id) finished for \(transaction.productID)", category: "IAP")
}
private func showThankYou(for productID: String) async {
purchaseState = .thankYou(productID: productID)
try? await Task.sleep(for: .seconds(3))
if case .thankYou(let id) = purchaseState, id == productID {
purchaseState = .idle
}
}
// MARK: - Transaction Listener
/// Listens for transaction updates from StoreKit (e.g. Ask to Buy approvals,
/// transactions completed on other devices, interrupted purchases).
private func startTransactionListener() -> Task<Void, Never> {
Task.detached(priority: .background) { [weak self] in
for await result in Transaction.updates {
await self?.handleTransactionUpdate(result)
}
}
}
private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
await completeTransaction(transaction)
// Resolve a pending state caused by Ask to Buy or interrupted purchase.
if case .pending(let id) = purchaseState, id == transaction.productID {
await showThankYou(for: transaction.productID)
}
case .unverified(let transaction, let error):
AppLog(.warning, "Unverified transaction update for \(transaction.productID): \(error)", category: "IAP")
}
}
// MARK: - Donation History Persistence
private func recordDonation(productID: String) {
donationHistory[productID] = Date()
Self.persistHistory(donationHistory)
}
private static func loadPersistedHistory() -> [String: Date] {
guard
let data = UserDefaults.standard.data(forKey: historyDefaultsKey),
let decoded = try? JSONDecoder().decode([String: Date].self, from: data)
else { return [:] }
return decoded
}
private static func persistHistory(_ history: [String: Date]) {
guard let data = try? JSONEncoder().encode(history) else { return }
UserDefaults.standard.set(data, forKey: historyDefaultsKey)
}
}
+62 -1
View File
@@ -26,7 +26,68 @@ actor KeychainService {
try delete(key: tokenSecretKey)
}
// MARK: - Synchronous static helper (for use at app init before async context)
// MARK: - Per-profile credential methods
func saveCredentials(tokenId: String, tokenSecret: String, profileId: UUID) throws {
try save(value: tokenId, key: "tokenId-\(profileId.uuidString)")
try save(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
}
func loadCredentials(profileId: UUID) throws -> (tokenId: String, tokenSecret: String)? {
guard let id = try load(key: "tokenId-\(profileId.uuidString)"),
let secret = try load(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
return (id, secret)
}
func deleteCredentials(profileId: UUID) throws {
try delete(key: "tokenId-\(profileId.uuidString)")
try delete(key: "tokenSecret-\(profileId.uuidString)")
}
// MARK: - Synchronous static helpers (for use at app init / non-async contexts)
static func loadCredentialsSync(profileId: UUID) -> (tokenId: String, tokenSecret: String)? {
guard let tokenId = loadSync(key: "tokenId-\(profileId.uuidString)"),
let tokenSecret = loadSync(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
return (tokenId, tokenSecret)
}
@discardableResult
static func saveSync(value: String, key: String) -> Bool {
let service = "com.bookstax.credentials"
guard let data = value.data(using: .utf8) else { return false }
let deleteQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: data
]
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}
static func saveCredentialsSync(tokenId: String, tokenSecret: String, profileId: UUID) {
saveSync(value: tokenId, key: "tokenId-\(profileId.uuidString)")
saveSync(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
}
static func deleteCredentialsSync(profileId: UUID) {
let service = "com.bookstax.credentials"
for suffix in ["tokenId-\(profileId.uuidString)", "tokenSecret-\(profileId.uuidString)"] {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: suffix
]
SecItemDelete(query as CFDictionary)
}
}
static func loadSync(key: String) -> String? {
let service = "com.bookstax.credentials"
+2 -55
View File
@@ -1,59 +1,6 @@
import Foundation
import Observation
/// Manages in-app language selection independently of the system locale.
@Observable
final class LanguageManager {
static let shared = LanguageManager()
enum Language: String, CaseIterable, Identifiable {
case english = "en"
case german = "de"
case spanish = "es"
var id: String { rawValue }
var displayName: String {
switch self {
case .english: return "English"
case .german: return "Deutsch"
case .spanish: return "Español"
}
}
var flag: String {
switch self {
case .english: return "🇬🇧"
case .german: return "🇩🇪"
case .spanish: return "🇪🇸"
}
}
}
private(set) var current: Language
private init() {
let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
current = Language(rawValue: saved) ?? .english
}
func set(_ language: Language) {
current = language
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
}
/// Returns the localised string for key in the currently selected language.
func string(_ key: String) -> String {
guard let path = Bundle.main.path(forResource: current.rawValue, ofType: "lproj"),
let bundle = Bundle(path: path) else {
return NSLocalizedString(key, comment: "")
}
return bundle.localizedString(forKey: key, value: key, table: nil)
}
}
/// Convenience shorthand
/// Returns the localized string for the given key using the device system language.
func L(_ key: String) -> String {
LanguageManager.shared.string(key)
NSLocalizedString(key, comment: "")
}
-79
View File
@@ -1,80 +1 @@
import Foundation
import SwiftData
/// SyncService handles upserting API DTOs into the local SwiftData cache.
/// All methods are @MainActor because ModelContext must be used on the main actor.
@MainActor
final class SyncService {
static let shared = SyncService()
private let api = BookStackAPI.shared
private init() {}
// MARK: - Sync Shelves
func syncShelves(context: ModelContext) async throws {
let dtos = try await api.fetchShelves()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedShelf>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.shelfDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedShelf(from: dto))
}
}
try context.save()
}
// MARK: - Sync Books
func syncBooks(context: ModelContext) async throws {
let dtos = try await api.fetchBooks()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedBook>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.bookDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedBook(from: dto))
}
}
try context.save()
}
// MARK: - Sync Page (on demand, after viewing)
func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
let id = dto.id
let descriptor = FetchDescriptor<CachedPage>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.html = dto.html
existing.markdown = dto.markdown
existing.lastFetched = Date()
} else {
context.insert(CachedPage(from: dto))
}
try context.save()
}
// MARK: - Full sync
func syncAll(context: ModelContext) async throws {
async let shelvesTask: Void = syncShelves(context: context)
async let booksTask: Void = syncBooks(context: context)
_ = try await (shelvesTask, booksTask)
}
}
+54 -20
View File
@@ -5,17 +5,17 @@ import Observation
@Observable
final class OnboardingViewModel {
enum Step: Int, CaseIterable, Hashable {
case language = 0
case welcome = 1
case connect = 2
case ready = 3
enum Step: Hashable {
case welcome
case connect
case ready
}
// Navigation NavigationStack path (language is the root, not in the path)
var navPath: NavigationPath = NavigationPath()
// Input
var serverNameInput: String = ""
var serverURLInput: String = ""
var tokenIdInput: String = ""
var tokenSecretInput: String = ""
@@ -36,6 +36,10 @@ final class OnboardingViewModel {
// Completion
var isComplete: Bool = false
/// Set to true after successfully adding a server in the Settings "Add Server" flow.
var isAddComplete: Bool = false
/// When true, skips the ready step navigation (used in Add Server sheet).
var isAddServerMode: Bool = false
// MARK: - Navigation
@@ -77,6 +81,32 @@ final class OnboardingViewModel {
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
}
/// True when the URL looks like it points to a publicly accessible server
/// (not a private IP, localhost, or .local mDNS host).
var isRemoteServer: Bool {
guard let host = URL(string: serverURLInput)?.host ?? URL(string: "https://\(serverURLInput)")?.host,
!host.isEmpty else { return false }
// Loopback
if host == "localhost" || host == "127.0.0.1" || host == "::1" { return false }
// mDNS (.local) and plain hostnames without dots are local
if host.hasSuffix(".local") || !host.contains(".") { return false }
// Private IPv4 ranges: 10.x, 172.1631.x, 192.168.x
let octets = host.split(separator: ".").compactMap { Int($0) }
if octets.count == 4 {
if octets[0] == 10 { return false }
if octets[0] == 172, (16...31).contains(octets[1]) { return false }
if octets[0] == 192, octets[1] == 168 { return false }
// Any other IPv4 (public IP) remote
return true
}
// Domain name with dots treat as potentially remote
return true
}
// MARK: - Verification
func verifyAndSave() async {
@@ -117,6 +147,11 @@ final class OnboardingViewModel {
let appName = info.appName ?? "BookStack"
verifyPhase = .serverOK(appName: appName)
// Auto-populate server name from API if the user left it blank
if serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
serverNameInput = appName
}
// Configure the shared API client with validated credentials
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
verifyPhase = .checkingToken
@@ -124,25 +159,24 @@ final class OnboardingViewModel {
// Attempt to fetch user info (non-fatal some installs restrict /api/users)
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
// Persist server URL and credentials
UserDefaults.standard.set(url, forKey: "serverURL")
do {
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
} catch let error as BookStackError {
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "keychain", error: error)
return
} catch {
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
return
}
// Create and persist a ServerProfile via the shared store
let profile = ServerProfile(
id: UUID(),
name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
serverURL: url
)
ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
verifyPhase = .done(appName: appName, userName: userName)
// Navigate to the ready step
navPath.append(Step.ready)
if isAddServerMode {
// In the "Add Server" sheet: signal completion so the sheet dismisses
isAddComplete = true
} else {
// Normal onboarding: navigate to the ready step
navPath.append(Step.ready)
}
}
// MARK: - Complete
+23 -2
View File
@@ -23,6 +23,9 @@ final class PageEditorViewModel {
var title: String = ""
var markdownContent: String = ""
var activeTab: EditorTab = .write
/// True when the page was created in BookStack's HTML editor (markdown field is nil).
/// Opening it here will convert it to Markdown on next save.
private(set) var isHtmlOnlyPage: Bool = false
var isSaving: Bool = false
var saveError: BookStackError? = nil
@@ -48,11 +51,26 @@ final class PageEditorViewModel {
|| tags != lastSavedTags
}
var isSaveDisabled: Bool {
if isSaving || title.isEmpty { return true }
if case .create = mode {
return markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return false
}
init(mode: Mode) {
self.mode = mode
if case .edit(let page) = mode {
title = page.name
markdownContent = page.markdown ?? ""
if let md = page.markdown {
markdownContent = md
} else {
// Page was created in BookStack's HTML editor markdown field is absent.
// Leave markdownContent empty; the user's first edit will convert it to Markdown.
markdownContent = ""
isHtmlOnlyPage = true
}
tags = page.tags
}
// Snapshot the initial state so "no changes yet" returns false
@@ -88,7 +106,10 @@ final class PageEditorViewModel {
// MARK: - Save
func save() async {
guard !title.isEmpty, !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
// For new pages require both title and content; for existing pages only require a title.
let isCreate = if case .create = mode { true } else { false }
guard !title.isEmpty else { return }
guard !isCreate || !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSaving = true
saveError = nil
+145 -28
View File
@@ -3,28 +3,59 @@ import UIKit
import WebKit
import PhotosUI
// MARK: - UITextView subclass that intercepts image paste from the context menu
final class ImagePasteTextView: UITextView {
/// Called when the user selects Paste and the clipboard contains an image.
var onImagePaste: ((UIImage) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
// Show the standard Paste item whenever the clipboard has an image OR text.
if action == #selector(paste(_:)) {
return UIPasteboard.general.hasImages || UIPasteboard.general.hasStrings
}
return super.canPerformAction(action, withSender: sender)
}
override func paste(_ sender: Any?) {
// If there is an image on the clipboard, intercept and forward it.
if let image = UIPasteboard.general.image {
onImagePaste?(image)
} else {
super.paste(sender)
}
}
}
// MARK: - UITextView wrapper that exposes selection-aware formatting
struct MarkdownTextEditor: UIViewRepresentable {
@Binding var text: String
/// Called with the UITextView so the parent can apply formatting
var onTextViewReady: (UITextView) -> Void
/// Called when the user pastes an image via the context menu
var onImagePaste: ((UIImage) -> Void)? = nil
func makeUIView(context: Context) -> UITextView {
let tv = UITextView()
func makeUIView(context: Context) -> ImagePasteTextView {
let tv = ImagePasteTextView()
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.delegate = context.coordinator
tv.backgroundColor = .clear
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
tv.isScrollEnabled = true
tv.alwaysBounceVertical = true
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 80, right: 8)
// Set initial text (e.g. when editing an existing page)
tv.text = text
tv.onImagePaste = onImagePaste
onTextViewReady(tv)
return tv
}
func updateUIView(_ tv: UITextView, context: Context) {
func updateUIView(_ tv: ImagePasteTextView, context: Context) {
// Keep the paste closure up to date (captures may change across renders)
tv.onImagePaste = onImagePaste
// Only push changes that originated outside the UITextView (e.g. formatting toolbar).
// Skip updates triggered by the user typing to avoid cursor position resets.
guard !context.coordinator.isEditing, tv.text != text else { return }
@@ -70,9 +101,17 @@ struct PageEditorView: View {
@State private var textView: UITextView? = nil
@State private var imagePickerItem: PhotosPickerItem? = nil
@State private var showTagEditor = false
/// False while the UITextView is doing its initial layout for an existing page.
@State private var isEditorReady: Bool
init(mode: PageEditorViewModel.Mode) {
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
// Show a loading overlay only for edit mode new pages start empty so layout is instant.
if case .edit = mode {
_isEditorReady = State(initialValue: false)
} else {
_isEditorReady = State(initialValue: true)
}
}
var body: some View {
@@ -147,12 +186,15 @@ struct PageEditorView: View {
)
Rectangle().fill(Color(.separator)).frame(height: 0.5)
// Content area
if viewModel.activeTab == .write {
writeArea
} else {
MarkdownPreviewView(markdown: viewModel.markdownContent)
// Content area fills all remaining vertical space
Group {
if viewModel.activeTab == .write {
writeArea
} else {
MarkdownPreviewView(markdown: viewModel.markdownContent)
}
}
.frame(maxHeight: .infinity)
// Save error
if let error = viewModel.saveError {
@@ -160,12 +202,54 @@ struct PageEditorView: View {
.padding()
}
}
.frame(maxHeight: .infinity)
.overlay {
if !isEditorReady {
ZStack {
Color(.systemBackground).ignoresSafeArea()
ProgressView()
.controlSize(.large)
}
.transition(.opacity)
}
}
.animation(.easeOut(duration: 0.2), value: isEditorReady)
}
@ViewBuilder
private var writeArea: some View {
VStack(spacing: 0) {
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in textView = tv }
if viewModel.isHtmlOnlyPage {
HStack(spacing: 8) {
Image(systemName: "info.circle")
Text(L("editor.html.notice"))
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground))
Divider()
}
MarkdownTextEditor(text: $viewModel.markdownContent,
onTextViewReady: { tv in
textView = tv
// One run-loop pass lets UITextView finish its initial layout
// before we hide the loading overlay.
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(16))
isEditorReady = true
}
},
onImagePaste: { image in
Task {
let data = image.jpegData(compressionQuality: 0.85) ?? Data()
guard !data.isEmpty else { return }
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
}
})
.frame(maxHeight: .infinity)
if case .uploading = viewModel.imageUploadState {
HStack(spacing: 8) {
@@ -197,7 +281,9 @@ struct PageEditorView: View {
isUploadingImage: {
if case .uploading = viewModel.imageUploadState { return true }
return false
}()
}(),
clipboardHasImage: UIPasteboard.general.hasImages,
onPasteImage: { Task { await pasteImageFromClipboard() } }
) { action in applyFormat(action) }
}
}
@@ -229,7 +315,7 @@ struct PageEditorView: View {
}
}
}
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
.disabled(viewModel.isSaveDisabled)
.overlay { if viewModel.isSaving { ProgressView().scaleEffect(0.7) } }
.transition(.opacity)
}
@@ -245,6 +331,15 @@ struct PageEditorView: View {
}
}
// MARK: - Paste image from clipboard
private func pasteImageFromClipboard() async {
guard let uiImage = UIPasteboard.general.image else { return }
let data = uiImage.jpegData(compressionQuality: 0.85) ?? Data()
guard !data.isEmpty else { return }
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
}
// MARK: - Apply formatting to selected text (or insert at cursor)
private func applyFormat(_ action: FormatAction) {
@@ -303,7 +398,7 @@ struct PageEditorView: View {
private func replace(in tv: UITextView, range: NSRange, with string: String,
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
guard let swiftRange = Range(range, in: tv.text) else { return }
var newText = tv.text!
var newText = tv.text ?? ""
newText.replaceSubrange(swiftRange, with: string)
tv.text = newText
viewModel.markdownContent = newText
@@ -421,6 +516,8 @@ enum FormatAction {
struct FormattingToolbar: View {
@Binding var imagePickerItem: PhotosPickerItem?
let isUploadingImage: Bool
let clipboardHasImage: Bool
let onPasteImage: () -> Void
let onAction: (FormatAction) -> Void
var body: some View {
@@ -429,7 +526,7 @@ struct FormattingToolbar: View {
.fill(Color(.separator))
.frame(height: 0.5)
// Row 1: Headings + text formatting
// Row 1: Headings + inline formatting
HStack(spacing: 0) {
FormatButton("H1", action: .h1, onAction: onAction)
FormatButton("H2", action: .h2, onAction: onAction)
@@ -440,7 +537,7 @@ struct FormattingToolbar: View {
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
}
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, minHeight: 28)
Rectangle()
.fill(Color(.separator))
@@ -456,7 +553,7 @@ struct FormattingToolbar: View {
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
toolbarDivider
// Image picker
// Image picker (from photo library)
PhotosPicker(
selection: $imagePickerItem,
matching: .images,
@@ -467,15 +564,23 @@ struct FormattingToolbar: View {
ProgressView().controlSize(.small)
} else {
Image(systemName: "photo")
.font(.system(size: 14, weight: .regular))
.font(.system(size: 13, weight: .regular))
}
}
.frame(maxWidth: .infinity, minHeight: 36)
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(.secondary)
}
.disabled(isUploadingImage)
// Paste image from clipboard
Button(action: onPasteImage) {
Image(systemName: clipboardHasImage ? "doc.on.clipboard.fill" : "doc.on.clipboard")
.font(.system(size: 13, weight: .regular))
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(clipboardHasImage ? .primary : .tertiary)
}
.disabled(!clipboardHasImage || isUploadingImage)
}
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, minHeight: 28)
}
.background(Color(.systemBackground))
}
@@ -483,8 +588,8 @@ struct FormattingToolbar: View {
private var toolbarDivider: some View {
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5)
.padding(.vertical, 8)
.frame(width: 0.5, height: 16)
.padding(.horizontal, 2)
}
}
@@ -514,13 +619,13 @@ struct FormatButton: View {
Group {
if let label {
Text(label)
.font(.system(size: 12, weight: .medium, design: .rounded))
.font(.system(size: 11, weight: .medium, design: .rounded))
} else if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .regular))
.font(.system(size: 13, weight: .regular))
}
}
.frame(maxWidth: .infinity, minHeight: 36)
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
.onTapGesture {
@@ -533,11 +638,16 @@ struct FormatButton: View {
struct MarkdownPreviewView: View {
let markdown: String
@State private var webPage = WebPage()
@State private var htmlContent: String = ""
@Environment(\.colorScheme) private var colorScheme
private var serverBaseURL: URL {
UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) }
?? URL(string: "about:blank")!
}
var body: some View {
WebView(webPage)
HTMLWebView(html: htmlContent, baseURL: serverBaseURL, openLinksExternally: false)
.onAppear { loadPreview() }
.onChange(of: markdown) { loadPreview() }
.onChange(of: colorScheme) { loadPreview() }
@@ -550,7 +660,7 @@ struct MarkdownPreviewView: View {
let fg = isDark ? "#f2f2f7" : "#000000"
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
let fullHTML = """
htmlContent = """
<!DOCTYPE html><html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -566,7 +676,6 @@ struct MarkdownPreviewView: View {
</head>
<body>\(html)</body></html>
"""
webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!)
}
/// Minimal Markdown HTML converter for preview purposes.
@@ -593,6 +702,14 @@ struct MarkdownPreviewView: View {
with: "<h\(h)>$1</h\(h)>",
options: .regularExpression)
}
// Images (must come before links to avoid partial matches)
html = html.replacingOccurrences(of: #"!\[([^\]]*)\]\(([^)]+)\)"#,
with: "<img src=\"$2\" alt=\"$1\" style=\"max-width:100%;height:auto;border-radius:6px;\">",
options: .regularExpression)
// Links
html = html.replacingOccurrences(of: #"\[([^\]]+)\]\(([^)]+)\)"#,
with: "<a href=\"$2\">$1</a>",
options: .regularExpression)
// Horizontal rule
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
options: .regularExpression)
+2 -10
View File
@@ -142,7 +142,7 @@ struct BookDetailView: View {
.accessibilityLabel("Add content")
}
}
.sheet(isPresented: $showNewPage) {
.fullScreenCover(isPresented: $showNewPage) {
NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
}
.sheet(isPresented: $showNewChapter) {
@@ -172,15 +172,7 @@ struct BookDetailView: View {
NavigationLink(value: page) {
Label(L("book.open"), systemImage: "arrow.up.right.square")
}
Button {
let url = "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)"
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.keyWindow {
window.rootViewController?.present(activity, animated: true)
}
} label: {
ShareLink(item: "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)") {
Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
}
Divider()
+13 -10
View File
@@ -3,11 +3,12 @@ import SwiftUI
struct LibraryView: View {
@State private var viewModel = LibraryViewModel()
@State private var showNewShelf = false
@Environment(ConnectivityMonitor.self) private var connectivity
@State private var navPath = NavigationPath()
@Environment(\.accentTheme) private var theme
private let navState = AppNavigationState.shared
var body: some View {
NavigationStack {
NavigationStack(path: $navPath) {
Group {
if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
LoadingView(message: L("library.loading"))
@@ -78,13 +79,14 @@ struct LibraryView: View {
.navigationDestination(for: PageDTO.self) { page in
PageReaderView(page: page)
}
.safeAreaInset(edge: .top) {
if !connectivity.isConnected {
OfflineBanner()
}
}
}
.task { await viewModel.loadShelves() }
.onChange(of: navState.pendingBookNavigation) { _, book in
guard let book else { return }
navPath.append(book)
navState.pendingBookNavigation = nil
}
}
}
@@ -125,6 +127,7 @@ struct BreadcrumbBar: View {
.font(.subheadline.weight(.medium))
.foregroundStyle(theme.accentColor)
.lineLimit(1)
.fixedSize()
}
.buttonStyle(.plain)
} else {
@@ -132,11 +135,12 @@ struct BreadcrumbBar: View {
.font(.subheadline.weight(isLast ? .semibold : .medium))
.foregroundStyle(isLast ? .primary : .secondary)
.lineLimit(1)
.fixedSize()
}
}
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
@@ -210,5 +214,4 @@ struct ContentRowView: View {
#Preview("Library") {
LibraryView()
.environment(ConnectivityMonitor.shared)
}
+34 -12
View File
@@ -2,20 +2,42 @@ import SwiftUI
struct MainTabView: View {
@Environment(ConnectivityMonitor.self) private var connectivity
@State private var selectedTab = 0
@State private var showNudge = false
private let navState = AppNavigationState.shared
var body: some View {
TabView {
Tab(L("tab.library"), systemImage: "books.vertical") {
LibraryView()
}
Tab(L("tab.search"), systemImage: "magnifyingglass") {
SearchView()
}
Tab(L("tab.settings"), systemImage: "gear") {
SettingsView()
}
TabView(selection: $selectedTab) {
LibraryView()
.tabItem { Label(L("tab.library"), systemImage: "books.vertical") }
.tag(0)
QuickNoteView()
.tabItem { Label(L("tab.quicknote"), systemImage: "square.and.pencil") }
.tag(1)
SearchView()
.tabItem { Label(L("tab.search"), systemImage: "magnifyingglass") }
.tag(2)
SettingsView()
.tabItem { Label(L("tab.settings"), systemImage: "gear") }
.tag(3)
}
.onChange(of: navState.pendingBookNavigation) { _, book in
if book != nil { selectedTab = 0 }
}
.onChange(of: navState.navigateToSettings) { _, go in
if go { selectedTab = 3; navState.navigateToSettings = false }
}
.sheet(isPresented: $showNudge, onDismiss: {
DonationService.shared.recordNudgeSeen()
}) {
SupportNudgeSheet(isPresented: $showNudge)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.task {
// Small delay so the app settles before the sheet appears
try? await Task.sleep(for: .seconds(2))
showNudge = DonationService.shared.shouldShowNudge
}
}
}
+47 -111
View File
@@ -4,7 +4,6 @@ import SwiftUI
struct OnboardingView: View {
@State private var viewModel = OnboardingViewModel()
@State private var langManager = LanguageManager.shared
var body: some View {
Group {
@@ -12,7 +11,7 @@ struct OnboardingView: View {
Color.clear
} else {
NavigationStack(path: $viewModel.navPath) {
LanguageStepView(viewModel: viewModel)
WelcomeStepView(viewModel: viewModel)
.navigationDestination(for: OnboardingViewModel.Step.self) { step in
switch step {
case .welcome:
@@ -21,102 +20,12 @@ struct OnboardingView: View {
ConnectStepView(viewModel: viewModel)
case .ready:
ReadyStepView(onComplete: viewModel.completeOnboarding)
case .language:
EmptyView()
}
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
}
}
.environment(langManager)
}
}
// MARK: - Step 0: Language
struct LanguageStepView: View {
@Bindable var viewModel: OnboardingViewModel
@State private var selected: LanguageManager.Language = LanguageManager.shared.current
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "globe")
.font(.system(size: 52))
.foregroundStyle(Color.accentColor)
}
VStack(spacing: 8) {
Text(L("onboarding.language.title"))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text(L("onboarding.language.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
VStack(spacing: 12) {
ForEach(LanguageManager.Language.allCases) { lang in
Button {
selected = lang
LanguageManager.shared.set(lang)
} label: {
HStack(spacing: 14) {
Text(lang.flag)
.font(.title2)
Text(lang.displayName)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Spacer()
if selected == lang {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
}
}
.padding()
.background(
selected == lang
? Color.accentColor.opacity(0.1)
: Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected == lang ? Color.accentColor : Color.clear, lineWidth: 1.5)
)
}
.accessibilityLabel(lang.displayName)
}
}
.padding(.horizontal, 32)
Spacer()
Button {
viewModel.push(.welcome)
} label: {
Text(L("onboarding.welcome.cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.padding()
}
}
@@ -177,8 +86,8 @@ struct WelcomeStepView: View {
struct ConnectStepView: View {
@Bindable var viewModel: OnboardingViewModel
@State private var showTokenId = false
@State private var showTokenSecret = false
@State private var showTokenId = true
@State private var showTokenSecret = true
@State private var showHelp = false
@State private var verifyTask: Task<Void, Never>? = nil
@@ -195,6 +104,18 @@ struct ConnectStepView: View {
.foregroundStyle(.secondary)
}
// Server Name field
VStack(alignment: .leading, spacing: 6) {
Label(L("onboarding.server.name.label"), systemImage: "tag")
.font(.subheadline.bold())
TextField(L("onboarding.server.name.placeholder"), text: $viewModel.serverNameInput)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
// Server URL field
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -204,11 +125,19 @@ struct ConnectStepView: View {
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.URL)
.onChange(of: viewModel.serverURLInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
}
}
Button {
viewModel.serverURLInput = UIPasteboard.general.string ?? viewModel.serverURLInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
@@ -224,6 +153,12 @@ struct ConnectStepView: View {
.font(.footnote)
.foregroundStyle(.orange)
}
if viewModel.isRemoteServer {
Label(L("onboarding.server.warning.remote"), systemImage: "globe.badge.chevron.backward")
.font(.footnote)
.foregroundStyle(.orange)
}
}
// Help accordion
@@ -253,21 +188,20 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.none)
.onChange(of: viewModel.tokenIdInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
}
}
if UIPasteboard.general.hasStrings {
Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? viewModel.tokenIdInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye")
@@ -293,21 +227,20 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.none)
.onChange(of: viewModel.tokenSecretInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
}
}
if UIPasteboard.general.hasStrings {
Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? viewModel.tokenSecretInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
@@ -350,6 +283,9 @@ struct ConnectStepView: View {
.onDisappear {
verifyTask?.cancel()
}
.onChange(of: viewModel.serverURLInput) { _, _ in
// Clear name hint when URL changes so it re-auto-fills on next verify
}
}
private var canConnect: Bool {
@@ -0,0 +1,309 @@
import SwiftUI
struct QuickNoteView: View {
private let navState = AppNavigationState.shared
// Form fields
@State private var title = ""
@State private var content = ""
// Tag selection
@State private var availableTags: [TagDTO] = []
@State private var selectedTags: [TagDTO] = []
@State private var isLoadingTags = false
@State private var showTagPicker = false
// Location selection
@State private var shelves: [ShelfDTO] = []
@State private var books: [BookDTO] = []
@State private var selectedShelf: ShelfDTO? = nil
@State private var selectedBook: BookDTO? = nil
@State private var isLoadingShelves = false
@State private var isLoadingBooks = false
// Save state
@State private var isSaving = false
@State private var error: String? = nil
var body: some View {
NavigationStack {
Form {
// Note content
Section(L("quicknote.field.title")) {
TextField(L("quicknote.field.title.placeholder"), text: $title)
}
Section(L("quicknote.field.content")) {
TextEditor(text: $content)
.frame(minHeight: 120)
.font(.system(.body, design: .monospaced))
}
// Location: shelf book
Section {
if isLoadingShelves {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.shelf.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
Picker(L("quicknote.shelf.label"), selection: $selectedShelf) {
Text(L("quicknote.shelf.none")).tag(ShelfDTO?.none)
ForEach(shelves) { shelf in
Text(shelf.name).tag(ShelfDTO?.some(shelf))
}
}
.onChange(of: selectedShelf) { _, shelf in
selectedBook = nil
if let shelf {
Task { await loadBooks(for: shelf) }
} else {
Task { await loadAllBooks() }
}
}
}
if isLoadingBooks {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.book.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
Picker(L("quicknote.book.label"), selection: $selectedBook) {
Text(L("quicknote.book.none")).tag(BookDTO?.none)
ForEach(books) { book in
Text(book.name).tag(BookDTO?.some(book))
}
}
}
} header: {
Text(L("quicknote.section.location"))
}
// Tags section
Section {
if isLoadingTags {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.tags.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
if !selectedTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(selectedTags) { tag in
HStack(spacing: 4) {
Text(tag.value.isEmpty ? tag.name : "\(tag.name): \(tag.value)")
.font(.footnote)
.foregroundStyle(.primary)
Button {
selectedTags.removeAll { $0.id == tag.id }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.font(.footnote)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.12), in: Capsule())
.overlay(Capsule().strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1))
}
}
.padding(.vertical, 2)
}
}
Button {
showTagPicker = true
} label: {
Label(
selectedTags.isEmpty ? L("quicknote.tags.add") : L("quicknote.tags.edit"),
systemImage: "tag"
)
.foregroundStyle(Color.accentColor)
}
}
} header: {
Text(L("quicknote.section.tags"))
}
// Error feedback
if let err = error {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text(err).foregroundStyle(.red).font(.footnote)
}
}
}
}
.navigationTitle(L("quicknote.title"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("common.cancel")) {
resetForm()
}
.disabled(isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
} else {
Button(L("quicknote.save")) {
Task { await save() }
}
.disabled(title.isEmpty || selectedBook == nil)
}
}
}
.task {
await loadShelves()
await loadTags()
}
.sheet(isPresented: $showTagPicker) {
TagPickerSheet(
availableTags: availableTags,
selectedTags: $selectedTags
)
}
}
}
// MARK: - Load shelves / books
private func loadShelves() async {
isLoadingShelves = true
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
isLoadingShelves = false
await loadAllBooks()
}
private func loadAllBooks() async {
isLoadingBooks = true
books = (try? await BookStackAPI.shared.fetchBooks()) ?? []
isLoadingBooks = false
}
private func loadBooks(for shelf: ShelfDTO) async {
isLoadingBooks = true
books = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books ?? []
isLoadingBooks = false
}
private func loadTags() async {
isLoadingTags = true
availableTags = (try? await BookStackAPI.shared.fetchTags()) ?? []
isLoadingTags = false
}
// MARK: - Save
private func save() async {
guard let book = selectedBook else {
error = L("quicknote.error.nobook")
return
}
error = nil
isSaving = true
do {
let page = try await BookStackAPI.shared.createPage(
bookId: book.id,
name: title,
markdown: content,
tags: selectedTags
)
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
resetForm()
navState.pendingBookNavigation = book
} catch {
self.error = error.localizedDescription
}
isSaving = false
}
// MARK: - Helpers
private func resetForm() {
title = ""
content = ""
selectedTags = []
selectedShelf = nil
selectedBook = nil
}
}
// MARK: - Tag Picker Sheet
struct TagPickerSheet: View {
let availableTags: [TagDTO]
@Binding var selectedTags: [TagDTO]
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
private var filteredTags: [TagDTO] {
guard !searchText.isEmpty else { return availableTags }
return availableTags.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.value.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List {
if availableTags.isEmpty {
Text(L("quicknote.tags.empty"))
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
ForEach(filteredTags) { tag in
let isSelected = selectedTags.contains { $0.id == tag.id }
Button {
if isSelected {
selectedTags.removeAll { $0.id == tag.id }
} else {
selectedTags.append(tag)
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(tag.name)
.font(.body)
.foregroundStyle(.primary)
if !tag.value.isEmpty {
Text(tag.value)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
}
}
}
}
}
}
.navigationTitle(L("quicknote.tags.picker.title"))
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchText, prompt: L("editor.tags.search"))
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L("common.done")) { dismiss() }
}
}
}
}
}
+148 -88
View File
@@ -1,17 +1,18 @@
import SwiftUI
import WebKit
struct PageReaderView: View {
let page: PageDTO
@State private var webPage = WebPage()
@State private var htmlContent: String = ""
@State private var fullPage: PageDTO? = nil
@State private var isLoadingPage = false
@State private var comments: [CommentDTO] = []
@State private var isLoadingComments = false
@State private var showEditor = false
@State private var pageForEditing: PageDTO? = nil
@State private var isFetchingForEdit = false
@State private var newComment = ""
@State private var isPostingComment = false
@State private var commentError: String? = nil
@State private var commentsExpanded = false
@AppStorage("showComments") private var showComments = true
@Environment(\.colorScheme) private var colorScheme
@@ -23,40 +24,59 @@ struct PageReaderView: View {
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Page header
VStack(alignment: .leading, spacing: 4) {
Text(resolvedPage.name)
.font(.largeTitle.bold())
.padding(.horizontal)
.padding(.top)
VStack(spacing: 0) {
// Page header
VStack(alignment: .leading, spacing: 2) {
Text(resolvedPage.name)
.font(.title2.bold())
.padding(.horizontal)
.padding(.top, 12)
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.bottom, 8)
}
// Web content
WebView(webPage)
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.bottom, 8)
Divider()
.padding(.top)
}
// Comments section (hidden when user disabled in Settings)
if showComments {
DisclosureGroup {
commentsContent
// Web content fills all space not taken by the comments inset
HTMLWebView(html: htmlContent, baseURL: URL(string: serverURL))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
// Comments panel sits above the tab bar, inside the safe area
if showComments {
VStack(spacing: 0) {
Divider()
// Header row always visible, tap to expand/collapse
Button {
withAnimation(.easeInOut(duration: 0.2)) { commentsExpanded.toggle() }
} label: {
Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right")
.font(.headline)
HStack {
Label(String(format: L("reader.comments"), comments.count),
systemImage: "bubble.left.and.bubble.right")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Spacer()
Image(systemName: commentsExpanded ? "chevron.down" : "chevron.up")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
// Expandable body
if commentsExpanded {
Divider()
commentsContent
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.padding()
}
.background(Color(.secondarySystemBackground))
}
}
.navigationBarTitleDisplayMode(.inline)
@@ -75,34 +95,24 @@ struct PageReaderView: View {
.accessibilityLabel(L("reader.edit"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
let url = "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)"
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.keyWindow {
window.rootViewController?.present(activity, animated: true)
}
} label: {
ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel(L("reader.share"))
}
}
.sheet(isPresented: $showEditor) {
.fullScreenCover(item: $pageForEditing) { pageToEdit in
NavigationStack {
if let fullPage {
PageEditorView(mode: .edit(page: fullPage))
}
PageEditorView(mode: .edit(page: pageToEdit))
}
}
.task(id: page.id) {
await loadFullPage()
await loadComments()
}
.onChange(of: showEditor) { _, isShowing in
.onChange(of: pageForEditing) { _, newValue in
// Reload page content after editor is dismissed
if !isShowing { Task { await loadFullPage() } }
if newValue == nil { Task { await loadFullPage() } }
}
.onChange(of: colorScheme) {
loadContent()
@@ -113,42 +123,75 @@ struct PageReaderView: View {
@ViewBuilder
private var commentsContent: some View {
VStack(alignment: .leading, spacing: 12) {
if isLoadingComments {
ProgressView()
VStack(spacing: 0) {
// Scrollable comment list fixed height so layout is stable
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if isLoadingComments {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else if comments.isEmpty {
Text(L("reader.comments.empty"))
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
} else {
ForEach(comments) { comment in
CommentRow(comment: comment)
.padding(.horizontal)
.id(comment.id)
Divider().padding(.leading, 54)
}
}
}
.frame(maxWidth: .infinity)
.padding()
} else if comments.isEmpty {
Text(L("reader.comments.empty"))
.font(.footnote)
.foregroundStyle(.secondary)
.padding()
} else {
ForEach(comments) { comment in
CommentRow(comment: comment)
Divider()
}
.frame(height: 180)
.onChange(of: comments.count) {
if let last = comments.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
// New comment input
HStack(alignment: .bottom, spacing: 8) {
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
.lineLimit(1...4)
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
Button {
Task { await postComment() }
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
// Input always visible at the bottom of the panel
Divider()
VStack(spacing: 4) {
if let err = commentError {
Text(err)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.top, 4)
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
.accessibilityLabel("Post comment")
HStack(alignment: .bottom, spacing: 8) {
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
.lineLimit(1...3)
.padding(8)
.background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
Button {
Task { await postComment() }
} label: {
if isPostingComment {
ProgressView().controlSize(.small)
} else {
Image(systemName: "paperplane.fill")
.foregroundStyle(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.secondary : Color.accentColor)
}
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
.accessibilityLabel("Post comment")
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.padding(.top, 8)
.background(Color(.secondarySystemBackground))
}
.padding(.top, 8)
}
// MARK: - Helpers
@@ -160,26 +203,38 @@ struct PageReaderView: View {
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
} catch {
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader")
fullPage = page
// Leave fullPage = nil so the editor will re-fetch on demand rather than
// receiving the list summary (which has no markdown content).
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription)", category: "Reader")
}
isLoadingPage = false
loadContent()
}
private func openEditor() async {
// Full page is already fetched by loadFullPage; if still loading, wait briefly
if fullPage == nil {
isFetchingForEdit = true
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
isFetchingForEdit = false
// Always fetch the full page before opening the editor to guarantee we have markdown content.
// Clear pageForEditing at the start to ensure clean state.
pageForEditing = nil
isFetchingForEdit = true
do {
let fetchedPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Fetched full page content for editing: '\(page.name)'", category: "Reader")
// Only set pageForEditing after successful fetch this triggers the sheet to appear.
// Also update fullPage so the reader view has fresh content when we return.
fullPage = fetchedPage
pageForEditing = fetchedPage
} catch {
AppLog(.error, "Could not load page '\(page.name)' for editing: \(error.localizedDescription)", category: "Reader")
// Don't set pageForEditing sheet will not appear, user stays in reader.
}
showEditor = true
isFetchingForEdit = false
}
private func loadContent() {
let html = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!)
htmlContent = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
}
private func loadComments() async {
@@ -192,13 +247,18 @@ struct PageReaderView: View {
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingComment = true
commentError = nil
AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
do {
let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
comments.append(comment)
try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
newComment = ""
AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader")
AppLog(.info, "Comment posted on '\(page.name)' — reloading", category: "Reader")
await loadComments()
} catch let e as BookStackError {
commentError = e.localizedDescription
AppLog(.error, "Failed to post comment on '\(page.name)': \(e.localizedDescription)", category: "Reader")
} catch {
commentError = error.localizedDescription
AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
}
isPostingComment = false
@@ -292,7 +352,7 @@ struct CommentRow: View {
.font(.caption)
.foregroundStyle(.tertiary)
}
Text(comment.text)
Text(comment.html.strippingHTML)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -0,0 +1,28 @@
import SwiftUI
/// Sheet that lets the user connect an additional BookStack server.
/// Reuses ConnectStepView skips the language/welcome steps.
struct AddServerView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: OnboardingViewModel = {
let vm = OnboardingViewModel()
vm.isAddServerMode = true
return vm
}()
var body: some View {
NavigationStack {
ConnectStepView(viewModel: viewModel)
.navigationTitle(L("settings.servers.add"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
}
}
.onChange(of: viewModel.isAddComplete) { _, done in
if done { dismiss() }
}
}
}
@@ -0,0 +1,348 @@
import SwiftUI
struct DisplayOptionsView: View {
let profile: ServerProfile
@Environment(ServerProfileStore.self) private var profileStore
@Environment(\.dismiss) private var dismiss
// Local state mirrors profile values, saved on every change
@State private var appTheme: String = "system"
@State private var accentThemeRaw: String = AccentTheme.ocean.rawValue
@State private var appTextColor: Color = .primary
@State private var hasCustomTextColor: Bool = false
@State private var appBgColor: Color = Color(.systemBackground)
@State private var hasCustomBgColor: Bool = false
@State private var editorFontSize: Double = 16
@State private var readerFontSize: Double = 16
@State private var showResetConfirm = false
@State private var showResetAppearanceConfirm = false
@AppStorage("displayOptionsInfoSeen") private var infoSeen = false
@State private var showInfoAlert = false
private var selectedTheme: AccentTheme {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
var body: some View {
Form {
hintSection
appearanceSection
editorSection
readerSection
resetSection
previewSection
}
.navigationTitle(L("display.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(L("display.reset.all"), role: .destructive) {
showResetConfirm = true
}
.foregroundStyle(.red)
}
}
.confirmationDialog(L("display.reset.all.confirm"),
isPresented: $showResetConfirm,
titleVisibility: .visible) {
Button(L("display.reset.all.button"), role: .destructive) { resetAll() }
Button(L("common.cancel"), role: .cancel) {}
}
.confirmationDialog(L("display.reset.appearance.confirm"),
isPresented: $showResetAppearanceConfirm,
titleVisibility: .visible) {
Button(L("display.reset.all.button"), role: .destructive) { resetAppearance() }
Button(L("common.cancel"), role: .cancel) {}
}
.onAppear {
loadFromProfile()
if !infoSeen {
showInfoAlert = true
infoSeen = true
}
}
.alert(L("display.info.title"), isPresented: $showInfoAlert) {
Button(L("common.ok"), role: .cancel) {}
} message: {
Text(L("display.info.message"))
}
.onChange(of: appTheme) { persist() }
.onChange(of: accentThemeRaw) { persist() }
.onChange(of: appTextColor) { persist() }
.onChange(of: hasCustomTextColor) { persist() }
.onChange(of: appBgColor) { persist() }
.onChange(of: hasCustomBgColor) { persist() }
.onChange(of: editorFontSize) { persist() }
.onChange(of: readerFontSize) { persist() }
}
// MARK: - Sections
private var hintSection: some View {
Section {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle.fill")
.foregroundStyle(.blue)
.padding(.top, 2)
Text(String(format: L("display.hint"), profile.name))
.font(.footnote)
.foregroundStyle(.secondary)
}
.listRowBackground(Color(.secondarySystemBackground))
}
}
private var appearanceSection: some View {
Section(L("settings.appearance")) {
// Light / Dark / System picker
Picker(L("settings.appearance.theme"), selection: $appTheme) {
Text(L("settings.appearance.theme.system")).tag("system")
Text(L("settings.appearance.theme.light")).tag("light")
Text(L("settings.appearance.theme.dark")).tag("dark")
}
.pickerStyle(.segmented)
// Accent colour swatches
VStack(alignment: .leading, spacing: 10) {
Text(L("settings.appearance.accent"))
.font(.subheadline)
.foregroundStyle(.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 8), spacing: 10) {
ForEach(AccentTheme.allCases) { theme in
Button {
accentThemeRaw = theme.rawValue
} label: {
ZStack {
Circle()
.fill(theme.shelfColor)
.frame(width: 32, height: 32)
if selectedTheme == theme {
Circle()
.strokeBorder(.white, lineWidth: 2)
.frame(width: 32, height: 32)
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(theme.displayName)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
}
}
.padding(.vertical, 4)
// App text colour
colorRow(
label: L("display.app.textcolor"),
color: $appTextColor,
hasCustom: $hasCustomTextColor,
resetColor: .primary
)
// App background colour
colorRow(
label: L("display.app.bgcolor"),
color: $appBgColor,
hasCustom: $hasCustomBgColor,
resetColor: Color(.systemBackground)
)
}
}
private var editorSection: some View {
Section(L("display.editor")) {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(L("display.editor.fontsize"))
Spacer()
Text(String(format: L("display.fontsize.pt"), editorFontSize))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $editorFontSize, in: 10...28, step: 1)
.tint(.accentColor)
}
.padding(.vertical, 4)
}
}
private var readerSection: some View {
Section(L("display.reader")) {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(L("display.reader.fontsize"))
Spacer()
Text(String(format: L("display.fontsize.pt"), readerFontSize))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $readerFontSize, in: 10...28, step: 1)
.tint(.accentColor)
}
.padding(.vertical, 4)
}
}
private func colorRow(label: String, color: Binding<Color>, hasCustom: Binding<Bool>, resetColor: Color) -> some View {
HStack {
if hasCustom.wrappedValue {
ColorPicker(label, selection: color, supportsOpacity: false)
Button {
hasCustom.wrappedValue = false
color.wrappedValue = resetColor
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
} else {
Text(label)
Spacer()
Text(L("display.color.system"))
.foregroundStyle(.secondary)
.font(.footnote)
Button {
hasCustom.wrappedValue = true
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
}
}
private var resetSection: some View {
Section {
Button(role: .destructive) {
showResetAppearanceConfirm = true
} label: {
HStack {
Spacer()
Text(L("display.reset.appearance"))
Spacer()
}
}
}
}
private var previewSection: some View {
Section(L("display.preview")) {
previewCard
.listRowInsets(.init(top: 12, leading: 12, bottom: 12, trailing: 12))
}
}
// MARK: - Preview Card
private var previewCard: some View {
let bgColor: Color = hasCustomBgColor ? appBgColor : Color(.systemBackground)
let textColor: Color = hasCustomTextColor ? appTextColor : Color(.label)
let fontSize = CGFloat(readerFontSize)
let editorSize = CGFloat(editorFontSize)
return VStack(alignment: .leading, spacing: 10) {
// App / Reader preview
VStack(alignment: .leading, spacing: 6) {
Label(L("display.preview.reader"), systemImage: "doc.text")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.bottom, 2)
Text("Sample Heading")
.font(.system(size: fontSize + 4, weight: .bold))
.foregroundStyle(textColor)
Text("This is a preview of how your pages will look in the reader.")
.font(.system(size: fontSize))
.foregroundStyle(textColor)
.lineSpacing(fontSize * 0.3)
Text("let x = 42")
.font(.system(size: fontSize - 2, design: .monospaced))
.foregroundStyle(textColor.opacity(0.85))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(textColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 4))
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(bgColor, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
// Editor preview
VStack(alignment: .leading, spacing: 6) {
Label(L("display.preview.editor"), systemImage: "pencil")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.bottom, 2)
Text("## My Page\n\nStart writing your **Markdown** here…")
.font(.system(size: editorSize, design: .monospaced))
.foregroundStyle(Color(.label))
.lineSpacing(editorSize * 0.2)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
}
}
// MARK: - Helpers
private func loadFromProfile() {
let p = profileStore.profiles.first { $0.id == profile.id } ?? profile
appTheme = p.appTheme
accentThemeRaw = p.accentTheme
editorFontSize = p.editorFontSize
readerFontSize = p.readerFontSize
if let hex = p.appTextColor, let c = Color(hex: hex) {
appTextColor = c
hasCustomTextColor = true
}
if let hex = p.appBackgroundColor, let c = Color(hex: hex) {
appBgColor = c
hasCustomBgColor = true
}
}
private func persist() {
let textHex = hasCustomTextColor ? appTextColor.toHexString() : nil
let bgHex = hasCustomBgColor ? appBgColor.toHexString() : nil
profileStore.updateDisplayOptions(
for: profile,
editorFontSize: editorFontSize,
readerFontSize: readerFontSize,
appTextColor: textHex,
appBackgroundColor: bgHex,
appTheme: appTheme,
accentTheme: accentThemeRaw
)
}
private func resetAppearance() {
appTheme = "system"
accentThemeRaw = AccentTheme.ocean.rawValue
hasCustomTextColor = false
hasCustomBgColor = false
appTextColor = .primary
appBgColor = Color(.systemBackground)
persist()
}
private func resetAll() {
appTheme = "system"
accentThemeRaw = AccentTheme.ocean.rawValue
hasCustomTextColor = false
hasCustomBgColor = false
appTextColor = .primary
appBgColor = Color(.systemBackground)
editorFontSize = 16
readerFontSize = 16
persist()
}
}
@@ -0,0 +1,168 @@
import SwiftUI
import StoreKit
// MARK: - DonationSectionView
/// The "Support BookStax" section embedded in SettingsView.
/// Shows real products fetched from App Store Connect. Never displays
/// placeholder content all states (loading, empty, error) are explicit.
struct DonationSectionView: View {
@State private var service = DonationService.shared
var body: some View {
Section {
sectionContent
} header: {
Text(L("settings.donate"))
} footer: {
sectionFooter
}
.task { await service.loadProducts() }
}
// MARK: - Content
@ViewBuilder
private var sectionContent: some View {
switch service.loadState {
case .loading:
loadingRow
case .loaded(let products):
ForEach(products, id: \.id) { product in
DonationProductRow(product: product, service: service)
}
case .empty:
Text(L("settings.donate.empty"))
.foregroundStyle(.secondary)
.font(.subheadline)
case .failed:
HStack {
Label(L("settings.donate.error"), systemImage: "exclamationmark.triangle")
.foregroundStyle(.secondary)
.font(.subheadline)
Spacer()
Button(L("common.retry")) {
Task { await service.loadProducts() }
}
.buttonStyle(.borderless)
.foregroundStyle(.tint)
}
}
}
private var loadingRow: some View {
HStack {
Text(L("settings.donate.loading"))
.foregroundStyle(.secondary)
Spacer()
ProgressView()
.controlSize(.small)
}
}
@ViewBuilder
private var sectionFooter: some View {
if case .failed = service.loadState {
EmptyView()
} else {
Text(L("settings.donate.footer"))
}
}
}
// MARK: - DonationProductRow
private struct DonationProductRow: View {
let product: Product
let service: DonationService
private var isPurchasing: Bool {
service.purchaseState.activePurchasingID == product.id
}
private var isAnyPurchaseInProgress: Bool {
service.purchaseState.activePurchasingID != nil
}
private var isThankYou: Bool {
service.purchaseState.thankYouID == product.id
}
private var isPending: Bool {
service.purchaseState.pendingID == product.id
}
private var errorMessage: String? {
service.purchaseState.errorMessage(for: product.id)
}
private var lastDonatedDate: Date? {
service.donationHistory[product.id]
}
private var isDisabled: Bool {
isAnyPurchaseInProgress || isPending
}
var body: some View {
Button {
guard !isDisabled else { return }
if errorMessage != nil {
service.dismissError(for: product.id)
} else {
Task { await service.purchase(product) }
}
} label: {
HStack(spacing: 12) {
productInfo
Spacer()
trailingIndicator
}
}
.disabled(isDisabled)
}
// MARK: - Subviews
private var productInfo: some View {
VStack(alignment: .leading, spacing: 3) {
Text(product.displayName)
.foregroundStyle(.primary)
if let date = lastDonatedDate {
Text(String(format: L("settings.donate.donated.on"),
date.formatted(date: .abbreviated, time: .omitted)))
.font(.caption)
.foregroundStyle(.pink)
}
if isPending {
Text(L("settings.donate.pending"))
.font(.caption)
.foregroundStyle(.orange)
}
if let message = errorMessage {
Text(message)
.font(.caption)
.foregroundStyle(.red)
}
}
}
@ViewBuilder
private var trailingIndicator: some View {
if isPurchasing {
ProgressView()
.controlSize(.small)
} else if isThankYou {
Image(systemName: "heart.fill")
.foregroundStyle(.pink)
} else if errorMessage != nil {
Image(systemName: "arrow.counterclockwise")
.foregroundStyle(.secondary)
} else {
Text(product.displayPrice)
.foregroundStyle(.tint)
.bold()
.monospacedDigit()
}
}
}
@@ -0,0 +1,149 @@
import SwiftUI
struct EditServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(ServerProfileStore.self) private var profileStore
let profile: ServerProfile
@State private var name: String
@State private var serverURL: String
@State private var tokenId: String = ""
@State private var tokenSecret: String = ""
@State private var showTokenId = false
@State private var showTokenSecret = false
@State private var changeCredentials = false
@State private var isSaving = false
@State private var saveError: String? = nil
init(profile: ServerProfile) {
self.profile = profile
_name = State(initialValue: profile.name)
_serverURL = State(initialValue: profile.serverURL)
}
var body: some View {
NavigationStack {
Form {
// Name & URL
Section(L("onboarding.server.name.label")) {
TextField(L("onboarding.server.name.placeholder"), text: $name)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
}
Section(L("onboarding.server.title")) {
TextField(L("onboarding.server.placeholder"), text: $serverURL)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.URL)
}
// Optional credential rotation
Section {
Toggle(L("settings.servers.edit.changecreds"), isOn: $changeCredentials.animation())
} footer: {
Text(L("settings.servers.edit.changecreds.footer"))
.font(.footnote)
}
if changeCredentials {
Section(L("onboarding.token.id.label")) {
HStack {
Group {
if showTokenId {
TextField(L("onboarding.token.id.label"), text: $tokenId)
} else {
SecureField(L("onboarding.token.id.label"), text: $tokenId)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.username)
Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
Section(L("onboarding.token.secret.label")) {
HStack {
Group {
if showTokenSecret {
TextField(L("onboarding.token.secret.label"), text: $tokenSecret)
} else {
SecureField(L("onboarding.token.secret.label"), text: $tokenSecret)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.password)
Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
if let error = saveError {
Section {
Label(error, systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.navigationTitle(L("settings.servers.edit.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L("common.done")) {
save()
}
.disabled(!canSave || isSaving)
.overlay { if isSaving { ProgressView().scaleEffect(0.7) } }
}
}
}
}
// MARK: - Helpers
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!changeCredentials || (!tokenId.isEmpty && !tokenSecret.isEmpty))
}
private func save() {
isSaving = true
saveError = nil
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
cleanURL = "https://" + cleanURL
}
while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
profileStore.updateProfile(
profile,
newName: name.trimmingCharacters(in: .whitespacesAndNewlines),
newURL: cleanURL,
newTokenId: changeCredentials ? tokenId : nil,
newTokenSecret: changeCredentials ? tokenSecret : nil
)
isSaving = false
dismiss()
}
}
+124 -98
View File
@@ -3,23 +3,25 @@ import SafariServices
struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
@AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
@AppStorage("loggingEnabled") private var loggingEnabled = false
@Environment(ServerProfileStore.self) private var profileStore
@State private var donationService = DonationService.shared
private var selectedTheme: AccentTheme {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
@State private var showSignOutAlert = false
@State private var isSyncing = false
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
@State private var showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
@State private var showLogViewer = false
@State private var shareItems: [Any]? = nil
@State private var showAddServer = false
@State private var profileToSwitch: ServerProfile? = nil
@State private var profileToDelete: ServerProfile? = nil
@State private var profileToEdit: ServerProfile? = nil
@State private var showCacheClearedAlert = false
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
@@ -27,29 +29,6 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
Form {
// Language section
Section {
ForEach(LanguageManager.Language.allCases) { lang in
Button {
selectedLanguage = lang
LanguageManager.shared.set(lang)
} label: {
HStack {
Text(lang.flag)
Text(lang.displayName)
.foregroundStyle(.primary)
Spacer()
if selectedLanguage == lang {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
}
} header: {
Text(L("settings.language.header"))
}
// Appearance section
Section(L("settings.appearance")) {
Picker(L("settings.appearance.theme"), selection: $appTheme) {
@@ -59,7 +38,6 @@ struct SettingsView: View {
}
.pickerStyle(.segmented)
// Accent colour swatches
VStack(alignment: .leading, spacing: 10) {
Text(L("settings.appearance.accent"))
.font(.subheadline)
@@ -93,33 +71,56 @@ struct SettingsView: View {
.padding(.vertical, 4)
}
// Account section
Section(L("settings.account")) {
HStack {
Image(systemName: "person.circle.fill")
.font(.title)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(L("settings.account.connected"))
.font(.headline)
Text(serverURL)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
// Servers section
Section(L("settings.servers")) {
ForEach(profileStore.profiles) { profile in
Button {
if profile.id != profileStore.activeProfileId {
profileToSwitch = profile
}
} label: {
HStack(spacing: 12) {
Image(systemName: profile.id == profileStore.activeProfileId
? "checkmark.circle.fill" : "circle")
.foregroundStyle(profile.id == profileStore.activeProfileId
? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
Text(profile.serverURL)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if profile.id == profileStore.activeProfileId {
Text(L("settings.servers.active"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
profileToDelete = profile
} label: {
Label(L("settings.servers.delete.confirm"), systemImage: "trash")
}
.tint(.red)
Button {
profileToEdit = profile
} label: {
Label(L("settings.servers.edit"), systemImage: "pencil")
}
.tint(.blue)
}
}
.padding(.vertical, 4)
Button {
UIPasteboard.general.string = serverURL
showAddServer = true
} label: {
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
}
Button(role: .destructive) {
showSignOutAlert = true
} label: {
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
Label(L("settings.servers.add"), systemImage: "plus.circle")
}
}
@@ -157,31 +158,30 @@ struct SettingsView: View {
}
}
// Sync section
Section(L("settings.sync")) {
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
Button {
Task { await syncNow() }
// Data section
Section {
Button(role: .destructive) {
URLCache.shared.removeAllCachedResponses()
showCacheClearedAlert = true
} label: {
HStack {
Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
if isSyncing {
Spacer()
ProgressView()
}
}
Label(L("settings.data.clearcache"), systemImage: "trash")
}
.disabled(isSyncing)
} header: {
Text(L("settings.data"))
} footer: {
Text(L("settings.data.clearcache.footer"))
}
if let lastSynced {
LabeledContent(L("settings.sync.lastsynced")) {
Text(lastSynced.bookStackFormattedWithTime)
.foregroundStyle(.secondary)
}
// Supporter badge only visible after a donation
if donationService.hasEverDonated {
Section {
SupporterBadgeRow()
}
}
// Donate section
DonationSectionView()
// About section
Section(L("settings.about")) {
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
@@ -207,51 +207,77 @@ struct SettingsView: View {
.onAppear {
loggingEnabled = LogManager.shared.isEnabled
}
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
// Switch server confirmation
.alert(L("settings.servers.switch.title"), isPresented: Binding(
get: { profileToSwitch != nil },
set: { if !$0 { profileToSwitch = nil } }
)) {
Button(L("settings.servers.switch.confirm")) {
if let p = profileToSwitch { profileStore.activate(p) }
profileToSwitch = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToSwitch = nil }
} message: {
Text(L("settings.signout.alert.message"))
if let p = profileToSwitch {
Text(String(format: L("settings.servers.switch.message"), p.name))
}
}
// Delete inactive server confirmation
.alert(L("settings.servers.delete.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id != profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.message"), p.name))
}
}
// Delete ACTIVE server stronger warning
.alert(L("settings.servers.delete.active.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id == profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.active.message"), p.name))
}
}
.alert(L("settings.data.clearcache.done"), isPresented: $showCacheClearedAlert) {
Button(L("common.ok"), role: .cancel) {}
}
.sheet(isPresented: $showAddServer) { AddServerView() }
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
.sheet(item: $showSafari) { url in
SafariView(url: url)
.ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) {
LogViewerView()
SafariView(url: url).ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) { LogViewerView() }
.sheet(isPresented: Binding(
get: { shareItems != nil },
set: { if !$0 { shareItems = nil } }
)) {
if let items = shareItems {
ShareSheet(items: items)
}
if let items = shareItems { ShareSheet(items: items) }
}
}
}
// MARK: - Actions
private func signOut() {
Task {
try? await KeychainService.shared.deleteCredentials()
UserDefaults.standard.removeObject(forKey: "serverURL")
UserDefaults.standard.removeObject(forKey: "lastSynced")
private func removeProfile(_ profile: ServerProfile) {
profileStore.remove(profile)
if profileStore.profiles.isEmpty {
onboardingComplete = false
}
}
private func syncNow() async {
isSyncing = true
// SyncService.shared.syncAll() requires ModelContext from environment
// For now just update last synced date
try? await Task.sleep(for: .seconds(1))
let now = Date()
UserDefaults.standard.set(now, forKey: "lastSynced")
lastSynced = now
isSyncing = false
}
}
// MARK: - Safari View
@@ -0,0 +1,198 @@
import SwiftUI
import StoreKit
// MARK: - SupportNudgeSheet
/// Modal sheet that surfaces the donation options and encourages the user
/// to support active development. Shown at most every 6 months.
struct SupportNudgeSheet: View {
@Binding var isPresented: Bool
@State private var service = DonationService.shared
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 28) {
headerIcon
.padding(.top, 40)
textBlock
productList
Button {
isPresented = false
} label: {
Text(L("nudge.dismiss"))
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
}
.padding(.bottom, 32)
}
.padding(.horizontal, 24)
}
}
.task { await service.loadProducts() }
.onChange(of: service.purchaseState) { _, state in
// Auto-close after the thank-you moment
if case .thankYou = state {
Task {
try? await Task.sleep(for: .seconds(2))
isPresented = false
}
}
}
}
// MARK: - Subviews
private var headerIcon: some View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.pink.opacity(0.18), Color.orange.opacity(0.12)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 112, height: 112)
Image(systemName: "heart.fill")
.font(.system(size: 50))
.foregroundStyle(
LinearGradient(
colors: [.pink, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
private var textBlock: some View {
VStack(spacing: 10) {
Text(L("nudge.title"))
.font(.title2.bold())
.multilineTextAlignment(.center)
Text(L("nudge.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
}
@ViewBuilder
private var productList: some View {
switch service.loadState {
case .loading:
HStack {
Text(L("settings.donate.loading"))
.foregroundStyle(.secondary)
.font(.subheadline)
Spacer()
ProgressView().controlSize(.small)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
case .loaded(let products):
VStack(spacing: 8) {
ForEach(products, id: \.id) { product in
NudgeProductRow(product: product, service: service)
}
}
case .empty, .failed:
EmptyView()
}
}
}
// MARK: - NudgeProductRow
private struct NudgeProductRow: View {
let product: Product
let service: DonationService
private var isPurchasing: Bool { service.purchaseState.activePurchasingID == product.id }
private var isThankYou: Bool { service.purchaseState.thankYouID == product.id }
private var isDisabled: Bool { service.purchaseState.activePurchasingID != nil }
var body: some View {
Button {
guard !isDisabled else { return }
Task { await service.purchase(product) }
} label: {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 3) {
Text(product.displayName)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
trailingView
}
.padding(14)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
@ViewBuilder
private var trailingView: some View {
if isPurchasing {
ProgressView().controlSize(.small)
} else if isThankYou {
Image(systemName: "heart.fill")
.foregroundStyle(.pink)
} else {
Text(product.displayPrice)
.font(.subheadline.bold())
.monospacedDigit()
.foregroundStyle(
LinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing)
)
}
}
}
// MARK: - SupporterBadge
/// Inline badge shown in Settings for users who have donated.
struct SupporterBadgeRow: View {
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.pink, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 42, height: 42)
Image(systemName: "heart.fill")
.font(.system(size: 19))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(L("supporter.badge.title"))
.font(.body.bold())
.foregroundStyle(.primary)
Text(L("supporter.badge.subtitle"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
+12 -6
View File
@@ -3,7 +3,11 @@ import SwiftUI
struct ErrorBanner: View {
let error: BookStackError
var onRetry: (() -> Void)? = nil
var onSettings: (() -> Void)? = nil
private var isUnauthorized: Bool {
if case .unauthorized = error { return true }
return false
}
var body: some View {
HStack(spacing: 12) {
@@ -18,12 +22,14 @@ struct ErrorBanner: View {
Spacer()
if case .unauthorized = error, let onSettings {
Button("Settings", action: onSettings)
.buttonStyle(.bordered)
.controlSize(.small)
if isUnauthorized {
Button(L("settings.title")) {
AppNavigationState.shared.navigateToSettings = true
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if let onRetry {
Button("Retry", action: onRetry)
Button(L("common.retry"), action: onRetry)
.buttonStyle(.bordered)
.controlSize(.small)
}
+53
View File
@@ -0,0 +1,53 @@
import SwiftUI
import WebKit
/// A SwiftUI wrapper for WKWebView that loads HTML content.
/// Replaces the iOS 26-only WebPage / WebView combo.
struct HTMLWebView: UIViewRepresentable {
let html: String
let baseURL: URL?
/// When true, tapped links open in the default browser instead of navigating in-place.
var openLinksExternally: Bool = true
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.scrollView.bounces = true
webView.isOpaque = false
webView.backgroundColor = .clear
webView.navigationDelegate = context.coordinator
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let coordinator = context.coordinator
coordinator.openLinksExternally = openLinksExternally
// Only reload when the HTML has actually changed to avoid flicker.
guard coordinator.lastHTML != html else { return }
coordinator.lastHTML = html
webView.loadHTMLString(html, baseURL: baseURL)
}
// MARK: - Coordinator
final class Coordinator: NSObject, WKNavigationDelegate {
var lastHTML: String = ""
var openLinksExternally: Bool = true
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if openLinksExternally,
navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url {
UIApplication.shared.open(url)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
}
}
}
+10
View File
@@ -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>
+7 -21
View File
@@ -1,12 +1,4 @@
//
// bookstaxApp.swift
// bookstax
//
// Created by Sven Hanold on 19.03.26.
//
import SwiftUI
import SwiftData
@main
struct bookstaxApp: App {
@@ -14,6 +6,9 @@ struct bookstaxApp: App {
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
// ServerProfileStore is initialised here so migration runs at launch
@State private var profileStore = ServerProfileStore.shared
private var preferredColorScheme: ColorScheme? {
switch appTheme {
case "light": return .light
@@ -26,16 +21,6 @@ struct bookstaxApp: App {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
let sharedModelContainer: ModelContainer = {
let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
init() {
AppLog(.info, "BookStax launched", category: "App")
}
@@ -43,18 +28,19 @@ struct bookstaxApp: App {
var body: some Scene {
WindowGroup {
Group {
if onboardingComplete {
if onboardingComplete && profileStore.activeProfile != nil {
MainTabView()
.environment(ConnectivityMonitor.shared)
// Re-creates the entire tab hierarchy when the active server changes
.id(profileStore.activeProfileId)
} else {
OnboardingView()
}
}
.environment(profileStore)
.environment(\.accentTheme, accentTheme)
.tint(accentTheme.accentColor)
.preferredColorScheme(preferredColorScheme)
}
.modelContainer(sharedModelContainer)
}
}
+77
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Bitte gib die Adresse deines BookStack-Servers ein.";
"onboarding.server.error.invalid" = "Das sieht nicht nach einer gültigen Webadresse aus. Versuche z.B. https://bookstack.example.com";
"onboarding.server.warning.http" = "Unverschlüsselte Verbindung erkannt. Deine Daten könnten im Netzwerk sichtbar sein.";
"onboarding.server.warning.remote" = "Das sieht nach einer öffentlichen Internetadresse aus. BookStack im Internet zu betreiben ist ein Sicherheitsrisiko nutze besser ein VPN oder halte es im lokalen Netzwerk.";
"onboarding.token.title" = "Mit API-Token verbinden";
"onboarding.token.subtitle" = "BookStack verwendet API-Tokens für sicheren Zugriff. Du musst einen in deinem BookStack-Profil erstellen.";
"onboarding.token.help" = "Wie bekomme ich einen Token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
// MARK: - Tabs
"tab.quicknote" = "Notiz";
"tab.library" = "Bibliothek";
"tab.search" = "Suche";
"tab.create" = "Erstellen";
"tab.settings" = "Einstellungen";
// MARK: - Quick Note
"quicknote.title" = "Schnellnotiz";
"quicknote.field.title" = "Titel";
"quicknote.field.title.placeholder" = "Notiztitel";
"quicknote.field.content" = "Inhalt";
"quicknote.section.location" = "Speicherort";
"quicknote.section.tags" = "Tags";
"quicknote.shelf.label" = "Regal";
"quicknote.shelf.none" = "Beliebiges Regal";
"quicknote.shelf.loading" = "Regale werden geladen…";
"quicknote.book.label" = "Buch";
"quicknote.book.none" = "Buch auswählen";
"quicknote.book.loading" = "Bücher werden geladen…";
"quicknote.tags.loading" = "Tags werden geladen…";
"quicknote.tags.add" = "Tags hinzufügen";
"quicknote.tags.edit" = "Tags bearbeiten";
"quicknote.tags.empty" = "Keine Tags auf diesem Server vorhanden.";
"quicknote.tags.picker.title" = "Tags auswählen";
"quicknote.save" = "Speichern";
"quicknote.error.nobook" = "Bitte wähle zuerst ein Buch aus.";
"quicknote.saved.online" = "Notiz als neue Seite gespeichert.";
"quicknote.saved.offline" = "Lokal gespeichert — wird hochgeladen, sobald du online bist.";
"quicknote.pending.title" = "Offline-Notizen";
"quicknote.pending.upload" = "Jetzt hochladen";
"quicknote.pending.uploading" = "Wird hochgeladen…";
// MARK: - Library
"library.title" = "Bibliothek";
"library.loading" = "Bibliothek wird geladen…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Schließen ohne zu speichern?";
"editor.close.unsaved.confirm" = "Schließen";
"editor.image.uploading" = "Bild wird hochgeladen…";
"editor.html.notice" = "Diese Seite verwendet HTML-Formatierung. Beim Bearbeiten wird sie in Markdown umgewandelt.";
// MARK: - Search
"search.title" = "Suche";
@@ -190,6 +219,12 @@
"settings.reader" = "Leser";
"settings.reader.showcomments" = "Kommentare anzeigen";
// MARK: - Data
"settings.data" = "Daten";
"settings.data.clearcache" = "Cache leeren";
"settings.data.clearcache.footer" = "Löscht zwischengespeicherte Server-Antworten. Hilfreich, wenn Titel oder Inhalte nach einem Update noch veraltet angezeigt werden.";
"settings.data.clearcache.done" = "Cache geleert";
// MARK: - Logging
"settings.log" = "Protokoll";
"settings.log.enabled" = "Protokollierung aktivieren";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Tag-Filter entfernen";
// MARK: - Servers
"settings.servers" = "Server";
"settings.servers.add" = "Server hinzufügen…";
"settings.servers.active" = "Aktiv";
"settings.servers.switch.title" = "Server wechseln";
"settings.servers.switch.message" = "Zu \"%@\" wechseln? Die App wird neu geladen.";
"settings.servers.switch.confirm" = "Wechseln";
"settings.servers.delete.title" = "Server entfernen";
"settings.servers.delete.message" = "\"%@\" entfernen? Der Cache wird geleert. Dies kann nicht rückgängig gemacht werden.";
"settings.servers.delete.confirm" = "Entfernen";
"settings.servers.delete.active.title" = "Aktiven Server entfernen?";
"settings.servers.delete.active.message" = "\"%@\" ist dein aktueller Server. Durch das Entfernen werden alle zwischengespeicherten Inhalte gelöscht und du wirst von diesem Server abgemeldet.";
"settings.servers.edit" = "Bearbeiten";
"settings.servers.edit.title" = "Server bearbeiten";
"settings.servers.edit.changecreds" = "API-Token aktualisieren";
"settings.servers.edit.changecreds.footer" = "Aktivieren, um Token-ID und Secret für diesen Server zu ersetzen.";
"onboarding.server.name.label" = "Servername";
"onboarding.server.name.placeholder" = "z.B. Firmen-Wiki";
// MARK: - Donations
"settings.donate" = "BookStax unterstützen";
"settings.donate.page" = "Seite";
"settings.donate.book" = "Buch";
"settings.donate.encyclopedia" = "Enzyklopädie";
"settings.donate.footer" = "Gefällt dir BookStax? Deine Unterstützung hilft, die App kostenlos und in aktiver Entwicklung zu halten. Danke!";
"settings.donate.loading" = "Lädt…";
"settings.donate.error" = "Spendenoptionen konnten nicht geladen werden.";
"settings.donate.empty" = "Keine Spendenoptionen verfügbar.";
"settings.donate.donated.on" = "Gespendet am %@";
"settings.donate.pending" = "Ausstehende Bestätigung…";
// MARK: - Support Nudge
"nudge.title" = "Hilf mit, BookStax am Laufen zu halten";
"nudge.subtitle" = "BookStax ist ein Herzensprojekt — kostenlos, offen und werbefrei. Deine Unterstützung hilft, die App aktiv, modern und wachsend zu gestalten.";
"nudge.dismiss" = "Vielleicht später";
// MARK: - Supporter Badge
"supporter.badge.title" = "BookStax Supporter";
"supporter.badge.subtitle" = "Danke, dass du die Entwicklung unterstützt!";
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Abbrechen";
"common.retry" = "Wiederholen";
"common.error" = "Unbekannter Fehler";
"common.done" = "Fertig";
+77
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Please enter your BookStack server address.";
"onboarding.server.error.invalid" = "That doesn't look like a valid web address. Try something like https://bookstack.example.com";
"onboarding.server.warning.http" = "Non-encrypted connection detected. Your data may be visible on the network.";
"onboarding.server.warning.remote" = "This looks like a public internet address. Exposing BookStack to the internet is a security risk — consider using a VPN or keeping it on your local network.";
"onboarding.token.title" = "Connect with an API Token";
"onboarding.token.subtitle" = "BookStack uses API tokens for secure access. You'll need to create one in your BookStack profile.";
"onboarding.token.help" = "How do I get a token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
// MARK: - Tabs
"tab.quicknote" = "Quick Note";
"tab.library" = "Library";
"tab.search" = "Search";
"tab.create" = "Create";
"tab.settings" = "Settings";
// MARK: - Quick Note
"quicknote.title" = "Quick Note";
"quicknote.field.title" = "Title";
"quicknote.field.title.placeholder" = "Note title";
"quicknote.field.content" = "Content";
"quicknote.section.location" = "Location";
"quicknote.section.tags" = "Tags";
"quicknote.shelf.label" = "Shelf";
"quicknote.shelf.none" = "Any Shelf";
"quicknote.shelf.loading" = "Loading shelves…";
"quicknote.book.label" = "Book";
"quicknote.book.none" = "Select a book";
"quicknote.book.loading" = "Loading books…";
"quicknote.tags.loading" = "Loading tags…";
"quicknote.tags.add" = "Add Tags";
"quicknote.tags.edit" = "Edit Tags";
"quicknote.tags.empty" = "No tags available on this server.";
"quicknote.tags.picker.title" = "Select Tags";
"quicknote.save" = "Save";
"quicknote.error.nobook" = "Please select a book first.";
"quicknote.saved.online" = "Note saved as a new page.";
"quicknote.saved.offline" = "Saved locally — will upload when online.";
"quicknote.pending.title" = "Offline Notes";
"quicknote.pending.upload" = "Upload Now";
"quicknote.pending.uploading" = "Uploading…";
// MARK: - Library
"library.title" = "Library";
"library.loading" = "Loading library…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Close without saving?";
"editor.close.unsaved.confirm" = "Close";
"editor.image.uploading" = "Uploading image…";
"editor.html.notice" = "This page uses HTML formatting. Editing here will convert it to Markdown.";
// MARK: - Search
"search.title" = "Search";
@@ -190,6 +219,12 @@
"settings.reader" = "Reader";
"settings.reader.showcomments" = "Show Comments";
// MARK: - Data
"settings.data" = "Data";
"settings.data.clearcache" = "Clear Cache";
"settings.data.clearcache.footer" = "Clears cached server responses. Useful if titles or content appear outdated after an update.";
"settings.data.clearcache.done" = "Cache Cleared";
// MARK: - Logging
"settings.log" = "Logging";
"settings.log.enabled" = "Enable Logging";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Clear Tag Filter";
// MARK: - Servers
"settings.servers" = "Servers";
"settings.servers.add" = "Add Server…";
"settings.servers.active" = "Active";
"settings.servers.switch.title" = "Switch Server";
"settings.servers.switch.message" = "Switch to \"%@\"? The app will reload.";
"settings.servers.switch.confirm" = "Switch";
"settings.servers.delete.title" = "Remove Server";
"settings.servers.delete.message" = "Remove \"%@\"? Its cached content will be cleared. This cannot be undone.";
"settings.servers.delete.confirm" = "Remove";
"settings.servers.delete.active.title" = "Remove Active Server?";
"settings.servers.delete.active.message" = "\"%@\" is your current server. Removing it will clear all cached content and sign you out of this server.";
"settings.servers.edit" = "Edit";
"settings.servers.edit.title" = "Edit Server";
"settings.servers.edit.changecreds" = "Update API Token";
"settings.servers.edit.changecreds.footer" = "Enable to replace the stored Token ID and Secret for this server.";
"onboarding.server.name.label" = "Server Name";
"onboarding.server.name.placeholder" = "e.g. Work Wiki";
// MARK: - Donations
"settings.donate" = "Support BookStax";
"settings.donate.page" = "Page";
"settings.donate.book" = "Book";
"settings.donate.encyclopedia" = "Encyclopedia";
"settings.donate.footer" = "Enjoying BookStax? Your support helps keep the app free and in active development. Thank you!";
"settings.donate.loading" = "Loading…";
"settings.donate.error" = "Could not load donation options.";
"settings.donate.empty" = "No donation options available.";
"settings.donate.donated.on" = "Donated on %@";
"settings.donate.pending" = "Pending confirmation…";
// MARK: - Support Nudge
"nudge.title" = "Help keep BookStax going";
"nudge.subtitle" = "BookStax is a passion project — free, open, and ad-free. Your support helps keep it maintained, modern, and growing.";
"nudge.dismiss" = "Maybe later";
// MARK: - Supporter Badge
"supporter.badge.title" = "BookStax Supporter";
"supporter.badge.subtitle" = "Thank you for supporting the development!";
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Cancel";
"common.retry" = "Retry";
"common.error" = "Unknown error";
"common.done" = "Done";
+77
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Por favor, introduce la dirección de tu servidor BookStack.";
"onboarding.server.error.invalid" = "Eso no parece una dirección web válida. Prueba algo como https://bookstack.example.com";
"onboarding.server.warning.http" = "Conexión sin cifrar detectada. Tus datos podrían ser visibles en la red.";
"onboarding.server.warning.remote" = "Esto parece una dirección pública de internet. Exponer BookStack a internet es un riesgo de seguridad — considera usar una VPN o mantenerlo en tu red local.";
"onboarding.token.title" = "Conectar con un token API";
"onboarding.token.subtitle" = "BookStack usa tokens API para un acceso seguro. Deberás crear uno en tu perfil de BookStack.";
"onboarding.token.help" = "¿Cómo obtengo un token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
// MARK: - Tabs
"tab.quicknote" = "Nota rápida";
"tab.library" = "Biblioteca";
"tab.search" = "Búsqueda";
"tab.create" = "Crear";
"tab.settings" = "Ajustes";
// MARK: - Quick Note
"quicknote.title" = "Nota rápida";
"quicknote.field.title" = "Título";
"quicknote.field.title.placeholder" = "Título de la nota";
"quicknote.field.content" = "Contenido";
"quicknote.section.location" = "Ubicación";
"quicknote.section.tags" = "Etiquetas";
"quicknote.shelf.label" = "Estante";
"quicknote.shelf.none" = "Cualquier estante";
"quicknote.shelf.loading" = "Cargando estantes…";
"quicknote.book.label" = "Libro";
"quicknote.book.none" = "Selecciona un libro";
"quicknote.book.loading" = "Cargando libros…";
"quicknote.tags.loading" = "Cargando etiquetas…";
"quicknote.tags.add" = "Añadir etiquetas";
"quicknote.tags.edit" = "Editar etiquetas";
"quicknote.tags.empty" = "No hay etiquetas disponibles en este servidor.";
"quicknote.tags.picker.title" = "Seleccionar etiquetas";
"quicknote.save" = "Guardar";
"quicknote.error.nobook" = "Selecciona un libro primero.";
"quicknote.saved.online" = "Nota guardada como nueva página.";
"quicknote.saved.offline" = "Guardado localmente — se subirá cuando estés en línea.";
"quicknote.pending.title" = "Notas sin conexión";
"quicknote.pending.upload" = "Subir ahora";
"quicknote.pending.uploading" = "Subiendo…";
// MARK: - Library
"library.title" = "Biblioteca";
"library.loading" = "Cargando biblioteca…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "¿Cerrar sin guardar?";
"editor.close.unsaved.confirm" = "Cerrar";
"editor.image.uploading" = "Subiendo imagen…";
"editor.html.notice" = "Esta página usa formato HTML. Editarla aquí la convertirá a Markdown.";
// MARK: - Search
"search.title" = "Búsqueda";
@@ -190,6 +219,12 @@
"settings.reader" = "Lector";
"settings.reader.showcomments" = "Mostrar comentarios";
// MARK: - Data
"settings.data" = "Datos";
"settings.data.clearcache" = "Vaciar caché";
"settings.data.clearcache.footer" = "Borra las respuestas del servidor almacenadas en caché. Útil si los títulos o el contenido aparecen desactualizados tras una actualización.";
"settings.data.clearcache.done" = "Caché vaciada";
// MARK: - Logging
"settings.log" = "Registro";
"settings.log.enabled" = "Activar registro";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Etiqueta";
"search.filter.tag.clear" = "Eliminar filtro de etiqueta";
// MARK: - Servers
"settings.servers" = "Servidores";
"settings.servers.add" = "Añadir servidor…";
"settings.servers.active" = "Activo";
"settings.servers.switch.title" = "Cambiar servidor";
"settings.servers.switch.message" = "¿Cambiar a \"%@\"? La app se recargará.";
"settings.servers.switch.confirm" = "Cambiar";
"settings.servers.delete.title" = "Eliminar servidor";
"settings.servers.delete.message" = "¿Eliminar \"%@\"? Se borrará el contenido en caché. Esta acción no se puede deshacer.";
"settings.servers.delete.confirm" = "Eliminar";
"settings.servers.delete.active.title" = "¿Eliminar el servidor activo?";
"settings.servers.delete.active.message" = "\"%@\" es tu servidor actual. Al eliminarlo se borrará todo el contenido en caché y se cerrará sesión en este servidor.";
"settings.servers.edit" = "Editar";
"settings.servers.edit.title" = "Editar servidor";
"settings.servers.edit.changecreds" = "Actualizar token API";
"settings.servers.edit.changecreds.footer" = "Activa para reemplazar el Token ID y Secret almacenados para este servidor.";
"onboarding.server.name.label" = "Nombre del servidor";
"onboarding.server.name.placeholder" = "p.ej. Wiki de trabajo";
// MARK: - Donations
"settings.donate" = "Apoya BookStax";
"settings.donate.page" = "Página";
"settings.donate.book" = "Libro";
"settings.donate.encyclopedia" = "Enciclopedia";
"settings.donate.footer" = "¿Disfrutas BookStax? Tu apoyo ayuda a mantener la app gratuita y en desarrollo activo. ¡Gracias!";
"settings.donate.loading" = "Cargando…";
"settings.donate.error" = "No se pudieron cargar las opciones de donación.";
"settings.donate.empty" = "No hay opciones de donación disponibles.";
"settings.donate.donated.on" = "Donado el %@";
"settings.donate.pending" = "Confirmación pendiente…";
// MARK: - Support Nudge
"nudge.title" = "Ayuda a mantener BookStax";
"nudge.subtitle" = "BookStax es un proyecto personal — gratuito, abierto y sin anuncios. Tu apoyo ayuda a mantenerlo activo, moderno y en crecimiento.";
"nudge.dismiss" = "Quizás más tarde";
// MARK: - Supporter Badge
"supporter.badge.title" = "Supporter de BookStax";
"supporter.badge.subtitle" = "¡Gracias por apoyar el desarrollo!";
// MARK: - Common
"common.ok" = "Aceptar";
"common.cancel" = "Cancelar";
"common.retry" = "Reintentar";
"common.error" = "Error desconocido";
"common.done" = "Listo";
+297
View File
@@ -0,0 +1,297 @@
// MARK: - Onboarding
"onboarding.language.title" = "Choisissez votre langue";
"onboarding.language.subtitle" = "Vous pouvez le modifier plus tard dans les réglages.";
"onboarding.welcome.title" = "Bienvenue sur BookStax";
"onboarding.welcome.subtitle" = "Votre base de connaissances auto-hébergée,\ndans votre poche.";
"onboarding.welcome.cta" = "Commencer";
"onboarding.server.title" = "Où se trouve votre BookStack ?";
"onboarding.server.subtitle" = "Saisissez l'adresse web de votre installation BookStack. C'est la même URL que vous utilisez dans votre navigateur.";
"onboarding.server.placeholder" = "https://wiki.monentreprise.com";
"onboarding.server.next" = "Suivant";
"onboarding.server.error.empty" = "Veuillez saisir l'adresse de votre serveur BookStack.";
"onboarding.server.error.invalid" = "Cette adresse ne semble pas valide. Essayez quelque chose comme https://bookstack.exemple.com";
"onboarding.server.warning.http" = "Connexion non chiffrée détectée. Vos données peuvent être visibles sur le réseau.";
"onboarding.server.warning.remote" = "Cette adresse semble être publique. Exposer BookStack sur Internet est un risque de sécurité — pensez à utiliser un VPN ou à le garder sur votre réseau local.";
"onboarding.token.title" = "Connexion avec un jeton d'API";
"onboarding.token.subtitle" = "BookStack utilise des jetons d'API pour un accès sécurisé. Vous devez en créer un dans votre profil BookStack.";
"onboarding.token.help" = "Comment obtenir un jeton ?";
"onboarding.token.help.steps" = "1. Ouvrez votre instance BookStack dans un navigateur\n2. Cliquez sur votre avatar → Modifier le profil\n3. Faites défiler jusqu'à \"Jetons API\" → appuyez sur \"Créer un jeton\"\n4. Définissez un nom (ex. \"Mon iPhone\") et une date d'expiration\n5. Copiez l'ID et le secret du jeton — ils ne seront plus affichés\n\nRemarque : votre compte doit avoir la permission \"Accéder à l'API système\". Contactez votre administrateur si vous ne voyez pas la section Jetons API.";
"onboarding.token.id.label" = "ID du jeton";
"onboarding.token.secret.label" = "Secret du jeton";
"onboarding.token.paste" = "Coller depuis le presse-papiers";
"onboarding.token.verify" = "Vérifier la connexion";
"onboarding.verify.ready" = "Prêt à vérifier";
"onboarding.verify.reaching" = "Connexion au serveur…";
"onboarding.verify.found" = "%@ trouvé";
"onboarding.verify.checking" = "Vérification des identifiants…";
"onboarding.verify.connected" = "Connecté à %@";
"onboarding.verify.server.failed" = "Serveur inaccessible";
"onboarding.verify.token.failed" = "Échec de l'authentification";
"onboarding.verify.phase.server" = "Connexion au serveur";
"onboarding.verify.phase.token" = "Vérification du jeton";
"onboarding.verify.goback" = "Retour";
"onboarding.verify.retry" = "Réessayer";
"onboarding.ready.title" = "Tout est prêt !";
"onboarding.ready.subtitle" = "BookStax est connecté à votre base de connaissances.";
"onboarding.ready.cta" = "Ouvrir ma bibliothèque";
"onboarding.ready.feature.library" = "Parcourir la bibliothèque";
"onboarding.ready.feature.library.desc" = "Naviguez dans les étagères, livres, chapitres et pages";
"onboarding.ready.feature.search" = "Tout rechercher";
"onboarding.ready.feature.search.desc" = "Trouvez n'importe quel contenu instantanément";
"onboarding.ready.feature.create" = "Créer et modifier";
"onboarding.ready.feature.create.desc" = "Rédigez de nouvelles pages en Markdown";
// MARK: - Tabs
"tab.quicknote" = "Note rapide";
"tab.library" = "Bibliothèque";
"tab.search" = "Recherche";
"tab.create" = "Créer";
"tab.settings" = "Réglages";
// MARK: - Quick Note
"quicknote.title" = "Note rapide";
"quicknote.field.title" = "Titre";
"quicknote.field.title.placeholder" = "Titre de la note";
"quicknote.field.content" = "Contenu";
"quicknote.section.location" = "Emplacement";
"quicknote.section.tags" = "Étiquettes";
"quicknote.shelf.label" = "Étagère";
"quicknote.shelf.none" = "Toute étagère";
"quicknote.shelf.loading" = "Chargement des étagères…";
"quicknote.book.label" = "Livre";
"quicknote.book.none" = "Sélectionner un livre";
"quicknote.book.loading" = "Chargement des livres…";
"quicknote.tags.loading" = "Chargement des étiquettes…";
"quicknote.tags.add" = "Ajouter des étiquettes";
"quicknote.tags.edit" = "Modifier les étiquettes";
"quicknote.tags.empty" = "Aucune étiquette disponible sur ce serveur.";
"quicknote.tags.picker.title" = "Sélectionner des étiquettes";
"quicknote.save" = "Enregistrer";
"quicknote.error.nobook" = "Veuillez d'abord sélectionner un livre.";
"quicknote.saved.online" = "Note enregistrée comme nouvelle page.";
"quicknote.saved.offline" = "Enregistré localement — sera envoyé dès que vous serez en ligne.";
"quicknote.pending.title" = "Notes hors ligne";
"quicknote.pending.upload" = "Envoyer maintenant";
"quicknote.pending.uploading" = "Envoi en cours…";
// MARK: - Library
"library.title" = "Bibliothèque";
"library.loading" = "Chargement de la bibliothèque…";
"library.empty.title" = "Aucune étagère";
"library.empty.message" = "Votre bibliothèque est vide. Créez une étagère dans BookStack pour commencer.";
"library.refresh" = "Actualiser";
"library.shelves" = "Étagères";
"library.updated" = "Mis à jour %@";
"library.newshelf" = "Nouvelle étagère";
// MARK: - Shelf
"shelf.loading" = "Chargement des livres…";
"shelf.empty.title" = "Aucun livre";
"shelf.empty.message" = "Cette étagère ne contient pas encore de livres.";
"shelf.newbook" = "Nouveau livre";
// MARK: - Book
"book.loading" = "Chargement du contenu…";
"book.empty.title" = "Aucun contenu";
"book.empty.message" = "Ce livre ne contient pas encore de chapitres ni de pages.";
"book.addpage" = "Ajouter une page";
"book.newpage" = "Nouvelle page";
"book.newchapter" = "Nouveau chapitre";
"book.pages" = "Pages";
"book.delete" = "Supprimer";
"book.open" = "Ouvrir";
"book.sharelink" = "Partager le lien";
"book.addcontent" = "Ajouter du contenu";
// MARK: - Chapter
"chapter.new.title" = "Nouveau chapitre";
"chapter.new.name" = "Nom du chapitre";
"chapter.new.description" = "Description (facultatif)";
"chapter.details" = "Détails du chapitre";
"chapter.cancel" = "Annuler";
"chapter.create" = "Créer";
// MARK: - Page Reader
"reader.comments" = "Commentaires (%d)";
"reader.comments.empty" = "Aucun commentaire pour l'instant. Soyez le premier !";
"reader.comment.placeholder" = "Ajouter un commentaire…";
"reader.comment.post" = "Publier le commentaire";
"reader.edit" = "Modifier la page";
"reader.share" = "Partager la page";
"reader.nocontent" = "Aucun contenu";
// MARK: - Editor
"editor.new.title" = "Nouvelle page";
"editor.edit.title" = "Modifier la page";
"editor.title.placeholder" = "Titre de la page";
"editor.tab.write" = "Écrire";
"editor.tab.preview" = "Aperçu";
"editor.save" = "Enregistrer";
"editor.close" = "Fermer";
"editor.discard.keepediting" = "Continuer à modifier";
"editor.close.unsaved.title" = "Fermer sans enregistrer ?";
"editor.close.unsaved.confirm" = "Fermer";
"editor.image.uploading" = "Chargement de l'image…";
"editor.html.notice" = "Cette page utilise le format HTML. La modifier ici la convertira en Markdown.";
// MARK: - Search
"search.title" = "Recherche";
"search.prompt" = "Rechercher des livres, pages, chapitres…";
"search.loading" = "Recherche en cours…";
"search.empty.title" = "Rechercher dans BookStack";
"search.empty.message" = "Recherchez des pages, livres, chapitres et étagères dans toute votre base de connaissances.";
"search.recent" = "Recherches récentes";
"search.recent.clear" = "Effacer";
"search.filter" = "Filtrer les résultats";
"search.filter.all" = "Tous";
"search.opening" = "Ouverture…";
"search.error.title" = "Impossible d'ouvrir le résultat";
"search.type.page" = "Pages";
"search.type.book" = "Livres";
"search.type.chapter" = "Chapitres";
"search.type.shelf" = "Étagères";
// MARK: - New Content
"create.title" = "Créer";
"create.section" = "Que souhaitez-vous créer ?";
"create.page.title" = "Nouvelle page";
"create.page.desc" = "Rédigez une nouvelle page en Markdown";
"create.book.title" = "Nouveau livre";
"create.book.desc" = "Organisez des pages dans un livre";
"create.shelf.title" = "Nouvelle étagère";
"create.shelf.desc" = "Regroupez des livres dans une étagère";
"create.book.name" = "Nom du livre";
"create.book.details" = "Détails du livre";
"create.book.shelf.header" = "Étagère (facultatif)";
"create.book.shelf.footer" = "Assignez ce livre à une étagère pour mieux organiser votre bibliothèque.";
"create.book.shelf.none" = "Aucune";
"create.book.shelf.loading" = "Chargement des étagères…";
"create.shelf.name" = "Nom de l'étagère";
"create.shelf.details" = "Détails de l'étagère";
"create.page.filter.shelf" = "Filtrer par étagère (facultatif)";
"create.page.book.header" = "Livre";
"create.page.book.footer" = "La page sera créée dans ce livre.";
"create.page.book.select" = "Sélectionner un livre…";
"create.page.nobooks" = "Aucun livre disponible";
"create.page.nobooks.shelf" = "Aucun livre dans cette étagère";
"create.page.loading" = "Chargement…";
"create.page.next" = "Suivant";
"create.description" = "Description (facultatif)";
"create.cancel" = "Annuler";
"create.create" = "Créer";
"create.loading.books" = "Chargement des livres…";
"create.any.shelf" = "Toute étagère";
// MARK: - Settings
"settings.title" = "Réglages";
"settings.account" = "Compte";
"settings.account.connected" = "Connecté";
"settings.account.copyurl" = "Copier l'URL du serveur";
"settings.account.signout" = "Se déconnecter";
"settings.signout.alert.title" = "Se déconnecter";
"settings.signout.alert.message" = "Cela supprimera vos identifiants enregistrés et vous devrez vous reconnecter.";
"settings.signout.alert.confirm" = "Se déconnecter";
"settings.signout.alert.cancel" = "Annuler";
"settings.sync" = "Synchronisation";
"settings.sync.wifionly" = "Synchroniser en Wi-Fi uniquement";
"settings.sync.now" = "Synchroniser maintenant";
"settings.sync.lastsynced" = "Dernière synchronisation";
"settings.about" = "À propos";
"settings.about.version" = "Version";
"settings.about.docs" = "Documentation BookStack";
"settings.about.issue" = "Signaler un problème";
"settings.about.credit" = "BookStack est un logiciel open source créé par Dan Brown.";
"settings.language" = "Langue";
"settings.language.header" = "Langue";
// MARK: - Offline
"offline.banner" = "Vous êtes hors ligne — affichage du contenu mis en cache";
// MARK: - Appearance
"settings.appearance" = "Apparence";
"settings.appearance.theme" = "Thème";
"settings.appearance.theme.system" = "Système";
"settings.appearance.theme.light" = "Clair";
"settings.appearance.theme.dark" = "Sombre";
"settings.appearance.accent" = "Couleur d'accentuation";
// MARK: - Reader Settings
"settings.reader" = "Lecteur";
"settings.reader.showcomments" = "Afficher les commentaires";
// MARK: - Data
"settings.data" = "Données";
"settings.data.clearcache" = "Vider le cache";
"settings.data.clearcache.footer" = "Efface les réponses serveur mises en cache. Utile si les titres ou le contenu semblent obsolètes après une mise à jour.";
"settings.data.clearcache.done" = "Cache vidé";
// MARK: - Logging
"settings.log" = "Journalisation";
"settings.log.enabled" = "Activer la journalisation";
"settings.log.share" = "Partager le journal";
"settings.log.clear" = "Effacer le journal";
"settings.log.viewer.title" = "Journal de l'app";
"settings.log.entries" = "%d entrées";
// MARK: - Tags
"editor.tags.title" = "Étiquettes";
"editor.tags.add" = "Ajouter une étiquette";
"editor.tags.create" = "Créer une nouvelle étiquette";
"editor.tags.name" = "Nom de l'étiquette";
"editor.tags.value" = "Valeur (facultatif)";
"editor.tags.current" = "Étiquettes assignées";
"editor.tags.available" = "Étiquettes disponibles";
"editor.tags.loading" = "Chargement des étiquettes…";
"editor.tags.new" = "Créer une étiquette";
"editor.tags.search" = "Rechercher des étiquettes…";
"editor.tags.suggestions" = "Suggestions";
"search.filter.type" = "Type de contenu";
"search.filter.tag" = "Étiquette";
"search.filter.tag.clear" = "Effacer le filtre d'étiquette";
// MARK: - Servers
"settings.servers" = "Serveurs";
"settings.servers.add" = "Ajouter un serveur…";
"settings.servers.active" = "Actif";
"settings.servers.switch.title" = "Changer de serveur";
"settings.servers.switch.message" = "Passer à \"%@\" ? L'application va se recharger.";
"settings.servers.switch.confirm" = "Changer";
"settings.servers.delete.title" = "Supprimer le serveur";
"settings.servers.delete.message" = "Supprimer \"%@\" ? Son contenu mis en cache sera effacé. Cette action est irréversible.";
"settings.servers.delete.confirm" = "Supprimer";
"settings.servers.delete.active.title" = "Supprimer le serveur actif ?";
"settings.servers.delete.active.message" = "\"%@\" est votre serveur actuel. Le supprimer effacera tout le contenu mis en cache et vous déconnectera de ce serveur.";
"settings.servers.edit" = "Modifier";
"settings.servers.edit.title" = "Modifier le serveur";
"settings.servers.edit.changecreds" = "Mettre à jour le jeton d'API";
"settings.servers.edit.changecreds.footer" = "Activez cette option pour remplacer l'ID et le secret du jeton enregistrés pour ce serveur.";
"onboarding.server.name.label" = "Nom du serveur";
"onboarding.server.name.placeholder" = "ex. Wiki professionnel";
// MARK: - Donations
"settings.donate" = "Soutenir BookStax";
"settings.donate.page" = "Page";
"settings.donate.book" = "Livre";
"settings.donate.encyclopedia" = "Encyclopédie";
"settings.donate.footer" = "Vous appréciez BookStax ? Votre soutien contribue à maintenir l'app gratuite et en développement actif. Merci !";
"settings.donate.loading" = "Chargement…";
"settings.donate.error" = "Impossible de charger les options de don.";
"settings.donate.empty" = "Aucune option de don disponible.";
"settings.donate.donated.on" = "Don effectué le %@";
"settings.donate.pending" = "En attente de confirmation…";
// MARK: - Support Nudge
"nudge.title" = "Contribuez à faire vivre BookStax";
"nudge.subtitle" = "BookStax est un projet passionnel — gratuit, ouvert et sans publicité. Votre soutien aide à le maintenir moderne et en développement actif.";
"nudge.dismiss" = "Peut-être plus tard";
// MARK: - Supporter Badge
"supporter.badge.title" = "Supporter BookStax";
"supporter.badge.subtitle" = "Merci de soutenir le développement !";
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Annuler";
"common.retry" = "Réessayer";
"common.error" = "Erreur inconnue";
"common.done" = "Terminé";
+297
View File
@@ -0,0 +1,297 @@
// MARK: - Onboarding
"onboarding.language.title" = "Choisissez votre langue";
"onboarding.language.subtitle" = "Vous pouvez le modifier plus tard dans les réglages.";
"onboarding.welcome.title" = "Bienvenue sur BookStax";
"onboarding.welcome.subtitle" = "Votre base de connaissances auto-hébergée,\ndans votre poche.";
"onboarding.welcome.cta" = "Commencer";
"onboarding.server.title" = "Où se trouve votre BookStack ?";
"onboarding.server.subtitle" = "Saisissez l'adresse web de votre installation BookStack. C'est la même URL que vous utilisez dans votre navigateur.";
"onboarding.server.placeholder" = "https://wiki.monentreprise.com";
"onboarding.server.next" = "Suivant";
"onboarding.server.error.empty" = "Veuillez saisir l'adresse de votre serveur BookStack.";
"onboarding.server.error.invalid" = "Cette adresse ne semble pas valide. Essayez quelque chose comme https://bookstack.exemple.com";
"onboarding.server.warning.http" = "Connexion non chiffrée détectée. Vos données peuvent être visibles sur le réseau.";
"onboarding.server.warning.remote" = "Cette adresse semble être publique. Exposer BookStack sur Internet est un risque de sécurité — pensez à utiliser un VPN ou à le garder sur votre réseau local.";
"onboarding.token.title" = "Connexion avec un jeton d'API";
"onboarding.token.subtitle" = "BookStack utilise des jetons d'API pour un accès sécurisé. Vous devez en créer un dans votre profil BookStack.";
"onboarding.token.help" = "Comment obtenir un jeton ?";
"onboarding.token.help.steps" = "1. Ouvrez votre instance BookStack dans un navigateur\n2. Cliquez sur votre avatar → Modifier le profil\n3. Faites défiler jusqu'à \"Jetons API\" → appuyez sur \"Créer un jeton\"\n4. Définissez un nom (ex. \"Mon iPhone\") et une date d'expiration\n5. Copiez l'ID et le secret du jeton — ils ne seront plus affichés\n\nRemarque : votre compte doit avoir la permission \"Accéder à l'API système\". Contactez votre administrateur si vous ne voyez pas la section Jetons API.";
"onboarding.token.id.label" = "ID du jeton";
"onboarding.token.secret.label" = "Secret du jeton";
"onboarding.token.paste" = "Coller depuis le presse-papiers";
"onboarding.token.verify" = "Vérifier la connexion";
"onboarding.verify.ready" = "Prêt à vérifier";
"onboarding.verify.reaching" = "Connexion au serveur\u{2026}";
"onboarding.verify.found" = "%@ trouvé";
"onboarding.verify.checking" = "Vérification des identifiants\u{2026}";
"onboarding.verify.connected" = "Connecté à %@";
"onboarding.verify.server.failed" = "Serveur inaccessible";
"onboarding.verify.token.failed" = "Échec de l'authentification";
"onboarding.verify.phase.server" = "Connexion au serveur";
"onboarding.verify.phase.token" = "Vérification du jeton";
"onboarding.verify.goback" = "Retour";
"onboarding.verify.retry" = "Réessayer";
"onboarding.ready.title" = "Tout est prêt !";
"onboarding.ready.subtitle" = "BookStax est connecté à votre base de connaissances.";
"onboarding.ready.cta" = "Ouvrir ma bibliothèque";
"onboarding.ready.feature.library" = "Parcourir la bibliothèque";
"onboarding.ready.feature.library.desc" = "Naviguez dans les étagères, livres, chapitres et pages";
"onboarding.ready.feature.search" = "Tout rechercher";
"onboarding.ready.feature.search.desc" = "Trouvez n'importe quel contenu instantanément";
"onboarding.ready.feature.create" = "Créer et modifier";
"onboarding.ready.feature.create.desc" = "Rédigez de nouvelles pages en Markdown";
// MARK: - Tabs
"tab.quicknote" = "Note rapide";
"tab.library" = "Bibliothèque";
"tab.search" = "Recherche";
"tab.create" = "Créer";
"tab.settings" = "Réglages";
// MARK: - Quick Note
"quicknote.title" = "Note rapide";
"quicknote.field.title" = "Titre";
"quicknote.field.title.placeholder" = "Titre de la note";
"quicknote.field.content" = "Contenu";
"quicknote.section.location" = "Emplacement";
"quicknote.section.tags" = "Étiquettes";
"quicknote.shelf.label" = "Étagère";
"quicknote.shelf.none" = "Toute étagère";
"quicknote.shelf.loading" = "Chargement des étagères\u{2026}";
"quicknote.book.label" = "Livre";
"quicknote.book.none" = "Sélectionner un livre";
"quicknote.book.loading" = "Chargement des livres\u{2026}";
"quicknote.tags.loading" = "Chargement des étiquettes\u{2026}";
"quicknote.tags.add" = "Ajouter des étiquettes";
"quicknote.tags.edit" = "Modifier les étiquettes";
"quicknote.tags.empty" = "Aucune étiquette disponible sur ce serveur.";
"quicknote.tags.picker.title" = "Sélectionner des étiquettes";
"quicknote.save" = "Enregistrer";
"quicknote.error.nobook" = "Veuillez d'abord sélectionner un livre.";
"quicknote.saved.online" = "Note enregistrée comme nouvelle page.";
"quicknote.saved.offline" = "Enregistré localement — sera envoyé dès que vous serez en ligne.";
"quicknote.pending.title" = "Notes hors ligne";
"quicknote.pending.upload" = "Envoyer maintenant";
"quicknote.pending.uploading" = "Envoi en cours\u{2026}";
// MARK: - Library
"library.title" = "Bibliothèque";
"library.loading" = "Chargement de la bibliothèque\u{2026}";
"library.empty.title" = "Aucune étagère";
"library.empty.message" = "Votre bibliothèque est vide. Créez une étagère dans BookStack pour commencer.";
"library.refresh" = "Actualiser";
"library.shelves" = "Étagères";
"library.updated" = "Mis à jour %@";
"library.newshelf" = "Nouvelle étagère";
// MARK: - Shelf
"shelf.loading" = "Chargement des livres\u{2026}";
"shelf.empty.title" = "Aucun livre";
"shelf.empty.message" = "Cette étagère ne contient pas encore de livres.";
"shelf.newbook" = "Nouveau livre";
// MARK: - Book
"book.loading" = "Chargement du contenu\u{2026}";
"book.empty.title" = "Aucun contenu";
"book.empty.message" = "Ce livre ne contient pas encore de chapitres ni de pages.";
"book.addpage" = "Ajouter une page";
"book.newpage" = "Nouvelle page";
"book.newchapter" = "Nouveau chapitre";
"book.pages" = "Pages";
"book.delete" = "Supprimer";
"book.open" = "Ouvrir";
"book.sharelink" = "Partager le lien";
"book.addcontent" = "Ajouter du contenu";
// MARK: - Chapter
"chapter.new.title" = "Nouveau chapitre";
"chapter.new.name" = "Nom du chapitre";
"chapter.new.description" = "Description (facultatif)";
"chapter.details" = "Détails du chapitre";
"chapter.cancel" = "Annuler";
"chapter.create" = "Créer";
// MARK: - Page Reader
"reader.comments" = "Commentaires (%d)";
"reader.comments.empty" = "Aucun commentaire pour l'instant. Soyez le premier !";
"reader.comment.placeholder" = "Ajouter un commentaire\u{2026}";
"reader.comment.post" = "Publier le commentaire";
"reader.edit" = "Modifier la page";
"reader.share" = "Partager la page";
"reader.nocontent" = "Aucun contenu";
// MARK: - Editor
"editor.new.title" = "Nouvelle page";
"editor.edit.title" = "Modifier la page";
"editor.title.placeholder" = "Titre de la page";
"editor.tab.write" = "Écrire";
"editor.tab.preview" = "Aperçu";
"editor.save" = "Enregistrer";
"editor.close" = "Fermer";
"editor.discard.keepediting" = "Continuer à modifier";
"editor.close.unsaved.title" = "Fermer sans enregistrer ?";
"editor.close.unsaved.confirm" = "Fermer";
"editor.image.uploading" = "Chargement de l'image\u{2026}";
"editor.html.notice" = "Cette page utilise le format HTML. La modifier ici la convertira en Markdown.";
// MARK: - Search
"search.title" = "Recherche";
"search.prompt" = "Rechercher des livres, pages, chapitres\u{2026}";
"search.loading" = "Recherche en cours\u{2026}";
"search.empty.title" = "Rechercher dans BookStack";
"search.empty.message" = "Recherchez des pages, livres, chapitres et étagères dans toute votre base de connaissances.";
"search.recent" = "Recherches récentes";
"search.recent.clear" = "Effacer";
"search.filter" = "Filtrer les résultats";
"search.filter.all" = "Tous";
"search.opening" = "Ouverture\u{2026}";
"search.error.title" = "Impossible d'ouvrir le résultat";
"search.type.page" = "Pages";
"search.type.book" = "Livres";
"search.type.chapter" = "Chapitres";
"search.type.shelf" = "Étagères";
// MARK: - New Content
"create.title" = "Créer";
"create.section" = "Que souhaitez-vous créer ?";
"create.page.title" = "Nouvelle page";
"create.page.desc" = "Rédigez une nouvelle page en Markdown";
"create.book.title" = "Nouveau livre";
"create.book.desc" = "Organisez des pages dans un livre";
"create.shelf.title" = "Nouvelle étagère";
"create.shelf.desc" = "Regroupez des livres dans une étagère";
"create.book.name" = "Nom du livre";
"create.book.details" = "Détails du livre";
"create.book.shelf.header" = "Étagère (facultatif)";
"create.book.shelf.footer" = "Assignez ce livre à une étagère pour mieux organiser votre bibliothèque.";
"create.book.shelf.none" = "Aucune";
"create.book.shelf.loading" = "Chargement des étagères\u{2026}";
"create.shelf.name" = "Nom de l'étagère";
"create.shelf.details" = "Détails de l'étagère";
"create.page.filter.shelf" = "Filtrer par étagère (facultatif)";
"create.page.book.header" = "Livre";
"create.page.book.footer" = "La page sera créée dans ce livre.";
"create.page.book.select" = "Sélectionner un livre\u{2026}";
"create.page.nobooks" = "Aucun livre disponible";
"create.page.nobooks.shelf" = "Aucun livre dans cette étagère";
"create.page.loading" = "Chargement\u{2026}";
"create.page.next" = "Suivant";
"create.description" = "Description (facultatif)";
"create.cancel" = "Annuler";
"create.create" = "Créer";
"create.loading.books" = "Chargement des livres\u{2026}";
"create.any.shelf" = "Toute étagère";
// MARK: - Settings
"settings.title" = "Réglages";
"settings.account" = "Compte";
"settings.account.connected" = "Connecté";
"settings.account.copyurl" = "Copier l'URL du serveur";
"settings.account.signout" = "Se déconnecter";
"settings.signout.alert.title" = "Se déconnecter";
"settings.signout.alert.message" = "Cela supprimera vos identifiants enregistrés et vous devrez vous reconnecter.";
"settings.signout.alert.confirm" = "Se déconnecter";
"settings.signout.alert.cancel" = "Annuler";
"settings.sync" = "Synchronisation";
"settings.sync.wifionly" = "Synchroniser en Wi-Fi uniquement";
"settings.sync.now" = "Synchroniser maintenant";
"settings.sync.lastsynced" = "Dernière synchronisation";
"settings.about" = "À propos";
"settings.about.version" = "Version";
"settings.about.docs" = "Documentation BookStack";
"settings.about.issue" = "Signaler un problème";
"settings.about.credit" = "BookStack est un logiciel open source créé par Dan Brown.";
"settings.language" = "Langue";
"settings.language.header" = "Langue";
// MARK: - Offline
"offline.banner" = "Vous êtes hors ligne — affichage du contenu mis en cache";
// MARK: - Appearance
"settings.appearance" = "Apparence";
"settings.appearance.theme" = "Thème";
"settings.appearance.theme.system" = "Système";
"settings.appearance.theme.light" = "Clair";
"settings.appearance.theme.dark" = "Sombre";
"settings.appearance.accent" = "Couleur d'accentuation";
// MARK: - Reader Settings
"settings.reader" = "Lecteur";
"settings.reader.showcomments" = "Afficher les commentaires";
// MARK: - Data
"settings.data" = "Données";
"settings.data.clearcache" = "Vider le cache";
"settings.data.clearcache.footer" = "Efface les réponses serveur mises en cache. Utile si les titres ou le contenu semblent obsolètes après une mise à jour.";
"settings.data.clearcache.done" = "Cache vidé";
// MARK: - Logging
"settings.log" = "Journalisation";
"settings.log.enabled" = "Activer la journalisation";
"settings.log.share" = "Partager le journal";
"settings.log.clear" = "Effacer le journal";
"settings.log.viewer.title" = "Journal de l'app";
"settings.log.entries" = "%d entrées";
// MARK: - Tags
"editor.tags.title" = "Étiquettes";
"editor.tags.add" = "Ajouter une étiquette";
"editor.tags.create" = "Créer une nouvelle étiquette";
"editor.tags.name" = "Nom de l'étiquette";
"editor.tags.value" = "Valeur (facultatif)";
"editor.tags.current" = "Étiquettes assignées";
"editor.tags.available" = "Étiquettes disponibles";
"editor.tags.loading" = "Chargement des étiquettes\u{2026}";
"editor.tags.new" = "Créer une étiquette";
"editor.tags.search" = "Rechercher des étiquettes\u{2026}";
"editor.tags.suggestions" = "Suggestions";
"search.filter.type" = "Type de contenu";
"search.filter.tag" = "Étiquette";
"search.filter.tag.clear" = "Effacer le filtre d'étiquette";
// MARK: - Servers
"settings.servers" = "Serveurs";
"settings.servers.add" = "Ajouter un serveur\u{2026}";
"settings.servers.active" = "Actif";
"settings.servers.switch.title" = "Changer de serveur";
"settings.servers.switch.message" = "Passer à \"%@\" ? L'application va se recharger.";
"settings.servers.switch.confirm" = "Changer";
"settings.servers.delete.title" = "Supprimer le serveur";
"settings.servers.delete.message" = "Supprimer \"%@\" ? Son contenu mis en cache sera effacé. Cette action est irréversible.";
"settings.servers.delete.confirm" = "Supprimer";
"settings.servers.delete.active.title" = "Supprimer le serveur actif ?";
"settings.servers.delete.active.message" = "\"%@\" est votre serveur actuel. Le supprimer effacera tout le contenu mis en cache et vous déconnectera de ce serveur.";
"settings.servers.edit" = "Modifier";
"settings.servers.edit.title" = "Modifier le serveur";
"settings.servers.edit.changecreds" = "Mettre à jour le jeton d'API";
"settings.servers.edit.changecreds.footer" = "Activez cette option pour remplacer l'ID et le secret du jeton enregistrés pour ce serveur.";
"onboarding.server.name.label" = "Nom du serveur";
"onboarding.server.name.placeholder" = "ex. Wiki professionnel";
// MARK: - Donations
"settings.donate" = "Soutenir BookStax";
"settings.donate.page" = "Page";
"settings.donate.book" = "Livre";
"settings.donate.encyclopedia" = "Encyclopédie";
"settings.donate.footer" = "Vous appréciez BookStax ? Votre soutien contribue à maintenir l'app gratuite et en développement actif. Merci !";
"settings.donate.loading" = "Chargement\u{2026}";
"settings.donate.error" = "Impossible de charger les options de don.";
"settings.donate.empty" = "Aucune option de don disponible.";
"settings.donate.donated.on" = "Don effectué le %@";
"settings.donate.pending" = "En attente de confirmation\u{2026}";
// MARK: - Support Nudge
"nudge.title" = "Contribuez à faire vivre BookStax";
"nudge.subtitle" = "BookStax est un projet passionnel — gratuit, ouvert et sans publicité. Votre soutien aide à le maintenir moderne et en développement actif.";
"nudge.dismiss" = "Peut-être plus tard";
// MARK: - Supporter Badge
"supporter.badge.title" = "Supporter BookStax";
"supporter.badge.subtitle" = "Merci de soutenir le développement !";
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Annuler";
"common.retry" = "Réessayer";
"common.error" = "Erreur inconnue";
"common.done" = "Terminé";
+129
View File
@@ -0,0 +1,129 @@
import Testing
@testable import bookstax
@Suite("BookStackError errorDescription")
struct BookStackErrorTests {
@Test("invalidURL has non-nil description mentioning https")
func invalidURL() {
let desc = BookStackError.invalidURL.errorDescription
#expect(desc != nil)
#expect(desc!.contains("https"))
}
@Test("notAuthenticated has non-nil description")
func notAuthenticated() {
let desc = BookStackError.notAuthenticated.errorDescription
#expect(desc != nil)
#expect(!desc!.isEmpty)
}
@Test("unauthorized mentions token")
func unauthorized() {
let desc = BookStackError.unauthorized.errorDescription
#expect(desc != nil)
#expect(desc!.lowercased().contains("token"))
}
@Test("forbidden mentions 403 or permission")
func forbidden() {
let desc = BookStackError.forbidden.errorDescription
#expect(desc != nil)
let lower = desc!.lowercased()
#expect(lower.contains("403") || lower.contains("permission") || lower.contains("access"))
}
@Test("notFound includes resource name in description")
func notFound() {
let desc = BookStackError.notFound(resource: "MyPage").errorDescription
#expect(desc != nil)
#expect(desc!.contains("MyPage"))
}
@Test("httpError with message returns that message")
func httpErrorWithMessage() {
let desc = BookStackError.httpError(statusCode: 500, message: "Internal Server Error").errorDescription
#expect(desc == "Internal Server Error")
}
@Test("httpError without message includes status code")
func httpErrorWithoutMessage() {
let desc = BookStackError.httpError(statusCode: 503, message: nil).errorDescription
#expect(desc != nil)
#expect(desc!.contains("503"))
}
@Test("decodingError includes detail string")
func decodingError() {
let desc = BookStackError.decodingError("keyNotFound").errorDescription
#expect(desc != nil)
#expect(desc!.contains("keyNotFound"))
}
@Test("networkUnavailable mentions cache or offline")
func networkUnavailable() {
let desc = BookStackError.networkUnavailable.errorDescription
#expect(desc != nil)
let lower = desc!.lowercased()
#expect(lower.contains("cache") || lower.contains("offline") || lower.contains("internet"))
}
@Test("keychainError includes numeric status code")
func keychainError() {
let desc = BookStackError.keychainError(-25300).errorDescription
#expect(desc != nil)
#expect(desc!.contains("-25300"))
}
@Test("sslError mentions SSL or TLS")
func sslError() {
let desc = BookStackError.sslError.errorDescription
#expect(desc != nil)
let upper = desc!.uppercased()
#expect(upper.contains("SSL") || upper.contains("TLS"))
}
@Test("timeout mentions timed out or server")
func timeout() {
let desc = BookStackError.timeout.errorDescription
#expect(desc != nil)
#expect(!desc!.isEmpty)
}
@Test("notReachable includes the host name")
func notReachable() {
let desc = BookStackError.notReachable(host: "wiki.company.com").errorDescription
#expect(desc != nil)
#expect(desc!.contains("wiki.company.com"))
}
@Test("notBookStack includes the host name")
func notBookStack() {
let desc = BookStackError.notBookStack(host: "example.com").errorDescription
#expect(desc != nil)
#expect(desc!.contains("example.com"))
}
@Test("unknown returns the provided message verbatim")
func unknown() {
let msg = "Something went very wrong"
let desc = BookStackError.unknown(msg).errorDescription
#expect(desc == msg)
}
@Test("All cases produce non-nil, non-empty descriptions")
func allCasesHaveDescriptions() {
let errors: [BookStackError] = [
.invalidURL, .notAuthenticated, .unauthorized, .forbidden,
.notFound(resource: "X"), .httpError(statusCode: 400, message: nil),
.decodingError("err"), .networkUnavailable, .keychainError(0),
.sslError, .timeout, .notReachable(host: "host"),
.notBookStack(host: "host"), .unknown("oops")
]
for error in errors {
let desc = error.errorDescription
#expect(desc != nil, "nil description for \(error)")
#expect(!desc!.isEmpty, "empty description for \(error)")
}
}
}
+168
View File
@@ -0,0 +1,168 @@
import Testing
import SwiftUI
@testable import bookstax
// MARK: - Color Hex Parsing
@Suite("Color Hex Parsing")
struct ColorHexParsingTests {
// MARK: Valid 6-digit hex
@Test("6-digit hex without # prefix parses successfully")
func sixDigitNoPound() {
#expect(Color(hex: "FF0000") != nil)
}
@Test("6-digit hex with # prefix parses successfully")
func sixDigitWithPound() {
#expect(Color(hex: "#FF0000") != nil)
}
@Test("Black hex #000000 parses successfully")
func blackHex() {
#expect(Color(hex: "#000000") != nil)
}
@Test("White hex #FFFFFF parses successfully")
func whiteHex() {
#expect(Color(hex: "#FFFFFF") != nil)
}
@Test("Lowercase hex parses successfully")
func lowercaseHex() {
#expect(Color(hex: "#ff6600") != nil)
}
// MARK: Valid 3-digit hex
@Test("3-digit hex #FFF is valid shorthand")
func threeDigitFFF() {
#expect(Color(hex: "#FFF") != nil)
}
@Test("3-digit hex #000 is valid shorthand")
func threeDigitZero() {
#expect(Color(hex: "#000") != nil)
}
@Test("3-digit hex #F60 is expanded to #FF6600")
func threeDigitExpansion() {
let threeDigit = Color(hex: "#F60")
let sixDigit = Color(hex: "#FF6600")
// Both should parse successfully
#expect(threeDigit != nil)
#expect(sixDigit != nil)
}
// MARK: Invalid inputs
@Test("5-digit hex returns nil")
func fiveDigitInvalid() {
#expect(Color(hex: "#FFFFF") == nil)
}
@Test("7-digit hex returns nil")
func sevenDigitInvalid() {
#expect(Color(hex: "#FFFFFFF") == nil)
}
@Test("Empty string returns nil")
func emptyStringInvalid() {
#expect(Color(hex: "") == nil)
}
@Test("Non-hex characters return nil")
func nonHexCharacters() {
#expect(Color(hex: "#GGGGGG") == nil)
}
@Test("Just # returns nil")
func onlyHash() {
#expect(Color(hex: "#") == nil)
}
}
// MARK: - Color Hex Round-trip
@Suite("Color Hex Round-trip")
struct ColorHexRoundTripTests {
@Test("Red #FF0000 round-trips correctly")
func redRoundTrip() {
let color = Color(hex: "#FF0000")!
let hex = color.toHexString()
#expect(hex == "#FF0000")
}
@Test("Green #00FF00 round-trips correctly")
func greenRoundTrip() {
let color = Color(hex: "#00FF00")!
let hex = color.toHexString()
#expect(hex == "#00FF00")
}
@Test("Blue #0000FF round-trips correctly")
func blueRoundTrip() {
let color = Color(hex: "#0000FF")!
let hex = color.toHexString()
#expect(hex == "#0000FF")
}
@Test("Custom color #3A7B55 round-trips correctly")
func customColorRoundTrip() {
let color = Color(hex: "#3A7B55")!
let hex = color.toHexString()
#expect(hex == "#3A7B55")
}
}
// MARK: - AccentTheme
@Suite("AccentTheme Properties")
struct AccentThemeTests {
@Test("All cases are represented in CaseIterable")
func allCasesPresent() {
#expect(AccentTheme.allCases.count == 8)
}
@Test("id equals rawValue")
func idEqualsRawValue() {
for theme in AccentTheme.allCases {
#expect(theme.id == theme.rawValue)
}
}
@Test("Every theme has a non-empty displayName")
func displayNamesNonEmpty() {
for theme in AccentTheme.allCases {
#expect(!theme.displayName.isEmpty)
}
}
@Test("accentColor equals shelfColor")
func accentColorEqualsShelfColor() {
for theme in AccentTheme.allCases {
#expect(theme.accentColor == theme.shelfColor)
}
}
@Test("Ocean theme has expected displayName")
func oceanDisplayName() {
#expect(AccentTheme.ocean.displayName == "Ocean")
}
@Test("Graphite theme has expected displayName")
func graphiteDisplayName() {
#expect(AccentTheme.graphite.displayName == "Graphite")
}
@Test("All themes can be init'd from rawValue")
func initFromRawValue() {
for theme in AccentTheme.allCases {
let reinit = AccentTheme(rawValue: theme.rawValue)
#expect(reinit == theme)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - TagDTO
@Suite("TagDTO")
struct TagDTOTests {
@Test("id is composed of name and value")
func idFormat() {
let tag = TagDTO(name: "status", value: "published", order: 0)
#expect(tag.id == "status:published")
}
@Test("id with empty value still includes colon separator")
func idEmptyValue() {
let tag = TagDTO(name: "featured", value: "", order: 0)
#expect(tag.id == "featured:")
}
@Test("Two tags with same name/value have equal ids")
func duplicateIds() {
let t1 = TagDTO(name: "env", value: "prod", order: 0)
let t2 = TagDTO(name: "env", value: "prod", order: 1)
#expect(t1.id == t2.id)
}
@Test("Tags decode from JSON correctly")
func decodeFromJSON() throws {
let json = """
{"name":"topic","value":"swift","order":3}
""".data(using: .utf8)!
let tag = try JSONDecoder().decode(TagDTO.self, from: json)
#expect(tag.name == "topic")
#expect(tag.value == "swift")
#expect(tag.order == 3)
}
}
// MARK: - PageDTO
private let iso8601Formatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private func pageJSON(
slug: String? = nil,
priority: Int? = nil,
draft: Bool? = nil,
tags: String? = nil,
markdown: String? = nil
) -> Data {
var fields: [String] = [
#""id":1,"book_id":10,"name":"Test Page""#,
#""created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z""#
]
if let s = slug { fields.append("\"slug\":\"\(s)\"") }
if let p = priority { fields.append("\"priority\":\(p)") }
if let d = draft { fields.append("\"draft\":\(d)") }
if let t = tags { fields.append("\"tags\":[\(t)]") }
if let m = markdown { fields.append("\"markdown\":\"\(m)\"") }
return ("{\(fields.joined(separator: ","))}").data(using: .utf8)!
}
private func makeDecoder() -> JSONDecoder {
// Mirrors the decoder used in BookStackAPI
let decoder = JSONDecoder()
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
if let date = formatter.date(from: str) { return date }
// Fallback without fractional seconds
let fallback = ISO8601DateFormatter()
if let date = fallback.date(from: str) { return date }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(str)")
}
return decoder
}
@Suite("PageDTO JSON Decoding")
struct PageDTOTests {
@Test("Minimal JSON (no optional fields) decodes with defaults")
func minimalDecoding() throws {
let data = pageJSON()
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.id == 1)
#expect(page.bookId == 10)
#expect(page.name == "Test Page")
#expect(page.slug == "") // defaults to ""
#expect(page.priority == 0) // defaults to 0
#expect(page.draftStatus == false) // defaults to false
#expect(page.tags.isEmpty) // defaults to []
#expect(page.markdown == nil)
#expect(page.html == nil)
#expect(page.chapterId == nil)
}
@Test("slug is decoded when present")
func slugDecoded() throws {
let data = pageJSON(slug: "my-page")
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.slug == "my-page")
}
@Test("priority is decoded when present")
func priorityDecoded() throws {
let data = pageJSON(priority: 5)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.priority == 5)
}
@Test("draft true decodes correctly")
func draftDecoded() throws {
let data = pageJSON(draft: true)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.draftStatus == true)
}
@Test("tags array is decoded when present")
func tagsDecoded() throws {
let tagJSON = #"{"name":"lang","value":"swift","order":0}"#
let data = pageJSON(tags: tagJSON)
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.tags.count == 1)
#expect(page.tags.first?.name == "lang")
}
@Test("markdown is decoded when present")
func markdownDecoded() throws {
let data = pageJSON(markdown: "# Hello")
let page = try makeDecoder().decode(PageDTO.self, from: data)
#expect(page.markdown == "# Hello")
}
}
// MARK: - SearchResultDTO
@Suite("SearchResultDTO JSON Decoding")
struct SearchResultDTOTests {
private func resultJSON(withTags: Bool = false) -> Data {
let tags = withTags ? #","tags":[{"name":"topic","value":"swift","order":0}]"# : ""
return """
{"id":7,"name":"Swift Basics","slug":"swift-basics","type":"page",
"url":"https://example.com/books/1/page/7","preview":"An intro to Swift"\(tags)}
""".data(using: .utf8)!
}
@Test("Result with no tags field defaults to empty array")
func defaultsEmptyTags() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON())
#expect(result.tags.isEmpty)
}
@Test("Result with tags decodes them correctly")
func decodesTagsWhenPresent() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON(withTags: true))
#expect(result.tags.count == 1)
}
@Test("All fields decode correctly")
func allFieldsDecode() throws {
let result = try JSONDecoder().decode(SearchResultDTO.self, from: resultJSON())
#expect(result.id == 7)
#expect(result.name == "Swift Basics")
#expect(result.slug == "swift-basics")
#expect(result.type == .page)
#expect(result.preview == "An intro to Swift")
}
}
// MARK: - SearchResultDTO.ContentType
@Suite("SearchResultDTO.ContentType")
struct ContentTypeTests {
@Test("All content types have non-empty systemImage")
func systemImagesNonEmpty() {
for type_ in SearchResultDTO.ContentType.allCases {
#expect(!type_.systemImage.isEmpty)
}
}
@Test("Page system image is doc.text")
func pageSystemImage() {
#expect(SearchResultDTO.ContentType.page.systemImage == "doc.text")
}
@Test("Book system image is book.closed")
func bookSystemImage() {
#expect(SearchResultDTO.ContentType.book.systemImage == "book.closed")
}
@Test("Chapter system image is list.bullet.rectangle")
func chapterSystemImage() {
#expect(SearchResultDTO.ContentType.chapter.systemImage == "list.bullet.rectangle")
}
@Test("Shelf system image is books.vertical")
func shelfSystemImage() {
#expect(SearchResultDTO.ContentType.shelf.systemImage == "books.vertical")
}
@Test("ContentType decodes from raw string value")
func rawValueDecoding() throws {
let data = #""page""#.data(using: .utf8)!
let type_ = try JSONDecoder().decode(SearchResultDTO.ContentType.self, from: data)
#expect(type_ == .page)
}
}
// MARK: - PaginatedResponse
@Suite("PaginatedResponse Decoding")
struct PaginatedResponseTests {
@Test("Paginated shelf response decodes total and data")
func paginatedDecoding() throws {
let json = """
{"data":[{"id":1,"name":"My Shelf","slug":"my-shelf","description":"",
"created_at":"2024-01-01T00:00:00.000Z","updated_at":"2024-01-01T00:00:00.000Z"}],
"total":1}
""".data(using: .utf8)!
let decoded = try makeDecoder().decode(PaginatedResponse<ShelfDTO>.self, from: json)
#expect(decoded.total == 1)
#expect(decoded.data.count == 1)
#expect(decoded.data.first?.name == "My Shelf")
}
}
+151
View File
@@ -0,0 +1,151 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - DonationPurchaseState Enum
@Suite("DonationPurchaseState Computed Properties")
struct DonationPurchaseStateTests {
@Test("activePurchasingID returns id when purchasing")
func activePurchasingID() {
let state = DonationPurchaseState.purchasing(productID: "donatebook")
#expect(state.activePurchasingID == "donatebook")
}
@Test("activePurchasingID returns nil for non-purchasing states")
@available(iOS 18.0, *)
func activePurchasingIDNil() {
#expect(DonationPurchaseState.idle.activePurchasingID == nil)
#expect(DonationPurchaseState.thankYou(productID: "x").activePurchasingID == nil)
#expect(DonationPurchaseState.pending(productID: "x").activePurchasingID == nil)
#expect(DonationPurchaseState.failed(productID: "x", message: "err").activePurchasingID == nil)
}
@Test("thankYouID returns id when in thankYou state")
func thankYouID() {
let state = DonationPurchaseState.thankYou(productID: "doneatepage")
#expect(state.thankYouID == "doneatepage")
}
@Test("thankYouID returns nil for other states")
func thankYouIDNil() {
#expect(DonationPurchaseState.idle.thankYouID == nil)
#expect(DonationPurchaseState.purchasing(productID: "x").thankYouID == nil)
}
@Test("pendingID returns id when in pending state")
func pendingID() {
let state = DonationPurchaseState.pending(productID: "donateencyclopaedia")
#expect(state.pendingID == "donateencyclopaedia")
}
@Test("pendingID returns nil for other states")
func pendingIDNil() {
#expect(DonationPurchaseState.idle.pendingID == nil)
#expect(DonationPurchaseState.thankYou(productID: "x").pendingID == nil)
}
@Test("errorMessage returns message when failed for matching id")
func errorMessageMatch() {
let state = DonationPurchaseState.failed(productID: "donatebook", message: "Payment declined")
#expect(state.errorMessage(for: "donatebook") == "Payment declined")
}
@Test("errorMessage returns nil for wrong product id")
func errorMessageNoMatch() {
let state = DonationPurchaseState.failed(productID: "donatebook", message: "error")
#expect(state.errorMessage(for: "doneatepage") == nil)
}
@Test("errorMessage returns nil for non-failed states")
func errorMessageNotFailed() {
#expect(DonationPurchaseState.idle.errorMessage(for: "x") == nil)
#expect(DonationPurchaseState.purchasing(productID: "x").errorMessage(for: "x") == nil)
}
@Test("isIdle returns true only for .idle")
func isIdleCheck() {
#expect(DonationPurchaseState.idle.isIdle == true)
#expect(DonationPurchaseState.purchasing(productID: "x").isIdle == false)
#expect(DonationPurchaseState.thankYou(productID: "x").isIdle == false)
#expect(DonationPurchaseState.failed(productID: "x", message: "e").isIdle == false)
}
@Test("Equatable: same purchasing states are equal")
func equatablePurchasing() {
#expect(DonationPurchaseState.purchasing(productID: "a") == DonationPurchaseState.purchasing(productID: "a"))
#expect(DonationPurchaseState.purchasing(productID: "a") != DonationPurchaseState.purchasing(productID: "b"))
}
@Test("Equatable: idle equals idle")
func equatableIdle() {
#expect(DonationPurchaseState.idle == DonationPurchaseState.idle)
#expect(DonationPurchaseState.idle != DonationPurchaseState.purchasing(productID: "x"))
}
}
// MARK: - shouldShowNudge Timing
@Suite("DonationService shouldShowNudge", .serialized)
@MainActor
struct DonationServiceNudgeTests {
private let installKey = "bookstax.installDate"
private let nudgeKey = "bookstax.lastNudgeDate"
private let historyKey = "bookstax.donationHistory"
init() {
// Clean UserDefaults before each test so DonationService reads fresh state
UserDefaults.standard.removeObject(forKey: installKey)
UserDefaults.standard.removeObject(forKey: nudgeKey)
UserDefaults.standard.removeObject(forKey: historyKey)
}
@Test("Within 3-day grace period: nudge should not show")
func gracePeriodPreventsNudge() {
let recentInstall = Date().addingTimeInterval(-(2 * 24 * 3600)) // 2 days ago
UserDefaults.standard.set(recentInstall, forKey: installKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("After 3-day grace period: nudge should show")
func afterGracePeriodNudgeShows() {
let oldInstall = Date().addingTimeInterval(-(4 * 24 * 3600)) // 4 days ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
#expect(DonationService.shared.shouldShowNudge == true)
}
@Test("No install date recorded: nudge should not show (safe fallback)")
func noInstallDateFallback() {
// No install date stored graceful fallback
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("Nudge recently dismissed: should not show again")
func recentNudgeHidden() {
let oldInstall = Date().addingTimeInterval(-(90 * 24 * 3600))
let recentNudge = Date().addingTimeInterval(-(10 * 24 * 3600)) // dismissed 10 days ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(recentNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
@Test("Nudge dismissed ~6 months ago: should show again")
func sixMonthsLaterShowAgain() {
let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600))
let oldNudge = Date().addingTimeInterval(-(183 * 24 * 3600)) // ~6 months ago
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(oldNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == true)
}
@Test("Nudge not yet 6 months: should not show")
func beforeSixMonthsHidden() {
let oldInstall = Date().addingTimeInterval(-(200 * 24 * 3600))
let recentNudge = Date().addingTimeInterval(-(100 * 24 * 3600)) // only 100 days
UserDefaults.standard.set(oldInstall, forKey: installKey)
UserDefaults.standard.set(recentNudge, forKey: nudgeKey)
#expect(DonationService.shared.shouldShowNudge == false)
}
}
@@ -0,0 +1,226 @@
import Testing
@testable import bookstax
// MARK: - URL Validation
@Suite("OnboardingViewModel URL Validation")
struct OnboardingViewModelURLTests {
// MARK: Empty input
@Test("Empty URL sets error and returns false")
func emptyURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.validateServerURL() == false)
#expect(vm.serverURLError != nil)
}
@Test("Whitespace-only URL sets error and returns false")
func whitespaceURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = " "
#expect(vm.validateServerURL() == false)
#expect(vm.serverURLError != nil)
}
// MARK: Auto-prefix https://
@Test("URL without scheme gets https:// prepended")
func autoprefixHTTPS() {
let vm = OnboardingViewModel()
vm.serverURLInput = "wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput.hasPrefix("https://"))
}
@Test("URL already starting with https:// is left unchanged")
func httpsAlreadyPresent() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput == "https://wiki.example.com")
}
@Test("URL starting with http:// is left as-is (not double-prefixed)")
func httpNotDoublePrefixed() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://wiki.example.com"
let result = vm.validateServerURL()
#expect(result == true)
#expect(vm.serverURLInput == "http://wiki.example.com")
}
// MARK: Trailing slash removal
@Test("Trailing slash is stripped from URL")
func trailingSlash() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com/"
_ = vm.validateServerURL()
#expect(vm.serverURLInput == "https://wiki.example.com")
}
@Test("Multiple trailing slashes only last slash stripped then accepted")
func multipleSlashesInPath() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.example.com/path/"
_ = vm.validateServerURL()
#expect(vm.serverURLInput == "https://wiki.example.com/path")
}
// MARK: Successful validation
@Test("Valid URL clears any previous error")
func validURLClearsError() {
let vm = OnboardingViewModel()
vm.serverURLError = "Previous error"
vm.serverURLInput = "https://books.mycompany.com"
#expect(vm.validateServerURL() == true)
#expect(vm.serverURLError == nil)
}
}
// MARK: - isHTTP Flag
@Suite("OnboardingViewModel isHTTP")
struct OnboardingViewModelHTTPTests {
@Test("http:// URL sets isHTTP = true")
func httpURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://wiki.local"
#expect(vm.isHTTP == true)
}
@Test("https:// URL sets isHTTP = false")
func httpsURL() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.local"
#expect(vm.isHTTP == false)
}
@Test("Empty URL is not HTTP")
func emptyNotHTTP() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.isHTTP == false)
}
}
// MARK: - isRemoteServer Detection
@Suite("OnboardingViewModel isRemoteServer")
struct OnboardingViewModelRemoteTests {
// MARK: Local / private addresses
@Test("localhost is not remote")
func localhost() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://localhost"
#expect(vm.isRemoteServer == false)
}
@Test("127.0.0.1 is not remote")
func loopback() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://127.0.0.1"
#expect(vm.isRemoteServer == false)
}
@Test("IPv6 loopback ::1 is not remote")
func ipv6Loopback() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://[::1]"
#expect(vm.isRemoteServer == false)
}
@Test(".local mDNS host is not remote")
func mdnsLocal() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://bookstack.local"
#expect(vm.isRemoteServer == false)
}
@Test("Plain hostname without dots is not remote")
func plainHostname() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://mywiki"
#expect(vm.isRemoteServer == false)
}
@Test("10.x.x.x private range is not remote")
func privateClass_A() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://10.0.1.50"
#expect(vm.isRemoteServer == false)
}
@Test("192.168.x.x private range is not remote")
func privateClass_C() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://192.168.1.100"
#expect(vm.isRemoteServer == false)
}
@Test("172.16.x.x private range is not remote")
func privateClass_B_low() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.16.0.1"
#expect(vm.isRemoteServer == false)
}
@Test("172.31.x.x private range is not remote")
func privateClass_B_high() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.31.255.255"
#expect(vm.isRemoteServer == false)
}
@Test("172.15.x.x is outside private range and is remote")
func justBelowPrivateClass_B() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.15.0.1"
#expect(vm.isRemoteServer == true)
}
@Test("172.32.x.x is outside private range and is remote")
func justAbovePrivateClass_B() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://172.32.0.1"
#expect(vm.isRemoteServer == true)
}
// MARK: Remote addresses
@Test("Public IP 8.8.8.8 is remote")
func publicIP() {
let vm = OnboardingViewModel()
vm.serverURLInput = "http://8.8.8.8"
#expect(vm.isRemoteServer == true)
}
@Test("Public domain with subdomain is remote")
func publicDomain() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://wiki.mycompany.com"
#expect(vm.isRemoteServer == true)
}
@Test("Top-level domain name is remote")
func topLevelDomain() {
let vm = OnboardingViewModel()
vm.serverURLInput = "https://bookstack.io"
#expect(vm.isRemoteServer == true)
}
@Test("Empty URL is not remote")
func emptyIsNotRemote() {
let vm = OnboardingViewModel()
vm.serverURLInput = ""
#expect(vm.isRemoteServer == false)
}
}
@@ -0,0 +1,244 @@
import Testing
@testable import bookstax
import Foundation
// MARK: - Helpers
private func makePageDTO(markdown: String? = "# Hello", tags: [TagDTO] = []) -> PageDTO {
PageDTO(
id: 42,
bookId: 1,
chapterId: nil,
name: "Test Page",
slug: "test-page",
html: nil,
markdown: markdown,
priority: 0,
draftStatus: false,
tags: tags,
createdAt: Date(),
updatedAt: Date()
)
}
// MARK: - Initialisation
@Suite("PageEditorViewModel Initialisation")
struct PageEditorViewModelInitTests {
@Test("Create mode starts with empty title and content")
func createModeDefaults() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
#expect(vm.title.isEmpty)
#expect(vm.markdownContent.isEmpty)
#expect(vm.tags.isEmpty)
#expect(vm.isHtmlOnlyPage == false)
}
@Test("Edit mode populates title and markdown from page")
func editModePopulates() {
let page = makePageDTO(markdown: "## Content")
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.title == "Test Page")
#expect(vm.markdownContent == "## Content")
#expect(vm.isHtmlOnlyPage == false)
}
@Test("Edit mode with nil markdown sets isHtmlOnlyPage")
func htmlOnlyPage() {
let page = makePageDTO(markdown: nil)
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.isHtmlOnlyPage == true)
#expect(vm.markdownContent.isEmpty)
}
@Test("Edit mode with existing tags loads them")
func editModeLoadsTags() {
let tags = [TagDTO(name: "topic", value: "swift", order: 0)]
let page = makePageDTO(tags: tags)
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "topic")
}
}
// MARK: - hasUnsavedChanges
@Suite("PageEditorViewModel hasUnsavedChanges")
struct PageEditorViewModelUnsavedTests {
@Test("No changes after init → hasUnsavedChanges is false")
func noChangesAfterInit() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
#expect(vm.hasUnsavedChanges == false)
}
@Test("Changing title → hasUnsavedChanges is true")
func titleChange() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "New Title"
#expect(vm.hasUnsavedChanges == true)
}
@Test("Changing markdownContent → hasUnsavedChanges is true")
func contentChange() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.markdownContent = "Some text"
#expect(vm.hasUnsavedChanges == true)
}
@Test("Adding a tag → hasUnsavedChanges is true")
func tagAddition() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.addTag(name: "new-tag")
#expect(vm.hasUnsavedChanges == true)
}
@Test("Restoring original values → hasUnsavedChanges is false again")
func revertChanges() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "Changed"
vm.title = ""
#expect(vm.hasUnsavedChanges == false)
}
}
// MARK: - isSaveDisabled
@Suite("PageEditorViewModel isSaveDisabled")
struct PageEditorViewModelSaveDisabledTests {
@Test("Empty title disables save in create mode")
func emptyTitleCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.markdownContent = "Some content"
#expect(vm.isSaveDisabled == true)
}
@Test("Empty content disables save in create mode even if title is set")
func emptyContentCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = ""
#expect(vm.isSaveDisabled == true)
}
@Test("Whitespace-only content disables save in create mode")
func whitespaceContentCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = " \n "
#expect(vm.isSaveDisabled == true)
}
@Test("Title and content both set enables save in create mode")
func validCreate() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.title = "My Page"
vm.markdownContent = "Hello world"
#expect(vm.isSaveDisabled == false)
}
@Test("Edit mode only requires title empty content is allowed")
func editOnlyNeedsTitle() {
let page = makePageDTO(markdown: nil)
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.title = "Existing Page"
vm.markdownContent = ""
#expect(vm.isSaveDisabled == false)
}
@Test("Empty title disables save in edit mode")
func emptyTitleEdit() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
vm.title = ""
#expect(vm.isSaveDisabled == true)
}
}
// MARK: - Tag Management
@Suite("PageEditorViewModel Tags")
struct PageEditorViewModelTagTests {
@Test("addTag appends a new tag")
func addTag() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "status", value: "draft")
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "status")
#expect(vm.tags.first?.value == "draft")
}
@Test("addTag trims whitespace from name and value")
func addTagTrims() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: " topic ", value: " swift ")
#expect(vm.tags.first?.name == "topic")
#expect(vm.tags.first?.value == "swift")
}
@Test("addTag with empty name after trimming is ignored")
func addTagEmptyNameIgnored() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: " ")
#expect(vm.tags.isEmpty)
}
@Test("addTag prevents duplicate (same name + value) entries")
func addTagNoDuplicates() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "lang", value: "swift")
vm.addTag(name: "lang", value: "swift")
#expect(vm.tags.count == 1)
}
@Test("addTag allows same name with different value")
func addTagSameNameDifferentValue() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "env", value: "dev")
vm.addTag(name: "env", value: "prod")
#expect(vm.tags.count == 2)
}
@Test("removeTag removes the matching tag by id")
func removeTag() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "remove-me", value: "yes")
let tag = vm.tags[0]
vm.removeTag(tag)
#expect(vm.tags.isEmpty)
}
@Test("removeTag does not remove non-matching tags")
func removeTagKeepsOthers() {
let vm = PageEditorViewModel(mode: .create(bookId: 1))
vm.addTag(name: "keep", value: "")
vm.addTag(name: "remove", value: "")
let toRemove = vm.tags.first { $0.name == "remove" }!
vm.removeTag(toRemove)
#expect(vm.tags.count == 1)
#expect(vm.tags.first?.name == "keep")
}
}
// MARK: - uploadTargetPageId
@Suite("PageEditorViewModel uploadTargetPageId")
struct PageEditorViewModelUploadIDTests {
@Test("Create mode returns 0 as upload target")
func createModeUploadTarget() {
let vm = PageEditorViewModel(mode: .create(bookId: 5))
#expect(vm.uploadTargetPageId == 0)
}
@Test("Edit mode returns the existing page id")
func editModeUploadTarget() {
let page = makePageDTO()
let vm = PageEditorViewModel(mode: .edit(page: page))
#expect(vm.uploadTargetPageId == 42)
}
}
+109
View File
@@ -0,0 +1,109 @@
import Foundation
import Testing
@testable import bookstax
// MARK: - Recent Searches
@Suite("SearchViewModel Recent Searches", .serialized)
struct SearchViewModelRecentTests {
private let recentKey = "recentSearches"
init() {
// Start each test with a clean slate
UserDefaults.standard.removeObject(forKey: recentKey)
}
// MARK: addToRecent
@Test("Adding a query inserts it at position 0")
func addInsertsAtFront() {
let vm = SearchViewModel()
vm.addToRecent("swift")
vm.addToRecent("swiftui")
let recent = vm.recentSearches
#expect(recent.first == "swiftui")
#expect(recent[1] == "swift")
}
@Test("Adding duplicate moves it to front without creating duplicates")
func addDeduplicates() {
let vm = SearchViewModel()
vm.addToRecent("bookstack")
vm.addToRecent("wiki")
vm.addToRecent("bookstack") // duplicate
let recent = vm.recentSearches
#expect(recent.first == "bookstack")
#expect(recent.count == 2)
#expect(!recent.dropFirst().contains("bookstack"))
}
@Test("List is capped at 10 entries")
func cappedAtTen() {
let vm = SearchViewModel()
for i in 1...12 {
vm.addToRecent("query\(i)")
}
#expect(vm.recentSearches.count == 10)
}
@Test("Oldest entries are dropped when cap is exceeded")
func oldestDropped() {
let vm = SearchViewModel()
for i in 1...11 {
vm.addToRecent("query\(i)")
}
let recent = vm.recentSearches
// query1 was added first, so it falls off after 11 adds
#expect(!recent.contains("query1"))
#expect(recent.contains("query11"))
}
// MARK: clearRecentSearches
@Test("clearRecentSearches empties the list")
func clearResetsToEmpty() {
let vm = SearchViewModel()
vm.addToRecent("something")
vm.clearRecentSearches()
#expect(vm.recentSearches.isEmpty)
}
// MARK: Persistence
@Test("Recent searches persist across ViewModel instances")
func persistsAcrossInstances() {
let vm1 = SearchViewModel()
vm1.addToRecent("persistent")
let vm2 = SearchViewModel()
#expect(vm2.recentSearches.contains("persistent"))
}
}
// MARK: - Query Minimum Length
@Suite("SearchViewModel Query Logic")
struct SearchViewModelQueryTests {
@Test("Short query clears results without triggering search")
func shortQueryClearsResults() {
let vm = SearchViewModel()
vm.results = [SearchResultDTO(id: 1, name: "dummy", slug: "dummy",
type: .page, url: "", preview: nil)]
vm.query = "x" // only 1 character
vm.onQueryChanged()
#expect(vm.results.isEmpty)
}
@Test("Query with 2+ chars does not clear results immediately")
func sufficientQueryKeepsResults() {
let vm = SearchViewModel()
vm.query = "sw" // 2 characters triggers debounce but does not clear
vm.onQueryChanged()
// Results not cleared by onQueryChanged when query is long enough
// (actual search would require API; here we just verify results aren't wiped)
// Results were empty to start with, so we just confirm no crash
#expect(vm.results.isEmpty) // no API call, so still empty
}
}
+175
View File
@@ -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)
}
}
+88
View File
@@ -0,0 +1,88 @@
import Testing
@testable import bookstax
@Suite("String strippingHTML")
struct StringHTMLTests {
// MARK: Basic tag removal
@Test("Simple tag is removed")
func simpleTag() {
#expect("<p>Hello</p>".strippingHTML == "Hello")
}
@Test("Bold tag is removed")
func boldTag() {
#expect("<b>Bold</b>".strippingHTML == "Bold")
}
@Test("Nested tags are fully stripped")
func nestedTags() {
#expect("<div><p><span>Deep</span></p></div>".strippingHTML == "Deep")
}
@Test("Self-closing tags are removed")
func selfClosingTag() {
let result = "Before<br/>After".strippingHTML
// NSAttributedString adds a newline for <br>, so just check both words are present
#expect(result.contains("Before"))
#expect(result.contains("After"))
}
// MARK: HTML entities
@Test("&amp; decodes to &")
func ampersandEntity() {
#expect("Cats &amp; Dogs".strippingHTML == "Cats & Dogs")
}
@Test("&lt; and &gt; decode to < and >")
func angleEntities() {
#expect("&lt;tag&gt;".strippingHTML == "<tag>")
}
@Test("&nbsp; is decoded (non-empty result)")
func nbspEntity() {
let result = "Hello&nbsp;World".strippingHTML
#expect(!result.isEmpty)
#expect(result.contains("Hello"))
#expect(result.contains("World"))
}
@Test("&quot; decodes to double quote")
func quotEntity() {
#expect("Say &quot;hi&quot;".strippingHTML == "Say \"hi\"")
}
// MARK: Edge cases
@Test("Empty string returns empty string")
func emptyString() {
#expect("".strippingHTML == "")
}
@Test("Plain text without HTML is returned unchanged")
func plainText() {
#expect("No tags here".strippingHTML == "No tags here")
}
@Test("Leading and trailing whitespace is trimmed")
func trimmingWhitespace() {
#expect("<p> hello </p>".strippingHTML == "hello")
}
@Test("HTML with attributes strips fully")
func tagsWithAttributes() {
let html = "<a href=\"https://example.com\" class=\"link\">Click here</a>"
#expect(html.strippingHTML == "Click here")
}
@Test("Complex real-world snippet is reduced to plain text")
func complexSnippet() {
let html = "<h1>Title</h1><p>First paragraph.</p><ul><li>Item 1</li></ul>"
let result = html.strippingHTML
#expect(result.contains("Title"))
#expect(result.contains("First paragraph"))
#expect(result.contains("Item 1"))
}
}