Rebuild the IAP part

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