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";