Rebuild the IAP part

This commit is contained in:
2026-04-07 17:28:54 +02:00
parent 0d8a998ddf
commit 5590100990
25 changed files with 745 additions and 502 deletions
+8 -2
View File
@@ -263,10 +263,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -291,10 +294,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<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>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</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>
</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>
@@ -10,5 +10,13 @@
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>261299D52F6C686D00EC1C97</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
+29
View File
@@ -73,6 +73,35 @@ enum AccentTheme: String, CaseIterable, Identifiable {
var accentColor: Color { shelfColor }
}
// MARK: - Color Hex Helpers
extension Color {
/// Initialises a Color from a CSS-style hex string (#RRGGBB or #RGB).
init?(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h = String(h.dropFirst()) }
let len = h.count
guard len == 6 || len == 3 else { return nil }
if len == 3 {
h = h.map { "\($0)\($0)" }.joined()
}
guard let value = UInt64(h, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255
let g = Double((value >> 8) & 0xFF) / 255
let b = Double( value & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
/// Returns an #RRGGBB hex string for the color (resolved in the light trait environment).
func toHexString() -> String {
let ui = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
let ri = Int(r * 255), gi = Int(g * 255), bi = Int(b * 255)
return String(format: "#%02X%02X%02X", ri, gi, bi)
}
}
// MARK: - Environment Key
private struct AccentThemeKey: EnvironmentKey {
+2 -2
View File
@@ -25,7 +25,7 @@ enum BookStackError: LocalizedError, Sendable {
case .unauthorized:
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
case .forbidden:
return "Access denied. Either your account lacks the \"Access System API\" role permission, or your reverse proxy (nginx/Caddy) is not forwarding the Authorization header. Add `proxy_set_header Authorization $http_authorization;` to your proxy config."
return "Access denied (403). Your account may lack permission for this action."
case .notFound(let resource):
return "\(resource) could not be found. It may have been deleted or moved."
case .httpError(let code, let message):
@@ -37,7 +37,7 @@ enum BookStackError: LocalizedError, Sendable {
case .keychainError(let status):
return "Credential storage failed (code \(status))."
case .sslError:
return "SSL certificate error. If your server uses a self-signed certificate, contact your admin to install a trusted certificate."
return "SSL/TLS connection failed. Possible causes: untrusted or expired certificate, mismatched TLS version, or a reverse-proxy configuration issue. Check your server's HTTPS setup."
case .timeout:
return "Request timed out. Make sure your device can reach the server."
case .notReachable(let host):
+3 -3
View File
@@ -130,11 +130,11 @@ nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
bookId = try c.decode(Int.self, forKey: .bookId)
chapterId = try c.decodeIfPresent(Int.self, forKey: .chapterId)
name = try c.decode(String.self, forKey: .name)
slug = try c.decode(String.self, forKey: .slug)
slug = (try? c.decode(String.self, forKey: .slug)) ?? ""
html = try c.decodeIfPresent(String.self, forKey: .html)
markdown = try c.decodeIfPresent(String.self, forKey: .markdown)
priority = try c.decode(Int.self, forKey: .priority)
draftStatus = try c.decode(Bool.self, forKey: .draftStatus)
priority = (try? c.decode(Int.self, forKey: .priority)) ?? 0
draftStatus = (try? c.decodeIfPresent(Bool.self, forKey: .draftStatus)) ?? false
tags = (try? c.decodeIfPresent([TagDTO].self, forKey: .tags)) ?? []
createdAt = try c.decode(Date.self, forKey: .createdAt)
updatedAt = try c.decode(Date.self, forKey: .updatedAt)
+49 -20
View File
@@ -7,6 +7,14 @@ 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
@@ -28,6 +36,12 @@ final class ServerProfileStore {
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
@@ -46,15 +60,14 @@ final class ServerProfileStore {
guard let creds = KeychainService.loadCredentialsSync(profileId: profile.id) else { return }
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
// Keep legacy "serverURL" key in sync for BookStackAPI
UserDefaults.standard.set(profile.serverURL, forKey: "serverURL")
Task {
await BookStackAPI.shared.configure(
serverURL: profile.serverURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
}
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
)
}
// MARK: - Remove
@@ -82,19 +95,35 @@ final class ServerProfileStore {
KeychainService.saveCredentialsSync(tokenId: id, tokenSecret: secret, profileId: profile.id)
}
save()
// If this is the active profile, re-configure the API client
if activeProfileId == profile.id {
UserDefaults.standard.set(newURL, forKey: "serverURL")
let creds = (newTokenId != nil && newTokenSecret != nil)
? (tokenId: newTokenId!, tokenSecret: newTokenSecret!)
: KeychainService.loadCredentialsSync(profileId: profile.id) ?? (tokenId: "", tokenSecret: "")
Task {
await BookStackAPI.shared.configure(
serverURL: newURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
}
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)
}
}
// 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")
}
}
@@ -131,7 +160,7 @@ final class ServerProfileStore {
save()
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
// Leave legacy "serverURL" in place so BookStackAPI continues working after migration.
// Leave legacy "serverURL" in place so CredentialStore continues working after migration.
AppLog(.info, "Migrated legacy server config to profile \(profile.id)", category: "ServerProfile")
}
}
-94
View File
@@ -1,95 +1 @@
import Foundation
import SwiftData
@Model
final class CachedShelf {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var shelfDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.shelfDescription = shelfDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: ShelfDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
shelfDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedBook {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var bookDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.bookDescription = bookDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: BookDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
bookDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedPage {
@Attribute(.unique) var id: Int
var bookId: Int
var chapterId: Int?
var name: String
var slug: String
var html: String?
var markdown: String?
var lastFetched: Date
init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
self.id = id
self.bookId = bookId
self.chapterId = chapterId
self.name = name
self.slug = slug
self.html = html
self.markdown = markdown
self.lastFetched = Date()
}
convenience init(from dto: PageDTO) {
self.init(
id: dto.id,
bookId: dto.bookId,
chapterId: dto.chapterId,
name: dto.name,
slug: dto.slug,
html: dto.html,
markdown: dto.markdown
)
}
}
@@ -0,0 +1,15 @@
import Foundation
import Observation
/// Shared navigation state for cross-tab navigation requests.
@Observable
final class AppNavigationState {
static let shared = AppNavigationState()
private init() {}
/// When set, MainTabView switches to the Library tab and LibraryView pushes this book.
var pendingBookNavigation: BookDTO? = nil
/// When set to true, MainTabView switches to the Settings tab (e.g. after a 401).
var navigateToSettings: Bool = false
}
+139 -38
View File
@@ -1,35 +1,98 @@
import Foundation
// MARK: - CredentialStore
/// Thread-safe, synchronously-bootstrapped credential store.
/// Populated from Keychain at app launch no async step required.
final class CredentialStore: @unchecked Sendable {
static let shared = CredentialStore()
private let lock = NSLock()
private var _serverURL: String
private var _tokenId: String
private var _tokenSecret: String
private init() {
if let idStr = UserDefaults.standard.string(forKey: "activeProfileId"),
let uuid = UUID(uuidString: idStr),
let creds = KeychainService.loadCredentialsSync(profileId: uuid),
let rawURL = UserDefaults.standard.string(forKey: "serverURL") {
_serverURL = Self.normalise(rawURL)
_tokenId = creds.tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = creds.tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
// Fall back to legacy single-profile keys
_serverURL = Self.normalise(UserDefaults.standard.string(forKey: "serverURL") ?? "")
_tokenId = (KeychainService.loadSync(key: "tokenId") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = (KeychainService.loadSync(key: "tokenSecret") ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
func update(serverURL: String, tokenId: String, tokenSecret: String) {
lock.withLock {
_serverURL = Self.normalise(serverURL)
_tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
_tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
}
AppLog(.info, "Credentials updated for \(Self.normalise(serverURL))", category: "API")
}
func snapshot() -> (serverURL: String, tokenId: String, tokenSecret: String) {
lock.withLock { (_serverURL, _tokenId, _tokenSecret) }
}
var isConfigured: Bool { lock.withLock { !_serverURL.isEmpty && !_tokenId.isEmpty } }
static func normalise(_ url: String) -> String {
var s = url.trimmingCharacters(in: .whitespacesAndNewlines)
while s.hasSuffix("/") { s = String(s.dropLast()) }
return s
}
}
// MARK: - BookStackAPI
actor BookStackAPI {
static let shared = BookStackAPI()
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
// No actor-local credential state all reads go through CredentialStore.
private let decoder: JSONDecoder = {
let d = JSONDecoder()
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(abbreviation: "UTC")
d.dateDecodingStrategy = .formatted(formatter)
// BookStack returns ISO8601 with variable fractional seconds and timezone formats.
// Try formats in order: microseconds (6 digits), milliseconds (3 digits), no fractions.
let formats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", // 2024-01-15T10:30:00.000000Z
"yyyy-MM-dd'T'HH:mm:ss.SSSZ", // 2024-01-15T10:30:00.000Z
"yyyy-MM-dd'T'HH:mm:ssZ", // 2024-01-15T10:30:00Z
"yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", // 2024-01-15T10:30:00.000000+00:00
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", // 2024-01-15T10:30:00.000+00:00
"yyyy-MM-dd'T'HH:mm:ssXXXXX", // 2024-01-15T10:30:00+00:00
].map { fmt -> DateFormatter in
let f = DateFormatter()
f.dateFormat = fmt
f.locale = Locale(identifier: "en_US_POSIX")
return f
}
d.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
for formatter in formats {
if let date = formatter.date(from: string) { return date }
}
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Cannot decode date: \(string)")
}
return d
}()
// MARK: - Configuration
/// Kept for compatibility delegates to CredentialStore.
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
self.serverURL = clean
self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
AppLog(.info, "API configured for \(clean)", category: "API")
CredentialStore.shared.update(serverURL: serverURL, tokenId: tokenId, tokenSecret: tokenSecret)
}
func getServerURL() -> String { serverURL }
func getServerURL() -> String { CredentialStore.shared.snapshot().serverURL }
// MARK: - Core Request (no body)
@@ -58,11 +121,12 @@ actor BookStackAPI {
method: String,
bodyData: Data?
) async throws -> T {
guard !serverURL.isEmpty else {
let creds = CredentialStore.shared.snapshot()
guard !creds.serverURL.isEmpty else {
AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
throw BookStackError.notAuthenticated
}
guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
guard let url = URL(string: "\(creds.serverURL)/api/\(endpoint)") else {
AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
throw BookStackError.invalidURL
}
@@ -71,7 +135,7 @@ actor BookStackAPI {
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
@@ -91,14 +155,26 @@ actor BookStackAPI {
case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable
case .cannotFindHost, .dnsLookupFailed:
mapped = .notReachable(host: serverURL)
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
mapped = .notReachable(host: creds.serverURL)
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
mapped = .sslError
case .cannotConnectToHost:
// Could be TLS rejection or TCP refused check underlying error
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
mapped = .sslError
} else {
mapped = .notReachable(host: creds.serverURL)
}
default:
AppLog(.warning, "\(method) /api/\(endpoint) — unhandled URLError \(urlError.code.rawValue): \(urlError.localizedDescription)", category: "API")
mapped = .unknown(urlError.localizedDescription)
}
AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
AppLog(.error, "\(method) /api/\(endpoint) — network error (\(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "API")
throw mapped
}
@@ -114,7 +190,7 @@ actor BookStackAPI {
let mapped: BookStackError
switch http.statusCode {
case 401: mapped = .unauthorized
case 403: mapped = .forbidden
case 403: mapped = .httpError(statusCode: 403, message: errorMessage ?? "Access denied. Your account may lack permission for this action.")
case 404: mapped = .notFound(resource: "Resource")
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
}
@@ -140,11 +216,13 @@ actor BookStackAPI {
}
private func parseErrorMessage(from data: Data) -> String? {
struct APIErrorEnvelope: Codable {
struct Inner: Codable { let message: String? }
let error: Inner?
}
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
// Shape 1: {"error": {"message": "..."}} (older BookStack)
if let errorObj = json["error"] as? [String: Any],
let msg = errorObj["message"] as? String { return msg }
// Shape 2: {"message": "...", "errors": {...}} (validation / newer BookStack)
if let msg = json["message"] as? String { return msg }
return nil
}
// MARK: - Shelves
@@ -385,16 +463,29 @@ actor BookStackAPI {
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
AppLog(.error, "Network error reaching \(url) (URLError \(urlError.code.rawValue)): \(urlError.localizedDescription)", category: "Auth")
switch urlError.code {
case .timedOut:
throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost:
throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError
case .cannotConnectToHost:
// TLS handshake abort arrives as cannotConnectToHost with an SSL underlying error
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
throw BookStackError.sslError
}
throw BookStackError.notReachable(host: url)
case .cannotFindHost, .dnsLookupFailed:
throw BookStackError.notReachable(host: url)
default:
AppLog(.warning, "Unhandled URLError \(urlError.code.rawValue) for \(url)", category: "Auth")
throw BookStackError.notReachable(host: url)
}
}
@@ -422,7 +513,7 @@ actor BookStackAPI {
case 403:
let msg = parseErrorMessage(from: data)
AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
throw BookStackError.forbidden
throw BookStackError.httpError(statusCode: 403, message: msg ?? "Access denied. Your account may lack the \"Access System API\" role permission.")
case 404:
// Old BookStack version without /api/system fall back to /api/books probe
@@ -455,9 +546,18 @@ actor BookStackAPI {
switch urlError.code {
case .timedOut: throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
case .secureConnectionFailed,
.serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot,
.clientCertificateRequired, .clientCertificateRejected,
.appTransportSecurityRequiresSecureConnection:
throw BookStackError.sslError
case .cannotConnectToHost:
if let underlying = urlError.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSOSStatusErrorDomain || underlying.code == errSSLClosedAbort {
throw BookStackError.sslError
}
throw BookStackError.notReachable(host: url)
default: throw BookStackError.notReachable(host: url)
}
}
@@ -502,8 +602,9 @@ actor BookStackAPI {
/// - mimeType: e.g. "image/jpeg" or "image/png"
/// - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
let creds = CredentialStore.shared.snapshot()
guard !creds.serverURL.isEmpty else { throw BookStackError.notAuthenticated }
guard let url = URL(string: "\(creds.serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
@@ -528,7 +629,7 @@ actor BookStackAPI {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("Token \(creds.tokenId):\(creds.tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
-90
View File
@@ -1,91 +1 @@
import Foundation
import SwiftData
/// SyncService handles upserting API DTOs into the local SwiftData cache.
/// All methods are @MainActor because ModelContext must be used on the main actor.
@MainActor
final class SyncService {
static let shared = SyncService()
private let api = BookStackAPI.shared
private init() {}
// MARK: - Sync Shelves
func syncShelves(context: ModelContext) async throws {
let dtos = try await api.fetchShelves()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedShelf>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.shelfDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedShelf(from: dto))
}
}
try context.save()
}
// MARK: - Sync Books
func syncBooks(context: ModelContext) async throws {
let dtos = try await api.fetchBooks()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedBook>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.bookDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedBook(from: dto))
}
}
try context.save()
}
// MARK: - Sync Page (on demand, after viewing)
func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
let id = dto.id
let descriptor = FetchDescriptor<CachedPage>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.html = dto.html
existing.markdown = dto.markdown
existing.lastFetched = Date()
} else {
context.insert(CachedPage(from: dto))
}
try context.save()
}
// MARK: - Full sync
func syncAll(context: ModelContext) async throws {
async let shelvesTask: Void = syncShelves(context: context)
async let booksTask: Void = syncBooks(context: context)
_ = try await (shelvesTask, booksTask)
}
// MARK: - Clear all cached content
func clearAllCache(context: ModelContext) throws {
try context.delete(model: CachedShelf.self)
try context.delete(model: CachedBook.self)
try context.delete(model: CachedPage.self)
try context.save()
UserDefaults.standard.removeObject(forKey: "lastSynced")
AppLog(.info, "Cleared all cached content", category: "SyncService")
}
}
@@ -82,6 +82,32 @@ final class OnboardingViewModel {
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
}
/// True when the URL looks like it points to a publicly accessible server
/// (not a private IP, localhost, or .local mDNS host).
var isRemoteServer: Bool {
guard let host = URL(string: serverURLInput)?.host ?? URL(string: "https://\(serverURLInput)")?.host,
!host.isEmpty else { return false }
// Loopback
if host == "localhost" || host == "127.0.0.1" || host == "::1" { return false }
// mDNS (.local) and plain hostnames without dots are local
if host.hasSuffix(".local") || !host.contains(".") { return false }
// Private IPv4 ranges: 10.x, 172.1631.x, 192.168.x
let octets = host.split(separator: ".").compactMap { Int($0) }
if octets.count == 4 {
if octets[0] == 10 { return false }
if octets[0] == 172, (16...31).contains(octets[1]) { return false }
if octets[0] == 192, octets[1] == 168 { return false }
// Any other IPv4 (public IP) remote
return true
}
// Domain name with dots treat as potentially remote
return true
}
// MARK: - Verification
func verifyAndSave() async {
+23 -2
View File
@@ -23,6 +23,9 @@ final class PageEditorViewModel {
var title: String = ""
var markdownContent: String = ""
var activeTab: EditorTab = .write
/// True when the page was created in BookStack's HTML editor (markdown field is nil).
/// Opening it here will convert it to Markdown on next save.
private(set) var isHtmlOnlyPage: Bool = false
var isSaving: Bool = false
var saveError: BookStackError? = nil
@@ -48,11 +51,26 @@ final class PageEditorViewModel {
|| tags != lastSavedTags
}
var isSaveDisabled: Bool {
if isSaving || title.isEmpty { return true }
if case .create = mode {
return markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return false
}
init(mode: Mode) {
self.mode = mode
if case .edit(let page) = mode {
title = page.name
markdownContent = page.markdown ?? ""
if let md = page.markdown {
markdownContent = md
} else {
// Page was created in BookStack's HTML editor markdown field is absent.
// Leave markdownContent empty; the user's first edit will convert it to Markdown.
markdownContent = ""
isHtmlOnlyPage = true
}
tags = page.tags
}
// Snapshot the initial state so "no changes yet" returns false
@@ -88,7 +106,10 @@ final class PageEditorViewModel {
// MARK: - Save
func save() async {
guard !title.isEmpty, !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
// For new pages require both title and content; for existing pages only require a title.
let isCreate = if case .create = mode { true } else { false }
guard !title.isEmpty else { return }
guard !isCreate || !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSaving = true
saveError = nil
+14 -1
View File
@@ -200,6 +200,19 @@ struct PageEditorView: View {
@ViewBuilder
private var writeArea: some View {
VStack(spacing: 0) {
if viewModel.isHtmlOnlyPage {
HStack(spacing: 8) {
Image(systemName: "info.circle")
Text(L("editor.html.notice"))
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground))
Divider()
}
MarkdownTextEditor(text: $viewModel.markdownContent,
onTextViewReady: { tv in textView = tv },
onImagePaste: { image in
@@ -275,7 +288,7 @@ struct PageEditorView: View {
}
}
}
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
.disabled(viewModel.isSaveDisabled)
.overlay { if viewModel.isSaving { ProgressView().scaleEffect(0.7) } }
.transition(.opacity)
}
+9 -8
View File
@@ -3,11 +3,12 @@ import SwiftUI
struct LibraryView: View {
@State private var viewModel = LibraryViewModel()
@State private var showNewShelf = false
@Environment(ConnectivityMonitor.self) private var connectivity
@State private var navPath = NavigationPath()
@Environment(\.accentTheme) private var theme
private let navState = AppNavigationState.shared
var body: some View {
NavigationStack {
NavigationStack(path: $navPath) {
Group {
if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
LoadingView(message: L("library.loading"))
@@ -78,13 +79,14 @@ struct LibraryView: View {
.navigationDestination(for: PageDTO.self) { page in
PageReaderView(page: page)
}
.safeAreaInset(edge: .top) {
if !connectivity.isConnected {
OfflineBanner()
}
}
}
.task { await viewModel.loadShelves() }
.onChange(of: navState.pendingBookNavigation) { _, book in
guard let book else { return }
navPath.append(book)
navState.pendingBookNavigation = nil
}
}
}
@@ -212,5 +214,4 @@ struct ContentRowView: View {
#Preview("Library") {
LibraryView()
.environment(ConnectivityMonitor.shared)
}
+16 -4
View File
@@ -2,21 +2,33 @@ import SwiftUI
struct MainTabView: View {
@Environment(ConnectivityMonitor.self) private var connectivity
@State private var selectedTab = 0
private let navState = AppNavigationState.shared
var body: some View {
TabView {
Tab(L("tab.library"), systemImage: "books.vertical") {
TabView(selection: $selectedTab) {
Tab(L("tab.library"), systemImage: "books.vertical", value: 0) {
LibraryView()
}
Tab(L("tab.search"), systemImage: "magnifyingglass") {
Tab(L("tab.quicknote"), systemImage: "square.and.pencil", value: 1) {
QuickNoteView()
}
Tab(L("tab.search"), systemImage: "magnifyingglass", value: 2) {
SearchView()
}
Tab(L("tab.settings"), systemImage: "gear") {
Tab(L("tab.settings"), systemImage: "gear", value: 3) {
SettingsView()
}
}
.onChange(of: navState.pendingBookNavigation) { _, book in
if book != nil { selectedTab = 0 }
}
.onChange(of: navState.navigateToSettings) { _, go in
if go { selectedTab = 3; navState.navigateToSettings = false }
}
}
}
+29 -20
View File
@@ -177,8 +177,8 @@ struct WelcomeStepView: View {
struct ConnectStepView: View {
@Bindable var viewModel: OnboardingViewModel
@State private var showTokenId = false
@State private var showTokenSecret = false
@State private var showTokenId = true
@State private var showTokenSecret = true
@State private var showHelp = false
@State private var verifyTask: Task<Void, Never>? = nil
@@ -222,6 +222,13 @@ struct ConnectStepView: View {
viewModel.resetVerification()
}
}
Button {
viewModel.serverURLInput = UIPasteboard.general.string ?? viewModel.serverURLInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
@@ -237,6 +244,12 @@ struct ConnectStepView: View {
.font(.footnote)
.foregroundStyle(.orange)
}
if viewModel.isRemoteServer {
Label(L("onboarding.server.warning.remote"), systemImage: "globe.badge.chevron.backward")
.font(.footnote)
.foregroundStyle(.orange)
}
}
// Help accordion
@@ -266,22 +279,20 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.username)
.textContentType(.none)
.onChange(of: viewModel.tokenIdInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
}
}
if UIPasteboard.general.hasStrings {
Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
Button {
viewModel.tokenIdInput = UIPasteboard.general.string ?? viewModel.tokenIdInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye")
@@ -307,22 +318,20 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.password)
.textContentType(.none)
.onChange(of: viewModel.tokenSecretInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
}
}
if UIPasteboard.general.hasStrings {
Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
Button {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? viewModel.tokenSecretInput
} label: {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
+159 -112
View File
@@ -1,14 +1,17 @@
import SwiftUI
import SwiftData
struct QuickNoteView: View {
@Environment(\.modelContext) private var modelContext
@Environment(ConnectivityMonitor.self) private var connectivity
private let navState = AppNavigationState.shared
// Form fields
@State private var title = ""
@State private var content = ""
@State private var tagsRaw = ""
// 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] = []
@@ -20,19 +23,14 @@ struct QuickNoteView: View {
// Save state
@State private var isSaving = false
@State private var savedMessage: String? = nil
@State private var error: String? = nil
// Pending notes
@Query(sort: \PendingNote.createdAt) private var pendingNotes: [PendingNote]
@State private var isUploadingPending = false
var body: some View {
NavigationStack {
Form {
// Note content
Section(L("quicknote.field.title")) {
TextField(L("quicknote.field.title"), text: $title)
TextField(L("quicknote.field.title.placeholder"), text: $title)
}
Section(L("quicknote.field.content")) {
@@ -41,14 +39,8 @@ struct QuickNoteView: View {
.font(.system(.body, design: .monospaced))
}
Section(L("quicknote.field.tags")) {
TextField(L("quicknote.field.tags"), text: $tagsRaw)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
// Location: shelf book
Section(L("quicknote.section.location")) {
Section {
if isLoadingShelves {
HStack {
ProgressView().controlSize(.small)
@@ -57,7 +49,7 @@ struct QuickNoteView: View {
.padding(.leading, 8)
}
} else {
Picker(L("create.shelf.title"), selection: $selectedShelf) {
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))
@@ -81,24 +73,69 @@ struct QuickNoteView: View {
.padding(.leading, 8)
}
} else {
Picker(L("create.book.title"), selection: $selectedBook) {
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"))
}
// Feedback
if let msg = savedMessage {
Section {
// Tags section
Section {
if isLoadingTags {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text(msg).foregroundStyle(.secondary).font(.footnote)
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 {
@@ -107,42 +144,15 @@ struct QuickNoteView: View {
}
}
}
// Pending notes section
if !pendingNotes.isEmpty {
Section {
ForEach(pendingNotes) { note in
VStack(alignment: .leading, spacing: 2) {
Text(note.title).font(.body)
Text(note.bookName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: deletePending)
Button {
Task { await uploadPending() }
} label: {
if isUploadingPending {
HStack {
ProgressView().controlSize(.small)
Text(L("quicknote.pending.uploading"))
.foregroundStyle(.secondary)
.padding(.leading, 6)
}
} else {
Label(L("quicknote.pending.upload"), systemImage: "arrow.up.circle")
}
}
.disabled(isUploadingPending || !connectivity.isConnected)
} header: {
Text(L("quicknote.pending.title"))
}
}
}
.navigationTitle(L("quicknote.title"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("common.cancel")) {
resetForm()
}
.disabled(isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
@@ -156,6 +166,13 @@ struct QuickNoteView: View {
}
.task {
await loadShelves()
await loadTags()
}
.sheet(isPresented: $showTagPicker) {
TagPickerSheet(
availableTags: availableTags,
selectedTags: $selectedTags
)
}
}
}
@@ -181,6 +198,12 @@ struct QuickNoteView: View {
isLoadingBooks = false
}
private func loadTags() async {
isLoadingTags = true
availableTags = (try? await BookStackAPI.shared.fetchTags()) ?? []
isLoadingTags = false
}
// MARK: - Save
private func save() async {
@@ -189,74 +212,98 @@ struct QuickNoteView: View {
return
}
error = nil
savedMessage = nil
isSaving = true
let tagDTOs = parsedTags()
if connectivity.isConnected {
do {
let page = try await BookStackAPI.shared.createPage(
bookId: book.id,
name: title,
markdown: content,
tags: tagDTOs
)
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
savedMessage = L("quicknote.saved.online")
resetForm()
} catch {
self.error = error.localizedDescription
}
} else {
let tagsString = tagDTOs.map { "\($0.name):\($0.value)" }.joined(separator: ",")
let pending = PendingNote(
title: title,
markdown: content,
tags: tagsString,
do {
let page = try await BookStackAPI.shared.createPage(
bookId: book.id,
bookName: book.name
name: title,
markdown: content,
tags: selectedTags
)
modelContext.insert(pending)
try? modelContext.save()
savedMessage = L("quicknote.saved.offline")
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
resetForm()
navState.pendingBookNavigation = book
} catch {
self.error = error.localizedDescription
}
isSaving = false
}
// MARK: - Pending
private func uploadPending() async {
isUploadingPending = true
await SyncService.shared.flushPendingNotes(context: modelContext)
isUploadingPending = false
}
private func deletePending(at offsets: IndexSet) {
for i in offsets {
modelContext.delete(pendingNotes[i])
}
try? modelContext.save()
}
// MARK: - Helpers
private func parsedTags() -> [TagDTO] {
tagsRaw.split(separator: ",").compactMap { token -> TagDTO? in
let parts = token.trimmingCharacters(in: .whitespaces)
.split(separator: ":", maxSplits: 1)
.map(String.init)
guard parts.count == 2 else { return nil }
return TagDTO(name: parts[0].trimmingCharacters(in: .whitespaces),
value: parts[1].trimmingCharacters(in: .whitespaces),
order: 0)
}
}
private func resetForm() {
title = ""
content = ""
tagsRaw = ""
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() }
}
}
}
}
}
+26 -17
View File
@@ -8,7 +8,7 @@ struct PageReaderView: View {
@State private var isLoadingPage = false
@State private var comments: [CommentDTO] = []
@State private var isLoadingComments = false
@State private var showEditor = false
@State private var pageForEditing: PageDTO? = nil
@State private var isFetchingForEdit = false
@State private var newComment = ""
@State private var isPostingComment = false
@@ -110,20 +110,18 @@ struct PageReaderView: View {
.accessibilityLabel(L("reader.share"))
}
}
.fullScreenCover(isPresented: $showEditor) {
.fullScreenCover(item: $pageForEditing) { pageToEdit in
NavigationStack {
if let fullPage {
PageEditorView(mode: .edit(page: fullPage))
}
PageEditorView(mode: .edit(page: pageToEdit))
}
}
.task(id: page.id) {
await loadFullPage()
await loadComments()
}
.onChange(of: showEditor) { _, isShowing in
.onChange(of: pageForEditing) { _, newValue in
// Reload page content after editor is dismissed
if !isShowing { Task { await loadFullPage() } }
if newValue == nil { Task { await loadFullPage() } }
}
.onChange(of: colorScheme) {
loadContent()
@@ -214,21 +212,34 @@ struct PageReaderView: View {
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
} catch {
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader")
fullPage = page
// Leave fullPage = nil so the editor will re-fetch on demand rather than
// receiving the list summary (which has no markdown content).
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription)", category: "Reader")
}
isLoadingPage = false
loadContent()
}
private func openEditor() async {
// Full page is already fetched by loadFullPage; if still loading, wait briefly
if fullPage == nil {
isFetchingForEdit = true
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
isFetchingForEdit = false
// Always fetch the full page before opening the editor to guarantee we have markdown content.
// Clear pageForEditing at the start to ensure clean state.
pageForEditing = nil
isFetchingForEdit = true
do {
let fetchedPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Fetched full page content for editing: '\(page.name)'", category: "Reader")
// Only set pageForEditing after successful fetch this triggers the sheet to appear.
// Also update fullPage so the reader view has fresh content when we return.
fullPage = fetchedPage
pageForEditing = fetchedPage
} catch {
AppLog(.error, "Could not load page '\(page.name)' for editing: \(error.localizedDescription)", category: "Reader")
// Don't set pageForEditing sheet will not appear, user stays in reader.
}
showEditor = true
isFetchingForEdit = false
}
private func loadContent() {
@@ -240,8 +251,6 @@ struct PageReaderView: View {
isLoadingComments = true
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
isLoadingComments = false
// Auto-expand if there are comments
if !comments.isEmpty { commentsExpanded = true }
}
private func postComment() async {
+5 -62
View File
@@ -1,10 +1,8 @@
import SwiftUI
import SafariServices
import SwiftData
struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
@AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
@@ -15,13 +13,10 @@ struct SettingsView: View {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
@State private var showSignOutAlert = false
@State private var isSyncing = false
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
@State private var showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
@State private var showLogViewer = false
@State private var shareItems: [Any]? = nil
@Environment(\.modelContext) private var modelContext
@State private var showAddServer = false
@State private var profileToSwitch: ServerProfile? = nil
@State private var profileToDelete: ServerProfile? = nil
@@ -65,7 +60,6 @@ struct SettingsView: View {
}
.pickerStyle(.segmented)
// Accent colour swatches
VStack(alignment: .leading, spacing: 10) {
Text(L("settings.appearance.accent"))
.font(.subheadline)
@@ -186,31 +180,6 @@ struct SettingsView: View {
}
}
// Sync section
Section(L("settings.sync")) {
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
Button {
Task { await syncNow() }
} label: {
HStack {
Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
if isSyncing {
Spacer()
ProgressView()
}
}
}
.disabled(isSyncing)
if let lastSynced {
LabeledContent(L("settings.sync.lastsynced")) {
Text(lastSynced.bookStackFormattedWithTime)
.foregroundStyle(.secondary)
}
}
}
// About section
Section(L("settings.about")) {
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
@@ -281,28 +250,17 @@ struct SettingsView: View {
Text(String(format: L("settings.servers.delete.active.message"), p.name))
}
}
// Add server sheet
.sheet(isPresented: $showAddServer) {
AddServerView()
}
// Edit server sheet
.sheet(item: $profileToEdit) { profile in
EditServerView(profile: profile)
}
.sheet(isPresented: $showAddServer) { AddServerView() }
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
.sheet(item: $showSafari) { url in
SafariView(url: url)
.ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) {
LogViewerView()
SafariView(url: url).ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) { LogViewerView() }
.sheet(isPresented: Binding(
get: { shareItems != nil },
set: { if !$0 { shareItems = nil } }
)) {
if let items = shareItems {
ShareSheet(items: items)
}
if let items = shareItems { ShareSheet(items: items) }
}
}
}
@@ -311,25 +269,10 @@ struct SettingsView: View {
private func removeProfile(_ profile: ServerProfile) {
profileStore.remove(profile)
// Always clear the cache it may contain content from this server
try? SyncService.shared.clearAllCache(context: modelContext)
lastSynced = nil
// If no profiles remain, return to onboarding
if profileStore.profiles.isEmpty {
onboardingComplete = false
}
}
private func syncNow() async {
isSyncing = true
// SyncService.shared.syncAll() requires ModelContext from environment
// For now just update last synced date
try? await Task.sleep(for: .seconds(1))
let now = Date()
UserDefaults.standard.set(now, forKey: "lastSynced")
lastSynced = now
isSyncing = false
}
}
// MARK: - Safari View
+12 -6
View File
@@ -3,7 +3,11 @@ import SwiftUI
struct ErrorBanner: View {
let error: BookStackError
var onRetry: (() -> Void)? = nil
var onSettings: (() -> Void)? = nil
private var isUnauthorized: Bool {
if case .unauthorized = error { return true }
return false
}
var body: some View {
HStack(spacing: 12) {
@@ -18,12 +22,14 @@ struct ErrorBanner: View {
Spacer()
if case .unauthorized = error, let onSettings {
Button("Settings", action: onSettings)
.buttonStyle(.bordered)
.controlSize(.small)
if isUnauthorized {
Button(L("settings.title")) {
AppNavigationState.shared.navigateToSettings = true
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if let onRetry {
Button("Retry", action: onRetry)
Button(L("common.retry"), action: onRetry)
.buttonStyle(.bordered)
.controlSize(.small)
}
+1 -21
View File
@@ -1,12 +1,4 @@
//
// bookstaxApp.swift
// bookstax
//
// Created by Sven Hanold on 19.03.26.
//
import SwiftUI
import SwiftData
@main
struct bookstaxApp: App {
@@ -15,7 +7,7 @@ struct bookstaxApp: App {
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
// ServerProfileStore is initialised here so migration runs at launch
private var profileStore = ServerProfileStore.shared
@State private var profileStore = ServerProfileStore.shared
private var preferredColorScheme: ColorScheme? {
switch appTheme {
@@ -29,16 +21,6 @@ struct bookstaxApp: App {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
let sharedModelContainer: ModelContainer = {
let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
init() {
AppLog(.info, "BookStax launched", category: "App")
}
@@ -60,7 +42,5 @@ struct bookstaxApp: App {
.tint(accentTheme.accentColor)
.preferredColorScheme(preferredColorScheme)
}
.modelContainer(sharedModelContainer)
}
}
+31
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Bitte gib die Adresse deines BookStack-Servers ein.";
"onboarding.server.error.invalid" = "Das sieht nicht nach einer gültigen Webadresse aus. Versuche z.B. https://bookstack.example.com";
"onboarding.server.warning.http" = "Unverschlüsselte Verbindung erkannt. Deine Daten könnten im Netzwerk sichtbar sein.";
"onboarding.server.warning.remote" = "Das sieht nach einer öffentlichen Internetadresse aus. BookStack im Internet zu betreiben ist ein Sicherheitsrisiko nutze besser ein VPN oder halte es im lokalen Netzwerk.";
"onboarding.token.title" = "Mit API-Token verbinden";
"onboarding.token.subtitle" = "BookStack verwendet API-Tokens für sicheren Zugriff. Du musst einen in deinem BookStack-Profil erstellen.";
"onboarding.token.help" = "Wie bekomme ich einen Token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
// MARK: - Tabs
"tab.quicknote" = "Notiz";
"tab.library" = "Bibliothek";
"tab.search" = "Suche";
"tab.create" = "Erstellen";
"tab.settings" = "Einstellungen";
// MARK: - Quick Note
"quicknote.title" = "Schnellnotiz";
"quicknote.field.title" = "Titel";
"quicknote.field.title.placeholder" = "Notiztitel";
"quicknote.field.content" = "Inhalt";
"quicknote.section.location" = "Speicherort";
"quicknote.section.tags" = "Tags";
"quicknote.shelf.label" = "Regal";
"quicknote.shelf.none" = "Beliebiges Regal";
"quicknote.shelf.loading" = "Regale werden geladen…";
"quicknote.book.label" = "Buch";
"quicknote.book.none" = "Buch auswählen";
"quicknote.book.loading" = "Bücher werden geladen…";
"quicknote.tags.loading" = "Tags werden geladen…";
"quicknote.tags.add" = "Tags hinzufügen";
"quicknote.tags.edit" = "Tags bearbeiten";
"quicknote.tags.empty" = "Keine Tags auf diesem Server vorhanden.";
"quicknote.tags.picker.title" = "Tags auswählen";
"quicknote.save" = "Speichern";
"quicknote.error.nobook" = "Bitte wähle zuerst ein Buch aus.";
"quicknote.saved.online" = "Notiz als neue Seite gespeichert.";
"quicknote.saved.offline" = "Lokal gespeichert — wird hochgeladen, sobald du online bist.";
"quicknote.pending.title" = "Offline-Notizen";
"quicknote.pending.upload" = "Jetzt hochladen";
"quicknote.pending.uploading" = "Wird hochgeladen…";
// MARK: - Library
"library.title" = "Bibliothek";
"library.loading" = "Bibliothek wird geladen…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Schließen ohne zu speichern?";
"editor.close.unsaved.confirm" = "Schließen";
"editor.image.uploading" = "Bild wird hochgeladen…";
"editor.html.notice" = "Diese Seite verwendet HTML-Formatierung. Beim Bearbeiten wird sie in Markdown umgewandelt.";
// MARK: - Search
"search.title" = "Suche";
@@ -235,5 +264,7 @@
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Abbrechen";
"common.retry" = "Wiederholen";
"common.error" = "Unbekannter Fehler";
"common.done" = "Fertig";
+31
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Please enter your BookStack server address.";
"onboarding.server.error.invalid" = "That doesn't look like a valid web address. Try something like https://bookstack.example.com";
"onboarding.server.warning.http" = "Non-encrypted connection detected. Your data may be visible on the network.";
"onboarding.server.warning.remote" = "This looks like a public internet address. Exposing BookStack to the internet is a security risk — consider using a VPN or keeping it on your local network.";
"onboarding.token.title" = "Connect with an API Token";
"onboarding.token.subtitle" = "BookStack uses API tokens for secure access. You'll need to create one in your BookStack profile.";
"onboarding.token.help" = "How do I get a token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
// MARK: - Tabs
"tab.quicknote" = "Quick Note";
"tab.library" = "Library";
"tab.search" = "Search";
"tab.create" = "Create";
"tab.settings" = "Settings";
// MARK: - Quick Note
"quicknote.title" = "Quick Note";
"quicknote.field.title" = "Title";
"quicknote.field.title.placeholder" = "Note title";
"quicknote.field.content" = "Content";
"quicknote.section.location" = "Location";
"quicknote.section.tags" = "Tags";
"quicknote.shelf.label" = "Shelf";
"quicknote.shelf.none" = "Any Shelf";
"quicknote.shelf.loading" = "Loading shelves…";
"quicknote.book.label" = "Book";
"quicknote.book.none" = "Select a book";
"quicknote.book.loading" = "Loading books…";
"quicknote.tags.loading" = "Loading tags…";
"quicknote.tags.add" = "Add Tags";
"quicknote.tags.edit" = "Edit Tags";
"quicknote.tags.empty" = "No tags available on this server.";
"quicknote.tags.picker.title" = "Select Tags";
"quicknote.save" = "Save";
"quicknote.error.nobook" = "Please select a book first.";
"quicknote.saved.online" = "Note saved as a new page.";
"quicknote.saved.offline" = "Saved locally — will upload when online.";
"quicknote.pending.title" = "Offline Notes";
"quicknote.pending.upload" = "Upload Now";
"quicknote.pending.uploading" = "Uploading…";
// MARK: - Library
"library.title" = "Library";
"library.loading" = "Loading library…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "Close without saving?";
"editor.close.unsaved.confirm" = "Close";
"editor.image.uploading" = "Uploading image…";
"editor.html.notice" = "This page uses HTML formatting. Editing here will convert it to Markdown.";
// MARK: - Search
"search.title" = "Search";
@@ -235,5 +264,7 @@
// MARK: - Common
"common.ok" = "OK";
"common.cancel" = "Cancel";
"common.retry" = "Retry";
"common.error" = "Unknown error";
"common.done" = "Done";
+31
View File
@@ -11,6 +11,7 @@
"onboarding.server.error.empty" = "Por favor, introduce la dirección de tu servidor BookStack.";
"onboarding.server.error.invalid" = "Eso no parece una dirección web válida. Prueba algo como https://bookstack.example.com";
"onboarding.server.warning.http" = "Conexión sin cifrar detectada. Tus datos podrían ser visibles en la red.";
"onboarding.server.warning.remote" = "Esto parece una dirección pública de internet. Exponer BookStack a internet es un riesgo de seguridad — considera usar una VPN o mantenerlo en tu red local.";
"onboarding.token.title" = "Conectar con un token API";
"onboarding.token.subtitle" = "BookStack usa tokens API para un acceso seguro. Deberás crear uno en tu perfil de BookStack.";
"onboarding.token.help" = "¿Cómo obtengo un token?";
@@ -41,11 +42,38 @@
"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
// MARK: - Tabs
"tab.quicknote" = "Nota rápida";
"tab.library" = "Biblioteca";
"tab.search" = "Búsqueda";
"tab.create" = "Crear";
"tab.settings" = "Ajustes";
// MARK: - Quick Note
"quicknote.title" = "Nota rápida";
"quicknote.field.title" = "Título";
"quicknote.field.title.placeholder" = "Título de la nota";
"quicknote.field.content" = "Contenido";
"quicknote.section.location" = "Ubicación";
"quicknote.section.tags" = "Etiquetas";
"quicknote.shelf.label" = "Estante";
"quicknote.shelf.none" = "Cualquier estante";
"quicknote.shelf.loading" = "Cargando estantes…";
"quicknote.book.label" = "Libro";
"quicknote.book.none" = "Selecciona un libro";
"quicknote.book.loading" = "Cargando libros…";
"quicknote.tags.loading" = "Cargando etiquetas…";
"quicknote.tags.add" = "Añadir etiquetas";
"quicknote.tags.edit" = "Editar etiquetas";
"quicknote.tags.empty" = "No hay etiquetas disponibles en este servidor.";
"quicknote.tags.picker.title" = "Seleccionar etiquetas";
"quicknote.save" = "Guardar";
"quicknote.error.nobook" = "Selecciona un libro primero.";
"quicknote.saved.online" = "Nota guardada como nueva página.";
"quicknote.saved.offline" = "Guardado localmente — se subirá cuando estés en línea.";
"quicknote.pending.title" = "Notas sin conexión";
"quicknote.pending.upload" = "Subir ahora";
"quicknote.pending.uploading" = "Subiendo…";
// MARK: - Library
"library.title" = "Biblioteca";
"library.loading" = "Cargando biblioteca…";
@@ -104,6 +132,7 @@
"editor.close.unsaved.title" = "¿Cerrar sin guardar?";
"editor.close.unsaved.confirm" = "Cerrar";
"editor.image.uploading" = "Subiendo imagen…";
"editor.html.notice" = "Esta página usa formato HTML. Editarla aquí la convertirá a Markdown.";
// MARK: - Search
"search.title" = "Búsqueda";
@@ -235,5 +264,7 @@
// MARK: - Common
"common.ok" = "Aceptar";
"common.cancel" = "Cancelar";
"common.retry" = "Reintentar";
"common.error" = "Error desconocido";
"common.done" = "Listo";