Compare commits
13 Commits
8b57d8ff61
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bea01caaf | |||
| 187c3e4fc6 | |||
| a48e857ada | |||
| fb33681f0f | |||
| 7f312ece18 | |||
| f5ea1ee23e | |||
| 67de78837f | |||
| 5590100990 | |||
| 0d8a998ddf | |||
| bcb6a93dd5 | |||
| c4a4833bec | |||
| 6b3b2db013 | |||
| da22b50ae4 |
+139
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,180 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Data Models
|
||||
|
||||
struct ShelfSummary: Identifiable, Decodable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let slug: String
|
||||
}
|
||||
|
||||
struct BookSummary: Identifiable, Decodable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let slug: String
|
||||
}
|
||||
|
||||
struct ChapterSummary: Identifiable, Decodable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct PageResult: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
enum ShareAPIError: LocalizedError {
|
||||
case notConfigured
|
||||
case networkError(Error)
|
||||
case httpError(Int)
|
||||
case decodingError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConfigured:
|
||||
return NSLocalizedString("error.notConfigured", bundle: .main, comment: "")
|
||||
case .networkError(let err):
|
||||
return String(format: NSLocalizedString("error.network.format", bundle: .main, comment: ""),
|
||||
err.localizedDescription)
|
||||
case .httpError(let code):
|
||||
return String(format: NSLocalizedString("error.http.format", bundle: .main, comment: ""),
|
||||
code)
|
||||
case .decodingError:
|
||||
return NSLocalizedString("error.decoding", bundle: .main, comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol (for testability)
|
||||
|
||||
protocol ShareAPIServiceProtocol: Sendable {
|
||||
func fetchShelves() async throws -> [ShelfSummary]
|
||||
func fetchBooks(shelfId: Int) async throws -> [BookSummary]
|
||||
func fetchChapters(bookId: Int) async throws -> [ChapterSummary]
|
||||
func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult
|
||||
}
|
||||
|
||||
// MARK: - Live Implementation
|
||||
|
||||
actor ShareExtensionAPIService: ShareAPIServiceProtocol {
|
||||
|
||||
private let baseURL: String
|
||||
private let tokenId: String
|
||||
private let tokenSecret: String
|
||||
private let session: URLSession
|
||||
|
||||
init(serverURL: String, tokenId: String, tokenSecret: String) {
|
||||
self.baseURL = serverURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
self.tokenId = tokenId
|
||||
self.tokenSecret = tokenSecret
|
||||
self.session = URLSession(configuration: .default)
|
||||
}
|
||||
|
||||
// MARK: - API calls
|
||||
|
||||
func fetchShelves() async throws -> [ShelfSummary] {
|
||||
let data = try await get(path: "/api/shelves?count=500")
|
||||
return try decode(PaginatedResult<ShelfSummary>.self, from: data).data
|
||||
}
|
||||
|
||||
func fetchBooks(shelfId: Int) async throws -> [BookSummary] {
|
||||
let data = try await get(path: "/api/shelves/\(shelfId)")
|
||||
return try decode(ShelfDetail.self, from: data).books ?? []
|
||||
}
|
||||
|
||||
func fetchChapters(bookId: Int) async throws -> [ChapterSummary] {
|
||||
let data = try await get(path: "/api/books/\(bookId)")
|
||||
let contents = try decode(BookDetail.self, from: data).contents ?? []
|
||||
return contents.filter { $0.type == "chapter" }
|
||||
.map { ChapterSummary(id: $0.id, name: $0.name) }
|
||||
}
|
||||
|
||||
func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult {
|
||||
var body: [String: Any] = [
|
||||
"book_id": bookId,
|
||||
"name": title,
|
||||
"markdown": markdown
|
||||
]
|
||||
if let chapterId { body["chapter_id"] = chapterId }
|
||||
|
||||
let bodyData = try JSONSerialization.data(withJSONObject: body)
|
||||
let url = try makeURL(path: "/api/pages")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = bodyData
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
authorize(&request)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validate(response)
|
||||
return try decode(PageResult.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func get(path: String) async throws -> Data {
|
||||
let url = try makeURL(path: path)
|
||||
var request = URLRequest(url: url)
|
||||
authorize(&request)
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validate(response)
|
||||
return data
|
||||
} catch let error as ShareAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ShareAPIError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeURL(path: String) throws -> URL {
|
||||
guard let url = URL(string: baseURL + path) else {
|
||||
throw ShareAPIError.notConfigured
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func authorize(_ request: inout URLRequest) {
|
||||
request.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
private func validate(_ response: URLResponse) throws {
|
||||
guard let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode) else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw ShareAPIError.httpError(code)
|
||||
}
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
throw ShareAPIError.decodingError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response wrapper types (private)
|
||||
|
||||
private struct PaginatedResult<T: Decodable>: Decodable {
|
||||
let data: [T]
|
||||
}
|
||||
|
||||
private struct ShelfDetail: Decodable {
|
||||
let books: [BookSummary]?
|
||||
}
|
||||
|
||||
private struct BookContentItem: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: String
|
||||
}
|
||||
|
||||
private struct BookDetail: Decodable {
|
||||
let contents: [BookContentItem]?
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Shared Keychain service for passing credentials between the main app
|
||||
/// and the Share Extension via App Group "group.de.hanold.bookstax".
|
||||
///
|
||||
/// - The main app calls `saveCredentials` whenever a profile is activated.
|
||||
/// - The extension calls `loadCredentials` to authenticate API requests.
|
||||
/// - Accessibility is `afterFirstUnlock` so the extension can run while the
|
||||
/// device is locked after the user has unlocked it at least once.
|
||||
///
|
||||
/// Add this file to **both** the main app target and `BookStaxShareExtension`.
|
||||
enum ShareExtensionKeychainService {
|
||||
|
||||
private static let service = "de.hanold.bookstax.shared"
|
||||
private static let account = "activeCredentials"
|
||||
private static let accessGroup = "group.de.hanold.bookstax"
|
||||
|
||||
private struct Credentials: Codable {
|
||||
let serverURL: String
|
||||
let tokenId: String
|
||||
let tokenSecret: String
|
||||
}
|
||||
|
||||
// MARK: - Save (called from main app)
|
||||
|
||||
/// Persists the active profile credentials in the shared keychain.
|
||||
static func saveCredentials(serverURL: String, tokenId: String, tokenSecret: String) {
|
||||
guard let data = try? JSONEncoder().encode(
|
||||
Credentials(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
) else { return }
|
||||
|
||||
let baseQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup
|
||||
]
|
||||
|
||||
// Try update first; add if not found.
|
||||
let updateStatus = SecItemUpdate(
|
||||
baseQuery as CFDictionary,
|
||||
[kSecValueData: data] as CFDictionary
|
||||
)
|
||||
if updateStatus == errSecItemNotFound {
|
||||
var addQuery = baseQuery
|
||||
addQuery[kSecValueData] = data
|
||||
addQuery[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(addQuery as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load (called from Share Extension)
|
||||
|
||||
/// Returns the stored credentials, or `nil` if the user has not yet
|
||||
/// configured the main app.
|
||||
static func loadCredentials() -> (serverURL: String, tokenId: String, tokenSecret: String)? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let creds = try? JSONDecoder().decode(Credentials.self, from: data)
|
||||
else { return nil }
|
||||
return (creds.serverURL, creds.tokenId, creds.tokenSecret)
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
/// Removes the shared credentials (e.g., on logout).
|
||||
static func clearCredentials() {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: accessGroup
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ShareExtensionView: View {
|
||||
|
||||
@ObservedObject var viewModel: ShareViewModel
|
||||
|
||||
var onCancel: () -> Void
|
||||
var onComplete: () -> Void
|
||||
var onOpenURL: (URL) -> Void
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if !viewModel.isConfigured {
|
||||
notConfiguredView
|
||||
} else if viewModel.isSaved {
|
||||
successView
|
||||
} else {
|
||||
formView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Save to BookStax")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel", action: onCancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard viewModel.isConfigured, !viewModel.isSaved else { return }
|
||||
await viewModel.loadShelves()
|
||||
}
|
||||
.alert(
|
||||
"Error",
|
||||
isPresented: Binding(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { if !$0 { viewModel.errorMessage = nil } }
|
||||
),
|
||||
actions: {
|
||||
Button("OK") { viewModel.errorMessage = nil }
|
||||
},
|
||||
message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Not configured
|
||||
|
||||
private var notConfiguredView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("BookStax Not Configured")
|
||||
.font(.headline)
|
||||
Text("Please open BookStax and sign in to your BookStack server.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Close", action: onCancel)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Success
|
||||
|
||||
private var successView: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Page saved!")
|
||||
.font(.headline)
|
||||
Text(viewModel.pageTitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if let url = URL(string: viewModel.serverURL), !viewModel.serverURL.isEmpty {
|
||||
Button {
|
||||
onOpenURL(url)
|
||||
onComplete()
|
||||
} label: {
|
||||
Label("Open BookStax", systemImage: "safari")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
Button("Done", action: onComplete)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding()
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form
|
||||
|
||||
private var formView: some View {
|
||||
Form {
|
||||
Section("Selected Text") {
|
||||
Text(viewModel.sharedText)
|
||||
.lineLimit(4)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Page Title") {
|
||||
TextField("Page title", text: $viewModel.pageTitle)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section("Location") {
|
||||
NavigationLink {
|
||||
ShelfPickerView(viewModel: viewModel)
|
||||
} label: {
|
||||
LabeledRow(label: "Shelf", value: viewModel.selectedShelf?.name)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
BookPickerView(viewModel: viewModel)
|
||||
} label: {
|
||||
LabeledRow(label: "Book", value: viewModel.selectedBook?.name)
|
||||
}
|
||||
.disabled(viewModel.selectedShelf == nil)
|
||||
|
||||
NavigationLink {
|
||||
ChapterPickerView(viewModel: viewModel)
|
||||
} label: {
|
||||
LabeledRow(label: "Chapter", value: viewModel.selectedChapter?.name,
|
||||
placeholder: "Optional")
|
||||
}
|
||||
.disabled(viewModel.selectedBook == nil)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await viewModel.savePage() }
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Save")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaveDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper
|
||||
|
||||
private struct LabeledRow: View {
|
||||
let label: String
|
||||
let value: String?
|
||||
var placeholder: String = "Select"
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(LocalizedStringKey(label))
|
||||
Spacer()
|
||||
Text(value.map { LocalizedStringKey($0) } ?? LocalizedStringKey(placeholder))
|
||||
.foregroundStyle(value == nil ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Null implementation used when BookStax is not configured (no keychain credentials).
|
||||
private struct NullShareAPIService: ShareAPIServiceProtocol {
|
||||
func fetchShelves() async throws -> [ShelfSummary] { [] }
|
||||
func fetchBooks(shelfId: Int) async throws -> [BookSummary] { [] }
|
||||
func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { [] }
|
||||
func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult {
|
||||
throw ShareAPIError.notConfigured
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for the BookStax Share Extension.
|
||||
/// `NSExtensionPrincipalClass` in Info.plist points to this class.
|
||||
final class ShareViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemGroupedBackground
|
||||
|
||||
Task { @MainActor in
|
||||
let text = await extractSharedText()
|
||||
let viewModel = makeViewModel(for: text)
|
||||
embedSwiftUI(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel factory
|
||||
|
||||
private func makeViewModel(for text: String) -> ShareViewModel {
|
||||
if let creds = ShareExtensionKeychainService.loadCredentials() {
|
||||
let api = ShareExtensionAPIService(
|
||||
serverURL: creds.serverURL,
|
||||
tokenId: creds.tokenId,
|
||||
tokenSecret: creds.tokenSecret
|
||||
)
|
||||
let defaults = UserDefaults(suiteName: "group.de.hanold.bookstax")
|
||||
return ShareViewModel(
|
||||
sharedText: text,
|
||||
apiService: api,
|
||||
serverURL: creds.serverURL,
|
||||
isConfigured: true,
|
||||
defaults: defaults
|
||||
)
|
||||
} else {
|
||||
return ShareViewModel(
|
||||
sharedText: text,
|
||||
apiService: NullShareAPIService(),
|
||||
serverURL: "",
|
||||
isConfigured: false,
|
||||
defaults: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI embedding
|
||||
|
||||
private func embedSwiftUI(viewModel: ShareViewModel) {
|
||||
let contentView = ShareExtensionView(
|
||||
viewModel: viewModel,
|
||||
onCancel: { [weak self] in self?.cancel() },
|
||||
onComplete: { [weak self] in self?.complete() },
|
||||
onOpenURL: { [weak self] url in self?.open(url) }
|
||||
)
|
||||
let host = UIHostingController(rootView: contentView)
|
||||
addChild(host)
|
||||
view.addSubview(host.view)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
}
|
||||
|
||||
// MARK: - Extension context actions
|
||||
|
||||
private func cancel() {
|
||||
extensionContext?.cancelRequest(
|
||||
withError: NSError(
|
||||
domain: NSCocoaErrorDomain,
|
||||
code: NSUserCancelledError,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Abgebrochen"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func complete() {
|
||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
private func open(_ url: URL) {
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - Text extraction
|
||||
|
||||
/// Extracts plain text or a URL string from the incoming NSExtensionItems.
|
||||
private func extractSharedText() async -> String {
|
||||
guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { return "" }
|
||||
|
||||
for item in items {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
if let text = try? await provider.loadItem(
|
||||
forTypeIdentifier: UTType.plainText.identifier
|
||||
) as? String {
|
||||
return text
|
||||
}
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
if let url = try? await provider.loadItem(
|
||||
forTypeIdentifier: UTType.url.identifier
|
||||
) as? URL {
|
||||
return url.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
final class ShareViewModel: ObservableObject {
|
||||
|
||||
// MARK: Published state
|
||||
|
||||
@Published var shelves: [ShelfSummary] = []
|
||||
@Published var books: [BookSummary] = []
|
||||
@Published var chapters: [ChapterSummary] = []
|
||||
|
||||
@Published var selectedShelf: ShelfSummary?
|
||||
@Published var selectedBook: BookSummary?
|
||||
@Published var selectedChapter: ChapterSummary?
|
||||
|
||||
@Published var pageTitle: String = ""
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSaved: Bool = false
|
||||
|
||||
// MARK: Read-only
|
||||
|
||||
let sharedText: String
|
||||
let isConfigured: Bool
|
||||
let serverURL: String
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let apiService: ShareAPIServiceProtocol
|
||||
private let defaults: UserDefaults?
|
||||
|
||||
private let lastShelfIDKey = "shareExtension.lastShelfID"
|
||||
private let lastBookIDKey = "shareExtension.lastBookID"
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var isSaveDisabled: Bool {
|
||||
pageTitle.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| selectedBook == nil
|
||||
|| isLoading
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(
|
||||
sharedText: String,
|
||||
apiService: ShareAPIServiceProtocol,
|
||||
serverURL: String = "",
|
||||
isConfigured: Bool = true,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
self.sharedText = sharedText
|
||||
self.isConfigured = isConfigured
|
||||
self.serverURL = serverURL
|
||||
self.apiService = apiService
|
||||
self.defaults = defaults
|
||||
|
||||
// Auto-populate title from the first non-empty line of the shared text.
|
||||
let firstLine = sharedText
|
||||
.components(separatedBy: .newlines)
|
||||
.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } ?? ""
|
||||
self.pageTitle = String(firstLine.prefix(100))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Loads all shelves and restores the last used shelf/book selection.
|
||||
func loadShelves() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
shelves = try await apiService.fetchShelves()
|
||||
|
||||
// Restore last selected shelf
|
||||
let lastShelfID = defaults?.integer(forKey: lastShelfIDKey) ?? 0
|
||||
if lastShelfID != 0, let match = shelves.first(where: { $0.id == lastShelfID }) {
|
||||
await selectShelf(match)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func selectShelf(_ shelf: ShelfSummary) async {
|
||||
selectedShelf = shelf
|
||||
selectedBook = nil
|
||||
selectedChapter = nil
|
||||
books = []
|
||||
chapters = []
|
||||
defaults?.set(shelf.id, forKey: lastShelfIDKey)
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
books = try await apiService.fetchBooks(shelfId: shelf.id)
|
||||
|
||||
// Restore last selected book
|
||||
let lastBookID = defaults?.integer(forKey: lastBookIDKey) ?? 0
|
||||
if lastBookID != 0, let match = books.first(where: { $0.id == lastBookID }) {
|
||||
await selectBook(match)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func selectBook(_ book: BookSummary) async {
|
||||
selectedBook = book
|
||||
selectedChapter = nil
|
||||
chapters = []
|
||||
defaults?.set(book.id, forKey: lastBookIDKey)
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
chapters = try await apiService.fetchChapters(bookId: book.id)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func savePage() async {
|
||||
guard let book = selectedBook,
|
||||
!pageTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
_ = try await apiService.createPage(
|
||||
bookId: book.id,
|
||||
chapterId: selectedChapter?.id,
|
||||
title: pageTitle.trimmingCharacters(in: .whitespaces),
|
||||
markdown: sharedText
|
||||
)
|
||||
isSaved = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ShelfPickerView: View {
|
||||
|
||||
@ObservedObject var viewModel: ShareViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("Select Shelf")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if viewModel.isLoading && viewModel.shelves.isEmpty {
|
||||
ProgressView("Loading shelves…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.shelves.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No shelves found",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("No shelves were found on the server.")
|
||||
)
|
||||
} else {
|
||||
List(viewModel.shelves) { shelf in
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.selectShelf(shelf)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(shelf.name)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if viewModel.selectedShelf?.id == shelf.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* ShareExtensionView */
|
||||
"Save to BookStax" = "In BookStax speichern";
|
||||
"Cancel" = "Abbrechen";
|
||||
"Selected Text" = "Markierter Text";
|
||||
"Page Title" = "Titel der Seite";
|
||||
"Page title" = "Seitentitel";
|
||||
"Location" = "Ablageort";
|
||||
"Shelf" = "Regal";
|
||||
"Book" = "Buch";
|
||||
"Chapter" = "Kapitel";
|
||||
"Select" = "Auswählen";
|
||||
"Optional" = "Optional";
|
||||
"Save" = "Speichern";
|
||||
|
||||
/* Success */
|
||||
"Page saved!" = "Seite gespeichert!";
|
||||
"Open BookStax" = "BookStax öffnen";
|
||||
"Done" = "Fertig";
|
||||
|
||||
/* Not configured */
|
||||
"BookStax Not Configured" = "BookStax nicht konfiguriert";
|
||||
"Please open BookStax and sign in to your BookStack server." = "Bitte öffne die BookStax-App und melde dich bei deinem BookStack-Server an.";
|
||||
"Close" = "Schließen";
|
||||
|
||||
/* Alert */
|
||||
"Error" = "Fehler";
|
||||
"OK" = "OK";
|
||||
|
||||
/* Shelf picker */
|
||||
"Select Shelf" = "Regal wählen";
|
||||
"Loading shelves\u{2026}" = "Regale werden geladen\u{2026}";
|
||||
"No shelves found" = "Keine Regale gefunden";
|
||||
"No shelves were found on the server." = "Es wurden keine Regale auf dem Server gefunden.";
|
||||
|
||||
/* Book picker */
|
||||
"Select Book" = "Buch wählen";
|
||||
"Loading books\u{2026}" = "Bücher werden geladen\u{2026}";
|
||||
"No books found" = "Keine Bücher gefunden";
|
||||
"This shelf has no books yet." = "Dieses Regal enthält noch keine Bücher.";
|
||||
|
||||
/* Chapter picker */
|
||||
"Select Chapter" = "Kapitel wählen";
|
||||
"Loading chapters\u{2026}" = "Kapitel werden geladen\u{2026}";
|
||||
"No chapter (directly in book)" = "Kein Kapitel (direkt im Buch)";
|
||||
"This book has no chapters." = "Dieses Buch enthält keine Kapitel.";
|
||||
|
||||
/* API errors (semantic keys) */
|
||||
"error.notConfigured" = "BookStax ist nicht konfiguriert. Bitte öffne die App und melde dich an.";
|
||||
"error.network.format" = "Netzwerkfehler: %@";
|
||||
"error.http.format" = "Serverfehler (HTTP %d). Bitte versuche es erneut.";
|
||||
"error.decoding" = "Die Serverantwort konnte nicht verarbeitet werden.";
|
||||
@@ -0,0 +1,51 @@
|
||||
/* ShareExtensionView */
|
||||
"Save to BookStax" = "Save to BookStax";
|
||||
"Cancel" = "Cancel";
|
||||
"Selected Text" = "Selected Text";
|
||||
"Page Title" = "Page Title";
|
||||
"Page title" = "Page title";
|
||||
"Location" = "Location";
|
||||
"Shelf" = "Shelf";
|
||||
"Book" = "Book";
|
||||
"Chapter" = "Chapter";
|
||||
"Select" = "Select";
|
||||
"Optional" = "Optional";
|
||||
"Save" = "Save";
|
||||
|
||||
/* Success */
|
||||
"Page saved!" = "Page saved!";
|
||||
"Open BookStax" = "Open BookStax";
|
||||
"Done" = "Done";
|
||||
|
||||
/* Not configured */
|
||||
"BookStax Not Configured" = "BookStax Not Configured";
|
||||
"Please open BookStax and sign in to your BookStack server." = "Please open BookStax and sign in to your BookStack server.";
|
||||
"Close" = "Close";
|
||||
|
||||
/* Alert */
|
||||
"Error" = "Error";
|
||||
"OK" = "OK";
|
||||
|
||||
/* Shelf picker */
|
||||
"Select Shelf" = "Select Shelf";
|
||||
"Loading shelves\u{2026}" = "Loading shelves\u{2026}";
|
||||
"No shelves found" = "No shelves found";
|
||||
"No shelves were found on the server." = "No shelves were found on the server.";
|
||||
|
||||
/* Book picker */
|
||||
"Select Book" = "Select Book";
|
||||
"Loading books\u{2026}" = "Loading books\u{2026}";
|
||||
"No books found" = "No books found";
|
||||
"This shelf has no books yet." = "This shelf has no books yet.";
|
||||
|
||||
/* Chapter picker */
|
||||
"Select Chapter" = "Select Chapter";
|
||||
"Loading chapters\u{2026}" = "Loading chapters\u{2026}";
|
||||
"No chapter (directly in book)" = "No chapter (directly in book)";
|
||||
"This book has no chapters." = "This book has no chapters.";
|
||||
|
||||
/* API errors (semantic keys) */
|
||||
"error.notConfigured" = "BookStax is not configured. Please open the app and sign in.";
|
||||
"error.network.format" = "Network error: %@";
|
||||
"error.http.format" = "Server error (HTTP %d). Please try again.";
|
||||
"error.decoding" = "The server response could not be processed.";
|
||||
@@ -0,0 +1,51 @@
|
||||
/* ShareExtensionView */
|
||||
"Save to BookStax" = "Guardar en BookStax";
|
||||
"Cancel" = "Cancelar";
|
||||
"Selected Text" = "Texto seleccionado";
|
||||
"Page Title" = "Título de la página";
|
||||
"Page title" = "Título de la página";
|
||||
"Location" = "Ubicación";
|
||||
"Shelf" = "Estante";
|
||||
"Book" = "Libro";
|
||||
"Chapter" = "Capítulo";
|
||||
"Select" = "Seleccionar";
|
||||
"Optional" = "Opcional";
|
||||
"Save" = "Guardar";
|
||||
|
||||
/* Success */
|
||||
"Page saved!" = "¡Página guardada!";
|
||||
"Open BookStax" = "Abrir BookStax";
|
||||
"Done" = "Listo";
|
||||
|
||||
/* Not configured */
|
||||
"BookStax Not Configured" = "BookStax no configurado";
|
||||
"Please open BookStax and sign in to your BookStack server." = "Por favor abre BookStax e inicia sesión en tu servidor BookStack.";
|
||||
"Close" = "Cerrar";
|
||||
|
||||
/* Alert */
|
||||
"Error" = "Error";
|
||||
"OK" = "OK";
|
||||
|
||||
/* Shelf picker */
|
||||
"Select Shelf" = "Seleccionar estante";
|
||||
"Loading shelves\u{2026}" = "Cargando estantes\u{2026}";
|
||||
"No shelves found" = "No se encontraron estantes";
|
||||
"No shelves were found on the server." = "No se encontraron estantes en el servidor.";
|
||||
|
||||
/* Book picker */
|
||||
"Select Book" = "Seleccionar libro";
|
||||
"Loading books\u{2026}" = "Cargando libros\u{2026}";
|
||||
"No books found" = "No se encontraron libros";
|
||||
"This shelf has no books yet." = "Este estante no tiene libros todavía.";
|
||||
|
||||
/* Chapter picker */
|
||||
"Select Chapter" = "Seleccionar capítulo";
|
||||
"Loading chapters\u{2026}" = "Cargando capítulos\u{2026}";
|
||||
"No chapter (directly in book)" = "Sin capítulo (directamente en el libro)";
|
||||
"This book has no chapters." = "Este libro no tiene capítulos.";
|
||||
|
||||
/* API errors (semantic keys) */
|
||||
"error.notConfigured" = "BookStax no está configurado. Por favor abre la app e inicia sesión.";
|
||||
"error.network.format" = "Error de red: %@";
|
||||
"error.http.format" = "Error del servidor (HTTP %d). Por favor inténtalo de nuevo.";
|
||||
"error.decoding" = "La respuesta del servidor no se pudo procesar.";
|
||||
@@ -0,0 +1,51 @@
|
||||
/* ShareExtensionView */
|
||||
"Save to BookStax" = "Enregistrer dans BookStax";
|
||||
"Cancel" = "Annuler";
|
||||
"Selected Text" = "Texte sélectionné";
|
||||
"Page Title" = "Titre de la page";
|
||||
"Page title" = "Titre de la page";
|
||||
"Location" = "Emplacement";
|
||||
"Shelf" = "Étagère";
|
||||
"Book" = "Livre";
|
||||
"Chapter" = "Chapitre";
|
||||
"Select" = "Choisir";
|
||||
"Optional" = "Facultatif";
|
||||
"Save" = "Enregistrer";
|
||||
|
||||
/* Success */
|
||||
"Page saved!" = "Page enregistrée !";
|
||||
"Open BookStax" = "Ouvrir BookStax";
|
||||
"Done" = "Terminé";
|
||||
|
||||
/* Not configured */
|
||||
"BookStax Not Configured" = "BookStax non configuré";
|
||||
"Please open BookStax and sign in to your BookStack server." = "Veuillez ouvrir BookStax et vous connecter à votre serveur BookStack.";
|
||||
"Close" = "Fermer";
|
||||
|
||||
/* Alert */
|
||||
"Error" = "Erreur";
|
||||
"OK" = "OK";
|
||||
|
||||
/* Shelf picker */
|
||||
"Select Shelf" = "Choisir une étagère";
|
||||
"Loading shelves\u{2026}" = "Chargement des étagères\u{2026}";
|
||||
"No shelves found" = "Aucune étagère trouvée";
|
||||
"No shelves were found on the server." = "Aucune étagère n'a été trouvée sur le serveur.";
|
||||
|
||||
/* Book picker */
|
||||
"Select Book" = "Choisir un livre";
|
||||
"Loading books\u{2026}" = "Chargement des livres\u{2026}";
|
||||
"No books found" = "Aucun livre trouvé";
|
||||
"This shelf has no books yet." = "Cette étagère ne contient aucun livre.";
|
||||
|
||||
/* Chapter picker */
|
||||
"Select Chapter" = "Choisir un chapitre";
|
||||
"Loading chapters\u{2026}" = "Chargement des chapitres\u{2026}";
|
||||
"No chapter (directly in book)" = "Pas de chapitre (directement dans le livre)";
|
||||
"This book has no chapters." = "Ce livre ne contient aucun chapitre.";
|
||||
|
||||
/* API errors (semantic keys) */
|
||||
"error.notConfigured" = "BookStax n'est pas configuré. Veuillez ouvrir l'app et vous connecter.";
|
||||
"error.network.format" = "Erreur réseau : %@";
|
||||
"error.http.format" = "Erreur serveur (HTTP %d). Veuillez réessayer.";
|
||||
"error.decoding" = "La réponse du serveur n'a pas pu être traitée.";
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,115 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */; };
|
||||
23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */; };
|
||||
26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */; };
|
||||
26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 26FD17072F8A9643006E87F3 /* Donations.storekit */; };
|
||||
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */; };
|
||||
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */; };
|
||||
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054EC160F48247753D5E360 /* SearchViewModelTests.swift */; };
|
||||
84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AC406884F8446C6F4FA215 /* DTOTests.swift */; };
|
||||
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0841C05048CA5AE635439A8 /* Foundation.framework */; };
|
||||
DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E478C272640163A74D17B3DE /* DonationServiceTests.swift */; };
|
||||
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2480561934949230710825EA /* StringHTMLTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 26F69D862F964C1700A6C5E6;
|
||||
remoteInfo = BookStaxShareExtension;
|
||||
};
|
||||
992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 261299D52F6C686D00EC1C97;
|
||||
remoteInfo = bookstax;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
26F69D912F964C1700A6C5E6 /* BookStaxShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = OnboardingViewModelTests.swift; path = bookstaxTests/OnboardingViewModelTests.swift; sourceTree = "<group>"; };
|
||||
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PageEditorViewModelTests.swift; path = bookstaxTests/PageEditorViewModelTests.swift; sourceTree = "<group>"; };
|
||||
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccentThemeTests.swift; path = bookstaxTests/AccentThemeTests.swift; sourceTree = "<group>"; };
|
||||
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bookstaxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2480561934949230710825EA /* StringHTMLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StringHTMLTests.swift; path = bookstaxTests/StringHTMLTests.swift; sourceTree = "<group>"; };
|
||||
261299D62F6C686D00EC1C97 /* bookstax.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bookstax.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BookStaxShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewModelTests.swift; sourceTree = "<group>"; };
|
||||
26FD17062F8A95E1006E87F3 /* Tips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tips.storekit; sourceTree = "<group>"; };
|
||||
26FD17072F8A9643006E87F3 /* Donations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donations.storekit; sourceTree = "<group>"; };
|
||||
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SearchViewModelTests.swift; path = bookstaxTests/SearchViewModelTests.swift; sourceTree = "<group>"; };
|
||||
57AC406884F8446C6F4FA215 /* DTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DTOTests.swift; path = bookstaxTests/DTOTests.swift; sourceTree = "<group>"; };
|
||||
944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIErrorTests.swift; path = bookstaxTests/APIErrorTests.swift; sourceTree = "<group>"; };
|
||||
C0841C05048CA5AE635439A8 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
E478C272640163A74D17B3DE /* DonationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DonationServiceTests.swift; path = bookstaxTests/DonationServiceTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
|
||||
};
|
||||
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
ShareExtensionAPIService.swift,
|
||||
ShareExtensionKeychainService.swift,
|
||||
ShareViewModel.swift,
|
||||
);
|
||||
target = 261299D52F6C686D00EC1C97 /* bookstax */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
261299D82F6C686D00EC1C97 /* bookstax */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = bookstax;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
26F69DB42F96515900A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "bookstax" target */,
|
||||
26F69D922F964C1700A6C5E6 /* Exceptions for "BookStaxShareExtension" folder in "BookStaxShareExtension" target */,
|
||||
);
|
||||
path = BookStaxShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1A1B96526910505E82E2CFDB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C7308555E59969ECCBEAEEFC /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
261299D32F6C686D00EC1C97 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -26,14 +122,34 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26F69D842F964C1700A6C5E6 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1BB5D3095A0460024F7BA321 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0841C05048CA5AE635439A8 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
261299CD2F6C686D00EC1C97 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
26FD17062F8A95E1006E87F3 /* Tips.storekit */,
|
||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
|
||||
261299D72F6C686D00EC1C97 /* Products */,
|
||||
26FD17072F8A9643006E87F3 /* Donations.storekit */,
|
||||
EB2578937899373803DA341A /* Frameworks */,
|
||||
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -41,10 +157,36 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
261299D62F6C686D00EC1C97 /* bookstax.app */,
|
||||
2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */,
|
||||
26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
46815FA4B6CE7CC70A5E1AC0 /* bookstaxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2480561934949230710825EA /* StringHTMLTests.swift */,
|
||||
944952BFC6DCCE66317FF8D7 /* APIErrorTests.swift */,
|
||||
4054EC160F48247753D5E360 /* SearchViewModelTests.swift */,
|
||||
57AC406884F8446C6F4FA215 /* DTOTests.swift */,
|
||||
06ACD7F7DBD5175662183FB5 /* PageEditorViewModelTests.swift */,
|
||||
01B196AE0433DB616DBF32EF /* OnboardingViewModelTests.swift */,
|
||||
0F2E04000A7F4412894F47CF /* AccentThemeTests.swift */,
|
||||
E478C272640163A74D17B3DE /* DonationServiceTests.swift */,
|
||||
26F69DAF2F9650A200A6C5E6 /* ShareViewModelTests.swift */,
|
||||
);
|
||||
name = bookstaxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EB2578937899373803DA341A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1BB5D3095A0460024F7BA321 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -55,20 +197,60 @@
|
||||
261299D22F6C686D00EC1C97 /* Sources */,
|
||||
261299D32F6C686D00EC1C97 /* Frameworks */,
|
||||
261299D42F6C686D00EC1C97 /* Resources */,
|
||||
26F69D962F964C1700A6C5E6 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||
);
|
||||
name = bookstax;
|
||||
productName = bookstax;
|
||||
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */;
|
||||
buildPhases = (
|
||||
26F69D832F964C1700A6C5E6 /* Sources */,
|
||||
26F69D842F964C1700A6C5E6 /* Frameworks */,
|
||||
26F69D852F964C1700A6C5E6 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
261299D82F6C686D00EC1C97 /* bookstax */,
|
||||
26F69D882F964C1700A6C5E6 /* BookStaxShareExtension */,
|
||||
);
|
||||
name = bookstax;
|
||||
name = BookStaxShareExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = bookstax;
|
||||
productReference = 261299D62F6C686D00EC1C97 /* bookstax.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
productName = BookStaxShareExtension;
|
||||
productReference = 26F69D872F964C1700A6C5E6 /* BookStaxShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AD8774751A52779622D7AED5 /* bookstaxTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */;
|
||||
buildPhases = (
|
||||
67E32E036FC96F91F25C740D /* Sources */,
|
||||
1A1B96526910505E82E2CFDB /* Frameworks */,
|
||||
AA28FE166C71A3A60AC62034 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
90647D0E4313E7A718C1C384 /* PBXTargetDependency */,
|
||||
);
|
||||
name = bookstaxTests;
|
||||
productName = bookstaxTests;
|
||||
productReference = 2285E361AEE1AD0F1A3D1318 /* bookstaxTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
@@ -77,12 +259,15 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastSwiftUpdateCheck = 2640;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
261299D52F6C686D00EC1C97 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
26F69D862F964C1700A6C5E6 = {
|
||||
CreatedOnToolsVersion = 26.4.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 261299D12F6C686D00EC1C97 /* Build configuration list for PBXProject "bookstax" */;
|
||||
@@ -93,6 +278,7 @@
|
||||
Base,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
);
|
||||
mainGroup = 261299CD2F6C686D00EC1C97;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
@@ -102,12 +288,29 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
261299D52F6C686D00EC1C97 /* bookstax */,
|
||||
AD8774751A52779622D7AED5 /* bookstaxTests */,
|
||||
26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
261299D42F6C686D00EC1C97 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26FD17082F8A9643006E87F3 /* Donations.storekit in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26F69D852F964C1700A6C5E6 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA28FE166C71A3A60AC62034 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -124,9 +327,63 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
26F69D832F964C1700A6C5E6 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
67E32E036FC96F91F25C740D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
26F69DB02F9650A200A6C5E6 /* ShareViewModelTests.swift in Sources */,
|
||||
EBEA92CF7BFC556D0B10E657 /* StringHTMLTests.swift in Sources */,
|
||||
049D789A53BC64AFB1C810A5 /* APIErrorTests.swift in Sources */,
|
||||
6B19ECCECBCC5040054B4916 /* SearchViewModelTests.swift in Sources */,
|
||||
84A3204FA6CF24CC3D1126AE /* DTOTests.swift in Sources */,
|
||||
23B7C03076B6043F7EA4BBA8 /* PageEditorViewModelTests.swift in Sources */,
|
||||
2AB9247CBE750A713DA8151B /* OnboardingViewModelTests.swift in Sources */,
|
||||
57538A9C5792A8F0B00134DE /* AccentThemeTests.swift in Sources */,
|
||||
DBD5B145DFCF7E2B9FFE0C20 /* DonationServiceTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
26F69D902F964C1700A6C5E6 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 26F69D862F964C1700A6C5E6 /* BookStaxShareExtension */;
|
||||
targetProxy = 26F69D8F2F964C1700A6C5E6 /* PBXContainerItemProxy */;
|
||||
};
|
||||
90647D0E4313E7A718C1C384 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = bookstax;
|
||||
target = 261299D52F6C686D00EC1C97 /* bookstax */;
|
||||
targetProxy = 992CA2ADDB48DFB1EE203820 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1C68E5D77B468BD3A7F1C349 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
||||
PRODUCT_NAME = bookstaxTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
261299DF2F6C686E00EC1C97 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -253,20 +510,25 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -281,20 +543,25 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = bookstax/bookstax.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -304,6 +571,82 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
26F69D942F964C1700A6C5E6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
26F69D952F964C1700A6C5E6 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = BookStaxShareExtension/BookStaxShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = BookStaxShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BookStaxShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax.BookStaxShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C9DF29CF9FF31B97AC4E31E5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstaxTests;
|
||||
PRODUCT_NAME = bookstaxTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bookstax.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bookstax";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -325,6 +668,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
26F69D932F964C1700A6C5E6 /* Build configuration list for PBXNativeTarget "BookStaxShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
26F69D942F964C1700A6C5E6 /* Debug */,
|
||||
26F69D952F964C1700A6C5E6 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
29568FB35D3D7050B63B6901 /* Build configuration list for PBXNativeTarget "bookstaxTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1C68E5D77B468BD3A7F1C349 /* Release */,
|
||||
C9DF29CF9FF31B97AC4E31E5 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 261299CE2F6C686D00EC1C97 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
||||
BuildableName = ".xctest"
|
||||
BlueprintName = "bookstaxTests"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AD8774751A52779622D7AED5"
|
||||
BuildableName = ".xctest"
|
||||
BlueprintName = "bookstaxTests"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Donations.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -4,11 +4,24 @@
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>BookStaxShareExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>bookstax.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>261299D52F6C686D00EC1C97</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -73,6 +73,35 @@ enum AccentTheme: String, CaseIterable, Identifiable {
|
||||
var accentColor: Color { shelfColor }
|
||||
}
|
||||
|
||||
// MARK: - Color Hex Helpers
|
||||
|
||||
extension Color {
|
||||
/// Initialises a Color from a CSS-style hex string (#RRGGBB or #RGB).
|
||||
init?(hex: String) {
|
||||
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if h.hasPrefix("#") { h = String(h.dropFirst()) }
|
||||
let len = h.count
|
||||
guard len == 6 || len == 3 else { return nil }
|
||||
if len == 3 {
|
||||
h = h.map { "\($0)\($0)" }.joined()
|
||||
}
|
||||
guard let value = UInt64(h, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255
|
||||
let g = Double((value >> 8) & 0xFF) / 255
|
||||
let b = Double( value & 0xFF) / 255
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
/// Returns an #RRGGBB hex string for the color (resolved in the light trait environment).
|
||||
func toHexString() -> String {
|
||||
let ui = UIColor(self)
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
let ri = Int(r * 255), gi = Int(g * 255), bi = Int(b * 255)
|
||||
return String(format: "#%02X%02X%02X", ri, gi, bi)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment Key
|
||||
|
||||
private struct AccentThemeKey: EnvironmentKey {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension String {
|
||||
/// Strips HTML tags and decodes common HTML entities for plain-text display.
|
||||
var strippingHTML: String {
|
||||
// Use NSAttributedString to parse HTML — handles entities and nested tags correctly
|
||||
guard let data = data(using: .utf8),
|
||||
let attributed = try? NSAttributedString(
|
||||
data: data,
|
||||
options: [.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue],
|
||||
documentAttributes: nil)
|
||||
else {
|
||||
// Fallback: remove tags with regex
|
||||
return replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return attributed.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,6 @@ extension SearchResultDTO {
|
||||
extension CommentDTO {
|
||||
static let mock = CommentDTO(
|
||||
id: 1,
|
||||
text: "Great documentation! Very helpful.",
|
||||
html: "<p>Great documentation! Very helpful.</p>",
|
||||
pageId: 1,
|
||||
createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
|
||||
|
||||
@@ -25,7 +25,7 @@ enum BookStackError: LocalizedError, Sendable {
|
||||
case .unauthorized:
|
||||
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
|
||||
case .forbidden:
|
||||
return "Access denied. Either your account lacks the \"Access System API\" role permission, or your reverse proxy (nginx/Caddy) is not forwarding the Authorization header. Add `proxy_set_header Authorization $http_authorization;` to your proxy config."
|
||||
return "Access denied (403). Your account may lack permission for this action."
|
||||
case .notFound(let resource):
|
||||
return "\(resource) could not be found. It may have been deleted or moved."
|
||||
case .httpError(let code, let message):
|
||||
@@ -37,7 +37,7 @@ enum BookStackError: LocalizedError, Sendable {
|
||||
case .keychainError(let status):
|
||||
return "Credential storage failed (code \(status))."
|
||||
case .sslError:
|
||||
return "SSL certificate error. If your server uses a self-signed certificate, contact your admin to install a trusted certificate."
|
||||
return "SSL/TLS connection failed. Possible causes: untrusted or expired certificate, mismatched TLS version, or a reverse-proxy configuration issue. Check your server's HTTPS setup."
|
||||
case .timeout:
|
||||
return "Request timed out. Make sure your device can reach the server."
|
||||
case .notReachable(let host):
|
||||
|
||||
@@ -130,11 +130,11 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
|
||||
bookId = try c.decode(Int.self, forKey: .bookId)
|
||||
chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
slug = try c.decode(String.self, forKey: .slug)
|
||||
slug = (try? c.decode(String.self, forKey: .slug)) ?? ""
|
||||
html = try c.decodeIfPresent(String.self, forKey: .html)
|
||||
markdown = try c.decodeIfPresent(String.self, forKey: .markdown)
|
||||
priority = try c.decode(Int.self, forKey: .priority)
|
||||
draftStatus = try c.decode(Bool.self, forKey: .draftStatus)
|
||||
priority = (try? c.decode(Int.self, forKey: .priority)) ?? 0
|
||||
draftStatus = (try? c.decodeIfPresent(Bool.self, forKey: .draftStatus)) ?? false
|
||||
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
|
||||
createdAt = try c.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try c.decode(Date.self, forKey: .updatedAt)
|
||||
@@ -207,7 +207,6 @@ nonisolated struct TagListResponseDTO: Codable, Sendable {
|
||||
|
||||
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let text: String
|
||||
let html: String
|
||||
let pageId: Int
|
||||
let createdBy: UserSummaryDTO
|
||||
@@ -215,14 +214,19 @@ nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
|
||||
let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, text, html
|
||||
case pageId = "entity_id"
|
||||
case id, html
|
||||
case pageId = "commentable_id"
|
||||
case createdBy = "created_by"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal shape returned by the list endpoint — only used to get IDs for detail fetches.
|
||||
nonisolated struct CommentSummaryDTO: Codable, Sendable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class CachedShelf {
|
||||
@Attribute(.unique) var id: Int
|
||||
var name: String
|
||||
var slug: String
|
||||
var shelfDescription: String
|
||||
var coverURL: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.shelfDescription = shelfDescription
|
||||
self.coverURL = coverURL
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: ShelfDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
shelfDescription: dto.description,
|
||||
coverURL: dto.cover?.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class CachedBook {
|
||||
@Attribute(.unique) var id: Int
|
||||
var name: String
|
||||
var slug: String
|
||||
var bookDescription: String
|
||||
var coverURL: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.bookDescription = bookDescription
|
||||
self.coverURL = coverURL
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: BookDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
bookDescription: dto.description,
|
||||
coverURL: dto.cover?.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class CachedPage {
|
||||
@Attribute(.unique) var id: Int
|
||||
var bookId: Int
|
||||
var chapterId: Int?
|
||||
var name: String
|
||||
var slug: String
|
||||
var html: String?
|
||||
var markdown: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
|
||||
self.id = id
|
||||
self.bookId = bookId
|
||||
self.chapterId = chapterId
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.html = html
|
||||
self.markdown = markdown
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: PageDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
bookId: dto.bookId,
|
||||
chapterId: dto.chapterId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
html: dto.html,
|
||||
markdown: dto.markdown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Shared navigation state for cross-tab navigation requests.
|
||||
@Observable
|
||||
final class AppNavigationState {
|
||||
static let shared = AppNavigationState()
|
||||
private init() {}
|
||||
|
||||
/// When set, MainTabView switches to the Library tab and LibraryView pushes this book.
|
||||
var pendingBookNavigation: BookDTO? = nil
|
||||
|
||||
/// When set to true, MainTabView switches to the Settings tab (e.g. after a 401).
|
||||
var navigateToSettings: Bool = false
|
||||
}
|
||||
@@ -1,35 +1,98 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CredentialStore
|
||||
|
||||
/// Thread-safe, synchronously-bootstrapped credential store.
|
||||
/// Populated from Keychain at app launch — no async step required.
|
||||
final class CredentialStore: @unchecked Sendable {
|
||||
static let shared = CredentialStore()
|
||||
private let lock = NSLock()
|
||||
private var _serverURL: String
|
||||
private var _tokenId: String
|
||||
private var _tokenSecret: String
|
||||
|
||||
private init() {
|
||||
if let idStr = UserDefaults.standard.string(forKey: "activeProfileId"),
|
||||
let uuid = UUID(uuidString: idStr),
|
||||
let creds = KeychainService.loadCredentialsSync(profileId: uuid),
|
||||
let rawURL = UserDefaults.standard.string(forKey: "serverURL") {
|
||||
_serverURL = Self.normalise(rawURL)
|
||||
_tokenId = creds.tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
_tokenSecret = creds.tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
// Fall back to legacy single-profile keys
|
||||
_serverURL = Self.normalise(UserDefaults.standard.string(forKey: "serverURL") ?? "")
|
||||
_tokenId = (KeychainService.loadSync(key: "tokenId") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
_tokenSecret = (KeychainService.loadSync(key: "tokenSecret") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
func update(serverURL: String, tokenId: String, tokenSecret: String) {
|
||||
lock.withLock {
|
||||
_serverURL = Self.normalise(serverURL)
|
||||
_tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
_tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
AppLog(.info, "Credentials updated for \(Self.normalise(serverURL))", category: "API")
|
||||
}
|
||||
|
||||
func snapshot() -> (serverURL: String, tokenId: String, tokenSecret: String) {
|
||||
lock.withLock { (_serverURL, _tokenId, _tokenSecret) }
|
||||
}
|
||||
|
||||
var isConfigured: Bool { lock.withLock { !_serverURL.isEmpty && !_tokenId.isEmpty } }
|
||||
|
||||
static func normalise(_ url: String) -> String {
|
||||
var s = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while s.hasSuffix("/") { s = String(s.dropLast()) }
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookStackAPI
|
||||
|
||||
actor BookStackAPI {
|
||||
static let shared = BookStackAPI()
|
||||
|
||||
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
|
||||
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
|
||||
// No actor-local credential state — all reads go through CredentialStore.
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
d.dateDecodingStrategy = .formatted(formatter)
|
||||
// BookStack returns ISO8601 with variable fractional seconds and timezone formats.
|
||||
// Try formats in order: microseconds (6 digits), milliseconds (3 digits), no fractions.
|
||||
let formats = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", // 2024-01-15T10:30:00.000000Z
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ", // 2024-01-15T10:30:00.000Z
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ", // 2024-01-15T10:30:00Z
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // 2024-01-15T10:30:00.000000+00:00
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // 2024-01-15T10:30:00.000+00:00
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXXXX", // 2024-01-15T10:30:00+00:00
|
||||
].map { fmt -> DateFormatter in
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = fmt
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}
|
||||
d.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let string = try container.decode(String.self)
|
||||
for formatter in formats {
|
||||
if let date = formatter.date(from: string) { return date }
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(in: container,
|
||||
debugDescription: "Cannot decode date: \(string)")
|
||||
}
|
||||
return d
|
||||
}()
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Kept for compatibility — delegates to CredentialStore.
|
||||
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
|
||||
var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
|
||||
self.serverURL = clean
|
||||
self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
AppLog(.info, "API configured for \(clean)", category: "API")
|
||||
CredentialStore.shared.update(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
}
|
||||
|
||||
func getServerURL() -> String { serverURL }
|
||||
func getServerURL() -> String { CredentialStore.shared.snapshot().serverURL }
|
||||
|
||||
// MARK: - Core Request (no body)
|
||||
|
||||
@@ -58,11 +121,12 @@ actor BookStackAPI {
|
||||
method: String,
|
||||
bodyData: Data?
|
||||
) async throws -> T {
|
||||
guard !serverURL.isEmpty else {
|
||||
let creds = CredentialStore.shared.snapshot()
|
||||
guard !creds.serverURL.isEmpty else {
|
||||
AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
|
||||
throw BookStackError.notAuthenticated
|
||||
}
|
||||
guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
|
||||
guard let url = URL(string: "\(creds.serverURL)/api/\(endpoint)") else {
|
||||
AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
|
||||
throw BookStackError.invalidURL
|
||||
}
|
||||
@@ -71,7 +135,7 @@ actor BookStackAPI {
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.timeoutInterval = 30
|
||||
|
||||
@@ -91,14 +155,26 @@ actor BookStackAPI {
|
||||
case .notConnectedToInternet, .networkConnectionLost:
|
||||
mapped = .networkUnavailable
|
||||
case .cannotFindHost, .dnsLookupFailed:
|
||||
mapped = .notReachable(host: serverURL)
|
||||
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
|
||||
mapped = .notReachable(host: creds.serverURL)
|
||||
case .secureConnectionFailed,
|
||||
.serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
|
||||
.clientCertificateRequired, .clientCertificateRejected,
|
||||
.appTransportSecurityRequiresSecureConnection:
|
||||
mapped = .sslError
|
||||
case .cannotConnectToHost:
|
||||
// Could be TLS rejection or TCP refused — check underlying error
|
||||
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
|
||||
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
|
||||
mapped = .sslError
|
||||
} else {
|
||||
mapped = .notReachable(host: creds.serverURL)
|
||||
}
|
||||
default:
|
||||
AppLog(.warning, "\(method) /api/\(endpoint) — unhandled URLError \(urlError.code.rawValue): \(urlError.localizedDescription)", category: "API")
|
||||
mapped = .unknown(urlError.localizedDescription)
|
||||
}
|
||||
AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
|
||||
AppLog(.error, "\(method) /api/\(endpoint) — network error (\(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "API")
|
||||
throw mapped
|
||||
}
|
||||
|
||||
@@ -114,7 +190,7 @@ actor BookStackAPI {
|
||||
let mapped: BookStackError
|
||||
switch http.statusCode {
|
||||
case 401: mapped = .unauthorized
|
||||
case 403: mapped = .forbidden
|
||||
case 403: mapped = .httpError(statusCode: 403, message: errorMessage ?? "Access denied. Your account may lack permission for this action.")
|
||||
case 404: mapped = .notFound(resource: "Resource")
|
||||
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
|
||||
}
|
||||
@@ -140,11 +216,13 @@ actor BookStackAPI {
|
||||
}
|
||||
|
||||
private func parseErrorMessage(from data: Data) -> String? {
|
||||
struct APIErrorEnvelope: Codable {
|
||||
struct Inner: Codable { let message: String? }
|
||||
let error: Inner?
|
||||
}
|
||||
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
// Shape 1: {"error": {"message": "..."}} (older BookStack)
|
||||
if let errorObj = json["error"] as? [String: Any],
|
||||
let msg = errorObj["message"] as? String { return msg }
|
||||
// Shape 2: {"message": "...", "errors": {...}} (validation / newer BookStack)
|
||||
if let msg = json["message"] as? String { return msg }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Shelves
|
||||
@@ -317,25 +395,37 @@ actor BookStackAPI {
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(pageId: Int) async throws -> [CommentDTO] {
|
||||
let response: PaginatedResponse<CommentDTO> = try await request(
|
||||
endpoint: "comments?entity_type=page&entity_id=\(pageId)"
|
||||
let list: PaginatedResponse<CommentSummaryDTO> = try await request(
|
||||
endpoint: "comments?filter[commentable_type]=page&filter[commentable_id]=\(pageId)&count=100"
|
||||
)
|
||||
return response.data
|
||||
// Fetch full detail for each comment in parallel to get html + user info
|
||||
return try await withThrowingTaskGroup(of: CommentDTO.self) { group in
|
||||
for summary in list.data {
|
||||
group.addTask {
|
||||
try await self.request(endpoint: "comments/\(summary.id)")
|
||||
}
|
||||
}
|
||||
var results: [CommentDTO] = []
|
||||
for try await comment in group { results.append(comment) }
|
||||
return results.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
}
|
||||
|
||||
func postComment(pageId: Int, text: String) async throws -> CommentDTO {
|
||||
func postComment(pageId: Int, text: String) async throws {
|
||||
struct Body: Encodable, Sendable {
|
||||
let text: String
|
||||
let entityId: Int
|
||||
let entityType: String
|
||||
let pageId: Int
|
||||
let html: String
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case entityId = "entity_id"
|
||||
case entityType = "entity_type"
|
||||
case pageId = "page_id"
|
||||
case html
|
||||
}
|
||||
}
|
||||
return try await request(endpoint: "comments", method: "POST",
|
||||
body: Body(text: text, entityId: pageId, entityType: "page"))
|
||||
// The POST response shape differs from CommentDTO — use EmptyResponse and discard it
|
||||
struct PostCommentResponse: Decodable, Sendable { let id: Int }
|
||||
// BookStack expects HTML content — wrap plain text in a paragraph tag
|
||||
let html = "<p>\(text.replacingOccurrences(of: "\n", with: "<br>"))</p>"
|
||||
let _: PostCommentResponse = try await request(endpoint: "comments", method: "POST",
|
||||
body: Body(pageId: pageId, html: html))
|
||||
}
|
||||
|
||||
func deleteComment(id: Int) async throws {
|
||||
@@ -373,16 +463,29 @@ actor BookStackAPI {
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: req)
|
||||
} catch let urlError as URLError {
|
||||
AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
|
||||
AppLog(.error, "Network error reaching \(url) (URLError \(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "Auth")
|
||||
switch urlError.code {
|
||||
case .timedOut:
|
||||
throw BookStackError.timeout
|
||||
case .notConnectedToInternet, .networkConnectionLost:
|
||||
throw BookStackError.networkUnavailable
|
||||
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
|
||||
case .secureConnectionFailed,
|
||||
.serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
|
||||
.clientCertificateRequired, .clientCertificateRejected,
|
||||
.appTransportSecurityRequiresSecureConnection:
|
||||
throw BookStackError.sslError
|
||||
case .cannotConnectToHost:
|
||||
// TLS handshake abort arrives as cannotConnectToHost with an SSL underlying error
|
||||
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
|
||||
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
|
||||
throw BookStackError.sslError
|
||||
}
|
||||
throw BookStackError.notReachable(host: url)
|
||||
case .cannotFindHost, .dnsLookupFailed:
|
||||
throw BookStackError.notReachable(host: url)
|
||||
default:
|
||||
AppLog(.warning, "Unhandled URLError \(urlError.code.rawValue) for \(url)", category: "Auth")
|
||||
throw BookStackError.notReachable(host: url)
|
||||
}
|
||||
}
|
||||
@@ -410,7 +513,7 @@ actor BookStackAPI {
|
||||
case 403:
|
||||
let msg = parseErrorMessage(from: data)
|
||||
AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
|
||||
throw BookStackError.forbidden
|
||||
throw BookStackError.httpError(statusCode: 403, message: msg ?? "Access denied. Your account may lack the \"Access System API\" role permission.")
|
||||
|
||||
case 404:
|
||||
// Old BookStack version without /api/system — fall back to /api/books probe
|
||||
@@ -443,9 +546,18 @@ actor BookStackAPI {
|
||||
switch urlError.code {
|
||||
case .timedOut: throw BookStackError.timeout
|
||||
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
|
||||
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
|
||||
case .secureConnectionFailed,
|
||||
.serverCertificateUntrusted, .serverCertificateHasBadDate,
|
||||
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
|
||||
.clientCertificateRequired, .clientCertificateRejected,
|
||||
.appTransportSecurityRequiresSecureConnection:
|
||||
throw BookStackError.sslError
|
||||
case .cannotConnectToHost:
|
||||
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
|
||||
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
|
||||
throw BookStackError.sslError
|
||||
}
|
||||
throw BookStackError.notReachable(host: url)
|
||||
default: throw BookStackError.notReachable(host: url)
|
||||
}
|
||||
}
|
||||
@@ -490,8 +602,9 @@ actor BookStackAPI {
|
||||
/// - mimeType: e.g. "image/jpeg" or "image/png"
|
||||
/// - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
|
||||
func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
|
||||
guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
|
||||
guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
|
||||
let creds = CredentialStore.shared.snapshot()
|
||||
guard !creds.serverURL.isEmpty else { throw BookStackError.notAuthenticated }
|
||||
guard let url = URL(string: "\(creds.serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
@@ -516,7 +629,7 @@ actor BookStackAPI {
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.httpBody = body
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,68 @@ actor KeychainService {
|
||||
try delete(key: tokenSecretKey)
|
||||
}
|
||||
|
||||
// MARK: - Synchronous static helper (for use at app init before async context)
|
||||
// MARK: - Per-profile credential methods
|
||||
|
||||
func saveCredentials(tokenId: String, tokenSecret: String, profileId: UUID) throws {
|
||||
try save(value: tokenId, key: "tokenId-\(profileId.uuidString)")
|
||||
try save(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
func loadCredentials(profileId: UUID) throws -> (tokenId: String, tokenSecret: String)? {
|
||||
guard let id = try load(key: "tokenId-\(profileId.uuidString)"),
|
||||
let secret = try load(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
|
||||
return (id, secret)
|
||||
}
|
||||
|
||||
func deleteCredentials(profileId: UUID) throws {
|
||||
try delete(key: "tokenId-\(profileId.uuidString)")
|
||||
try delete(key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
// MARK: - Synchronous static helpers (for use at app init / non-async contexts)
|
||||
|
||||
static func loadCredentialsSync(profileId: UUID) -> (tokenId: String, tokenSecret: String)? {
|
||||
guard let tokenId = loadSync(key: "tokenId-\(profileId.uuidString)"),
|
||||
let tokenSecret = loadSync(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
|
||||
return (tokenId, tokenSecret)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveSync(value: String, key: String) -> Bool {
|
||||
let service = "com.bookstax.credentials"
|
||||
guard let data = value.data(using: .utf8) else { return false }
|
||||
let deleteQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
let addQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecValueData: data
|
||||
]
|
||||
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func saveCredentialsSync(tokenId: String, tokenSecret: String, profileId: UUID) {
|
||||
saveSync(value: tokenId, key: "tokenId-\(profileId.uuidString)")
|
||||
saveSync(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
static func deleteCredentialsSync(profileId: UUID) {
|
||||
let service = "com.bookstax.credentials"
|
||||
for suffix in ["tokenId-\(profileId.uuidString)", "tokenSecret-\(profileId.uuidString)"] {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: suffix
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadSync(key: String) -> String? {
|
||||
let service = "com.bookstax.credentials"
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Manages in-app language selection independently of the system locale.
|
||||
@Observable
|
||||
final class LanguageManager {
|
||||
|
||||
static let shared = LanguageManager()
|
||||
|
||||
enum Language: String, CaseIterable, Identifiable {
|
||||
case english = "en"
|
||||
case german = "de"
|
||||
case spanish = "es"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .english: return "English"
|
||||
case .german: return "Deutsch"
|
||||
case .spanish: return "Español"
|
||||
}
|
||||
}
|
||||
|
||||
var flag: String {
|
||||
switch self {
|
||||
case .english: return "🇬🇧"
|
||||
case .german: return "🇩🇪"
|
||||
case .spanish: return "🇪🇸"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var current: Language
|
||||
|
||||
private init() {
|
||||
let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
|
||||
current = Language(rawValue: saved) ?? .english
|
||||
}
|
||||
|
||||
func set(_ language: Language) {
|
||||
current = language
|
||||
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
|
||||
}
|
||||
|
||||
/// Returns the localised string for key in the currently selected language.
|
||||
func string(_ key: String) -> String {
|
||||
guard let path = Bundle.main.path(forResource: current.rawValue, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) else {
|
||||
return NSLocalizedString(key, comment: "")
|
||||
}
|
||||
return bundle.localizedString(forKey: key, value: key, table: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience shorthand
|
||||
/// Returns the localized string for the given key using the device system language.
|
||||
func L(_ key: String) -> String {
|
||||
LanguageManager.shared.string(key)
|
||||
NSLocalizedString(key, comment: "")
|
||||
}
|
||||
|
||||
@@ -1,80 +1 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// SyncService handles upserting API DTOs into the local SwiftData cache.
|
||||
/// All methods are @MainActor because ModelContext must be used on the main actor.
|
||||
@MainActor
|
||||
final class SyncService {
|
||||
static let shared = SyncService()
|
||||
|
||||
private let api = BookStackAPI.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Sync Shelves
|
||||
|
||||
func syncShelves(context: ModelContext) async throws {
|
||||
let dtos = try await api.fetchShelves()
|
||||
for dto in dtos {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedShelf>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.name = dto.name
|
||||
existing.shelfDescription = dto.description
|
||||
existing.coverURL = dto.cover?.url
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedShelf(from: dto))
|
||||
}
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Sync Books
|
||||
|
||||
func syncBooks(context: ModelContext) async throws {
|
||||
let dtos = try await api.fetchBooks()
|
||||
for dto in dtos {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedBook>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.name = dto.name
|
||||
existing.bookDescription = dto.description
|
||||
existing.coverURL = dto.cover?.url
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedBook(from: dto))
|
||||
}
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Sync Page (on demand, after viewing)
|
||||
|
||||
func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedPage>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.html = dto.html
|
||||
existing.markdown = dto.markdown
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedPage(from: dto))
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Full sync
|
||||
|
||||
func syncAll(context: ModelContext) async throws {
|
||||
async let shelvesTask: Void = syncShelves(context: context)
|
||||
async let booksTask: Void = syncBooks(context: context)
|
||||
_ = try await (shelvesTask, booksTask)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@ import Observation
|
||||
@Observable
|
||||
final class OnboardingViewModel {
|
||||
|
||||
enum Step: Int, CaseIterable, Hashable {
|
||||
case language = 0
|
||||
case welcome = 1
|
||||
case connect = 2
|
||||
case ready = 3
|
||||
enum Step: Hashable {
|
||||
case welcome
|
||||
case connect
|
||||
case ready
|
||||
}
|
||||
|
||||
// Navigation — NavigationStack path (language is the root, not in the path)
|
||||
var navPath: NavigationPath = NavigationPath()
|
||||
|
||||
// Input
|
||||
var serverNameInput: String = ""
|
||||
var serverURLInput: String = ""
|
||||
var tokenIdInput: String = ""
|
||||
var tokenSecretInput: String = ""
|
||||
@@ -36,6 +36,10 @@ final class OnboardingViewModel {
|
||||
|
||||
// Completion
|
||||
var isComplete: Bool = false
|
||||
/// Set to true after successfully adding a server in the Settings "Add Server" flow.
|
||||
var isAddComplete: Bool = false
|
||||
/// When true, skips the ready step navigation (used in Add Server sheet).
|
||||
var isAddServerMode: Bool = false
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
@@ -77,6 +81,32 @@ final class OnboardingViewModel {
|
||||
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
|
||||
}
|
||||
|
||||
/// True when the URL looks like it points to a publicly accessible server
|
||||
/// (not a private IP, localhost, or .local mDNS host).
|
||||
var isRemoteServer: Bool {
|
||||
guard let host = URL(string: serverURLInput)?.host ?? URL(string: "https://\(serverURLInput)")?.host,
|
||||
!host.isEmpty else { return false }
|
||||
|
||||
// Loopback
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" { return false }
|
||||
|
||||
// mDNS (.local) and plain hostnames without dots are local
|
||||
if host.hasSuffix(".local") || !host.contains(".") { return false }
|
||||
|
||||
// Private IPv4 ranges: 10.x, 172.16–31.x, 192.168.x
|
||||
let octets = host.split(separator: ".").compactMap { Int($0) }
|
||||
if octets.count == 4 {
|
||||
if octets[0] == 10 { return false }
|
||||
if octets[0] == 172, (16...31).contains(octets[1]) { return false }
|
||||
if octets[0] == 192, octets[1] == 168 { return false }
|
||||
// Any other IPv4 (public IP) → remote
|
||||
return true
|
||||
}
|
||||
|
||||
// Domain name with dots → treat as potentially remote
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
func verifyAndSave() async {
|
||||
@@ -117,6 +147,11 @@ final class OnboardingViewModel {
|
||||
let appName = info.appName ?? "BookStack"
|
||||
verifyPhase = .serverOK(appName: appName)
|
||||
|
||||
// Auto-populate server name from API if the user left it blank
|
||||
if serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
serverNameInput = appName
|
||||
}
|
||||
|
||||
// Configure the shared API client with validated credentials
|
||||
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
verifyPhase = .checkingToken
|
||||
@@ -124,25 +159,24 @@ final class OnboardingViewModel {
|
||||
// Attempt to fetch user info (non-fatal — some installs restrict /api/users)
|
||||
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
|
||||
|
||||
// Persist server URL and credentials
|
||||
UserDefaults.standard.set(url, forKey: "serverURL")
|
||||
do {
|
||||
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
} catch let error as BookStackError {
|
||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
||||
verifyPhase = .failed(phase: "keychain", error: error)
|
||||
return
|
||||
} catch {
|
||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
||||
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
// Create and persist a ServerProfile via the shared store
|
||||
let profile = ServerProfile(
|
||||
id: UUID(),
|
||||
name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
serverURL: url
|
||||
)
|
||||
ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
|
||||
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
|
||||
verifyPhase = .done(appName: appName, userName: userName)
|
||||
|
||||
// Navigate to the ready step
|
||||
navPath.append(Step.ready)
|
||||
if isAddServerMode {
|
||||
// In the "Add Server" sheet: signal completion so the sheet dismisses
|
||||
isAddComplete = true
|
||||
} else {
|
||||
// Normal onboarding: navigate to the ready step
|
||||
navPath.append(Step.ready)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Complete
|
||||
|
||||
@@ -23,6 +23,9 @@ final class PageEditorViewModel {
|
||||
var title: String = ""
|
||||
var markdownContent: String = ""
|
||||
var activeTab: EditorTab = .write
|
||||
/// True when the page was created in BookStack's HTML editor (markdown field is nil).
|
||||
/// Opening it here will convert it to Markdown on next save.
|
||||
private(set) var isHtmlOnlyPage: Bool = false
|
||||
|
||||
var isSaving: Bool = false
|
||||
var saveError: BookStackError? = nil
|
||||
@@ -48,11 +51,26 @@ final class PageEditorViewModel {
|
||||
|| tags != lastSavedTags
|
||||
}
|
||||
|
||||
var isSaveDisabled: Bool {
|
||||
if isSaving || title.isEmpty { return true }
|
||||
if case .create = mode {
|
||||
return markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
init(mode: Mode) {
|
||||
self.mode = mode
|
||||
if case .edit(let page) = mode {
|
||||
title = page.name
|
||||
markdownContent = page.markdown ?? ""
|
||||
if let md = page.markdown {
|
||||
markdownContent = md
|
||||
} else {
|
||||
// Page was created in BookStack's HTML editor — markdown field is absent.
|
||||
// Leave markdownContent empty; the user's first edit will convert it to Markdown.
|
||||
markdownContent = ""
|
||||
isHtmlOnlyPage = true
|
||||
}
|
||||
tags = page.tags
|
||||
}
|
||||
// Snapshot the initial state so "no changes yet" returns false
|
||||
@@ -88,7 +106,10 @@ final class PageEditorViewModel {
|
||||
// MARK: - Save
|
||||
|
||||
func save() async {
|
||||
guard !title.isEmpty, !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
// For new pages require both title and content; for existing pages only require a title.
|
||||
let isCreate = if case .create = mode { true } else { false }
|
||||
guard !title.isEmpty else { return }
|
||||
guard !isCreate || !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
|
||||
|
||||
@@ -3,28 +3,59 @@ import UIKit
|
||||
import WebKit
|
||||
import PhotosUI
|
||||
|
||||
// MARK: - UITextView subclass that intercepts image paste from the context menu
|
||||
|
||||
final class ImagePasteTextView: UITextView {
|
||||
/// Called when the user selects Paste and the clipboard contains an image.
|
||||
var onImagePaste: ((UIImage) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
// Show the standard Paste item whenever the clipboard has an image OR text.
|
||||
if action == #selector(paste(_:)) {
|
||||
return UIPasteboard.general.hasImages || UIPasteboard.general.hasStrings
|
||||
}
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
// If there is an image on the clipboard, intercept and forward it.
|
||||
if let image = UIPasteboard.general.image {
|
||||
onImagePaste?(image)
|
||||
} else {
|
||||
super.paste(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextView wrapper that exposes selection-aware formatting
|
||||
|
||||
struct MarkdownTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
/// Called with the UITextView so the parent can apply formatting
|
||||
var onTextViewReady: (UITextView) -> Void
|
||||
/// Called when the user pastes an image via the context menu
|
||||
var onImagePaste: ((UIImage) -> Void)? = nil
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let tv = UITextView()
|
||||
func makeUIView(context: Context) -> ImagePasteTextView {
|
||||
let tv = ImagePasteTextView()
|
||||
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
|
||||
tv.autocorrectionType = .no
|
||||
tv.autocapitalizationType = .none
|
||||
tv.delegate = context.coordinator
|
||||
tv.backgroundColor = .clear
|
||||
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||
tv.isScrollEnabled = true
|
||||
tv.alwaysBounceVertical = true
|
||||
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 80, right: 8)
|
||||
// Set initial text (e.g. when editing an existing page)
|
||||
tv.text = text
|
||||
tv.onImagePaste = onImagePaste
|
||||
onTextViewReady(tv)
|
||||
return tv
|
||||
}
|
||||
|
||||
func updateUIView(_ tv: UITextView, context: Context) {
|
||||
func updateUIView(_ tv: ImagePasteTextView, context: Context) {
|
||||
// Keep the paste closure up to date (captures may change across renders)
|
||||
tv.onImagePaste = onImagePaste
|
||||
// Only push changes that originated outside the UITextView (e.g. formatting toolbar).
|
||||
// Skip updates triggered by the user typing to avoid cursor position resets.
|
||||
guard !context.coordinator.isEditing, tv.text != text else { return }
|
||||
@@ -70,9 +101,17 @@ struct PageEditorView: View {
|
||||
@State private var textView: UITextView? = nil
|
||||
@State private var imagePickerItem: PhotosPickerItem? = nil
|
||||
@State private var showTagEditor = false
|
||||
/// False while the UITextView is doing its initial layout for an existing page.
|
||||
@State private var isEditorReady: Bool
|
||||
|
||||
init(mode: PageEditorViewModel.Mode) {
|
||||
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
|
||||
// Show a loading overlay only for edit mode — new pages start empty so layout is instant.
|
||||
if case .edit = mode {
|
||||
_isEditorReady = State(initialValue: false)
|
||||
} else {
|
||||
_isEditorReady = State(initialValue: true)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -147,12 +186,15 @@ struct PageEditorView: View {
|
||||
)
|
||||
Rectangle().fill(Color(.separator)).frame(height: 0.5)
|
||||
|
||||
// Content area
|
||||
if viewModel.activeTab == .write {
|
||||
writeArea
|
||||
} else {
|
||||
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
||||
// Content area — fills all remaining vertical space
|
||||
Group {
|
||||
if viewModel.activeTab == .write {
|
||||
writeArea
|
||||
} else {
|
||||
MarkdownPreviewView(markdown: viewModel.markdownContent)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
// Save error
|
||||
if let error = viewModel.saveError {
|
||||
@@ -160,12 +202,54 @@ struct PageEditorView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.overlay {
|
||||
if !isEditorReady {
|
||||
ZStack {
|
||||
Color(.systemBackground).ignoresSafeArea()
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeOut(duration: 0.2), value: isEditorReady)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var writeArea: some View {
|
||||
VStack(spacing: 0) {
|
||||
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in textView = tv }
|
||||
if viewModel.isHtmlOnlyPage {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
Text(L("editor.html.notice"))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
Divider()
|
||||
}
|
||||
MarkdownTextEditor(text: $viewModel.markdownContent,
|
||||
onTextViewReady: { tv in
|
||||
textView = tv
|
||||
// One run-loop pass lets UITextView finish its initial layout
|
||||
// before we hide the loading overlay.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(16))
|
||||
isEditorReady = true
|
||||
}
|
||||
},
|
||||
onImagePaste: { image in
|
||||
Task {
|
||||
let data = image.jpegData(compressionQuality: 0.85) ?? Data()
|
||||
guard !data.isEmpty else { return }
|
||||
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
|
||||
}
|
||||
})
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
if case .uploading = viewModel.imageUploadState {
|
||||
HStack(spacing: 8) {
|
||||
@@ -197,7 +281,9 @@ struct PageEditorView: View {
|
||||
isUploadingImage: {
|
||||
if case .uploading = viewModel.imageUploadState { return true }
|
||||
return false
|
||||
}()
|
||||
}(),
|
||||
clipboardHasImage: UIPasteboard.general.hasImages,
|
||||
onPasteImage: { Task { await pasteImageFromClipboard() } }
|
||||
) { action in applyFormat(action) }
|
||||
}
|
||||
}
|
||||
@@ -229,7 +315,7 @@ struct PageEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
|
||||
.disabled(viewModel.isSaveDisabled)
|
||||
.overlay { if viewModel.isSaving { ProgressView().scaleEffect(0.7) } }
|
||||
.transition(.opacity)
|
||||
}
|
||||
@@ -245,6 +331,15 @@ struct PageEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paste image from clipboard
|
||||
|
||||
private func pasteImageFromClipboard() async {
|
||||
guard let uiImage = UIPasteboard.general.image else { return }
|
||||
let data = uiImage.jpegData(compressionQuality: 0.85) ?? Data()
|
||||
guard !data.isEmpty else { return }
|
||||
await viewModel.uploadImage(data: data, filename: "clipboard.jpg", mimeType: "image/jpeg")
|
||||
}
|
||||
|
||||
// MARK: - Apply formatting to selected text (or insert at cursor)
|
||||
|
||||
private func applyFormat(_ action: FormatAction) {
|
||||
@@ -303,7 +398,7 @@ struct PageEditorView: View {
|
||||
private func replace(in tv: UITextView, range: NSRange, with string: String,
|
||||
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
|
||||
guard let swiftRange = Range(range, in: tv.text) else { return }
|
||||
var newText = tv.text!
|
||||
var newText = tv.text ?? ""
|
||||
newText.replaceSubrange(swiftRange, with: string)
|
||||
tv.text = newText
|
||||
viewModel.markdownContent = newText
|
||||
@@ -421,6 +516,8 @@ enum FormatAction {
|
||||
struct FormattingToolbar: View {
|
||||
@Binding var imagePickerItem: PhotosPickerItem?
|
||||
let isUploadingImage: Bool
|
||||
let clipboardHasImage: Bool
|
||||
let onPasteImage: () -> Void
|
||||
let onAction: (FormatAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -429,7 +526,7 @@ struct FormattingToolbar: View {
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
|
||||
// Row 1: Headings + text formatting
|
||||
// Row 1: Headings + inline formatting
|
||||
HStack(spacing: 0) {
|
||||
FormatButton("H1", action: .h1, onAction: onAction)
|
||||
FormatButton("H2", action: .h2, onAction: onAction)
|
||||
@@ -440,7 +537,7 @@ struct FormattingToolbar: View {
|
||||
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
|
||||
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
@@ -456,7 +553,7 @@ struct FormattingToolbar: View {
|
||||
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
|
||||
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
|
||||
toolbarDivider
|
||||
// Image picker
|
||||
// Image picker (from photo library)
|
||||
PhotosPicker(
|
||||
selection: $imagePickerItem,
|
||||
matching: .images,
|
||||
@@ -467,15 +564,23 @@ struct FormattingToolbar: View {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.disabled(isUploadingImage)
|
||||
// Paste image from clipboard
|
||||
Button(action: onPasteImage) {
|
||||
Image(systemName: clipboardHasImage ? "doc.on.clipboard.fill" : "doc.on.clipboard")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
.foregroundStyle(clipboardHasImage ? .primary : .tertiary)
|
||||
}
|
||||
.disabled(!clipboardHasImage || isUploadingImage)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
@@ -483,8 +588,8 @@ struct FormattingToolbar: View {
|
||||
private var toolbarDivider: some View {
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(width: 0.5)
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: 0.5, height: 16)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +619,13 @@ struct FormatButton: View {
|
||||
Group {
|
||||
if let label {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
} else if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
.foregroundStyle(.secondary)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
@@ -533,11 +638,16 @@ struct FormatButton: View {
|
||||
|
||||
struct MarkdownPreviewView: View {
|
||||
let markdown: String
|
||||
@State private var webPage = WebPage()
|
||||
@State private var htmlContent: String = ""
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var serverBaseURL: URL {
|
||||
UserDefaults.standard.string(forKey: "serverURL").flatMap { URL(string: $0) }
|
||||
?? URL(string: "about:blank")!
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
WebView(webPage)
|
||||
HTMLWebView(html: htmlContent, baseURL: serverBaseURL, openLinksExternally: false)
|
||||
.onAppear { loadPreview() }
|
||||
.onChange(of: markdown) { loadPreview() }
|
||||
.onChange(of: colorScheme) { loadPreview() }
|
||||
@@ -550,7 +660,7 @@ struct MarkdownPreviewView: View {
|
||||
let fg = isDark ? "#f2f2f7" : "#000000"
|
||||
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
|
||||
|
||||
let fullHTML = """
|
||||
htmlContent = """
|
||||
<!DOCTYPE html><html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -566,7 +676,6 @@ struct MarkdownPreviewView: View {
|
||||
</head>
|
||||
<body>\(html)</body></html>
|
||||
"""
|
||||
webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!)
|
||||
}
|
||||
|
||||
/// Minimal Markdown → HTML converter for preview purposes.
|
||||
@@ -593,6 +702,14 @@ struct MarkdownPreviewView: View {
|
||||
with: "<h\(h)>$1</h\(h)>",
|
||||
options: .regularExpression)
|
||||
}
|
||||
// Images (must come before links to avoid partial matches)
|
||||
html = html.replacingOccurrences(of: #"!\[([^\]]*)\]\(([^)]+)\)"#,
|
||||
with: "<img src=\"$2\" alt=\"$1\" style=\"max-width:100%;height:auto;border-radius:6px;\">",
|
||||
options: .regularExpression)
|
||||
// Links
|
||||
html = html.replacingOccurrences(of: #"\[([^\]]+)\]\(([^)]+)\)"#,
|
||||
with: "<a href=\"$2\">$1</a>",
|
||||
options: .regularExpression)
|
||||
// Horizontal rule
|
||||
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
|
||||
options: .regularExpression)
|
||||
|
||||
@@ -142,7 +142,7 @@ struct BookDetailView: View {
|
||||
.accessibilityLabel("Add content")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNewPage) {
|
||||
.fullScreenCover(isPresented: $showNewPage) {
|
||||
NavigationStack { PageEditorView(mode: .create(bookId: book.id)) }
|
||||
}
|
||||
.sheet(isPresented: $showNewChapter) {
|
||||
@@ -172,15 +172,7 @@ struct BookDetailView: View {
|
||||
NavigationLink(value: page) {
|
||||
Label(L("book.open"), systemImage: "arrow.up.right.square")
|
||||
}
|
||||
Button {
|
||||
let url = "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)"
|
||||
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
if let window = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first?.keyWindow {
|
||||
window.rootViewController?.present(activity, animated: true)
|
||||
}
|
||||
} label: {
|
||||
ShareLink(item: "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)") {
|
||||
Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Divider()
|
||||
|
||||
@@ -3,11 +3,12 @@ import SwiftUI
|
||||
struct LibraryView: View {
|
||||
@State private var viewModel = LibraryViewModel()
|
||||
@State private var showNewShelf = false
|
||||
@Environment(ConnectivityMonitor.self) private var connectivity
|
||||
@State private var navPath = NavigationPath()
|
||||
@Environment(\.accentTheme) private var theme
|
||||
private let navState = AppNavigationState.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $navPath) {
|
||||
Group {
|
||||
if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
|
||||
LoadingView(message: L("library.loading"))
|
||||
@@ -78,13 +79,14 @@ struct LibraryView: View {
|
||||
.navigationDestination(for: PageDTO.self) { page in
|
||||
PageReaderView(page: page)
|
||||
}
|
||||
.safeAreaInset(edge: .top) {
|
||||
if !connectivity.isConnected {
|
||||
OfflineBanner()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.task { await viewModel.loadShelves() }
|
||||
.onChange(of: navState.pendingBookNavigation) { _, book in
|
||||
guard let book else { return }
|
||||
navPath.append(book)
|
||||
navState.pendingBookNavigation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ struct BreadcrumbBar: View {
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
@@ -132,11 +135,12 @@ struct BreadcrumbBar: View {
|
||||
.font(.subheadline.weight(isLast ? .semibold : .medium))
|
||||
.foregroundStyle(isLast ? .primary : .secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,5 +214,4 @@ struct ContentRowView: View {
|
||||
|
||||
#Preview("Library") {
|
||||
LibraryView()
|
||||
.environment(ConnectivityMonitor.shared)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,42 @@ import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(ConnectivityMonitor.self) private var connectivity
|
||||
@State private var selectedTab = 0
|
||||
@State private var showNudge = false
|
||||
private let navState = AppNavigationState.shared
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab(L("tab.library"), systemImage: "books.vertical") {
|
||||
LibraryView()
|
||||
}
|
||||
|
||||
Tab(L("tab.search"), systemImage: "magnifyingglass") {
|
||||
SearchView()
|
||||
}
|
||||
|
||||
Tab(L("tab.settings"), systemImage: "gear") {
|
||||
SettingsView()
|
||||
}
|
||||
TabView(selection: $selectedTab) {
|
||||
LibraryView()
|
||||
.tabItem { Label(L("tab.library"), systemImage: "books.vertical") }
|
||||
.tag(0)
|
||||
QuickNoteView()
|
||||
.tabItem { Label(L("tab.quicknote"), systemImage: "square.and.pencil") }
|
||||
.tag(1)
|
||||
SearchView()
|
||||
.tabItem { Label(L("tab.search"), systemImage: "magnifyingglass") }
|
||||
.tag(2)
|
||||
SettingsView()
|
||||
.tabItem { Label(L("tab.settings"), systemImage: "gear") }
|
||||
.tag(3)
|
||||
}
|
||||
.onChange(of: navState.pendingBookNavigation) { _, book in
|
||||
if book != nil { selectedTab = 0 }
|
||||
}
|
||||
.onChange(of: navState.navigateToSettings) { _, go in
|
||||
if go { selectedTab = 3; navState.navigateToSettings = false }
|
||||
}
|
||||
.sheet(isPresented: $showNudge, onDismiss: {
|
||||
DonationService.shared.recordNudgeSeen()
|
||||
}) {
|
||||
SupportNudgeSheet(isPresented: $showNudge)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.task {
|
||||
// Small delay so the app settles before the sheet appears
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
showNudge = DonationService.shared.shouldShowNudge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@State private var viewModel = OnboardingViewModel()
|
||||
@State private var langManager = LanguageManager.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -12,7 +11,7 @@ struct OnboardingView: View {
|
||||
Color.clear
|
||||
} else {
|
||||
NavigationStack(path: $viewModel.navPath) {
|
||||
LanguageStepView(viewModel: viewModel)
|
||||
WelcomeStepView(viewModel: viewModel)
|
||||
.navigationDestination(for: OnboardingViewModel.Step.self) { step in
|
||||
switch step {
|
||||
case .welcome:
|
||||
@@ -21,102 +20,12 @@ struct OnboardingView: View {
|
||||
ConnectStepView(viewModel: viewModel)
|
||||
case .ready:
|
||||
ReadyStepView(onComplete: viewModel.completeOnboarding)
|
||||
case .language:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(langManager)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 0: Language
|
||||
|
||||
struct LanguageStepView: View {
|
||||
@Bindable var viewModel: OnboardingViewModel
|
||||
@State private var selected: LanguageManager.Language = LanguageManager.shared.current
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
.frame(width: 120, height: 120)
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(L("onboarding.language.title"))
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(L("onboarding.language.subtitle"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(LanguageManager.Language.allCases) { lang in
|
||||
Button {
|
||||
selected = lang
|
||||
LanguageManager.shared.set(lang)
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Text(lang.flag)
|
||||
.font(.title2)
|
||||
Text(lang.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if selected == lang {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
selected == lang
|
||||
? Color.accentColor.opacity(0.1)
|
||||
: Color(.secondarySystemBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(selected == lang ? Color.accentColor : Color.clear, lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
.accessibilityLabel(lang.displayName)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.push(.welcome)
|
||||
} label: {
|
||||
Text(L("onboarding.welcome.cta"))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +86,8 @@ struct WelcomeStepView: View {
|
||||
|
||||
struct ConnectStepView: View {
|
||||
@Bindable var viewModel: OnboardingViewModel
|
||||
@State private var showTokenId = false
|
||||
@State private var showTokenSecret = false
|
||||
@State private var showTokenId = true
|
||||
@State private var showTokenSecret = true
|
||||
@State private var showHelp = false
|
||||
@State private var verifyTask: Task<Void, Never>? = nil
|
||||
|
||||
@@ -195,6 +104,18 @@ struct ConnectStepView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Server Name field
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("onboarding.server.name.label"), systemImage: "tag")
|
||||
.font(.subheadline.bold())
|
||||
|
||||
TextField(L("onboarding.server.name.placeholder"), text: $viewModel.serverNameInput)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Server URL field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -204,11 +125,19 @@ struct ConnectStepView: View {
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.URL)
|
||||
.onChange(of: viewModel.serverURLInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
viewModel.serverURLInput = UIPasteboard.general.string ?? viewModel.serverURLInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
@@ -224,6 +153,12 @@ struct ConnectStepView: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if viewModel.isRemoteServer {
|
||||
Label(L("onboarding.server.warning.remote"), systemImage: "globe.badge.chevron.backward")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
// Help accordion
|
||||
@@ -253,21 +188,20 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.none)
|
||||
.onChange(of: viewModel.tokenIdInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
|
||||
if UIPasteboard.general.hasStrings {
|
||||
Button {
|
||||
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(L("onboarding.token.paste"))
|
||||
Button {
|
||||
viewModel.tokenIdInput = UIPasteboard.general.string ?? viewModel.tokenIdInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button { showTokenId.toggle() } label: {
|
||||
Image(systemName: showTokenId ? "eye.slash" : "eye")
|
||||
@@ -293,21 +227,20 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.none)
|
||||
.onChange(of: viewModel.tokenSecretInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
|
||||
if UIPasteboard.general.hasStrings {
|
||||
Button {
|
||||
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(L("onboarding.token.paste"))
|
||||
Button {
|
||||
viewModel.tokenSecretInput = UIPasteboard.general.string ?? viewModel.tokenSecretInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button { showTokenSecret.toggle() } label: {
|
||||
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
|
||||
@@ -350,6 +283,9 @@ struct ConnectStepView: View {
|
||||
.onDisappear {
|
||||
verifyTask?.cancel()
|
||||
}
|
||||
.onChange(of: viewModel.serverURLInput) { _, _ in
|
||||
// Clear name hint when URL changes so it re-auto-fills on next verify
|
||||
}
|
||||
}
|
||||
|
||||
private var canConnect: Bool {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QuickNoteView: View {
|
||||
private let navState = AppNavigationState.shared
|
||||
|
||||
// Form fields
|
||||
@State private var title = ""
|
||||
@State private var content = ""
|
||||
|
||||
// Tag selection
|
||||
@State private var availableTags: [TagDTO] = []
|
||||
@State private var selectedTags: [TagDTO] = []
|
||||
@State private var isLoadingTags = false
|
||||
@State private var showTagPicker = false
|
||||
|
||||
// Location selection
|
||||
@State private var shelves: [ShelfDTO] = []
|
||||
@State private var books: [BookDTO] = []
|
||||
@State private var selectedShelf: ShelfDTO? = nil
|
||||
@State private var selectedBook: BookDTO? = nil
|
||||
@State private var isLoadingShelves = false
|
||||
@State private var isLoadingBooks = false
|
||||
|
||||
// Save state
|
||||
@State private var isSaving = false
|
||||
@State private var error: String? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Note content
|
||||
Section(L("quicknote.field.title")) {
|
||||
TextField(L("quicknote.field.title.placeholder"), text: $title)
|
||||
}
|
||||
|
||||
Section(L("quicknote.field.content")) {
|
||||
TextEditor(text: $content)
|
||||
.frame(minHeight: 120)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
// Location: shelf → book
|
||||
Section {
|
||||
if isLoadingShelves {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L("quicknote.shelf.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
} else {
|
||||
Picker(L("quicknote.shelf.label"), selection: $selectedShelf) {
|
||||
Text(L("quicknote.shelf.none")).tag(ShelfDTO?.none)
|
||||
ForEach(shelves) { shelf in
|
||||
Text(shelf.name).tag(ShelfDTO?.some(shelf))
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedShelf) { _, shelf in
|
||||
selectedBook = nil
|
||||
if let shelf {
|
||||
Task { await loadBooks(for: shelf) }
|
||||
} else {
|
||||
Task { await loadAllBooks() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isLoadingBooks {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L("quicknote.book.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
} else {
|
||||
Picker(L("quicknote.book.label"), selection: $selectedBook) {
|
||||
Text(L("quicknote.book.none")).tag(BookDTO?.none)
|
||||
ForEach(books) { book in
|
||||
Text(book.name).tag(BookDTO?.some(book))
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L("quicknote.section.location"))
|
||||
}
|
||||
|
||||
// Tags section
|
||||
Section {
|
||||
if isLoadingTags {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L("quicknote.tags.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
} else {
|
||||
if !selectedTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(selectedTags) { tag in
|
||||
HStack(spacing: 4) {
|
||||
Text(tag.value.isEmpty ? tag.name : "\(tag.name): \(tag.value)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.primary)
|
||||
Button {
|
||||
selectedTags.removeAll { $0.id == tag.id }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showTagPicker = true
|
||||
} label: {
|
||||
Label(
|
||||
selectedTags.isEmpty ? L("quicknote.tags.add") : L("quicknote.tags.edit"),
|
||||
systemImage: "tag"
|
||||
)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L("quicknote.section.tags"))
|
||||
}
|
||||
|
||||
// Error feedback
|
||||
if let err = error {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
|
||||
Text(err).foregroundStyle(.red).font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("quicknote.title"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L("common.cancel")) {
|
||||
resetForm()
|
||||
}
|
||||
.disabled(isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(L("quicknote.save")) {
|
||||
Task { await save() }
|
||||
}
|
||||
.disabled(title.isEmpty || selectedBook == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadShelves()
|
||||
await loadTags()
|
||||
}
|
||||
.sheet(isPresented: $showTagPicker) {
|
||||
TagPickerSheet(
|
||||
availableTags: availableTags,
|
||||
selectedTags: $selectedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load shelves / books
|
||||
|
||||
private func loadShelves() async {
|
||||
isLoadingShelves = true
|
||||
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
|
||||
isLoadingShelves = false
|
||||
await loadAllBooks()
|
||||
}
|
||||
|
||||
private func loadAllBooks() async {
|
||||
isLoadingBooks = true
|
||||
books = (try? await BookStackAPI.shared.fetchBooks()) ?? []
|
||||
isLoadingBooks = false
|
||||
}
|
||||
|
||||
private func loadBooks(for shelf: ShelfDTO) async {
|
||||
isLoadingBooks = true
|
||||
books = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books ?? []
|
||||
isLoadingBooks = false
|
||||
}
|
||||
|
||||
private func loadTags() async {
|
||||
isLoadingTags = true
|
||||
availableTags = (try? await BookStackAPI.shared.fetchTags()) ?? []
|
||||
isLoadingTags = false
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() async {
|
||||
guard let book = selectedBook else {
|
||||
error = L("quicknote.error.nobook")
|
||||
return
|
||||
}
|
||||
error = nil
|
||||
isSaving = true
|
||||
|
||||
do {
|
||||
let page = try await BookStackAPI.shared.createPage(
|
||||
bookId: book.id,
|
||||
name: title,
|
||||
markdown: content,
|
||||
tags: selectedTags
|
||||
)
|
||||
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
|
||||
resetForm()
|
||||
navState.pendingBookNavigation = book
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func resetForm() {
|
||||
title = ""
|
||||
content = ""
|
||||
selectedTags = []
|
||||
selectedShelf = nil
|
||||
selectedBook = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tag Picker Sheet
|
||||
|
||||
struct TagPickerSheet: View {
|
||||
let availableTags: [TagDTO]
|
||||
@Binding var selectedTags: [TagDTO]
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
|
||||
private var filteredTags: [TagDTO] {
|
||||
guard !searchText.isEmpty else { return availableTags }
|
||||
return availableTags.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.value.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if availableTags.isEmpty {
|
||||
Text(L("quicknote.tags.empty"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(filteredTags) { tag in
|
||||
let isSelected = selectedTags.contains { $0.id == tag.id }
|
||||
Button {
|
||||
if isSelected {
|
||||
selectedTags.removeAll { $0.id == tag.id }
|
||||
} else {
|
||||
selectedTags.append(tag)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tag.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
if !tag.value.isEmpty {
|
||||
Text(tag.value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("quicknote.tags.picker.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $searchText, prompt: L("editor.tags.search"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L("common.done")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct PageReaderView: View {
|
||||
let page: PageDTO
|
||||
@State private var webPage = WebPage()
|
||||
@State private var htmlContent: String = ""
|
||||
@State private var fullPage: PageDTO? = nil
|
||||
@State private var isLoadingPage = false
|
||||
@State private var comments: [CommentDTO] = []
|
||||
@State private var isLoadingComments = false
|
||||
@State private var showEditor = false
|
||||
@State private var pageForEditing: PageDTO? = nil
|
||||
@State private var isFetchingForEdit = false
|
||||
@State private var newComment = ""
|
||||
@State private var isPostingComment = false
|
||||
@State private var commentError: String? = nil
|
||||
@State private var commentsExpanded = false
|
||||
@AppStorage("showComments") private var showComments = true
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -23,40 +24,59 @@ struct PageReaderView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(resolvedPage.name)
|
||||
.font(.largeTitle.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
VStack(spacing: 0) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(resolvedPage.name)
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
|
||||
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Web content
|
||||
WebView(webPage)
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// Comments section (hidden when user disabled in Settings)
|
||||
if showComments {
|
||||
DisclosureGroup {
|
||||
commentsContent
|
||||
// Web content — fills all space not taken by the comments inset
|
||||
HTMLWebView(html: htmlContent, baseURL: URL(string: serverURL))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
// Comments panel sits above the tab bar, inside the safe area
|
||||
if showComments {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
// Header row — always visible, tap to expand/collapse
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { commentsExpanded.toggle() }
|
||||
} label: {
|
||||
Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right")
|
||||
.font(.headline)
|
||||
HStack {
|
||||
Label(String(format: L("reader.comments"), comments.count),
|
||||
systemImage: "bubble.left.and.bubble.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: commentsExpanded ? "chevron.down" : "chevron.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Expandable body
|
||||
if commentsExpanded {
|
||||
Divider()
|
||||
commentsContent
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -75,34 +95,24 @@ struct PageReaderView: View {
|
||||
.accessibilityLabel(L("reader.edit"))
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
let url = "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)"
|
||||
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
if let window = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first?.keyWindow {
|
||||
window.rootViewController?.present(activity, animated: true)
|
||||
}
|
||||
} label: {
|
||||
ShareLink(item: "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)") {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.accessibilityLabel(L("reader.share"))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
.fullScreenCover(item: $pageForEditing) { pageToEdit in
|
||||
NavigationStack {
|
||||
if let fullPage {
|
||||
PageEditorView(mode: .edit(page: fullPage))
|
||||
}
|
||||
PageEditorView(mode: .edit(page: pageToEdit))
|
||||
}
|
||||
}
|
||||
.task(id: page.id) {
|
||||
await loadFullPage()
|
||||
await loadComments()
|
||||
}
|
||||
.onChange(of: showEditor) { _, isShowing in
|
||||
.onChange(of: pageForEditing) { _, newValue in
|
||||
// Reload page content after editor is dismissed
|
||||
if !isShowing { Task { await loadFullPage() } }
|
||||
if newValue == nil { Task { await loadFullPage() } }
|
||||
}
|
||||
.onChange(of: colorScheme) {
|
||||
loadContent()
|
||||
@@ -113,42 +123,75 @@ struct PageReaderView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var commentsContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if isLoadingComments {
|
||||
ProgressView()
|
||||
VStack(spacing: 0) {
|
||||
// Scrollable comment list — fixed height so layout is stable
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if isLoadingComments {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else if comments.isEmpty {
|
||||
Text(L("reader.comments.empty"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(comments) { comment in
|
||||
CommentRow(comment: comment)
|
||||
.padding(.horizontal)
|
||||
.id(comment.id)
|
||||
Divider().padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else if comments.isEmpty {
|
||||
Text(L("reader.comments.empty"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(comments) { comment in
|
||||
CommentRow(comment: comment)
|
||||
Divider()
|
||||
}
|
||||
.frame(height: 180)
|
||||
.onChange(of: comments.count) {
|
||||
if let last = comments.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New comment input
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
.padding(10)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Button {
|
||||
Task { await postComment() }
|
||||
} label: {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
|
||||
// Input — always visible at the bottom of the panel
|
||||
Divider()
|
||||
VStack(spacing: 4) {
|
||||
if let err = commentError {
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
|
||||
.accessibilityLabel("Post comment")
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.padding(8)
|
||||
.background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Button {
|
||||
Task { await postComment() }
|
||||
} label: {
|
||||
if isPostingComment {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.foregroundStyle(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.secondary : Color.accentColor)
|
||||
}
|
||||
}
|
||||
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
|
||||
.accessibilityLabel("Post comment")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@@ -160,26 +203,38 @@ struct PageReaderView: View {
|
||||
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
|
||||
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
|
||||
} catch {
|
||||
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader")
|
||||
fullPage = page
|
||||
// Leave fullPage = nil so the editor will re-fetch on demand rather than
|
||||
// receiving the list summary (which has no markdown content).
|
||||
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription)", category: "Reader")
|
||||
}
|
||||
isLoadingPage = false
|
||||
loadContent()
|
||||
}
|
||||
|
||||
private func openEditor() async {
|
||||
// Full page is already fetched by loadFullPage; if still loading, wait briefly
|
||||
if fullPage == nil {
|
||||
isFetchingForEdit = true
|
||||
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
|
||||
isFetchingForEdit = false
|
||||
// Always fetch the full page before opening the editor to guarantee we have markdown content.
|
||||
// Clear pageForEditing at the start to ensure clean state.
|
||||
pageForEditing = nil
|
||||
isFetchingForEdit = true
|
||||
|
||||
do {
|
||||
let fetchedPage = try await BookStackAPI.shared.fetchPage(id: page.id)
|
||||
AppLog(.info, "Fetched full page content for editing: '\(page.name)'", category: "Reader")
|
||||
|
||||
// Only set pageForEditing after successful fetch — this triggers the sheet to appear.
|
||||
// Also update fullPage so the reader view has fresh content when we return.
|
||||
fullPage = fetchedPage
|
||||
pageForEditing = fetchedPage
|
||||
} catch {
|
||||
AppLog(.error, "Could not load page '\(page.name)' for editing: \(error.localizedDescription)", category: "Reader")
|
||||
// Don't set pageForEditing — sheet will not appear, user stays in reader.
|
||||
}
|
||||
showEditor = true
|
||||
|
||||
isFetchingForEdit = false
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
let html = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
|
||||
webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!)
|
||||
htmlContent = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
|
||||
}
|
||||
|
||||
private func loadComments() async {
|
||||
@@ -192,13 +247,18 @@ struct PageReaderView: View {
|
||||
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingComment = true
|
||||
commentError = nil
|
||||
AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
|
||||
do {
|
||||
let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
|
||||
comments.append(comment)
|
||||
try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
|
||||
newComment = ""
|
||||
AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader")
|
||||
AppLog(.info, "Comment posted on '\(page.name)' — reloading", category: "Reader")
|
||||
await loadComments()
|
||||
} catch let e as BookStackError {
|
||||
commentError = e.localizedDescription
|
||||
AppLog(.error, "Failed to post comment on '\(page.name)': \(e.localizedDescription)", category: "Reader")
|
||||
} catch {
|
||||
commentError = error.localizedDescription
|
||||
AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
|
||||
}
|
||||
isPostingComment = false
|
||||
@@ -292,7 +352,7 @@ struct CommentRow: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(comment.text)
|
||||
Text(comment.html.strippingHTML)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet that lets the user connect an additional BookStack server.
|
||||
/// Reuses ConnectStepView — skips the language/welcome steps.
|
||||
struct AddServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: OnboardingViewModel = {
|
||||
let vm = OnboardingViewModel()
|
||||
vm.isAddServerMode = true
|
||||
return vm
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ConnectStepView(viewModel: viewModel)
|
||||
.navigationTitle(L("settings.servers.add"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L("create.cancel")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.isAddComplete) { _, done in
|
||||
if done { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DisplayOptionsView: View {
|
||||
let profile: ServerProfile
|
||||
|
||||
@Environment(ServerProfileStore.self) private var profileStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Local state — mirrors profile values, saved on every change
|
||||
@State private var appTheme: String = "system"
|
||||
@State private var accentThemeRaw: String = AccentTheme.ocean.rawValue
|
||||
@State private var appTextColor: Color = .primary
|
||||
@State private var hasCustomTextColor: Bool = false
|
||||
@State private var appBgColor: Color = Color(.systemBackground)
|
||||
@State private var hasCustomBgColor: Bool = false
|
||||
@State private var editorFontSize: Double = 16
|
||||
@State private var readerFontSize: Double = 16
|
||||
@State private var showResetConfirm = false
|
||||
@State private var showResetAppearanceConfirm = false
|
||||
@AppStorage("displayOptionsInfoSeen") private var infoSeen = false
|
||||
@State private var showInfoAlert = false
|
||||
|
||||
private var selectedTheme: AccentTheme {
|
||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
hintSection
|
||||
appearanceSection
|
||||
editorSection
|
||||
readerSection
|
||||
resetSection
|
||||
previewSection
|
||||
}
|
||||
.navigationTitle(L("display.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(L("display.reset.all"), role: .destructive) {
|
||||
showResetConfirm = true
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(L("display.reset.all.confirm"),
|
||||
isPresented: $showResetConfirm,
|
||||
titleVisibility: .visible) {
|
||||
Button(L("display.reset.all.button"), role: .destructive) { resetAll() }
|
||||
Button(L("common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.confirmationDialog(L("display.reset.appearance.confirm"),
|
||||
isPresented: $showResetAppearanceConfirm,
|
||||
titleVisibility: .visible) {
|
||||
Button(L("display.reset.all.button"), role: .destructive) { resetAppearance() }
|
||||
Button(L("common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
loadFromProfile()
|
||||
if !infoSeen {
|
||||
showInfoAlert = true
|
||||
infoSeen = true
|
||||
}
|
||||
}
|
||||
.alert(L("display.info.title"), isPresented: $showInfoAlert) {
|
||||
Button(L("common.ok"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L("display.info.message"))
|
||||
}
|
||||
.onChange(of: appTheme) { persist() }
|
||||
.onChange(of: accentThemeRaw) { persist() }
|
||||
.onChange(of: appTextColor) { persist() }
|
||||
.onChange(of: hasCustomTextColor) { persist() }
|
||||
.onChange(of: appBgColor) { persist() }
|
||||
.onChange(of: hasCustomBgColor) { persist() }
|
||||
.onChange(of: editorFontSize) { persist() }
|
||||
.onChange(of: readerFontSize) { persist() }
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var hintSection: some View {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top, 2)
|
||||
Text(String(format: L("display.hint"), profile.name))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.listRowBackground(Color(.secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
Section(L("settings.appearance")) {
|
||||
// Light / Dark / System picker
|
||||
Picker(L("settings.appearance.theme"), selection: $appTheme) {
|
||||
Text(L("settings.appearance.theme.system")).tag("system")
|
||||
Text(L("settings.appearance.theme.light")).tag("light")
|
||||
Text(L("settings.appearance.theme.dark")).tag("dark")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// Accent colour swatches
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(L("settings.appearance.accent"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 8), spacing: 10) {
|
||||
ForEach(AccentTheme.allCases) { theme in
|
||||
Button {
|
||||
accentThemeRaw = theme.rawValue
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(theme.shelfColor)
|
||||
.frame(width: 32, height: 32)
|
||||
if selectedTheme == theme {
|
||||
Circle()
|
||||
.strokeBorder(.white, lineWidth: 2)
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(theme.displayName)
|
||||
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// App text colour
|
||||
colorRow(
|
||||
label: L("display.app.textcolor"),
|
||||
color: $appTextColor,
|
||||
hasCustom: $hasCustomTextColor,
|
||||
resetColor: .primary
|
||||
)
|
||||
|
||||
// App background colour
|
||||
colorRow(
|
||||
label: L("display.app.bgcolor"),
|
||||
color: $appBgColor,
|
||||
hasCustom: $hasCustomBgColor,
|
||||
resetColor: Color(.systemBackground)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var editorSection: some View {
|
||||
Section(L("display.editor")) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(L("display.editor.fontsize"))
|
||||
Spacer()
|
||||
Text(String(format: L("display.fontsize.pt"), editorFontSize))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: $editorFontSize, in: 10...28, step: 1)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var readerSection: some View {
|
||||
Section(L("display.reader")) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(L("display.reader.fontsize"))
|
||||
Spacer()
|
||||
Text(String(format: L("display.fontsize.pt"), readerFontSize))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: $readerFontSize, in: 10...28, step: 1)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorRow(label: String, color: Binding<Color>, hasCustom: Binding<Bool>, resetColor: Color) -> some View {
|
||||
HStack {
|
||||
if hasCustom.wrappedValue {
|
||||
ColorPicker(label, selection: color, supportsOpacity: false)
|
||||
Button {
|
||||
hasCustom.wrappedValue = false
|
||||
color.wrappedValue = resetColor
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Text(L("display.color.system"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
Button {
|
||||
hasCustom.wrappedValue = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resetSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showResetAppearanceConfirm = true
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(L("display.reset.appearance"))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
Section(L("display.preview")) {
|
||||
previewCard
|
||||
.listRowInsets(.init(top: 12, leading: 12, bottom: 12, trailing: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Card
|
||||
|
||||
private var previewCard: some View {
|
||||
let bgColor: Color = hasCustomBgColor ? appBgColor : Color(.systemBackground)
|
||||
let textColor: Color = hasCustomTextColor ? appTextColor : Color(.label)
|
||||
let fontSize = CGFloat(readerFontSize)
|
||||
let editorSize = CGFloat(editorFontSize)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
// App / Reader preview
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("display.preview.reader"), systemImage: "doc.text")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Text("Sample Heading")
|
||||
.font(.system(size: fontSize + 4, weight: .bold))
|
||||
.foregroundStyle(textColor)
|
||||
Text("This is a preview of how your pages will look in the reader.")
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundStyle(textColor)
|
||||
.lineSpacing(fontSize * 0.3)
|
||||
Text("let x = 42")
|
||||
.font(.system(size: fontSize - 2, design: .monospaced))
|
||||
.foregroundStyle(textColor.opacity(0.85))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(textColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(bgColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
|
||||
|
||||
// Editor preview
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("display.preview.editor"), systemImage: "pencil")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Text("## My Page\n\nStart writing your **Markdown** here…")
|
||||
.font(.system(size: editorSize, design: .monospaced))
|
||||
.foregroundStyle(Color(.label))
|
||||
.lineSpacing(editorSize * 0.2)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadFromProfile() {
|
||||
let p = profileStore.profiles.first { $0.id == profile.id } ?? profile
|
||||
appTheme = p.appTheme
|
||||
accentThemeRaw = p.accentTheme
|
||||
editorFontSize = p.editorFontSize
|
||||
readerFontSize = p.readerFontSize
|
||||
if let hex = p.appTextColor, let c = Color(hex: hex) {
|
||||
appTextColor = c
|
||||
hasCustomTextColor = true
|
||||
}
|
||||
if let hex = p.appBackgroundColor, let c = Color(hex: hex) {
|
||||
appBgColor = c
|
||||
hasCustomBgColor = true
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let textHex = hasCustomTextColor ? appTextColor.toHexString() : nil
|
||||
let bgHex = hasCustomBgColor ? appBgColor.toHexString() : nil
|
||||
profileStore.updateDisplayOptions(
|
||||
for: profile,
|
||||
editorFontSize: editorFontSize,
|
||||
readerFontSize: readerFontSize,
|
||||
appTextColor: textHex,
|
||||
appBackgroundColor: bgHex,
|
||||
appTheme: appTheme,
|
||||
accentTheme: accentThemeRaw
|
||||
)
|
||||
}
|
||||
|
||||
private func resetAppearance() {
|
||||
appTheme = "system"
|
||||
accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
hasCustomTextColor = false
|
||||
hasCustomBgColor = false
|
||||
appTextColor = .primary
|
||||
appBgColor = Color(.systemBackground)
|
||||
persist()
|
||||
}
|
||||
|
||||
private func resetAll() {
|
||||
appTheme = "system"
|
||||
accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
hasCustomTextColor = false
|
||||
hasCustomBgColor = false
|
||||
appTextColor = .primary
|
||||
appBgColor = Color(.systemBackground)
|
||||
editorFontSize = 16
|
||||
readerFontSize = 16
|
||||
persist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
// MARK: - DonationSectionView
|
||||
|
||||
/// The "Support BookStax" section embedded in SettingsView.
|
||||
/// Shows real products fetched from App Store Connect. Never displays
|
||||
/// placeholder content — all states (loading, empty, error) are explicit.
|
||||
struct DonationSectionView: View {
|
||||
@State private var service = DonationService.shared
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
sectionContent
|
||||
} header: {
|
||||
Text(L("settings.donate"))
|
||||
} footer: {
|
||||
sectionFooter
|
||||
}
|
||||
.task { await service.loadProducts() }
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionContent: some View {
|
||||
switch service.loadState {
|
||||
case .loading:
|
||||
loadingRow
|
||||
|
||||
case .loaded(let products):
|
||||
ForEach(products, id: \.id) { product in
|
||||
DonationProductRow(product: product, service: service)
|
||||
}
|
||||
|
||||
case .empty:
|
||||
Text(L("settings.donate.empty"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
|
||||
case .failed:
|
||||
HStack {
|
||||
Label(L("settings.donate.error"), systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Button(L("common.retry")) {
|
||||
Task { await service.loadProducts() }
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingRow: some View {
|
||||
HStack {
|
||||
Text(L("settings.donate.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sectionFooter: some View {
|
||||
if case .failed = service.loadState {
|
||||
EmptyView()
|
||||
} else {
|
||||
Text(L("settings.donate.footer"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DonationProductRow
|
||||
|
||||
private struct DonationProductRow: View {
|
||||
let product: Product
|
||||
let service: DonationService
|
||||
|
||||
private var isPurchasing: Bool {
|
||||
service.purchaseState.activePurchasingID == product.id
|
||||
}
|
||||
private var isAnyPurchaseInProgress: Bool {
|
||||
service.purchaseState.activePurchasingID != nil
|
||||
}
|
||||
private var isThankYou: Bool {
|
||||
service.purchaseState.thankYouID == product.id
|
||||
}
|
||||
private var isPending: Bool {
|
||||
service.purchaseState.pendingID == product.id
|
||||
}
|
||||
private var errorMessage: String? {
|
||||
service.purchaseState.errorMessage(for: product.id)
|
||||
}
|
||||
private var lastDonatedDate: Date? {
|
||||
service.donationHistory[product.id]
|
||||
}
|
||||
private var isDisabled: Bool {
|
||||
isAnyPurchaseInProgress || isPending
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard !isDisabled else { return }
|
||||
if errorMessage != nil {
|
||||
service.dismissError(for: product.id)
|
||||
} else {
|
||||
Task { await service.purchase(product) }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
productInfo
|
||||
Spacer()
|
||||
trailingIndicator
|
||||
}
|
||||
}
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var productInfo: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(product.displayName)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let date = lastDonatedDate {
|
||||
Text(String(format: L("settings.donate.donated.on"),
|
||||
date.formatted(date: .abbreviated, time: .omitted)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.pink)
|
||||
}
|
||||
|
||||
if isPending {
|
||||
Text(L("settings.donate.pending"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let message = errorMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingIndicator: some View {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if isThankYou {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.pink)
|
||||
} else if errorMessage != nil {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.foregroundStyle(.tint)
|
||||
.bold()
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EditServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(ServerProfileStore.self) private var profileStore
|
||||
|
||||
let profile: ServerProfile
|
||||
|
||||
@State private var name: String
|
||||
@State private var serverURL: String
|
||||
@State private var tokenId: String = ""
|
||||
@State private var tokenSecret: String = ""
|
||||
@State private var showTokenId = false
|
||||
@State private var showTokenSecret = false
|
||||
@State private var changeCredentials = false
|
||||
@State private var isSaving = false
|
||||
@State private var saveError: String? = nil
|
||||
|
||||
init(profile: ServerProfile) {
|
||||
self.profile = profile
|
||||
_name = State(initialValue: profile.name)
|
||||
_serverURL = State(initialValue: profile.serverURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Name & URL
|
||||
Section(L("onboarding.server.name.label")) {
|
||||
TextField(L("onboarding.server.name.placeholder"), text: $name)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
}
|
||||
|
||||
Section(L("onboarding.server.title")) {
|
||||
TextField(L("onboarding.server.placeholder"), text: $serverURL)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.URL)
|
||||
}
|
||||
|
||||
// Optional credential rotation
|
||||
Section {
|
||||
Toggle(L("settings.servers.edit.changecreds"), isOn: $changeCredentials.animation())
|
||||
} footer: {
|
||||
Text(L("settings.servers.edit.changecreds.footer"))
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
if changeCredentials {
|
||||
Section(L("onboarding.token.id.label")) {
|
||||
HStack {
|
||||
Group {
|
||||
if showTokenId {
|
||||
TextField(L("onboarding.token.id.label"), text: $tokenId)
|
||||
} else {
|
||||
SecureField(L("onboarding.token.id.label"), text: $tokenId)
|
||||
}
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.username)
|
||||
|
||||
Button { showTokenId.toggle() } label: {
|
||||
Image(systemName: showTokenId ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Section(L("onboarding.token.secret.label")) {
|
||||
HStack {
|
||||
Group {
|
||||
if showTokenSecret {
|
||||
TextField(L("onboarding.token.secret.label"), text: $tokenSecret)
|
||||
} else {
|
||||
SecureField(L("onboarding.token.secret.label"), text: $tokenSecret)
|
||||
}
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.password)
|
||||
|
||||
Button { showTokenSecret.toggle() } label: {
|
||||
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = saveError {
|
||||
Section {
|
||||
Label(error, systemImage: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("settings.servers.edit.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L("create.cancel")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L("common.done")) {
|
||||
save()
|
||||
}
|
||||
.disabled(!canSave || isSaving)
|
||||
.overlay { if isSaving { ProgressView().scaleEffect(0.7) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
!serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
(!changeCredentials || (!tokenId.isEmpty && !tokenSecret.isEmpty))
|
||||
}
|
||||
|
||||
private func save() {
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
|
||||
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
|
||||
cleanURL = "https://" + cleanURL
|
||||
}
|
||||
while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
|
||||
|
||||
profileStore.updateProfile(
|
||||
profile,
|
||||
newName: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
newURL: cleanURL,
|
||||
newTokenId: changeCredentials ? tokenId : nil,
|
||||
newTokenSecret: changeCredentials ? tokenSecret : nil
|
||||
)
|
||||
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,25 @@ import SafariServices
|
||||
|
||||
struct SettingsView: View {
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
|
||||
@AppStorage("showComments") private var showComments = true
|
||||
@AppStorage("appTheme") private var appTheme = "system"
|
||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
@AppStorage("loggingEnabled") private var loggingEnabled = false
|
||||
@Environment(ServerProfileStore.self) private var profileStore
|
||||
@State private var donationService = DonationService.shared
|
||||
|
||||
private var selectedTheme: AccentTheme {
|
||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||
}
|
||||
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||
@State private var showSignOutAlert = false
|
||||
@State private var isSyncing = false
|
||||
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
|
||||
@State private var showSafari: URL? = nil
|
||||
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
|
||||
@State private var showLogViewer = false
|
||||
@State private var shareItems: [Any]? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var profileToSwitch: ServerProfile? = nil
|
||||
@State private var profileToDelete: ServerProfile? = nil
|
||||
@State private var profileToEdit: ServerProfile? = nil
|
||||
@State private var showCacheClearedAlert = false
|
||||
|
||||
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
@@ -27,29 +29,6 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Language section
|
||||
Section {
|
||||
ForEach(LanguageManager.Language.allCases) { lang in
|
||||
Button {
|
||||
selectedLanguage = lang
|
||||
LanguageManager.shared.set(lang)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(lang.flag)
|
||||
Text(lang.displayName)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if selectedLanguage == lang {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L("settings.language.header"))
|
||||
}
|
||||
|
||||
// Appearance section
|
||||
Section(L("settings.appearance")) {
|
||||
Picker(L("settings.appearance.theme"), selection: $appTheme) {
|
||||
@@ -59,7 +38,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// Accent colour swatches
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(L("settings.appearance.accent"))
|
||||
.font(.subheadline)
|
||||
@@ -93,33 +71,56 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// Account section
|
||||
Section(L("settings.account")) {
|
||||
HStack {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title)
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading) {
|
||||
Text(L("settings.account.connected"))
|
||||
.font(.headline)
|
||||
Text(serverURL)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
// Servers section
|
||||
Section(L("settings.servers")) {
|
||||
ForEach(profileStore.profiles) { profile in
|
||||
Button {
|
||||
if profile.id != profileStore.activeProfileId {
|
||||
profileToSwitch = profile
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: profile.id == profileStore.activeProfileId
|
||||
? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(profile.id == profileStore.activeProfileId
|
||||
? Color.accentColor : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(profile.serverURL)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if profile.id == profileStore.activeProfileId {
|
||||
Text(L("settings.servers.active"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
profileToDelete = profile
|
||||
} label: {
|
||||
Label(L("settings.servers.delete.confirm"), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
profileToEdit = profile
|
||||
} label: {
|
||||
Label(L("settings.servers.edit"), systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = serverURL
|
||||
showAddServer = true
|
||||
} label: {
|
||||
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showSignOutAlert = true
|
||||
} label: {
|
||||
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
|
||||
Label(L("settings.servers.add"), systemImage: "plus.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,31 +158,30 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync section
|
||||
Section(L("settings.sync")) {
|
||||
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
|
||||
|
||||
Button {
|
||||
Task { await syncNow() }
|
||||
// Data section
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
URLCache.shared.removeAllCachedResponses()
|
||||
showCacheClearedAlert = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
|
||||
if isSyncing {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
Label(L("settings.data.clearcache"), systemImage: "trash")
|
||||
}
|
||||
.disabled(isSyncing)
|
||||
} header: {
|
||||
Text(L("settings.data"))
|
||||
} footer: {
|
||||
Text(L("settings.data.clearcache.footer"))
|
||||
}
|
||||
|
||||
if let lastSynced {
|
||||
LabeledContent(L("settings.sync.lastsynced")) {
|
||||
Text(lastSynced.bookStackFormattedWithTime)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Supporter badge — only visible after a donation
|
||||
if donationService.hasEverDonated {
|
||||
Section {
|
||||
SupporterBadgeRow()
|
||||
}
|
||||
}
|
||||
|
||||
// Donate section
|
||||
DonationSectionView()
|
||||
|
||||
// About section
|
||||
Section(L("settings.about")) {
|
||||
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
|
||||
@@ -207,51 +207,77 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
loggingEnabled = LogManager.shared.isEnabled
|
||||
}
|
||||
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
|
||||
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
|
||||
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
|
||||
// Switch server confirmation
|
||||
.alert(L("settings.servers.switch.title"), isPresented: Binding(
|
||||
get: { profileToSwitch != nil },
|
||||
set: { if !$0 { profileToSwitch = nil } }
|
||||
)) {
|
||||
Button(L("settings.servers.switch.confirm")) {
|
||||
if let p = profileToSwitch { profileStore.activate(p) }
|
||||
profileToSwitch = nil
|
||||
}
|
||||
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToSwitch = nil }
|
||||
} message: {
|
||||
Text(L("settings.signout.alert.message"))
|
||||
if let p = profileToSwitch {
|
||||
Text(String(format: L("settings.servers.switch.message"), p.name))
|
||||
}
|
||||
}
|
||||
// Delete inactive server confirmation
|
||||
.alert(L("settings.servers.delete.title"), isPresented: Binding(
|
||||
get: { profileToDelete != nil && profileToDelete?.id != profileStore.activeProfileId },
|
||||
set: { if !$0 { profileToDelete = nil } }
|
||||
)) {
|
||||
Button(L("settings.servers.delete.confirm"), role: .destructive) {
|
||||
if let p = profileToDelete { removeProfile(p) }
|
||||
profileToDelete = nil
|
||||
}
|
||||
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
|
||||
} message: {
|
||||
if let p = profileToDelete {
|
||||
Text(String(format: L("settings.servers.delete.message"), p.name))
|
||||
}
|
||||
}
|
||||
// Delete ACTIVE server — stronger warning
|
||||
.alert(L("settings.servers.delete.active.title"), isPresented: Binding(
|
||||
get: { profileToDelete != nil && profileToDelete?.id == profileStore.activeProfileId },
|
||||
set: { if !$0 { profileToDelete = nil } }
|
||||
)) {
|
||||
Button(L("settings.servers.delete.confirm"), role: .destructive) {
|
||||
if let p = profileToDelete { removeProfile(p) }
|
||||
profileToDelete = nil
|
||||
}
|
||||
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
|
||||
} message: {
|
||||
if let p = profileToDelete {
|
||||
Text(String(format: L("settings.servers.delete.active.message"), p.name))
|
||||
}
|
||||
}
|
||||
.alert(L("settings.data.clearcache.done"), isPresented: $showCacheClearedAlert) {
|
||||
Button(L("common.ok"), role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showAddServer) { AddServerView() }
|
||||
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
|
||||
.sheet(item: $showSafari) { url in
|
||||
SafariView(url: url)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.sheet(isPresented: $showLogViewer) {
|
||||
LogViewerView()
|
||||
SafariView(url: url).ignoresSafeArea()
|
||||
}
|
||||
.sheet(isPresented: $showLogViewer) { LogViewerView() }
|
||||
.sheet(isPresented: Binding(
|
||||
get: { shareItems != nil },
|
||||
set: { if !$0 { shareItems = nil } }
|
||||
)) {
|
||||
if let items = shareItems {
|
||||
ShareSheet(items: items)
|
||||
}
|
||||
if let items = shareItems { ShareSheet(items: items) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func signOut() {
|
||||
Task {
|
||||
try? await KeychainService.shared.deleteCredentials()
|
||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||
UserDefaults.standard.removeObject(forKey: "lastSynced")
|
||||
private func removeProfile(_ profile: ServerProfile) {
|
||||
profileStore.remove(profile)
|
||||
if profileStore.profiles.isEmpty {
|
||||
onboardingComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
private func syncNow() async {
|
||||
isSyncing = true
|
||||
// SyncService.shared.syncAll() requires ModelContext from environment
|
||||
// For now just update last synced date
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
let now = Date()
|
||||
UserDefaults.standard.set(now, forKey: "lastSynced")
|
||||
lastSynced = now
|
||||
isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Safari View
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
// MARK: - SupportNudgeSheet
|
||||
|
||||
/// Modal sheet that surfaces the donation options and encourages the user
|
||||
/// to support active development. Shown at most every 6 months.
|
||||
struct SupportNudgeSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@State private var service = DonationService.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
headerIcon
|
||||
.padding(.top, 40)
|
||||
|
||||
textBlock
|
||||
|
||||
productList
|
||||
|
||||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
Text(L("nudge.dismiss"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
.task { await service.loadProducts() }
|
||||
.onChange(of: service.purchaseState) { _, state in
|
||||
// Auto-close after the thank-you moment
|
||||
if case .thankYou = state {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var headerIcon: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.pink.opacity(0.18), Color.orange.opacity(0.12)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 112, height: 112)
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.pink, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var textBlock: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(L("nudge.title"))
|
||||
.font(.title2.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L("nudge.subtitle"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var productList: some View {
|
||||
switch service.loadState {
|
||||
case .loading:
|
||||
HStack {
|
||||
Text(L("settings.donate.loading"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
case .loaded(let products):
|
||||
VStack(spacing: 8) {
|
||||
ForEach(products, id: \.id) { product in
|
||||
NudgeProductRow(product: product, service: service)
|
||||
}
|
||||
}
|
||||
|
||||
case .empty, .failed:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NudgeProductRow
|
||||
|
||||
private struct NudgeProductRow: View {
|
||||
let product: Product
|
||||
let service: DonationService
|
||||
|
||||
private var isPurchasing: Bool { service.purchaseState.activePurchasingID == product.id }
|
||||
private var isThankYou: Bool { service.purchaseState.thankYouID == product.id }
|
||||
private var isDisabled: Bool { service.purchaseState.activePurchasingID != nil }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard !isDisabled else { return }
|
||||
Task { await service.purchase(product) }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(product.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Text(product.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
trailingView
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingView: some View {
|
||||
if isPurchasing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if isThankYou {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.pink)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.font(.subheadline.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.pink, .orange], startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SupporterBadge
|
||||
|
||||
/// Inline badge shown in Settings for users who have donated.
|
||||
struct SupporterBadgeRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.pink, .orange],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 42, height: 42)
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 19))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(L("supporter.badge.title"))
|
||||
.font(.body.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text(L("supporter.badge.subtitle"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import SwiftUI
|
||||
struct ErrorBanner: View {
|
||||
let error: BookStackError
|
||||
var onRetry: (() -> Void)? = nil
|
||||
var onSettings: (() -> Void)? = nil
|
||||
|
||||
private var isUnauthorized: Bool {
|
||||
if case .unauthorized = error { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -18,12 +22,14 @@ struct ErrorBanner: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if case .unauthorized = error, let onSettings {
|
||||
Button("Settings", action: onSettings)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
if isUnauthorized {
|
||||
Button(L("settings.title")) {
|
||||
AppNavigationState.shared.navigateToSettings = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else if let onRetry {
|
||||
Button("Retry", action: onRetry)
|
||||
Button(L("common.retry"), action: onRetry)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.de.hanold.bookstax</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,12 +1,4 @@
|
||||
//
|
||||
// bookstaxApp.swift
|
||||
// bookstax
|
||||
//
|
||||
// Created by Sven Hanold on 19.03.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct bookstaxApp: App {
|
||||
@@ -14,6 +6,9 @@ struct bookstaxApp: App {
|
||||
@AppStorage("appTheme") private var appTheme = "system"
|
||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
|
||||
// ServerProfileStore is initialised here so migration runs at launch
|
||||
@State private var profileStore = ServerProfileStore.shared
|
||||
|
||||
private var preferredColorScheme: ColorScheme? {
|
||||
switch appTheme {
|
||||
case "light": return .light
|
||||
@@ -26,16 +21,6 @@ struct bookstaxApp: App {
|
||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||
}
|
||||
|
||||
let sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
init() {
|
||||
AppLog(.info, "BookStax launched", category: "App")
|
||||
}
|
||||
@@ -43,18 +28,19 @@ struct bookstaxApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if onboardingComplete {
|
||||
if onboardingComplete && profileStore.activeProfile != nil {
|
||||
MainTabView()
|
||||
.environment(ConnectivityMonitor.shared)
|
||||
// Re-creates the entire tab hierarchy when the active server changes
|
||||
.id(profileStore.activeProfileId)
|
||||
} else {
|
||||
OnboardingView()
|
||||
}
|
||||
}
|
||||
.environment(profileStore)
|
||||
.environment(\.accentTheme, accentTheme)
|
||||
.tint(accentTheme.accentColor)
|
||||
.preferredColorScheme(preferredColorScheme)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"onboarding.server.error.empty" = "Bitte gib die Adresse deines BookStack-Servers ein.";
|
||||
"onboarding.server.error.invalid" = "Das sieht nicht nach einer gültigen Webadresse aus. Versuche z.B. https://bookstack.example.com";
|
||||
"onboarding.server.warning.http" = "Unverschlüsselte Verbindung erkannt. Deine Daten könnten im Netzwerk sichtbar sein.";
|
||||
"onboarding.server.warning.remote" = "Das sieht nach einer öffentlichen Internetadresse aus. BookStack im Internet zu betreiben ist ein Sicherheitsrisiko – nutze besser ein VPN oder halte es im lokalen Netzwerk.";
|
||||
"onboarding.token.title" = "Mit API-Token verbinden";
|
||||
"onboarding.token.subtitle" = "BookStack verwendet API-Tokens für sicheren Zugriff. Du musst einen in deinem BookStack-Profil erstellen.";
|
||||
"onboarding.token.help" = "Wie bekomme ich einen Token?";
|
||||
@@ -41,11 +42,38 @@
|
||||
"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
|
||||
|
||||
// MARK: - Tabs
|
||||
"tab.quicknote" = "Notiz";
|
||||
"tab.library" = "Bibliothek";
|
||||
"tab.search" = "Suche";
|
||||
"tab.create" = "Erstellen";
|
||||
"tab.settings" = "Einstellungen";
|
||||
|
||||
// MARK: - Quick Note
|
||||
"quicknote.title" = "Schnellnotiz";
|
||||
"quicknote.field.title" = "Titel";
|
||||
"quicknote.field.title.placeholder" = "Notiztitel";
|
||||
"quicknote.field.content" = "Inhalt";
|
||||
"quicknote.section.location" = "Speicherort";
|
||||
"quicknote.section.tags" = "Tags";
|
||||
"quicknote.shelf.label" = "Regal";
|
||||
"quicknote.shelf.none" = "Beliebiges Regal";
|
||||
"quicknote.shelf.loading" = "Regale werden geladen…";
|
||||
"quicknote.book.label" = "Buch";
|
||||
"quicknote.book.none" = "Buch auswählen";
|
||||
"quicknote.book.loading" = "Bücher werden geladen…";
|
||||
"quicknote.tags.loading" = "Tags werden geladen…";
|
||||
"quicknote.tags.add" = "Tags hinzufügen";
|
||||
"quicknote.tags.edit" = "Tags bearbeiten";
|
||||
"quicknote.tags.empty" = "Keine Tags auf diesem Server vorhanden.";
|
||||
"quicknote.tags.picker.title" = "Tags auswählen";
|
||||
"quicknote.save" = "Speichern";
|
||||
"quicknote.error.nobook" = "Bitte wähle zuerst ein Buch aus.";
|
||||
"quicknote.saved.online" = "Notiz als neue Seite gespeichert.";
|
||||
"quicknote.saved.offline" = "Lokal gespeichert — wird hochgeladen, sobald du online bist.";
|
||||
"quicknote.pending.title" = "Offline-Notizen";
|
||||
"quicknote.pending.upload" = "Jetzt hochladen";
|
||||
"quicknote.pending.uploading" = "Wird hochgeladen…";
|
||||
|
||||
// MARK: - Library
|
||||
"library.title" = "Bibliothek";
|
||||
"library.loading" = "Bibliothek wird geladen…";
|
||||
@@ -104,6 +132,7 @@
|
||||
"editor.close.unsaved.title" = "Schließen ohne zu speichern?";
|
||||
"editor.close.unsaved.confirm" = "Schließen";
|
||||
"editor.image.uploading" = "Bild wird hochgeladen…";
|
||||
"editor.html.notice" = "Diese Seite verwendet HTML-Formatierung. Beim Bearbeiten wird sie in Markdown umgewandelt.";
|
||||
|
||||
// MARK: - Search
|
||||
"search.title" = "Suche";
|
||||
@@ -190,6 +219,12 @@
|
||||
"settings.reader" = "Leser";
|
||||
"settings.reader.showcomments" = "Kommentare anzeigen";
|
||||
|
||||
// MARK: - Data
|
||||
"settings.data" = "Daten";
|
||||
"settings.data.clearcache" = "Cache leeren";
|
||||
"settings.data.clearcache.footer" = "Löscht zwischengespeicherte Server-Antworten. Hilfreich, wenn Titel oder Inhalte nach einem Update noch veraltet angezeigt werden.";
|
||||
"settings.data.clearcache.done" = "Cache geleert";
|
||||
|
||||
// MARK: - Logging
|
||||
"settings.log" = "Protokoll";
|
||||
"settings.log.enabled" = "Protokollierung aktivieren";
|
||||
@@ -214,7 +249,49 @@
|
||||
"search.filter.tag" = "Tag";
|
||||
"search.filter.tag.clear" = "Tag-Filter entfernen";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Server";
|
||||
"settings.servers.add" = "Server hinzufügen…";
|
||||
"settings.servers.active" = "Aktiv";
|
||||
"settings.servers.switch.title" = "Server wechseln";
|
||||
"settings.servers.switch.message" = "Zu \"%@\" wechseln? Die App wird neu geladen.";
|
||||
"settings.servers.switch.confirm" = "Wechseln";
|
||||
"settings.servers.delete.title" = "Server entfernen";
|
||||
"settings.servers.delete.message" = "\"%@\" entfernen? Der Cache wird geleert. Dies kann nicht rückgängig gemacht werden.";
|
||||
"settings.servers.delete.confirm" = "Entfernen";
|
||||
"settings.servers.delete.active.title" = "Aktiven Server entfernen?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" ist dein aktueller Server. Durch das Entfernen werden alle zwischengespeicherten Inhalte gelöscht und du wirst von diesem Server abgemeldet.";
|
||||
"settings.servers.edit" = "Bearbeiten";
|
||||
"settings.servers.edit.title" = "Server bearbeiten";
|
||||
"settings.servers.edit.changecreds" = "API-Token aktualisieren";
|
||||
"settings.servers.edit.changecreds.footer" = "Aktivieren, um Token-ID und Secret für diesen Server zu ersetzen.";
|
||||
"onboarding.server.name.label" = "Servername";
|
||||
"onboarding.server.name.placeholder" = "z.B. Firmen-Wiki";
|
||||
|
||||
// MARK: - Donations
|
||||
"settings.donate" = "BookStax unterstützen";
|
||||
"settings.donate.page" = "Seite";
|
||||
"settings.donate.book" = "Buch";
|
||||
"settings.donate.encyclopedia" = "Enzyklopädie";
|
||||
"settings.donate.footer" = "Gefällt dir BookStax? Deine Unterstützung hilft, die App kostenlos und in aktiver Entwicklung zu halten. Danke!";
|
||||
"settings.donate.loading" = "Lädt…";
|
||||
"settings.donate.error" = "Spendenoptionen konnten nicht geladen werden.";
|
||||
"settings.donate.empty" = "Keine Spendenoptionen verfügbar.";
|
||||
"settings.donate.donated.on" = "Gespendet am %@";
|
||||
"settings.donate.pending" = "Ausstehende Bestätigung…";
|
||||
|
||||
// MARK: - Support Nudge
|
||||
"nudge.title" = "Hilf mit, BookStax am Laufen zu halten";
|
||||
"nudge.subtitle" = "BookStax ist ein Herzensprojekt — kostenlos, offen und werbefrei. Deine Unterstützung hilft, die App aktiv, modern und wachsend zu gestalten.";
|
||||
"nudge.dismiss" = "Vielleicht später";
|
||||
|
||||
// MARK: - Supporter Badge
|
||||
"supporter.badge.title" = "BookStax Supporter";
|
||||
"supporter.badge.subtitle" = "Danke, dass du die Entwicklung unterstützt!";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "OK";
|
||||
"common.cancel" = "Abbrechen";
|
||||
"common.retry" = "Wiederholen";
|
||||
"common.error" = "Unbekannter Fehler";
|
||||
"common.done" = "Fertig";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"onboarding.server.error.empty" = "Please enter your BookStack server address.";
|
||||
"onboarding.server.error.invalid" = "That doesn't look like a valid web address. Try something like https://bookstack.example.com";
|
||||
"onboarding.server.warning.http" = "Non-encrypted connection detected. Your data may be visible on the network.";
|
||||
"onboarding.server.warning.remote" = "This looks like a public internet address. Exposing BookStack to the internet is a security risk — consider using a VPN or keeping it on your local network.";
|
||||
"onboarding.token.title" = "Connect with an API Token";
|
||||
"onboarding.token.subtitle" = "BookStack uses API tokens for secure access. You'll need to create one in your BookStack profile.";
|
||||
"onboarding.token.help" = "How do I get a token?";
|
||||
@@ -41,11 +42,38 @@
|
||||
"onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
|
||||
|
||||
// MARK: - Tabs
|
||||
"tab.quicknote" = "Quick Note";
|
||||
"tab.library" = "Library";
|
||||
"tab.search" = "Search";
|
||||
"tab.create" = "Create";
|
||||
"tab.settings" = "Settings";
|
||||
|
||||
// MARK: - Quick Note
|
||||
"quicknote.title" = "Quick Note";
|
||||
"quicknote.field.title" = "Title";
|
||||
"quicknote.field.title.placeholder" = "Note title";
|
||||
"quicknote.field.content" = "Content";
|
||||
"quicknote.section.location" = "Location";
|
||||
"quicknote.section.tags" = "Tags";
|
||||
"quicknote.shelf.label" = "Shelf";
|
||||
"quicknote.shelf.none" = "Any Shelf";
|
||||
"quicknote.shelf.loading" = "Loading shelves…";
|
||||
"quicknote.book.label" = "Book";
|
||||
"quicknote.book.none" = "Select a book";
|
||||
"quicknote.book.loading" = "Loading books…";
|
||||
"quicknote.tags.loading" = "Loading tags…";
|
||||
"quicknote.tags.add" = "Add Tags";
|
||||
"quicknote.tags.edit" = "Edit Tags";
|
||||
"quicknote.tags.empty" = "No tags available on this server.";
|
||||
"quicknote.tags.picker.title" = "Select Tags";
|
||||
"quicknote.save" = "Save";
|
||||
"quicknote.error.nobook" = "Please select a book first.";
|
||||
"quicknote.saved.online" = "Note saved as a new page.";
|
||||
"quicknote.saved.offline" = "Saved locally — will upload when online.";
|
||||
"quicknote.pending.title" = "Offline Notes";
|
||||
"quicknote.pending.upload" = "Upload Now";
|
||||
"quicknote.pending.uploading" = "Uploading…";
|
||||
|
||||
// MARK: - Library
|
||||
"library.title" = "Library";
|
||||
"library.loading" = "Loading library…";
|
||||
@@ -104,6 +132,7 @@
|
||||
"editor.close.unsaved.title" = "Close without saving?";
|
||||
"editor.close.unsaved.confirm" = "Close";
|
||||
"editor.image.uploading" = "Uploading image…";
|
||||
"editor.html.notice" = "This page uses HTML formatting. Editing here will convert it to Markdown.";
|
||||
|
||||
// MARK: - Search
|
||||
"search.title" = "Search";
|
||||
@@ -190,6 +219,12 @@
|
||||
"settings.reader" = "Reader";
|
||||
"settings.reader.showcomments" = "Show Comments";
|
||||
|
||||
// MARK: - Data
|
||||
"settings.data" = "Data";
|
||||
"settings.data.clearcache" = "Clear Cache";
|
||||
"settings.data.clearcache.footer" = "Clears cached server responses. Useful if titles or content appear outdated after an update.";
|
||||
"settings.data.clearcache.done" = "Cache Cleared";
|
||||
|
||||
// MARK: - Logging
|
||||
"settings.log" = "Logging";
|
||||
"settings.log.enabled" = "Enable Logging";
|
||||
@@ -214,7 +249,49 @@
|
||||
"search.filter.tag" = "Tag";
|
||||
"search.filter.tag.clear" = "Clear Tag Filter";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Servers";
|
||||
"settings.servers.add" = "Add Server…";
|
||||
"settings.servers.active" = "Active";
|
||||
"settings.servers.switch.title" = "Switch Server";
|
||||
"settings.servers.switch.message" = "Switch to \"%@\"? The app will reload.";
|
||||
"settings.servers.switch.confirm" = "Switch";
|
||||
"settings.servers.delete.title" = "Remove Server";
|
||||
"settings.servers.delete.message" = "Remove \"%@\"? Its cached content will be cleared. This cannot be undone.";
|
||||
"settings.servers.delete.confirm" = "Remove";
|
||||
"settings.servers.delete.active.title" = "Remove Active Server?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" is your current server. Removing it will clear all cached content and sign you out of this server.";
|
||||
"settings.servers.edit" = "Edit";
|
||||
"settings.servers.edit.title" = "Edit Server";
|
||||
"settings.servers.edit.changecreds" = "Update API Token";
|
||||
"settings.servers.edit.changecreds.footer" = "Enable to replace the stored Token ID and Secret for this server.";
|
||||
"onboarding.server.name.label" = "Server Name";
|
||||
"onboarding.server.name.placeholder" = "e.g. Work Wiki";
|
||||
|
||||
// MARK: - Donations
|
||||
"settings.donate" = "Support BookStax";
|
||||
"settings.donate.page" = "Page";
|
||||
"settings.donate.book" = "Book";
|
||||
"settings.donate.encyclopedia" = "Encyclopedia";
|
||||
"settings.donate.footer" = "Enjoying BookStax? Your support helps keep the app free and in active development. Thank you!";
|
||||
"settings.donate.loading" = "Loading…";
|
||||
"settings.donate.error" = "Could not load donation options.";
|
||||
"settings.donate.empty" = "No donation options available.";
|
||||
"settings.donate.donated.on" = "Donated on %@";
|
||||
"settings.donate.pending" = "Pending confirmation…";
|
||||
|
||||
// MARK: - Support Nudge
|
||||
"nudge.title" = "Help keep BookStax going";
|
||||
"nudge.subtitle" = "BookStax is a passion project — free, open, and ad-free. Your support helps keep it maintained, modern, and growing.";
|
||||
"nudge.dismiss" = "Maybe later";
|
||||
|
||||
// MARK: - Supporter Badge
|
||||
"supporter.badge.title" = "BookStax Supporter";
|
||||
"supporter.badge.subtitle" = "Thank you for supporting the development!";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "OK";
|
||||
"common.cancel" = "Cancel";
|
||||
"common.retry" = "Retry";
|
||||
"common.error" = "Unknown error";
|
||||
"common.done" = "Done";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"onboarding.server.error.empty" = "Por favor, introduce la dirección de tu servidor BookStack.";
|
||||
"onboarding.server.error.invalid" = "Eso no parece una dirección web válida. Prueba algo como https://bookstack.example.com";
|
||||
"onboarding.server.warning.http" = "Conexión sin cifrar detectada. Tus datos podrían ser visibles en la red.";
|
||||
"onboarding.server.warning.remote" = "Esto parece una dirección pública de internet. Exponer BookStack a internet es un riesgo de seguridad — considera usar una VPN o mantenerlo en tu red local.";
|
||||
"onboarding.token.title" = "Conectar con un token API";
|
||||
"onboarding.token.subtitle" = "BookStack usa tokens API para un acceso seguro. Deberás crear uno en tu perfil de BookStack.";
|
||||
"onboarding.token.help" = "¿Cómo obtengo un token?";
|
||||
@@ -41,11 +42,38 @@
|
||||
"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
|
||||
|
||||
// MARK: - Tabs
|
||||
"tab.quicknote" = "Nota rápida";
|
||||
"tab.library" = "Biblioteca";
|
||||
"tab.search" = "Búsqueda";
|
||||
"tab.create" = "Crear";
|
||||
"tab.settings" = "Ajustes";
|
||||
|
||||
// MARK: - Quick Note
|
||||
"quicknote.title" = "Nota rápida";
|
||||
"quicknote.field.title" = "Título";
|
||||
"quicknote.field.title.placeholder" = "Título de la nota";
|
||||
"quicknote.field.content" = "Contenido";
|
||||
"quicknote.section.location" = "Ubicación";
|
||||
"quicknote.section.tags" = "Etiquetas";
|
||||
"quicknote.shelf.label" = "Estante";
|
||||
"quicknote.shelf.none" = "Cualquier estante";
|
||||
"quicknote.shelf.loading" = "Cargando estantes…";
|
||||
"quicknote.book.label" = "Libro";
|
||||
"quicknote.book.none" = "Selecciona un libro";
|
||||
"quicknote.book.loading" = "Cargando libros…";
|
||||
"quicknote.tags.loading" = "Cargando etiquetas…";
|
||||
"quicknote.tags.add" = "Añadir etiquetas";
|
||||
"quicknote.tags.edit" = "Editar etiquetas";
|
||||
"quicknote.tags.empty" = "No hay etiquetas disponibles en este servidor.";
|
||||
"quicknote.tags.picker.title" = "Seleccionar etiquetas";
|
||||
"quicknote.save" = "Guardar";
|
||||
"quicknote.error.nobook" = "Selecciona un libro primero.";
|
||||
"quicknote.saved.online" = "Nota guardada como nueva página.";
|
||||
"quicknote.saved.offline" = "Guardado localmente — se subirá cuando estés en línea.";
|
||||
"quicknote.pending.title" = "Notas sin conexión";
|
||||
"quicknote.pending.upload" = "Subir ahora";
|
||||
"quicknote.pending.uploading" = "Subiendo…";
|
||||
|
||||
// MARK: - Library
|
||||
"library.title" = "Biblioteca";
|
||||
"library.loading" = "Cargando biblioteca…";
|
||||
@@ -104,6 +132,7 @@
|
||||
"editor.close.unsaved.title" = "¿Cerrar sin guardar?";
|
||||
"editor.close.unsaved.confirm" = "Cerrar";
|
||||
"editor.image.uploading" = "Subiendo imagen…";
|
||||
"editor.html.notice" = "Esta página usa formato HTML. Editarla aquí la convertirá a Markdown.";
|
||||
|
||||
// MARK: - Search
|
||||
"search.title" = "Búsqueda";
|
||||
@@ -190,6 +219,12 @@
|
||||
"settings.reader" = "Lector";
|
||||
"settings.reader.showcomments" = "Mostrar comentarios";
|
||||
|
||||
// MARK: - Data
|
||||
"settings.data" = "Datos";
|
||||
"settings.data.clearcache" = "Vaciar caché";
|
||||
"settings.data.clearcache.footer" = "Borra las respuestas del servidor almacenadas en caché. Útil si los títulos o el contenido aparecen desactualizados tras una actualización.";
|
||||
"settings.data.clearcache.done" = "Caché vaciada";
|
||||
|
||||
// MARK: - Logging
|
||||
"settings.log" = "Registro";
|
||||
"settings.log.enabled" = "Activar registro";
|
||||
@@ -214,7 +249,49 @@
|
||||
"search.filter.tag" = "Etiqueta";
|
||||
"search.filter.tag.clear" = "Eliminar filtro de etiqueta";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Servidores";
|
||||
"settings.servers.add" = "Añadir servidor…";
|
||||
"settings.servers.active" = "Activo";
|
||||
"settings.servers.switch.title" = "Cambiar servidor";
|
||||
"settings.servers.switch.message" = "¿Cambiar a \"%@\"? La app se recargará.";
|
||||
"settings.servers.switch.confirm" = "Cambiar";
|
||||
"settings.servers.delete.title" = "Eliminar servidor";
|
||||
"settings.servers.delete.message" = "¿Eliminar \"%@\"? Se borrará el contenido en caché. Esta acción no se puede deshacer.";
|
||||
"settings.servers.delete.confirm" = "Eliminar";
|
||||
"settings.servers.delete.active.title" = "¿Eliminar el servidor activo?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" es tu servidor actual. Al eliminarlo se borrará todo el contenido en caché y se cerrará sesión en este servidor.";
|
||||
"settings.servers.edit" = "Editar";
|
||||
"settings.servers.edit.title" = "Editar servidor";
|
||||
"settings.servers.edit.changecreds" = "Actualizar token API";
|
||||
"settings.servers.edit.changecreds.footer" = "Activa para reemplazar el Token ID y Secret almacenados para este servidor.";
|
||||
"onboarding.server.name.label" = "Nombre del servidor";
|
||||
"onboarding.server.name.placeholder" = "p.ej. Wiki de trabajo";
|
||||
|
||||
// MARK: - Donations
|
||||
"settings.donate" = "Apoya BookStax";
|
||||
"settings.donate.page" = "Página";
|
||||
"settings.donate.book" = "Libro";
|
||||
"settings.donate.encyclopedia" = "Enciclopedia";
|
||||
"settings.donate.footer" = "¿Disfrutas BookStax? Tu apoyo ayuda a mantener la app gratuita y en desarrollo activo. ¡Gracias!";
|
||||
"settings.donate.loading" = "Cargando…";
|
||||
"settings.donate.error" = "No se pudieron cargar las opciones de donación.";
|
||||
"settings.donate.empty" = "No hay opciones de donación disponibles.";
|
||||
"settings.donate.donated.on" = "Donado el %@";
|
||||
"settings.donate.pending" = "Confirmación pendiente…";
|
||||
|
||||
// MARK: - Support Nudge
|
||||
"nudge.title" = "Ayuda a mantener BookStax";
|
||||
"nudge.subtitle" = "BookStax es un proyecto personal — gratuito, abierto y sin anuncios. Tu apoyo ayuda a mantenerlo activo, moderno y en crecimiento.";
|
||||
"nudge.dismiss" = "Quizás más tarde";
|
||||
|
||||
// MARK: - Supporter Badge
|
||||
"supporter.badge.title" = "Supporter de BookStax";
|
||||
"supporter.badge.subtitle" = "¡Gracias por apoyar el desarrollo!";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "Aceptar";
|
||||
"common.cancel" = "Cancelar";
|
||||
"common.retry" = "Reintentar";
|
||||
"common.error" = "Error desconocido";
|
||||
"common.done" = "Listo";
|
||||
|
||||
+297
@@ -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é";
|
||||
@@ -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é";
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("& decodes to &")
|
||||
func ampersandEntity() {
|
||||
#expect("Cats & Dogs".strippingHTML == "Cats & Dogs")
|
||||
}
|
||||
|
||||
@Test("< and > decode to < and >")
|
||||
func angleEntities() {
|
||||
#expect("<tag>".strippingHTML == "<tag>")
|
||||
}
|
||||
|
||||
@Test(" is decoded (non-empty result)")
|
||||
func nbspEntity() {
|
||||
let result = "Hello World".strippingHTML
|
||||
#expect(!result.isEmpty)
|
||||
#expect(result.contains("Hello"))
|
||||
#expect(result.contains("World"))
|
||||
}
|
||||
|
||||
@Test("" decodes to double quote")
|
||||
func quotEntity() {
|
||||
#expect("Say "hi"".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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user