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