diff --git a/bookstax.xcodeproj/project.pbxproj b/bookstax.xcodeproj/project.pbxproj index 837e3cc..6f990e5 100644 --- a/bookstax.xcodeproj/project.pbxproj +++ b/bookstax.xcodeproj/project.pbxproj @@ -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; diff --git a/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme new file mode 100644 index 0000000..93e2aaf --- /dev/null +++ b/bookstax.xcodeproj/xcshareddata/xcschemes/bookstax.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist index 63bc7c9..ea882c1 100644 --- a/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/bookstax.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + 261299D52F6C686D00EC1C97 + + primary + + + diff --git a/bookstax/Extensions/AccentTheme.swift b/bookstax/Extensions/AccentTheme.swift index 88751fb..2ea011b 100644 --- a/bookstax/Extensions/AccentTheme.swift +++ b/bookstax/Extensions/AccentTheme.swift @@ -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 { diff --git a/bookstax/Models/APIError.swift b/bookstax/Models/APIError.swift index 34a0a5f..704393a 100644 --- a/bookstax/Models/APIError.swift +++ b/bookstax/Models/APIError.swift @@ -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): diff --git a/bookstax/Models/DTOs.swift b/bookstax/Models/DTOs.swift index 0bad087..537b104 100644 --- a/bookstax/Models/DTOs.swift +++ b/bookstax/Models/DTOs.swift @@ -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) diff --git a/bookstax/Models/ServerProfile.swift b/bookstax/Models/ServerProfile.swift index 5db7a62..6b22456 100644 --- a/bookstax/Models/ServerProfile.swift +++ b/bookstax/Models/ServerProfile.swift @@ -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") } } diff --git a/bookstax/Models/SwiftDataModels.swift b/bookstax/Models/SwiftDataModels.swift index c646cd0..fecc4ab 100644 --- a/bookstax/Models/SwiftDataModels.swift +++ b/bookstax/Models/SwiftDataModels.swift @@ -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 - ) - } -} diff --git a/bookstax/Services/AppNavigationState.swift b/bookstax/Services/AppNavigationState.swift new file mode 100644 index 0000000..e6331ff --- /dev/null +++ b/bookstax/Services/AppNavigationState.swift @@ -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 +} diff --git a/bookstax/Services/BookStackAPI.swift b/bookstax/Services/BookStackAPI.swift index 2ef2ecf..b543846 100644 --- a/bookstax/Services/BookStackAPI.swift +++ b/bookstax/Services/BookStackAPI.swift @@ -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 diff --git a/bookstax/Services/SyncService.swift b/bookstax/Services/SyncService.swift index f3c91e1..fecc4ab 100644 --- a/bookstax/Services/SyncService.swift +++ b/bookstax/Services/SyncService.swift @@ -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( - 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( - 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( - 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") - } -} diff --git a/bookstax/ViewModels/OnboardingViewModel.swift b/bookstax/ViewModels/OnboardingViewModel.swift index e70bb60..73c8c7e 100644 --- a/bookstax/ViewModels/OnboardingViewModel.swift +++ b/bookstax/ViewModels/OnboardingViewModel.swift @@ -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.16–31.x, 192.168.x + let octets = host.split(separator: ".").compactMap { Int($0) } + if octets.count == 4 { + if octets[0] == 10 { return false } + if octets[0] == 172, (16...31).contains(octets[1]) { return false } + if octets[0] == 192, octets[1] == 168 { return false } + // Any other IPv4 (public IP) → remote + return true + } + + // Domain name with dots → treat as potentially remote + return true + } + // MARK: - Verification func verifyAndSave() async { diff --git a/bookstax/ViewModels/PageEditorViewModel.swift b/bookstax/ViewModels/PageEditorViewModel.swift index 2eb1256..f932268 100644 --- a/bookstax/ViewModels/PageEditorViewModel.swift +++ b/bookstax/ViewModels/PageEditorViewModel.swift @@ -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 diff --git a/bookstax/Views/Editor/PageEditorView.swift b/bookstax/Views/Editor/PageEditorView.swift index a598add..1b32bce 100644 --- a/bookstax/Views/Editor/PageEditorView.swift +++ b/bookstax/Views/Editor/PageEditorView.swift @@ -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) } diff --git a/bookstax/Views/Library/LibraryView.swift b/bookstax/Views/Library/LibraryView.swift index 318fa2b..22d8314 100644 --- a/bookstax/Views/Library/LibraryView.swift +++ b/bookstax/Views/Library/LibraryView.swift @@ -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) } diff --git a/bookstax/Views/MainTabView.swift b/bookstax/Views/MainTabView.swift index 10da3ef..04e7bd8 100644 --- a/bookstax/Views/MainTabView.swift +++ b/bookstax/Views/MainTabView.swift @@ -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 } + } } } diff --git a/bookstax/Views/Onboarding/OnboardingView.swift b/bookstax/Views/Onboarding/OnboardingView.swift index fd4542c..884c0f7 100644 --- a/bookstax/Views/Onboarding/OnboardingView.swift +++ b/bookstax/Views/Onboarding/OnboardingView.swift @@ -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? = 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") diff --git a/bookstax/Views/QuickNote/QuickNoteView.swift b/bookstax/Views/QuickNote/QuickNoteView.swift index e299ca5..119dd54 100644 --- a/bookstax/Views/QuickNote/QuickNoteView.swift +++ b/bookstax/Views/QuickNote/QuickNoteView.swift @@ -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() } + } + } + } } } diff --git a/bookstax/Views/Reader/PageReaderView.swift b/bookstax/Views/Reader/PageReaderView.swift index e1771e0..ff5473e 100644 --- a/bookstax/Views/Reader/PageReaderView.swift +++ b/bookstax/Views/Reader/PageReaderView.swift @@ -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 { diff --git a/bookstax/Views/Settings/SettingsView.swift b/bookstax/Views/Settings/SettingsView.swift index 82b0789..4a83989 100644 --- a/bookstax/Views/Settings/SettingsView.swift +++ b/bookstax/Views/Settings/SettingsView.swift @@ -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 diff --git a/bookstax/Views/Shared/ErrorBanner.swift b/bookstax/Views/Shared/ErrorBanner.swift index 0bc0888..6521400 100644 --- a/bookstax/Views/Shared/ErrorBanner.swift +++ b/bookstax/Views/Shared/ErrorBanner.swift @@ -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) } diff --git a/bookstax/bookstaxApp.swift b/bookstax/bookstaxApp.swift index ce2b300..eb51e04 100644 --- a/bookstax/bookstaxApp.swift +++ b/bookstax/bookstaxApp.swift @@ -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) } } - diff --git a/bookstax/de.lproj/Localizable.strings b/bookstax/de.lproj/Localizable.strings index 29cddd8..1774a81 100644 --- a/bookstax/de.lproj/Localizable.strings +++ b/bookstax/de.lproj/Localizable.strings @@ -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"; diff --git a/bookstax/en.lproj/Localizable.strings b/bookstax/en.lproj/Localizable.strings index 0c38506..fe80659 100644 --- a/bookstax/en.lproj/Localizable.strings +++ b/bookstax/en.lproj/Localizable.strings @@ -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"; diff --git a/bookstax/es.lproj/Localizable.strings b/bookstax/es.lproj/Localizable.strings index dd14a46..cc47e80 100644 --- a/bookstax/es.lproj/Localizable.strings +++ b/bookstax/es.lproj/Localizable.strings @@ -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";