Rebuild the IAP part
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,15 +60,14 @@ 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")
|
||||||
serverURL: profile.serverURL,
|
CredentialStore.shared.update(
|
||||||
tokenId: creds.tokenId,
|
serverURL: profile.serverURL,
|
||||||
tokenSecret: creds.tokenSecret
|
tokenId: creds.tokenId,
|
||||||
)
|
tokenSecret: creds.tokenSecret
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remove
|
// MARK: - Remove
|
||||||
@@ -82,19 +95,35 @@ 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,
|
// MARK: - Display Options
|
||||||
tokenSecret: creds.tokenSecret
|
|
||||||
)
|
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()
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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
|
// MARK: - Verification
|
||||||
|
|
||||||
func verifyAndSave() async {
|
func verifyAndSave() async {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
|
} 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
|
||||||
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
|
} 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")
|
||||||
|
|||||||
@@ -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()
|
do {
|
||||||
|
let page = try await BookStackAPI.shared.createPage(
|
||||||
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,
|
|
||||||
bookId: book.id,
|
bookId: book.id,
|
||||||
bookName: book.name
|
name: title,
|
||||||
|
markdown: content,
|
||||||
|
tags: selectedTags
|
||||||
)
|
)
|
||||||
modelContext.insert(pending)
|
AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote")
|
||||||
try? modelContext.save()
|
|
||||||
savedMessage = L("quicknote.saved.offline")
|
|
||||||
resetForm()
|
resetForm()
|
||||||
|
navState.pendingBookNavigation = book
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
isFetchingForEdit = true
|
pageForEditing = nil
|
||||||
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
|
isFetchingForEdit = true
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")) {
|
||||||
.buttonStyle(.bordered)
|
AppNavigationState.shared.navigateToSettings = true
|
||||||
.controlSize(.small)
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.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,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user