Rebuild the IAP part
This commit is contained in:
@@ -263,10 +263,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -291,10 +294,13 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = Team.bookstax;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "261299D52F6C686D00EC1C97"
|
||||
BuildableName = "bookstax.app"
|
||||
BlueprintName = "bookstax"
|
||||
ReferencedContainer = "container:bookstax.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -10,5 +10,13 @@
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>261299D52F6C686D00EC1C97</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class CachedShelf {
|
||||
@Attribute(.unique) var id: Int
|
||||
var name: String
|
||||
var slug: String
|
||||
var shelfDescription: String
|
||||
var coverURL: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.shelfDescription = shelfDescription
|
||||
self.coverURL = coverURL
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: ShelfDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
shelfDescription: dto.description,
|
||||
coverURL: dto.cover?.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class CachedBook {
|
||||
@Attribute(.unique) var id: Int
|
||||
var name: String
|
||||
var slug: String
|
||||
var bookDescription: String
|
||||
var coverURL: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.bookDescription = bookDescription
|
||||
self.coverURL = coverURL
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: BookDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
bookDescription: dto.description,
|
||||
coverURL: dto.cover?.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class CachedPage {
|
||||
@Attribute(.unique) var id: Int
|
||||
var bookId: Int
|
||||
var chapterId: Int?
|
||||
var name: String
|
||||
var slug: String
|
||||
var html: String?
|
||||
var markdown: String?
|
||||
var lastFetched: Date
|
||||
|
||||
init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
|
||||
self.id = id
|
||||
self.bookId = bookId
|
||||
self.chapterId = chapterId
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.html = html
|
||||
self.markdown = markdown
|
||||
self.lastFetched = Date()
|
||||
}
|
||||
|
||||
convenience init(from dto: PageDTO) {
|
||||
self.init(
|
||||
id: dto.id,
|
||||
bookId: dto.bookId,
|
||||
chapterId: dto.chapterId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
html: dto.html,
|
||||
markdown: dto.markdown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Shared navigation state for cross-tab navigation requests.
|
||||
@Observable
|
||||
final class AppNavigationState {
|
||||
static let shared = AppNavigationState()
|
||||
private init() {}
|
||||
|
||||
/// When set, MainTabView switches to the Library tab and LibraryView pushes this book.
|
||||
var pendingBookNavigation: BookDTO? = nil
|
||||
|
||||
/// When set to true, MainTabView switches to the Settings tab (e.g. after a 401).
|
||||
var navigateToSettings: Bool = false
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,91 +1 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// SyncService handles upserting API DTOs into the local SwiftData cache.
|
||||
/// All methods are @MainActor because ModelContext must be used on the main actor.
|
||||
@MainActor
|
||||
final class SyncService {
|
||||
static let shared = SyncService()
|
||||
|
||||
private let api = BookStackAPI.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Sync Shelves
|
||||
|
||||
func syncShelves(context: ModelContext) async throws {
|
||||
let dtos = try await api.fetchShelves()
|
||||
for dto in dtos {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedShelf>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.name = dto.name
|
||||
existing.shelfDescription = dto.description
|
||||
existing.coverURL = dto.cover?.url
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedShelf(from: dto))
|
||||
}
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Sync Books
|
||||
|
||||
func syncBooks(context: ModelContext) async throws {
|
||||
let dtos = try await api.fetchBooks()
|
||||
for dto in dtos {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedBook>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.name = dto.name
|
||||
existing.bookDescription = dto.description
|
||||
existing.coverURL = dto.cover?.url
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedBook(from: dto))
|
||||
}
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Sync Page (on demand, after viewing)
|
||||
|
||||
func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
|
||||
let id = dto.id
|
||||
let descriptor = FetchDescriptor<CachedPage>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
if let existing = try context.fetch(descriptor).first {
|
||||
existing.html = dto.html
|
||||
existing.markdown = dto.markdown
|
||||
existing.lastFetched = Date()
|
||||
} else {
|
||||
context.insert(CachedPage(from: dto))
|
||||
}
|
||||
try context.save()
|
||||
}
|
||||
|
||||
// MARK: - Full sync
|
||||
|
||||
func syncAll(context: ModelContext) async throws {
|
||||
async let shelvesTask: Void = syncShelves(context: context)
|
||||
async let booksTask: Void = syncBooks(context: context)
|
||||
_ = try await (shelvesTask, booksTask)
|
||||
}
|
||||
|
||||
// MARK: - Clear all cached content
|
||||
|
||||
func clearAllCache(context: ModelContext) throws {
|
||||
try context.delete(model: CachedShelf.self)
|
||||
try context.delete(model: CachedBook.self)
|
||||
try context.delete(model: CachedPage.self)
|
||||
try context.save()
|
||||
UserDefaults.standard.removeObject(forKey: "lastSynced")
|
||||
AppLog(.info, "Cleared all cached content", category: "SyncService")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,32 @@ final class OnboardingViewModel {
|
||||
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
|
||||
}
|
||||
|
||||
/// True when the URL looks like it points to a publicly accessible server
|
||||
/// (not a private IP, localhost, or .local mDNS host).
|
||||
var isRemoteServer: Bool {
|
||||
guard let host = URL(string: serverURLInput)?.host ?? URL(string: "https://\(serverURLInput)")?.host,
|
||||
!host.isEmpty else { return false }
|
||||
|
||||
// Loopback
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" { return false }
|
||||
|
||||
// mDNS (.local) and plain hostnames without dots are local
|
||||
if host.hasSuffix(".local") || !host.contains(".") { return false }
|
||||
|
||||
// Private IPv4 ranges: 10.x, 172.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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@ struct WelcomeStepView: View {
|
||||
|
||||
struct ConnectStepView: View {
|
||||
@Bindable var viewModel: OnboardingViewModel
|
||||
@State private var showTokenId = false
|
||||
@State private var showTokenSecret = false
|
||||
@State private var showTokenId = true
|
||||
@State private var showTokenSecret = true
|
||||
@State private var showHelp = false
|
||||
@State private var verifyTask: Task<Void, Never>? = nil
|
||||
|
||||
@@ -222,6 +222,13 @@ struct ConnectStepView: View {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
viewModel.serverURLInput = UIPasteboard.general.string ?? viewModel.serverURLInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
@@ -237,6 +244,12 @@ struct ConnectStepView: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if viewModel.isRemoteServer {
|
||||
Label(L("onboarding.server.warning.remote"), systemImage: "globe.badge.chevron.backward")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
// Help accordion
|
||||
@@ -266,22 +279,20 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.username)
|
||||
.textContentType(.none)
|
||||
.onChange(of: viewModel.tokenIdInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
|
||||
if UIPasteboard.general.hasStrings {
|
||||
Button {
|
||||
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(L("onboarding.token.paste"))
|
||||
Button {
|
||||
viewModel.tokenIdInput = UIPasteboard.general.string ?? viewModel.tokenIdInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button { showTokenId.toggle() } label: {
|
||||
Image(systemName: showTokenId ? "eye.slash" : "eye")
|
||||
@@ -307,22 +318,20 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.password)
|
||||
.textContentType(.none)
|
||||
.onChange(of: viewModel.tokenSecretInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
}
|
||||
}
|
||||
|
||||
if UIPasteboard.general.hasStrings {
|
||||
Button {
|
||||
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(L("onboarding.token.paste"))
|
||||
Button {
|
||||
viewModel.tokenSecretInput = UIPasteboard.general.string ?? viewModel.tokenSecretInput
|
||||
} label: {
|
||||
Image(systemName: "clipboard")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button { showTokenSecret.toggle() } label: {
|
||||
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,11 @@ import SwiftUI
|
||||
struct ErrorBanner: View {
|
||||
let error: BookStackError
|
||||
var onRetry: (() -> Void)? = nil
|
||||
var onSettings: (() -> Void)? = nil
|
||||
|
||||
private var isUnauthorized: Bool {
|
||||
if case .unauthorized = error { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -18,12 +22,14 @@ struct ErrorBanner: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if case .unauthorized = error, let onSettings {
|
||||
Button("Settings", action: onSettings)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
if isUnauthorized {
|
||||
Button(L("settings.title")) {
|
||||
AppNavigationState.shared.navigateToSettings = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else if let onRetry {
|
||||
Button("Retry", action: onRetry)
|
||||
Button(L("common.retry"), action: onRetry)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user