Compare commits

...

11 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
65 changed files with 6667 additions and 608 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; objectVersion = 77;
objects = { 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 */ /* 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
};
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ShareExtensionAPIService.swift,
ShareExtensionKeychainService.swift,
ShareViewModel.swift,
);
target = 261299D52F6C686D00EC1C97 /* bookstax */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
261299D82F6C686D00EC1C97 /* bookstax */ = { 261299D82F6C686D00EC1C97 /* bookstax */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = bookstax; path = bookstax;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */,
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */,
);
path = BookStaxShareExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
1A1B96526910505E82E2CFDB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
261299D32F6C686D00EC1C97 /* Frameworks */ = { 261299D32F6C686D00EC1C97 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -26,14 +122,34 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
26F69D842F964C1700A6C5E6 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
1BB5D3095A0460024F7BA321 /* iOS */ = {
isa = PBXGroup;
children = (
C0841C05048CA5AE635439A8 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
261299CD2F6C686D00EC1C97 = { 261299CD2F6C686D00EC1C97 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
26FD17062F8A95E1006E87F3 /* Tips.storekit */,
261299D82F6C686D00EC1C97 /* bookstax */, 261299D82F6C686D00EC1C97 /* bookstax */,
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
261299D72F6C686D00EC1C97 /* Products */, 261299D72F6C686D00EC1C97 /* Products */,
26FD17072F8A9643006E87F3 /* Donations.storekit */,
EB2578937899373803DA341A /* Frameworks */,
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -41,10 +157,36 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
261299D62F6C686D00EC1C97 /* bookstax.app */, 261299D62F6C686D00EC1C97 /* bookstax.app */,
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -55,20 +197,60 @@
261299D22F6C686D00EC1C97 /* Sources */, 261299D22F6C686D00EC1C97 /* Sources */,
261299D32F6C686D00EC1C97 /* Frameworks */, 261299D32F6C686D00EC1C97 /* Frameworks */,
261299D42F6C686D00EC1C97 /* Resources */, 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 = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
261299D82F6C686D00EC1C97 /* bookstax */, 26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
); );
name = bookstax; name = BookStaxShareExtension;
packageProductDependencies = ( packageProductDependencies = (
); );
productName = bookstax; productName = BookStaxShareExtension;
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */; productReference = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
@@ -77,12 +259,15 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630; LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2630; LastUpgradeCheck = 2630;
TargetAttributes = { TargetAttributes = {
261299D52F6C686D00EC1C97 = { 261299D52F6C686D00EC1C97 = {
CreatedOnToolsVersion = 26.3; CreatedOnToolsVersion = 26.3;
}; };
26F69D862F964C1700A6C5E6 = {
CreatedOnToolsVersion = 26.4.1;
};
}; };
}; };
buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */; buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */;
@@ -93,6 +278,7 @@
Base, Base,
de, de,
es, es,
fr,
); );
mainGroup = 261299CD2F6C686D00EC1C97; mainGroup = 261299CD2F6C686D00EC1C97;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
@@ -102,12 +288,29 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
261299D52F6C686D00EC1C97 /* bookstax */, 261299D52F6C686D00EC1C97 /* bookstax */,
AD8774751A52779622D7AED5 /* bookstaxTests */,
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
261299D42F6C686D00EC1C97 /* Resources */ = { 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; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -124,9 +327,63 @@
); );
runOnlyForDeploymentPostprocessing = 0; 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 */ /* 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 */ /* 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 */ = { 261299DF2F6C686E00EC1C97 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -253,20 +510,25 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T; DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -281,20 +543,25 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T; DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax; PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -304,6 +571,82 @@
}; };
name = Release; name = Release;
}; };
26F69D942F964C1700A6C5E6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
26F69D952F964C1700A6C5E6 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
C9DF29CF9FF31B97AC4E31E5 /* Debug */ = {
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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -325,6 +668,24 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
26F69D942F964C1700A6C5E6 /* Debug */,
26F69D952F964C1700A6C5E6 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1C68E5D77B468BD3A7F1C349 /* Release */,
C9DF29CF9FF31B97AC4E31E5 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 261299CE2F6C686D00EC1C97 /* Project object */; 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> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>BookStaxShareExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>bookstax.xcscheme_^#shared#^_</key> <key>bookstax.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>261299D52F6C686D00EC1C97</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict> </dict>
</plist> </plist>
+29
View File
@@ -73,6 +73,35 @@ enum AccentTheme: String, CaseIterable, Identifiable {
var accentColor: Color { shelfColor } 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 // MARK: - Environment Key
private struct AccentThemeKey: EnvironmentKey { private struct AccentThemeKey: EnvironmentKey {
+2 -2
View File
@@ -25,7 +25,7 @@ enum BookStackError: LocalizedError, Sendable {
case .unauthorized: case .unauthorized:
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack." return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
case .forbidden: 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): case .notFound(let resource):
return "\(resource) could not be found. It may have been deleted or moved." return "\(resource) could not be found. It may have been deleted or moved."
case .httpError(let code, let message): case .httpError(let code, let message):
@@ -37,7 +37,7 @@ enum BookStackError: LocalizedError, Sendable {
case .keychainError(let status): case .keychainError(let status):
return "Credential storage failed (code \(status))." return "Credential storage failed (code \(status))."
case .sslError: 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: case .timeout:
return "Request timed out. Make sure your device can reach the server." return "Request timed out. Make sure your device can reach the server."
case .notReachable(let host): case .notReachable(let host):
+3 -3
View File
@@ -130,11 +130,11 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
bookId = try c.decode(Int.self, forKey: .bookId) bookId = try c.decode(Int.self, forKey: .bookId)
chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId) chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId)
name = try c.decode(String.self, forKey: .name) 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) html = try c.decodeIfPresent(String.self, forKey: .html)
markdown = try c.decodeIfPresent(String.self, forKey: .markdown) markdown = try c.decodeIfPresent(String.self, forKey: .markdown)
priority = try c.decode(Int.self, forKey: .priority) priority = (try? c.decode(Int.self, forKey: .priority)) ?? 0
draftStatus = try c.decode(Bool.self, forKey: .draftStatus) draftStatus = (try? c.decodeIfPresent(Bool.self, forKey: .draftStatus)) ?? false
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? [] tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
createdAt = try c.decode(Date.self, forKey: .createdAt) createdAt = try c.decode(Date.self, forKey: .createdAt)
updatedAt = try c.decode(Date.self, forKey: .updatedAt) updatedAt = try c.decode(Date.self, forKey: .updatedAt)
+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 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
}
+139 -38
View File
@@ -1,35 +1,98 @@
import Foundation 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 { actor BookStackAPI {
static let shared = BookStackAPI() static let shared = BookStackAPI()
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "" // No actor-local credential state all reads go through CredentialStore.
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
private let decoder: JSONDecoder = { private let decoder: JSONDecoder = {
let d = JSONDecoder() let d = JSONDecoder()
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z" // BookStack returns ISO8601 with variable fractional seconds and timezone formats.
let formatter = DateFormatter() // Try formats in order: microseconds (6 digits), milliseconds (3 digits), no fractions.
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ" let formats = [
formatter.locale = Locale(identifier: "en_US_POSIX") "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", // 2024-01-15T10:30:00.000000Z
formatter.timeZone = TimeZone(abbreviation: "UTC") "yyyy-MM-dd'T'HH:mm:ss.SSSZ", // 2024-01-15T10:30:00.000Z
d.dateDecodingStrategy = .formatted(formatter) "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 return d
}() }()
// MARK: - Configuration // MARK: - Configuration
/// Kept for compatibility delegates to CredentialStore.
func configure(serverURL: String, tokenId: String, tokenSecret: String) { func configure(serverURL: String, tokenId: String, tokenSecret: String) {
var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) CredentialStore.shared.update(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret)
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")
} }
func getServerURL() -> String { serverURL } func getServerURL() -> String { CredentialStore.shared.snapshot().serverURL }
// MARK: - Core Request (no body) // MARK: - Core Request (no body)
@@ -58,11 +121,12 @@ actor BookStackAPI {
method: String, method: String,
bodyData: Data? bodyData: Data?
) async throws -> T { ) 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") AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
throw BookStackError.notAuthenticated 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") AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
throw BookStackError.invalidURL throw BookStackError.invalidURL
} }
@@ -71,7 +135,7 @@ actor BookStackAPI {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = method 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.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30 req.timeoutInterval = 30
@@ -91,14 +155,26 @@ actor BookStackAPI {
case .notConnectedToInternet, .networkConnectionLost: case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable mapped = .networkUnavailable
case .cannotFindHost, .dnsLookupFailed: case .cannotFindHost, .dnsLookupFailed:
mapped = .notReachable(host: serverURL) mapped = .notReachable(host: creds.serverURL)
case .serverCertificateUntrusted, .serverCertificateHasBadDate, case .secureConnectionFailed,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot: .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
mapped = .sslError 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: default:
AppLog(.warning, "\(method) /api/\(endpoint) — unhandled URLError \(urlError.code.rawValue): \(urlError.localizedDescription)", category: "API")
mapped = .unknown(urlError.localizedDescription) 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 throw mapped
} }
@@ -114,7 +190,7 @@ actor BookStackAPI {
let mapped: BookStackError let mapped: BookStackError
switch http.statusCode { switch http.statusCode {
case 401: mapped = .unauthorized 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") case 404: mapped = .notFound(resource: "Resource")
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage) default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
} }
@@ -140,11 +216,13 @@ actor BookStackAPI {
} }
private func parseErrorMessage(from data: Data) -> String? { private func parseErrorMessage(from data: Data) -> String? {
struct APIErrorEnvelope: Codable { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
struct Inner: Codable { let message: String? } // Shape 1: {"error": {"message": "..."}} (older BookStack)
let error: Inner? if let errorObj = json["error"] as? [String: Any],
} let msg = errorObj["message"] as? String { return msg }
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message // Shape 2: {"message": "...", "errors": {...}} (validation / newer BookStack)
if let msg = json["message"] as? String { return msg }
return nil
} }
// MARK: - Shelves // MARK: - Shelves
@@ -385,16 +463,29 @@ actor BookStackAPI {
do { do {
(data, response) = try await URLSession.shared.data(for: req) (data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError { } 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 { switch urlError.code {
case .timedOut: case .timedOut:
throw BookStackError.timeout throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: case .notConnectedToInternet, .networkConnectionLost:
throw BookStackError.networkUnavailable throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate, case .secureConnectionFailed,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot: .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError 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: default:
AppLog(.warning, "Unhandled URLError \(urlError.code.rawValue) for \(url)", category: "Auth")
throw BookStackError.notReachable(host: url) throw BookStackError.notReachable(host: url)
} }
} }
@@ -422,7 +513,7 @@ actor BookStackAPI {
case 403: case 403:
let msg = parseErrorMessage(from: data) let msg = parseErrorMessage(from: data)
AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth") 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: case 404:
// Old BookStack version without /api/system fall back to /api/books probe // Old BookStack version without /api/system fall back to /api/books probe
@@ -455,9 +546,18 @@ actor BookStackAPI {
switch urlError.code { switch urlError.code {
case .timedOut: throw BookStackError.timeout case .timedOut: throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate, case .secureConnectionFailed,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot: .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError 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) default: throw BookStackError.notReachable(host: url)
} }
} }
@@ -502,8 +602,9 @@ actor BookStackAPI {
/// - mimeType: e.g. "image/jpeg" or "image/png" /// - mimeType: e.g. "image/jpeg" or "image/png"
/// - pageId: The page this image belongs to. Use 0 for new pages not yet saved. /// - 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 { func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated } let creds = CredentialStore.shared.snapshot()
guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL } 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)" let boundary = "Boundary-\(UUID().uuidString)"
var body = Data() var body = Data()
@@ -528,7 +629,7 @@ actor BookStackAPI {
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = "POST" 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("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body 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) 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? { static func loadSync(key: String) -> String? {
let service = "com.bookstax.credentials" let service = "com.bookstax.credentials"
+2 -55
View File
@@ -1,59 +1,6 @@
import Foundation import Foundation
import Observation
/// Manages in-app language selection independently of the system locale. /// Returns the localized string for the given key using the device system language.
@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
func L(_ key: String) -> String { func L(_ key: String) -> String {
LanguageManager.shared.string(key) NSLocalizedString(key, comment: "")
} }
-79
View File
@@ -1,80 +1 @@
import Foundation 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)
}
}
+53 -19
View File
@@ -5,17 +5,17 @@ import Observation
@Observable @Observable
final class OnboardingViewModel { final class OnboardingViewModel {
enum Step: Int, CaseIterable, Hashable { enum Step: Hashable {
case language = 0 case welcome
case welcome = 1 case connect
case connect = 2 case ready
case ready = 3
} }
// Navigation NavigationStack path (language is the root, not in the path) // Navigation NavigationStack path (language is the root, not in the path)
var navPath: NavigationPath = NavigationPath() var navPath: NavigationPath = NavigationPath()
// Input // Input
var serverNameInput: String = ""
var serverURLInput: String = "" var serverURLInput: String = ""
var tokenIdInput: String = "" var tokenIdInput: String = ""
var tokenSecretInput: String = "" var tokenSecretInput: String = ""
@@ -36,6 +36,10 @@ final class OnboardingViewModel {
// Completion // Completion
var isComplete: Bool = false 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 // MARK: - Navigation
@@ -77,6 +81,32 @@ final class OnboardingViewModel {
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://") 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 // MARK: - Verification
func verifyAndSave() async { func verifyAndSave() async {
@@ -117,6 +147,11 @@ final class OnboardingViewModel {
let appName = info.appName ?? "BookStack" let appName = info.appName ?? "BookStack"
verifyPhase = .serverOK(appName: appName) 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 // Configure the shared API client with validated credentials
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret) await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
verifyPhase = .checkingToken verifyPhase = .checkingToken
@@ -124,26 +159,25 @@ final class OnboardingViewModel {
// Attempt to fetch user info (non-fatal some installs restrict /api/users) // Attempt to fetch user info (non-fatal some installs restrict /api/users)
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
// Persist server URL and credentials // Create and persist a ServerProfile via the shared store
UserDefaults.standard.set(url, forKey: "serverURL") let profile = ServerProfile(
do { id: UUID(),
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret) name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
} catch let error as BookStackError { serverURL: url
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding") )
verifyPhase = .failed(phase: "keychain", error: error) ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
return
} catch {
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
return
}
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding") AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
verifyPhase = .done(appName: appName, userName: userName) verifyPhase = .done(appName: appName, userName: userName)
// Navigate to the ready step 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) navPath.append(Step.ready)
} }
}
// MARK: - Complete // MARK: - Complete
+23 -2
View File
@@ -23,6 +23,9 @@ final class PageEditorViewModel {
var title: String = "" var title: String = ""
var markdownContent: String = "" var markdownContent: String = ""
var activeTab: EditorTab = .write 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 isSaving: Bool = false
var saveError: BookStackError? = nil var saveError: BookStackError? = nil
@@ -48,11 +51,26 @@ final class PageEditorViewModel {
|| tags != lastSavedTags || 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) { init(mode: Mode) {
self.mode = mode self.mode = mode
if case .edit(let page) = mode { if case .edit(let page) = mode {
title = page.name 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 tags = page.tags
} }
// Snapshot the initial state so "no changes yet" returns false // Snapshot the initial state so "no changes yet" returns false
@@ -88,7 +106,10 @@ final class PageEditorViewModel {
// MARK: - Save // MARK: - Save
func save() async { 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 isSaving = true
saveError = nil saveError = nil
+51 -10
View File
@@ -101,9 +101,17 @@ struct PageEditorView: View {
@State private var textView: UITextView? = nil @State private var textView: UITextView? = nil
@State private var imagePickerItem: PhotosPickerItem? = nil @State private var imagePickerItem: PhotosPickerItem? = nil
@State private var showTagEditor = false @State private var showTagEditor = false
/// False while the UITextView is doing its initial layout for an existing page.
@State private var isEditorReady: Bool
init(mode: PageEditorViewModel.Mode) { init(mode: PageEditorViewModel.Mode) {
_viewModel = State(initialValue: PageEditorViewModel(mode: mode)) _viewModel = State(initialValue: PageEditorViewModel(mode: mode))
// Show a loading overlay only for edit mode new pages start empty so layout is instant.
if case .edit = mode {
_isEditorReady = State(initialValue: false)
} else {
_isEditorReady = State(initialValue: true)
}
} }
var body: some View { var body: some View {
@@ -195,13 +203,45 @@ struct PageEditorView: View {
} }
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.overlay {
if !isEditorReady {
ZStack {
Color(.systemBackground).ignoresSafeArea()
ProgressView()
.controlSize(.large)
}
.transition(.opacity)
}
}
.animation(.easeOut(duration: 0.2), value: isEditorReady)
} }
@ViewBuilder @ViewBuilder
private var writeArea: some View { private var writeArea: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
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, MarkdownTextEditor(text: $viewModel.markdownContent,
onTextViewReady: { tv in textView = tv }, onTextViewReady: { tv in
textView = tv
// One run-loop pass lets UITextView finish its initial layout
// before we hide the loading overlay.
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(16))
isEditorReady = true
}
},
onImagePaste: { image in onImagePaste: { image in
Task { Task {
let data = image.jpegData(compressionQuality: 0.85) ?? Data() let data = image.jpegData(compressionQuality: 0.85) ?? Data()
@@ -275,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) } } .overlay { if viewModel.isSaving { ProgressView().scaleEffect(0.7) } }
.transition(.opacity) .transition(.opacity)
} }
@@ -358,7 +398,7 @@ struct PageEditorView: View {
private func replace(in tv: UITextView, range: NSRange, with string: String, private func replace(in tv: UITextView, range: NSRange, with string: String,
cursorOffset: Int? = nil, selectRange: NSRange? = nil) { cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
guard let swiftRange = Range(range, in: tv.text) else { return } guard let swiftRange = Range(range, in: tv.text) else { return }
var newText = tv.text! var newText = tv.text ?? ""
newText.replaceSubrange(swiftRange, with: string) newText.replaceSubrange(swiftRange, with: string)
tv.text = newText tv.text = newText
viewModel.markdownContent = newText viewModel.markdownContent = newText
@@ -598,11 +638,16 @@ struct FormatButton: View {
struct MarkdownPreviewView: View { struct MarkdownPreviewView: View {
let markdown: String let markdown: String
@State private var webPage = WebPage() @State private var htmlContent: String = ""
@Environment(\.colorScheme) private var colorScheme @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 { var body: some View {
WebView(webPage) HTMLWebView(html: htmlContent, baseURL: serverBaseURL, openLinksExternally: false)
.onAppear { loadPreview() } .onAppear { loadPreview() }
.onChange(of: markdown) { loadPreview() } .onChange(of: markdown) { loadPreview() }
.onChange(of: colorScheme) { loadPreview() } .onChange(of: colorScheme) { loadPreview() }
@@ -615,7 +660,7 @@ struct MarkdownPreviewView: View {
let fg = isDark ? "#f2f2f7" : "#000000" let fg = isDark ? "#f2f2f7" : "#000000"
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7" let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
let fullHTML = """ htmlContent = """
<!DOCTYPE html><html> <!DOCTYPE html><html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -631,10 +676,6 @@ struct MarkdownPreviewView: View {
</head> </head>
<body>\(html)</body></html> <body>\(html)</body></html>
""" """
// Use the real server URL as base so WKWebView permits loading images from it.
let serverBase = UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) }
?? URL(string: "about:blank")!
webPage.load(html: fullHTML, baseURL: serverBase)
} }
/// Minimal Markdown HTML converter for preview purposes. /// Minimal Markdown HTML converter for preview purposes.
+1 -9
View File
@@ -172,15 +172,7 @@ struct BookDetailView: View {
NavigationLink(value: page) { NavigationLink(value: page) {
Label(L("book.open"), systemImage: "arrow.up.right.square") Label(L("book.open"), systemImage: "arrow.up.right.square")
} }
Button { ShareLink(item: "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)") {
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: {
Label(L("book.sharelink"), systemImage: "square.and.arrow.up") Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
} }
Divider() Divider()
+9 -8
View File
@@ -3,11 +3,12 @@ import SwiftUI
struct LibraryView: View { struct LibraryView: View {
@State private var viewModel = LibraryViewModel() @State private var viewModel = LibraryViewModel()
@State private var showNewShelf = false @State private var showNewShelf = false
@Environment(ConnectivityMonitor.self) private var connectivity @State private var navPath = NavigationPath()
@Environment(\.accentTheme) private var theme @Environment(\.accentTheme) private var theme
private let navState = AppNavigationState.shared
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $navPath) {
Group { Group {
if viewModel.isLoadingShelves && viewModel.shelves.isEmpty { if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
LoadingView(message: L("library.loading")) LoadingView(message: L("library.loading"))
@@ -78,13 +79,14 @@ struct LibraryView: View {
.navigationDestination(for: PageDTO.self) { page in .navigationDestination(for: PageDTO.self) { page in
PageReaderView(page: page) PageReaderView(page: page)
} }
.safeAreaInset(edge: .top) {
if !connectivity.isConnected {
OfflineBanner()
}
}
} }
.task { await viewModel.loadShelves() } .task { await viewModel.loadShelves() }
.onChange(of: navState.pendingBookNavigation) { _, book in
guard let book else { return }
navPath.append(book)
navState.pendingBookNavigation = nil
}
} }
} }
@@ -212,5 +214,4 @@ struct ContentRowView: View {
#Preview("Library") { #Preview("Library") {
LibraryView() LibraryView()
.environment(ConnectivityMonitor.shared)
} }
+30 -8
View File
@@ -2,20 +2,42 @@ import SwiftUI
struct MainTabView: View { struct MainTabView: View {
@Environment(ConnectivityMonitor.self) private var connectivity @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 { var body: some View {
TabView { TabView(selection: $selectedTab) {
Tab(L("tab.library"), systemImage: "books.vertical") {
LibraryView() LibraryView()
} .tabItem { Label(L("tab.library"), systemImage: "books.vertical") }
.tag(0)
Tab(L("tab.search"), systemImage: "magnifyingglass") { QuickNoteView()
.tabItem { Label(L("tab.quicknote"), systemImage: "square.and.pencil") }
.tag(1)
SearchView() SearchView()
} .tabItem { Label(L("tab.search"), systemImage: "magnifyingglass") }
.tag(2)
Tab(L("tab.settings"), systemImage: "gear") {
SettingsView() 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
} }
} }
} }
+39 -103
View File
@@ -4,7 +4,6 @@ import SwiftUI
struct OnboardingView: View { struct OnboardingView: View {
@State private var viewModel = OnboardingViewModel() @State private var viewModel = OnboardingViewModel()
@State private var langManager = LanguageManager.shared
var body: some View { var body: some View {
Group { Group {
@@ -12,7 +11,7 @@ struct OnboardingView: View {
Color.clear Color.clear
} else { } else {
NavigationStack(path: $viewModel.navPath) { NavigationStack(path: $viewModel.navPath) {
LanguageStepView(viewModel: viewModel) WelcomeStepView(viewModel: viewModel)
.navigationDestination(for: OnboardingViewModel.Step.self) { step in .navigationDestination(for: OnboardingViewModel.Step.self) { step in
switch step { switch step {
case .welcome: case .welcome:
@@ -21,102 +20,12 @@ struct OnboardingView: View {
ConnectStepView(viewModel: viewModel) ConnectStepView(viewModel: viewModel)
case .ready: case .ready:
ReadyStepView(onComplete: viewModel.completeOnboarding) 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 { struct ConnectStepView: View {
@Bindable var viewModel: OnboardingViewModel @Bindable var viewModel: OnboardingViewModel
@State private var showTokenId = false @State private var showTokenId = true
@State private var showTokenSecret = false @State private var showTokenSecret = true
@State private var showHelp = false @State private var showHelp = false
@State private var verifyTask: Task<Void, Never>? = nil @State private var verifyTask: Task<Void, Never>? = nil
@@ -195,6 +104,18 @@ struct ConnectStepView: View {
.foregroundStyle(.secondary) .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 // Server URL field
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
@@ -204,11 +125,19 @@ struct ConnectStepView: View {
.keyboardType(.URL) .keyboardType(.URL)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.textContentType(.URL)
.onChange(of: viewModel.serverURLInput) { .onChange(of: viewModel.serverURLInput) {
if case .idle = viewModel.verifyPhase { } else { if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification() viewModel.resetVerification()
} }
} }
Button {
viewModel.serverURLInput = UIPasteboard.general.string ?? viewModel.serverURLInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
} }
.padding() .padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
@@ -224,6 +153,12 @@ struct ConnectStepView: View {
.font(.footnote) .font(.footnote)
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
if viewModel.isRemoteServer {
Label(L("onboarding.server.warning.remote"), systemImage: "globe.badge.chevron.backward")
.font(.footnote)
.foregroundStyle(.orange)
}
} }
// Help accordion // Help accordion
@@ -253,21 +188,20 @@ struct ConnectStepView: View {
} }
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.textContentType(.none)
.onChange(of: viewModel.tokenIdInput) { .onChange(of: viewModel.tokenIdInput) {
if case .idle = viewModel.verifyPhase { } else { if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification() viewModel.resetVerification()
} }
} }
if UIPasteboard.general.hasStrings {
Button { Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? "" viewModel.tokenIdInput = UIPasteboard.general.string ?? viewModel.tokenIdInput
} label: { } label: {
Image(systemName: "clipboard") Image(systemName: "clipboard")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityLabel(L("onboarding.token.paste")) .buttonStyle(.plain)
}
Button { showTokenId.toggle() } label: { Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye") Image(systemName: showTokenId ? "eye.slash" : "eye")
@@ -293,21 +227,20 @@ struct ConnectStepView: View {
} }
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.textContentType(.none)
.onChange(of: viewModel.tokenSecretInput) { .onChange(of: viewModel.tokenSecretInput) {
if case .idle = viewModel.verifyPhase { } else { if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification() viewModel.resetVerification()
} }
} }
if UIPasteboard.general.hasStrings {
Button { Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? "" viewModel.tokenSecretInput = UIPasteboard.general.string ?? viewModel.tokenSecretInput
} label: { } label: {
Image(systemName: "clipboard") Image(systemName: "clipboard")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityLabel(L("onboarding.token.paste")) .buttonStyle(.plain)
}
Button { showTokenSecret.toggle() } label: { Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye") Image(systemName: showTokenSecret ? "eye.slash" : "eye")
@@ -350,6 +283,9 @@ struct ConnectStepView: View {
.onDisappear { .onDisappear {
verifyTask?.cancel() 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 { 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() }
}
}
}
}
}
+29 -30
View File
@@ -1,14 +1,13 @@
import SwiftUI import SwiftUI
import WebKit
struct PageReaderView: View { struct PageReaderView: View {
let page: PageDTO let page: PageDTO
@State private var webPage = WebPage() @State private var htmlContent: String = ""
@State private var fullPage: PageDTO? = nil @State private var fullPage: PageDTO? = nil
@State private var isLoadingPage = false @State private var isLoadingPage = false
@State private var comments: [CommentDTO] = [] @State private var comments: [CommentDTO] = []
@State private var isLoadingComments = false @State private var isLoadingComments = false
@State private var showEditor = false @State private var pageForEditing: PageDTO? = nil
@State private var isFetchingForEdit = false @State private var isFetchingForEdit = false
@State private var newComment = "" @State private var newComment = ""
@State private var isPostingComment = false @State private var isPostingComment = false
@@ -43,7 +42,7 @@ struct PageReaderView: View {
} }
// Web content fills all space not taken by the comments inset // Web content fills all space not taken by the comments inset
WebView(webPage) HTMLWebView(html: htmlContent, baseURL: URL(string: serverURL))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -96,34 +95,24 @@ struct PageReaderView: View {
.accessibilityLabel(L("reader.edit")) .accessibilityLabel(L("reader.edit"))
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") {
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: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
.accessibilityLabel(L("reader.share")) .accessibilityLabel(L("reader.share"))
} }
} }
.fullScreenCover(isPresented: $showEditor) { .fullScreenCover(item: $pageForEditing) { pageToEdit in
NavigationStack { NavigationStack {
if let fullPage { PageEditorView(mode: .edit(page: pageToEdit))
PageEditorView(mode: .edit(page: fullPage))
}
} }
} }
.task(id: page.id) { .task(id: page.id) {
await loadFullPage() await loadFullPage()
await loadComments() await loadComments()
} }
.onChange(of: showEditor) { _, isShowing in .onChange(of: pageForEditing) { _, newValue in
// Reload page content after editor is dismissed // Reload page content after editor is dismissed
if !isShowing { Task { await loadFullPage() } } if newValue == nil { Task { await loadFullPage() } }
} }
.onChange(of: colorScheme) { .onChange(of: colorScheme) {
loadContent() loadContent()
@@ -214,34 +203,44 @@ struct PageReaderView: View {
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id) fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader") AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
} catch { } catch {
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader") // Leave fullPage = nil so the editor will re-fetch on demand rather than
fullPage = page // receiving the list summary (which has no markdown content).
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription)", category: "Reader")
} }
isLoadingPage = false isLoadingPage = false
loadContent() loadContent()
} }
private func openEditor() async { private func openEditor() async {
// Full page is already fetched by loadFullPage; if still loading, wait briefly // Always fetch the full page before opening the editor to guarantee we have markdown content.
if fullPage == nil { // Clear pageForEditing at the start to ensure clean state.
pageForEditing = nil
isFetchingForEdit = true isFetchingForEdit = true
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
isFetchingForEdit = false 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() { private func loadContent() {
let html = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>") htmlContent = 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")!)
} }
private func loadComments() async { private func loadComments() async {
isLoadingComments = true isLoadingComments = true
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? [] comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
isLoadingComments = false isLoadingComments = false
// Auto-expand if there are comments
if !comments.isEmpty { commentsExpanded = true }
} }
private func postComment() async { private func postComment() async {
@@ -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()
}
}
+120 -94
View File
@@ -3,23 +3,25 @@ import SafariServices
struct SettingsView: View { struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false @AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
@AppStorage("showComments") private var showComments = true @AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system" @AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue @AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
@AppStorage("loggingEnabled") private var loggingEnabled = false @AppStorage("loggingEnabled") private var loggingEnabled = false
@Environment(ServerProfileStore.self) private var profileStore
@State private var donationService = DonationService.shared
private var selectedTheme: AccentTheme { private var selectedTheme: AccentTheme {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean AccentTheme(rawValue: accentThemeRaw) ?? .ocean
} }
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
@State private var showSignOutAlert = false @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 showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
@State private var showLogViewer = false @State private var showLogViewer = false
@State private var shareItems: [Any]? = nil @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 appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
@@ -27,29 +29,6 @@ struct SettingsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 // Appearance section
Section(L("settings.appearance")) { Section(L("settings.appearance")) {
Picker(L("settings.appearance.theme"), selection: $appTheme) { Picker(L("settings.appearance.theme"), selection: $appTheme) {
@@ -59,7 +38,6 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
// Accent colour swatches
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(L("settings.appearance.accent")) Text(L("settings.appearance.accent"))
.font(.subheadline) .font(.subheadline)
@@ -93,33 +71,56 @@ struct SettingsView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
// Account section // Servers section
Section(L("settings.account")) { Section(L("settings.servers")) {
HStack { ForEach(profileStore.profiles) { profile in
Image(systemName: "person.circle.fill") Button {
.font(.title) if profile.id != profileStore.activeProfileId {
.foregroundStyle(.blue) profileToSwitch = profile
VStack(alignment: .leading) { }
Text(L("settings.account.connected")) } label: {
.font(.headline) HStack(spacing: 12) {
Text(serverURL) 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) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .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 { Button {
UIPasteboard.general.string = serverURL showAddServer = true
} label: { } label: {
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc") Label(L("settings.servers.add"), systemImage: "plus.circle")
}
Button(role: .destructive) {
showSignOutAlert = true
} label: {
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
} }
} }
@@ -157,31 +158,30 @@ struct SettingsView: View {
} }
} }
// Sync section // Data section
Section(L("settings.sync")) { Section {
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly) Button(role: .destructive) {
URLCache.shared.removeAllCachedResponses()
Button { showCacheClearedAlert = true
Task { await syncNow() }
} label: { } label: {
HStack { Label(L("settings.data.clearcache"), systemImage: "trash")
Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
if isSyncing {
Spacer()
ProgressView()
} }
} header: {
Text(L("settings.data"))
} footer: {
Text(L("settings.data.clearcache.footer"))
} }
}
.disabled(isSyncing)
if let lastSynced { // Supporter badge only visible after a donation
LabeledContent(L("settings.sync.lastsynced")) { if donationService.hasEverDonated {
Text(lastSynced.bookStackFormattedWithTime) Section {
.foregroundStyle(.secondary) SupporterBadgeRow()
}
} }
} }
// Donate section
DonationSectionView()
// About section // About section
Section(L("settings.about")) { Section(L("settings.about")) {
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))") LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
@@ -207,51 +207,77 @@ struct SettingsView: View {
.onAppear { .onAppear {
loggingEnabled = LogManager.shared.isEnabled loggingEnabled = LogManager.shared.isEnabled
} }
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) { // Switch server confirmation
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() } .alert(L("settings.servers.switch.title"), isPresented: Binding(
Button(L("settings.signout.alert.cancel"), role: .cancel) {} 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: { } 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 .sheet(item: $showSafari) { url in
SafariView(url: url) SafariView(url: url).ignoresSafeArea()
.ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) {
LogViewerView()
} }
.sheet(isPresented: $showLogViewer) { LogViewerView() }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { shareItems != nil }, get: { shareItems != nil },
set: { if !$0 { shareItems = nil } } set: { if !$0 { shareItems = nil } }
)) { )) {
if let items = shareItems { if let items = shareItems { ShareSheet(items: items) }
ShareSheet(items: items)
}
} }
} }
} }
// MARK: - Actions // MARK: - Actions
private func signOut() { private func removeProfile(_ profile: ServerProfile) {
Task { profileStore.remove(profile)
try? await KeychainService.shared.deleteCredentials() if profileStore.profiles.isEmpty {
UserDefaults.standard.removeObject(forKey: "serverURL")
UserDefaults.standard.removeObject(forKey: "lastSynced")
onboardingComplete = false 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 // 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)
}
}
+10 -4
View File
@@ -3,7 +3,11 @@ import SwiftUI
struct ErrorBanner: View { struct ErrorBanner: View {
let error: BookStackError let error: BookStackError
var onRetry: (() -> Void)? = nil var onRetry: (() -> Void)? = nil
var onSettings: (() -> Void)? = nil
private var isUnauthorized: Bool {
if case .unauthorized = error { return true }
return false
}
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -18,12 +22,14 @@ struct ErrorBanner: View {
Spacer() Spacer()
if case .unauthorized = error, let onSettings { if isUnauthorized {
Button("Settings", action: onSettings) Button(L("settings.title")) {
AppNavigationState.shared.navigateToSettings = true
}
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
} else if let onRetry { } else if let onRetry {
Button("Retry", action: onRetry) Button(L("common.retry"), action: onRetry)
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .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 SwiftUI
import SwiftData
@main @main
struct bookstaxApp: App { struct bookstaxApp: App {
@@ -14,6 +6,9 @@ struct bookstaxApp: App {
@AppStorage("appTheme") private var appTheme = "system" @AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue @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? { private var preferredColorScheme: ColorScheme? {
switch appTheme { switch appTheme {
case "light": return .light case "light": return .light
@@ -26,16 +21,6 @@ struct bookstaxApp: App {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean 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() { init() {
AppLog(.info, "BookStax launched", category: "App") AppLog(.info, "BookStax launched", category: "App")
} }
@@ -43,18 +28,19 @@ struct bookstaxApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
if onboardingComplete { if onboardingComplete && profileStore.activeProfile != nil {
MainTabView() MainTabView()
.environment(ConnectivityMonitor.shared) .environment(ConnectivityMonitor.shared)
// Re-creates the entire tab hierarchy when the active server changes
.id(profileStore.activeProfileId)
} else { } else {
OnboardingView() OnboardingView()
} }
} }
.environment(profileStore)
.environment(\.accentTheme, accentTheme) .environment(\.accentTheme, accentTheme)
.tint(accentTheme.accentColor) .tint(accentTheme.accentColor)
.preferredColorScheme(preferredColorScheme) .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.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.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.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.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.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?"; "onboarding.token.help" = "Wie bekomme ich einen Token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben"; "onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
// MARK: - Tabs // MARK: - Tabs
"tab.quicknote" = "Notiz";
"tab.library" = "Bibliothek"; "tab.library" = "Bibliothek";
"tab.search" = "Suche"; "tab.search" = "Suche";
"tab.create" = "Erstellen"; "tab.create" = "Erstellen";
"tab.settings" = "Einstellungen"; "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 // MARK: - Library
"library.title" = "Bibliothek"; "library.title" = "Bibliothek";
"library.loading" = "Bibliothek wird geladen…"; "library.loading" = "Bibliothek wird geladen…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Schließen ohne zu speichern?"; "editor.close.unsaved.title" = "Schließen ohne zu speichern?";
"editor.close.unsaved.confirm" = "Schließen"; "editor.close.unsaved.confirm" = "Schließen";
"editor.image.uploading" = "Bild wird hochgeladen…"; "editor.image.uploading" = "Bild wird hochgeladen…";
"editor.html.notice" = "Diese Seite verwendet HTML-Formatierung. Beim Bearbeiten wird sie in Markdown umgewandelt.";
// MARK: - Search // MARK: - Search
"search.title" = "Suche"; "search.title" = "Suche";
@@ -190,6 +219,12 @@
"settings.reader" = "Leser"; "settings.reader" = "Leser";
"settings.reader.showcomments" = "Kommentare anzeigen"; "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 // MARK: - Logging
"settings.log" = "Protokoll"; "settings.log" = "Protokoll";
"settings.log.enabled" = "Protokollierung aktivieren"; "settings.log.enabled" = "Protokollierung aktivieren";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Tag"; "search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Tag-Filter entfernen"; "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 // MARK: - Common
"common.ok" = "OK"; "common.ok" = "OK";
"common.cancel" = "Abbrechen";
"common.retry" = "Wiederholen";
"common.error" = "Unbekannter Fehler"; "common.error" = "Unbekannter Fehler";
"common.done" = "Fertig"; "common.done" = "Fertig";
+77
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Please enter your BookStack server address."; "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.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.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.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.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?"; "onboarding.token.help" = "How do I get a token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Write new pages in Markdown"; "onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
// MARK: - Tabs // MARK: - Tabs
"tab.quicknote" = "Quick Note";
"tab.library" = "Library"; "tab.library" = "Library";
"tab.search" = "Search"; "tab.search" = "Search";
"tab.create" = "Create"; "tab.create" = "Create";
"tab.settings" = "Settings"; "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 // MARK: - Library
"library.title" = "Library"; "library.title" = "Library";
"library.loading" = "Loading library…"; "library.loading" = "Loading library…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Close without saving?"; "editor.close.unsaved.title" = "Close without saving?";
"editor.close.unsaved.confirm" = "Close"; "editor.close.unsaved.confirm" = "Close";
"editor.image.uploading" = "Uploading image…"; "editor.image.uploading" = "Uploading image…";
"editor.html.notice" = "This page uses HTML formatting. Editing here will convert it to Markdown.";
// MARK: - Search // MARK: - Search
"search.title" = "Search"; "search.title" = "Search";
@@ -190,6 +219,12 @@
"settings.reader" = "Reader"; "settings.reader" = "Reader";
"settings.reader.showcomments" = "Show Comments"; "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 // MARK: - Logging
"settings.log" = "Logging"; "settings.log" = "Logging";
"settings.log.enabled" = "Enable Logging"; "settings.log.enabled" = "Enable Logging";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Tag"; "search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Clear Tag Filter"; "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 // MARK: - Common
"common.ok" = "OK"; "common.ok" = "OK";
"common.cancel" = "Cancel";
"common.retry" = "Retry";
"common.error" = "Unknown error"; "common.error" = "Unknown error";
"common.done" = "Done"; "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.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.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.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.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.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?"; "onboarding.token.help" = "¿Cómo obtengo un token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown"; "onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
// MARK: - Tabs // MARK: - Tabs
"tab.quicknote" = "Nota rápida";
"tab.library" = "Biblioteca"; "tab.library" = "Biblioteca";
"tab.search" = "Búsqueda"; "tab.search" = "Búsqueda";
"tab.create" = "Crear"; "tab.create" = "Crear";
"tab.settings" = "Ajustes"; "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 // MARK: - Library
"library.title" = "Biblioteca"; "library.title" = "Biblioteca";
"library.loading" = "Cargando biblioteca…"; "library.loading" = "Cargando biblioteca…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "¿Cerrar sin guardar?"; "editor.close.unsaved.title" = "¿Cerrar sin guardar?";
"editor.close.unsaved.confirm" = "Cerrar"; "editor.close.unsaved.confirm" = "Cerrar";
"editor.image.uploading" = "Subiendo imagen…"; "editor.image.uploading" = "Subiendo imagen…";
"editor.html.notice" = "Esta página usa formato HTML. Editarla aquí la convertirá a Markdown.";
// MARK: - Search // MARK: - Search
"search.title" = "Búsqueda"; "search.title" = "Búsqueda";
@@ -190,6 +219,12 @@
"settings.reader" = "Lector"; "settings.reader" = "Lector";
"settings.reader.showcomments" = "Mostrar comentarios"; "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 // MARK: - Logging
"settings.log" = "Registro"; "settings.log" = "Registro";
"settings.log.enabled" = "Activar registro"; "settings.log.enabled" = "Activar registro";
@@ -214,7 +249,49 @@
"search.filter.tag" = "Etiqueta"; "search.filter.tag" = "Etiqueta";
"search.filter.tag.clear" = "Eliminar filtro de 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 // MARK: - Common
"common.ok" = "Aceptar"; "common.ok" = "Aceptar";
"common.cancel" = "Cancelar";
"common.retry" = "Reintentar";
"common.error" = "Error desconocido"; "common.error" = "Error desconocido";
"common.done" = "Listo"; "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"))
}
}