diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift new file mode 100644 index 0000000..5c6e8ce --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +/// Generic key/value row backing `OfflineCacheStore` — one table for every +/// cached response shape instead of a `@Model` per Outline type, so adding a +/// new cached endpoint never needs a schema migration. +@Model +public final class CachedPayload { + @Attribute(.unique) public var key: String + public var payload: Data + public var cachedAt: Date + + public init(key: String, payload: Data, cachedAt: Date) { + self.key = key + self.payload = payload + self.cachedAt = cachedAt + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift new file mode 100644 index 0000000..fe30556 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -0,0 +1,665 @@ +import Foundation + +/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline +/// support at the existing protocol boundary, so no view model needs to know +/// the network exists: +/// +/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`, +/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through +/// cache. Always tries live first — never pre-checks reachability, since +/// "did this specific request just fail" is a more honest signal than a +/// reachability monitor, and the two can disagree (captive portals, +/// flaky Wi-Fi). Falls back to the cache only on failure; a live success +/// always overwrites the cache, so staleness is bounded by "last time this +/// endpoint actually worked." +/// - **A small set of writes** (`updateDocument`, `updateCollection`, +/// pin/star/subscribe create+delete): applied optimistically against the +/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on +/// failure, then replayed in order by `flushPendingOperations()` once back +/// online. Deliberately doesn't include anything that creates new tree +/// structure (`createDocument`, `moveDocument`, `archiveDocument`, +/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need +/// real server-assigned ids to stay consistent with the rest of the tree, +/// and reconciling a locally-invented id with the one the server hands +/// back on sync is a much bigger problem than this pass takes on. Sharing, +/// permissions, search, and export also stay live-only — read the request, +/// they need someone else's server session, not just a network. +/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips +/// attempting `live` entirely — same fallback/queue paths as a real +/// failure, just chosen on purpose instead of discovered. +public actor CachingOutlineAPIClient: OutlineAPIClient { + public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled" + + private let live: OutlineAPIClient + private let cache: OfflineCacheStore + private let defaults: UserDefaults + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let keyEncoder: JSONEncoder + + public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { + self.live = live + self.cache = cache + self.defaults = defaults + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + + let keyEncoder = JSONEncoder() + keyEncoder.outputFormatting = .sortedKeys + self.keyEncoder = keyEncoder + } + + private var isManualOfflineModeEnabled: Bool { + defaults.bool(forKey: Self.offlineModeDefaultsKey) + } + + // MARK: - Cached reads + + public func documentInfo(id: String) async throws -> OutlineDocument { + try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } + } + + public func listDocuments( + collectionId: String?, + parentDocumentId: String?, + offset: Int, + limit: Int + ) async throws -> [OutlineDocument] { + let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" + return try await cachedFetch(key: key) { + try await self.live.listDocuments( + collectionId: collectionId, + parentDocumentId: parentDocumentId, + offset: offset, + limit: limit + ) + } + } + + public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { + try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } + } + + public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { + try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { + try await self.live.listViewedDocuments(offset: offset, limit: limit) + } + } + + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + try await cachedFetch(key: "collections:\(offset):\(limit)") { + try await self.live.listCollections(offset: offset, limit: limit) + } + } + + public func collectionInfo(id: String) async throws -> OutlineCollection { + try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } + } + + // MARK: - Queueable writes + + /// The one exception to "no new tree structure while offline" — + /// synthesizes a document under a `pending-` id (same scheme as + /// pin/star/subscribe), caches it so it's immediately readable/editable, + /// and queues the real create. On sync, the server-assigned id replaces + /// the placeholder in the cache; anything still referencing the old id + /// directly (a currently-open reader, most likely) won't follow that + /// rename automatically — a known, narrow limitation, not something this + /// pass tries to solve generally. + public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + if !isManualOfflineModeEnabled { + do { + let result = try await live.createDocument(request) + await cacheDocument(result) + return result + } catch { + return await queueDocumentCreate(request) + } + } + return await queueDocumentCreate(request) + } + + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + if !isManualOfflineModeEnabled { + do { + let result = try await live.updateDocument(request) + await cacheDocument(result) + return result + } catch { + return try await queueDocumentUpdate(request, dueTo: error) + } + } + return try await queueDocumentUpdate(request, dueTo: nil) + } + + public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { + if !isManualOfflineModeEnabled { + do { + let result = try await live.updateCollection(request) + await cacheCollection(result) + return result + } catch { + return try await queueCollectionUpdate(request, dueTo: error) + } + } + return try await queueCollectionUpdate(request, dueTo: nil) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + if !isManualOfflineModeEnabled { + do { return try await live.createPin(request) } catch { return await queuePinCreate(request) } + } + return await queuePinCreate(request) + } + + public func deletePin(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deletePin(id: id) + return + } catch { + await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") + return + } + } + await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + if !isManualOfflineModeEnabled { + do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) } + } + return await queueSubscriptionCreate(request) + } + + public func deleteSubscription(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deleteSubscription(id: id) + return + } catch { + await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") + return + } + } + await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") + } + + public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { + if !isManualOfflineModeEnabled { + do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) } + } + return await queueStarDocumentCreate(request) + } + + public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { + if !isManualOfflineModeEnabled { + do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) } + } + return await queueStarCollectionCreate(request) + } + + public func deleteStar(id: String) async throws { + if await cancelIfNeverSynced(id: id) { return } + if !isManualOfflineModeEnabled { + do { + try await live.deleteStar(id: id) + return + } catch { + await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") + return + } + } + await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") + } + + // MARK: - Pass-through + + public func authInfo() async throws -> OutlineAuthInfo { + try await live.authInfo() + } + + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { + try await live.searchDocuments(request) + } + + public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { + try await live.searchDocumentTitles(request) + } + + public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { + try await live.templatizeDocument(request) + } + + public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { + try await live.duplicateDocument(request) + } + + public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { + try await live.unpublishDocument(request) + } + + public func archiveDocument(id: String) async throws -> OutlineDocument { + try await live.archiveDocument(id: id) + } + + public func moveDocument(_ request: MoveDocumentRequest) async throws { + try await live.moveDocument(request) + } + + public func deleteDocument(_ request: DeleteDocumentRequest) async throws { + try await live.deleteDocument(request) + } + + public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { + try await live.documentInsights(request) + } + + public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { + try await live.listRevisions(request) + } + + public func exportDocument(id: String) async throws -> String { + try await live.exportDocument(id: id) + } + + public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { + try await live.createShare(request) + } + + public func shareInfo(documentId: String) async throws -> OutlineShare? { + try await live.shareInfo(documentId: documentId) + } + + public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { + try await live.updateShare(request) + } + + public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { + try await live.listShares(request) + } + + public func revokeShare(id: String) async throws { + try await live.revokeShare(id: id) + } + + public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { + try await live.listPins(request) + } + + public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { + try await live.listSubscriptions(request) + } + + public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { + try await live.listViews(request) + } + + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { + try await live.addDocumentUser(request) + } + + public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { + try await live.removeDocumentUser(request) + } + + public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { + try await live.documentUsers(request) + } + + public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { + try await live.listUsers(request) + } + + public func deleteCollection(id: String) async throws { + try await live.deleteCollection(id: id) + } + + public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { + try await live.exportCollection(request) + } + + public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { + try await live.listStars(request) + } + + public func currentUser() async throws -> OutlineUser { + try await live.currentUser() + } + + // MARK: - Sync management (Settings surface) + + public func pendingOperations() async -> [PendingOperationSummary] { + await cache.pendingOperations().map { + PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError) + } + } + + public func cacheStorageSummary() async -> CacheStorageSummary { + CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes()) + } + + public func clearCache() async { + await cache.clearAll() + } + + /// Replays every queued operation against `live`, in the order they were + /// queued. Each is independent — one failing doesn't block the rest. + public func flushPendingOperations() async -> SyncFlushSummary { + let operations = await cache.pendingOperations() + var succeeded = 0 + var failed = 0 + for operation in operations { + do { + try await replay(operation) + await cache.removeOperation(id: operation.id) + succeeded += 1 + } catch { + await cache.recordFailure(id: operation.id, error: errorDescription(error)) + failed += 1 + } + } + return SyncFlushSummary(succeeded: succeeded, failed: failed) + } + + /// "Full Local Sync": eagerly walks every collection and caches every + /// document's full content (not just whatever's been opened), so + /// browsing offline works for the whole workspace, not only what was + /// already viewed. Goes through `self`, not `live`, directly — the + /// existing cached-read methods already do the caching as a side effect, + /// this just has to drive the walk and separately cache each document by + /// id (`listDocuments`'s cache key is the list, not the individual doc). + public func performFullSync() async -> FullSyncSummary { + var documentsCount = 0 + var errors: [String] = [] + var collections: [OutlineCollection] = [] + var collectionsOffset = 0 + let collectionsLimit = 100 + // Outline rejects any `limit` over 100 outright — page in increments + // of that instead of guessing a total up front (the protocol doesn't + // expose `pagination`'s total count, only the page itself); a page + // shorter than the limit is what signals "that was the last one". + while true { + let page: [OutlineCollection] + do { + page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit) + } catch { + errors.append(errorDescription(error)) + break + } + collections.append(contentsOf: page) + guard page.count == collectionsLimit else { break } + collectionsOffset += collectionsLimit + } + + for collection in collections { + var offset = 0 + let limit = 100 + while true { + let documents: [OutlineDocument] + do { + documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit) + } catch { + errors.append("\(collection.name): \(errorDescription(error))") + break + } + for document in documents { + await cacheDocument(document) + } + documentsCount += documents.count + guard documents.count == limit else { break } + offset += limit + } + } + + return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date()) + } + + // MARK: - Helpers + + private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { + // Manual offline mode means "skip the network entirely," not just + // "prefer it" — without this check, a read would still hit `live` + // (and succeed, showing content beyond whatever's cached) any time + // the device actually had a connection, defeating the point of + // deliberately testing/working as if offline. + if isManualOfflineModeEnabled { + if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + return cached + } + throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } + do { + let result = try await fetch() + if let data = try? encoder.encode(result) { + await cache.save(data, forKey: key) + } + return result + } catch { + if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + return cached + } + throw error + } + } + + private func requestKey(_ prefix: String, _ request: some Encodable) -> String { + guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { + return prefix + } + return "\(prefix):\(json)" + } + + private func cacheDocument(_ document: OutlineDocument) async { + if let data = try? encoder.encode(document) { + await cache.save(data, forKey: "document:\(document.id)") + } + } + + private func cacheCollection(_ collection: OutlineCollection) async { + if let data = try? encoder.encode(collection) { + await cache.save(data, forKey: "collection:\(collection.id)") + } + } + + private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { + guard let data = try? encoder.encode(payload) else { return } + await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) + } + + /// A delete targeting a `pending-*` id can only mean "cancel the create + /// still sitting in the queue" — the server has never heard of that id, + /// so queuing the delete would just fail once synced. Returns whether it + /// found (and removed) a matching create, meaning the caller is done. + private func cancelIfNeverSynced(id: String) async -> Bool { + guard id.hasPrefix("pending-") else { return false } + return await cache.removeOperation(id: id) + } + + private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument { + let pendingId = "pending-\(UUID().uuidString)" + let now = Date() + let synthesized = OutlineDocument( + id: pendingId, + title: request.title, + text: request.text, + emoji: nil, + collectionId: request.collectionId, + parentDocumentId: request.parentDocumentId, + url: "/doc/\(pendingId)", + revision: nil, + fullWidth: nil, + createdAt: now, + updatedAt: now, + publishedAt: request.publish ? now : nil, + archivedAt: nil, + deletedAt: nil + ) + await cacheDocument(synthesized) + await enqueue(.createDocument, payload: request, id: pendingId) + return synthesized + } + + private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { + guard let baseData = await cache.load(forKey: "document:\(request.id)"), + let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { + throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } + let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) + let merged = OutlineDocument( + id: base.id, + title: request.title ?? base.title, + text: mergedText, + emoji: base.emoji, + collectionId: base.collectionId, + parentDocumentId: base.parentDocumentId, + url: base.url, + revision: base.revision, + fullWidth: request.fullWidth ?? base.fullWidth, + createdAt: base.createdAt, + updatedAt: Date(), + publishedAt: base.publishedAt, + archivedAt: base.archivedAt, + deletedAt: base.deletedAt + ) + await cacheDocument(merged) + + // Still-unsynced create for this exact document — fold the edit into + // the pending create's payload instead of queuing a separate update. + // A separate update would target `merged.id`, which is still the + // `pending-*` placeholder at replay time; the server has never heard + // of it and the update would just fail every retry. + if merged.id.hasPrefix("pending-"), + let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), + let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { + let resolvedCreate = CreateDocumentRequest( + title: merged.title, + text: merged.text, + collectionId: createRequest.collectionId, + parentDocumentId: createRequest.parentDocumentId, + publish: createRequest.publish + ) + await enqueue(.createDocument, payload: resolvedCreate, id: merged.id) + return merged + } + + // Fully resolved (not the original partial request) so replaying just + // this one queued operation reproduces `merged` exactly — that's what + // makes coalescing a second edit onto the same queued id safe. + let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth) + await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)") + return merged + } + + private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { + guard let baseData = await cache.load(forKey: "collection:\(request.id)"), + let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { + throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) + } + let merged = OutlineCollection( + id: base.id, + name: request.name ?? base.name, + description: request.description ?? base.description, + color: base.color, + icon: base.icon, + createdAt: base.createdAt, + updatedAt: Date() + ) + await cacheCollection(merged) + let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description) + await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)") + return merged + } + + private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.createPin, payload: request, id: pendingId) + return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil) + } + + private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.createSubscription, payload: request, id: pendingId) + return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event) + } + + private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.starDocument, payload: request, id: pendingId) + return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil) + } + + private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar { + let pendingId = "pending-\(UUID().uuidString)" + await enqueue(.starCollection, payload: request, id: pendingId) + return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId) + } + + private func replay(_ operation: PendingOperation) async throws { + guard let kind = PendingOperationKind(rawValue: operation.kind) else { + throw OutlineAPIError.decoding(DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)") + )) + } + switch kind { + case .createDocument: + let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) + let result = try await live.createDocument(request) + await cacheDocument(result) + // The placeholder id (== operation.id) is now a dead orphan — + // nothing server-side will ever answer to it again. + await cache.removeCacheEntry(forKey: "document:\(operation.id)") + case .updateDocument: + let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) + let result = try await live.updateDocument(request) + await cacheDocument(result) + case .updateCollection: + let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) + let result = try await live.updateCollection(request) + await cacheCollection(result) + case .createPin: + let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) + _ = try await live.createPin(request) + case .deletePin: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deletePin(id: request.id) + case .createSubscription: + let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) + _ = try await live.createSubscription(request) + case .deleteSubscription: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deleteSubscription(id: request.id) + case .starDocument: + let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) + _ = try await live.starDocument(request) + case .deleteStar: + let request = try decoder.decode(IDPayload.self, from: operation.payload) + try await live.deleteStar(id: request.id) + case .starCollection: + let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) + _ = try await live.starCollection(request) + } + } + + private func errorDescription(_ error: Error) -> String { + if let apiError = error as? OutlineAPIError { + switch apiError { + case .unauthorized: return "Sign-in expired." + case .notFound: return "Not found on the server." + case .server(let status, let message): return message ?? "Server error (\(status))." + case .decoding: return "Unexpected response shape." + case .transport: return "Couldn't reach the server." + case .tokenUnavailable: return "Couldn't access the saved sign-in." + } + } + return String(describing: error) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift new file mode 100644 index 0000000..1300a18 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -0,0 +1,104 @@ +import Foundation +import SwiftData + +/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it +/// last fetched successfully, keyed by request shape, so browsing keeps +/// working (stale) once the network stops. Also owns the offline write +/// queue (`PendingOperation`) — same store, same actor, since both need +/// synchronized access to the same on-disk SwiftData container. +@ModelActor +public actor OfflineCacheStore { + public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { + let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory) + return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration) + } + + // MARK: - Read-through cache + + public func save(_ data: Data, forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + if let existing = try? modelContext.fetch(descriptor).first { + existing.payload = data + existing.cachedAt = Date() + } else { + modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date())) + } + try? modelContext.save() + } + + public func load(forKey key: String) -> Data? { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + return try? modelContext.fetch(descriptor).first?.payload + } + + /// Used to drop a temporary `pending-*` document's cache entry once a + /// queued create syncs and the server hands back the real id — the + /// placeholder key would otherwise sit around as a dead orphan forever. + public func removeCacheEntry(forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + guard let existing = try? modelContext.fetch(descriptor).first else { return } + modelContext.delete(existing) + try? modelContext.save() + } + + public func itemCount() -> Int { + (try? modelContext.fetchCount(FetchDescriptor())) ?? 0 + } + + public func totalBytes() -> Int { + let descriptor = FetchDescriptor() + let rows = (try? modelContext.fetch(descriptor)) ?? [] + return rows.reduce(0) { $0 + $1.payload.count } + } + + public func clearAll() { + let descriptor = FetchDescriptor() + guard let rows = try? modelContext.fetch(descriptor) else { return } + rows.forEach { modelContext.delete($0) } + try? modelContext.save() + } + + // MARK: - Offline write queue + + /// Upserts by `id` — a second call with the same id (an edit coalescing + /// onto a still-unsynced edit, or a create being cancelled by its own + /// synthesized id) replaces the row in place rather than piling up. + public func enqueueOperation(id: String, kind: String, payload: Data) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + if let existing = try? modelContext.fetch(descriptor).first { + existing.kind = kind + existing.payload = payload + existing.lastError = nil + existing.attemptCount = 0 + } else { + modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date())) + } + try? modelContext.save() + } + + public func pendingOperations() -> [PendingOperation] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) + return (try? modelContext.fetch(descriptor)) ?? [] + } + + /// Returns whether a matching operation was actually found and removed — + /// callers use this to detect "this targeted something that never made + /// it to the server in the first place" and skip queuing a delete. + @discardableResult + public func removeOperation(id: String) -> Bool { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + guard let existing = try? modelContext.fetch(descriptor).first else { return false } + modelContext.delete(existing) + try? modelContext.save() + return true + } + + public func recordFailure(id: String, error: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + guard let existing = try? modelContext.fetch(descriptor).first else { return } + existing.lastAttemptAt = Date() + existing.lastError = error + existing.attemptCount += 1 + try? modelContext.save() + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift b/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift new file mode 100644 index 0000000..4fc3aba --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/PendingOperation.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +/// A queued mutation made while offline, waiting to replay against the live +/// server. `id` is deliberately overloaded: for actions that create a new +/// server-side record (pin, star, subscription), it's also the synthesized +/// placeholder id handed back to the caller immediately — so a matching +/// delete queued before that create ever syncs can cancel both out by id +/// instead of hitting a server that's never heard of the placeholder. For +/// actions that edit an existing record (document/collection updates), it's +/// deterministic per target id, so a second edit before the first syncs +/// coalesces into one queued operation instead of piling up. +@Model +public final class PendingOperation { + @Attribute(.unique) public var id: String + public var kind: String + public var payload: Data + public var createdAt: Date + public var lastAttemptAt: Date? + public var lastError: String? + public var attemptCount: Int + + public init( + id: String, + kind: String, + payload: Data, + createdAt: Date, + lastAttemptAt: Date? = nil, + lastError: String? = nil, + attemptCount: Int = 0 + ) { + self.id = id + self.kind = kind + self.payload = payload + self.createdAt = createdAt + self.lastAttemptAt = lastAttemptAt + self.lastError = lastError + self.attemptCount = attemptCount + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift new file mode 100644 index 0000000..601e412 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/SyncTypes.swift @@ -0,0 +1,50 @@ +import Foundation + +/// UI-facing snapshot of a queued offline mutation — deliberately not the +/// `@Model` type itself, so Settings can display it without holding a +/// reference into the SwiftData store. +public struct PendingOperationSummary: Identifiable, Sendable { + public let id: String + public let kind: String + public let createdAt: Date + public let attemptCount: Int + public let lastError: String? +} + +public struct CacheStorageSummary: Sendable { + public let itemCount: Int + public let totalBytes: Int +} + +public struct SyncFlushSummary: Sendable { + public let succeeded: Int + public let failed: Int +} + +public struct FullSyncSummary: Sendable { + public let collectionsCount: Int + public let documentsCount: Int + public let errors: [String] + public let finishedAt: Date +} + +/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and +/// replay later. Deliberately a small, explicit set — see its doc comment +/// for what's excluded and why. +enum PendingOperationKind: String, Codable, Sendable { + case createDocument + case updateDocument + case updateCollection + case createPin + case deletePin + case createSubscription + case deleteSubscription + case starDocument + case deleteStar + case starCollection +} + +/// Shared payload shape for the delete-by-id queueable operations. +struct IDPayload: Codable, Sendable { + let id: String +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift index 07de5ea..7aafdfc 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct CreateDocumentRequest: Encodable, Sendable { +public struct CreateDocumentRequest: Codable, Sendable { public let title: String public let text: String public let collectionId: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift index 29e2701..8aeb607 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -2,7 +2,7 @@ import Foundation /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to /// Collection" — these are distinct actions on the real server. -public struct CreatePinRequest: Encodable, Sendable { +public struct CreatePinRequest: Codable, Sendable { public let documentId: String public let collectionId: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift index c2cf5f2..10ab05e 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift @@ -1,7 +1,7 @@ import Foundation /// See `OutlineSubscription` — best-effort shape, not in the vendored spec. -public struct CreateSubscriptionRequest: Encodable, Sendable { +public struct CreateSubscriptionRequest: Codable, Sendable { public let documentId: String public let event: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift index b3f3bd8..66289ec 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct StarCollectionRequest: Encodable, Sendable { +public struct StarCollectionRequest: Codable, Sendable { public let collectionId: String public init(collectionId: String) { diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift index 54036a4..67ef4b9 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct StarDocumentRequest: Encodable, Sendable { +public struct StarDocumentRequest: Codable, Sendable { public let documentId: String public init(documentId: String) { diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift index 7b094f1..fb19007 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct UpdateCollectionRequest: Encodable, Sendable { +public struct UpdateCollectionRequest: Codable, Sendable { public let id: String public let name: String? public let description: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index 5e38781..e1bf459 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -1,6 +1,6 @@ import Foundation -public struct UpdateDocumentRequest: Encodable, Sendable { +public struct UpdateDocumentRequest: Codable, Sendable { public let id: String public let title: String? public let text: String? diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift new file mode 100644 index 0000000..ffeb429 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -0,0 +1,500 @@ +import XCTest +@testable import OutlineKit + +private struct NotStubbed: Error {} + +/// Conforms to the full protocol (so it can stand in for `live`), but only +/// the two methods under test in this file have real behavior — everything +/// else throws loudly if a test accidentally exercises it. +private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable { + var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)? + var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])? + var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)? + var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)? + var deletePinHandler: (@Sendable (String) async throws -> Void)? + var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])? + var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)? + + func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } + + func documentInfo(id: String) async throws -> OutlineDocument { + guard let handler = documentInfoHandler else { throw NotStubbed() } + return try await handler(id) + } + + func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { + guard let handler = listDocumentsHandler else { throw NotStubbed() } + return try await handler(collectionId, parentDocumentId, offset, limit) + } + + func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } + func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } + func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + guard let handler = createDocumentHandler else { throw NotStubbed() } + return try await handler(request) + } + + func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + guard let handler = updateDocumentHandler else { throw NotStubbed() } + return try await handler(request) + } + + func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() } + func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() } + func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() } + func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() } + func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() } + func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() } + func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() } + func exportDocument(id: String) async throws -> String { throw NotStubbed() } + func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() } + func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() } + func revokeShare(id: String) async throws { throw NotStubbed() } + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + guard let handler = createPinHandler else { throw NotStubbed() } + return try await handler(request) + } + + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() } + + func deletePin(id: String) async throws { + guard let handler = deletePinHandler else { throw NotStubbed() } + try await handler(id) + } + func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() } + func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } + func deleteSubscription(id: String) async throws { throw NotStubbed() } + func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() } + func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() } + func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() } + func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() } + func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() } + + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + guard let handler = listCollectionsHandler else { throw NotStubbed() } + return try await handler(offset, limit) + } + + func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() } + func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() } + func deleteCollection(id: String) async throws { throw NotStubbed() } + func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() } + func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() } + func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() } + func deleteStar(id: String) async throws { throw NotStubbed() } + func currentUser() async throws -> OutlineUser { throw NotStubbed() } +} + +private struct StubTransportError: Error {} + +final class CachingOutlineAPIClientTests: XCTestCase { + private func makeCache() throws -> OfflineCacheStore { + OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) + } + + private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument { + OutlineDocument( + id: id, + title: title, + text: "body", + url: "/doc/\(id)", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection { + OutlineCollection( + id: id, + name: name, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + func testDocumentInfoWritesThroughToCacheOnSuccess() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + stub.documentInfoHandler = { _ in document } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + } + + func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + var callCount = 0 + stub.documentInfoHandler = { _ in + callCount += 1 + if callCount == 1 { return document } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // First call succeeds and populates the cache. + _ = try await sut.documentInfo(id: "doc-1") + // Second call fails live — should transparently return the cached value. + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + XCTAssertEqual(callCount, 2) + } + + func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + do { + _ = try await sut.documentInfo(id: "doc-1") + XCTFail("Expected an error") + } catch is StubTransportError { + // expected + } + } + + func testListCollectionsFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let collections = [makeCollection()] + var callCount = 0 + stub.listCollectionsHandler = { _, _ in + callCount += 1 + if callCount == 1 { return collections } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.listCollections(offset: 0, limit: 25) + let result = try await sut.listCollections(offset: 0, limit: 25) + + XCTAssertEqual(result, collections) + } + + func testDifferentDocumentIdsAreCachedSeparately() async throws { + let stub = StubOutlineAPIClient() + let docOne = makeDocument(id: "doc-1", title: "One") + let docTwo = makeDocument(id: "doc-2", title: "Two") + stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.documentInfo(id: "doc-2") + stub.documentInfoHandler = { _ in throw StubTransportError() } + + let cachedOne = try await sut.documentInfo(id: "doc-1") + let cachedTwo = try await sut.documentInfo(id: "doc-2") + + XCTAssertEqual(cachedOne, docOne) + XCTAssertEqual(cachedTwo, docTwo) + } + + // MARK: - Offline writes + + func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // Populate the cache with the base document first (as a real open would). + _ = try await sut.documentInfo(id: "doc-1") + + let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + XCTAssertEqual(updated.title, "Edited Offline") + + // Re-opening the document (still offline) should reflect the optimistic edit. + stub.documentInfoHandler = { _ in throw StubTransportError() } + let reopened = try await sut.documentInfo(id: "doc-1") + XCTAssertEqual(reopened.title, "Edited Offline") + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.kind, "updateDocument") + } + + func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { + let stub = StubOutlineAPIClient() + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + do { + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) + XCTFail("Expected an error") + } catch is StubTransportError { + // expected — nothing to optimistically merge onto + } + } + + func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit")) + + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + } + + func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { + let stub = StubOutlineAPIClient() + stub.createPinHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) + XCTAssertTrue(pin.id.hasPrefix("pending-")) + let pendingCount1 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount1, 1) + + try await sut.deletePin(id: pin.id) + + let pendingCount0 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount0, 0) + } + + func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + let pendingCount1 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount1, 1) + + // Network's back — replay should now succeed. + stub.updateDocumentHandler = { request in + self.makeDocument(id: request.id, title: request.title ?? "") + } + let summary = await sut.flushPendingOperations() + + XCTAssertEqual(summary.succeeded, 1) + XCTAssertEqual(summary.failed, 0) + let pendingCount0 = await sut.pendingOperations().count + XCTAssertEqual(pendingCount0, 0) + } + + func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws { + let stub = StubOutlineAPIClient() + let original = makeDocument(id: "doc-1", title: "Original") + stub.documentInfoHandler = { _ in original } + stub.updateDocumentHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) + + // Still offline — replay should fail and keep the operation queued. + let summary = await sut.flushPendingOperations() + + XCTAssertEqual(summary.succeeded, 0) + XCTAssertEqual(summary.failed, 1) + let pending = await sut.pendingOperations() + XCTAssertEqual(pending.count, 1) + XCTAssertEqual(pending.first?.attemptCount, 1) + XCTAssertNotNil(pending.first?.lastError) + } + + func testManualOfflineModeSkipsLiveEntirely() async throws { + let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")! + defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline") + defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey) + + let stub = StubOutlineAPIClient() + var liveCallCount = 0 + stub.createPinHandler = { _ in + liveCallCount += 1 + return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) + + let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) + + XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call") + XCTAssertTrue(pin.id.hasPrefix("pending-")) + } + + /// Regression test: manual offline mode used to only gate writes — + /// reads (listCollections, documentInfo, etc.) still hit `live` first + /// any time the device actually had a connection, silently defeating + /// "skip the network entirely" for the one thing that mattered most: + /// the sidebar showing more than what was actually cached. + func testManualOfflineModeSkipsLiveForReadsToo() async throws { + let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")! + defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads") + + let stub = StubOutlineAPIClient() + let cachedCollection = makeCollection(id: "col-1") + var liveCallCount = 0 + stub.listCollectionsHandler = { _, _ in + liveCallCount += 1 + return [cachedCollection] + } + let cache = try makeCache() + let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) + + // Online first — populates the cache normally. + let firstResult = try await sut.listCollections(offset: 0, limit: 25) + XCTAssertEqual(firstResult, [cachedCollection]) + XCTAssertEqual(liveCallCount, 1) + + // Flip manual offline mode on, still "connected" (stub would happily + // answer) — the live call must not be attempted at all. + defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey) + let secondResult = try await sut.listCollections(offset: 0, limit: 25) + + XCTAssertEqual(secondResult, [cachedCollection]) + XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache") + } + + func testCacheStorageSummaryReflectsCachedItems() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in self.makeDocument() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + let summary = await sut.cacheStorageSummary() + + XCTAssertEqual(summary.itemCount, 1) + XCTAssertGreaterThan(summary.totalBytes, 0) + + await sut.clearCache() + let clearedSummary = await sut.cacheStorageSummary() + XCTAssertEqual(clearedSummary.itemCount, 0) + } + + // MARK: - Full sync + + func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws { + let stub = StubOutlineAPIClient() + var requestedLimits: [Int] = [] + // 105 collections across two pages (100 + 5) — regression test for a + // real bug: this used to ask for `limit: 250` in one shot, which + // Outline's server rejects outright ("Pagination limit is too large + // (max 100)"), turning the whole sync into a single silent failure. + stub.listCollectionsHandler = { offset, limit in + requestedLimits.append(limit) + let remaining = max(0, 105 - offset) + let count = min(limit, remaining) + return (0.. Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Settings") + .font(.headline) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + List(SettingsSection.allCases, selection: $selection) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + .listStyle(.sidebar) + + Divider() + + Button("Done", action: onDone) + .keyboardShortcut(.cancelAction) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .padding(12) + } + } +} +#endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift new file mode 100644 index 0000000..d62b4f1 --- /dev/null +++ b/Outpost/Features/Account/SettingsView.swift @@ -0,0 +1,420 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Settings *detail* content for one section — the section list itself now +/// lives in `ContentView_macOS`'s real sidebar (swapped in over the +/// collections tree while `AppNavigation.isShowingSettings` is set, not a +/// separate mini sidebar of its own), so this view only ever renders +/// whichever section is currently selected. +struct SettingsView: View { + let section: SettingsSection + + @Environment(SessionStore.self) private var session + @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false + @State private var isShowingLogoutConfirmation = false + @State private var isShowingAdvancedWarning = false + @State private var storageSummary: CacheStorageSummary? + @State private var pendingOperations: [PendingOperationSummary] = [] + @State private var isSyncing = false + @State private var isClearingCache = false + @State private var didClearCache = false + @State private var lastFullSyncSummary: FullSyncSummary? + @State private var lastFlushSummary: SyncFlushSummary? + + /// Full Local Sync and cache-clearing both need a real connection to be + /// safe — clearing while offline (or letting Full Local Sync think it + /// should be running) can leave the app with nothing local to show and + /// no way to refetch it. "Offline" here means either a real dropped + /// connection or the user's own manual toggle — both leave the app with + /// no server to talk to. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + + var body: some View { + // GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for + // "center short content inside a ScrollView" — a ScrollView proposes + // effectively unbounded height to its content, so `maxHeight: .infinity` + // alone just resolves to the content's own intrinsic size and does + // nothing; forcing a `minHeight` equal to the real viewport height is + // what gives `aboutDetail`'s own centered alignment somewhere to + // actually center within. Harmless for the other (already + // top/leading-aligned) sections — short ones just get blank space + // below, same as before. + GeometryReader { geometry in + ScrollView { + sectionDetail + .padding(28) + .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + .task { await refreshSyncState() } + .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) + } + + @ViewBuilder + private var sectionDetail: some View { + switch section { + case .appearance: appearanceDetail + case .account: accountDetail + case .offlineSync: offlineSyncDetail + case .advanced: advancedDetail + case .about: aboutDetail + } + } + + private var sectionHeader: some View { + Text(section.title) + .font(.title.bold()) + } + + // MARK: - Appearance + + private var appearanceDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + Picker("Appearance", selection: $appearance) { + ForEach(AppAppearance.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 320) + } + } + + // MARK: - Account + + private var accountDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + VStack(alignment: .leading, spacing: 10) { + labeledRow("Signed in as", session.userName ?? "—") + if let email = session.userEmail { + labeledRow("Email", email) + } + if let teamName = session.teamName { + labeledRow("Workspace", teamName) + } + } + .frame(maxWidth: 420) + + Button("Log Out…", role: .destructive) { + isShowingLogoutConfirmation = true + } + } + } + + // MARK: - Offline & Sync + + private var offlineSyncDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + VStack(alignment: .leading, spacing: 6) { + Toggle("Offline Mode", isOn: $isOfflineModeEnabled) + Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + + VStack(alignment: .leading, spacing: 6) { + Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) + .disabled(!isEffectivelyOnline) + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.") + .font(.caption) + .foregroundStyle(.secondary) + if !isEffectivelyOnline { + Text("Requires an internet connection to turn on or off.") + .font(.caption2) + .foregroundStyle(.orange) + } + } + .frame(maxWidth: 480, alignment: .leading) + .help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.") + + if isFullLocalSyncEnabled { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if isSyncing { + ProgressView().controlSize(.small) + Text("Syncing…") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Button("Sync Now") { Task { await runFullSync() } } + .controlSize(.small) + .disabled(!isEffectivelyOnline) + if let lastFullSyncSummary { + Text(fullSyncSummaryText(lastFullSyncSummary)) + .font(.caption) + .foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red) + } + } + } + if let lastFullSyncSummary, !isSyncing { + ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in + Text(message) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } + + Divider() + .frame(maxWidth: 480) + + pendingOperationsRow + .frame(maxWidth: 480, alignment: .leading) + } + } + + private var pendingOperationsRow: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Pending Sync") + .font(.subheadline.weight(.medium)) + Spacer() + if isSyncing { + ProgressView().controlSize(.small) + } else { + Button("Retry") { Task { await retrySync() } } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(Color.accentColor) + .disabled(pendingOperations.isEmpty) + } + } + + if pendingOperations.isEmpty { + Text("Everything's synced.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(pendingOperations) { operation in + pendingOperationRow(operation) + } + } + } + } + } + + private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill") + .foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange) + .font(.caption) + VStack(alignment: .leading, spacing: 2) { + Text(operationLabel(operation.kind)) + .font(.caption) + if let lastError = operation.lastError { + Text(lastError) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } + + private func operationLabel(_ kind: String) -> String { + switch kind { + case "updateDocument": return "Document edit" + case "updateCollection": return "Collection rename" + case "createPin": return "Pin" + case "deletePin": return "Unpin" + case "createSubscription": return "Subscribe" + case "deleteSubscription": return "Unsubscribe" + case "starDocument", "starCollection": return "Star" + case "deleteStar": return "Unstar" + default: return kind + } + } + + // MARK: - Advanced + + /// Everything here either does something destructive (clearing the + /// cache Full Local Sync just spent minutes building) or doesn't exist + /// yet — gating all of it behind an off-by-default master toggle plus a + /// confirmation to turn that toggle on is the safeguard: nothing here + /// can be reached by accident, and nothing outside this section can + /// touch the cache at all, so there's no path to "messed up Full Local + /// Sync" that doesn't go through this screen on purpose. + private var advancedDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + VStack(alignment: .leading, spacing: 6) { + Toggle("Enable Advanced Options", isOn: advancedOptionsBinding) + Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 12) { + comingSoonRow("Export All Data") + comingSoonRow("Developer Diagnostics") + comingSoonRow("Reset Local Database") + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("Clear All Cache") + Spacer() + if didClearCache { + Label("Cleared", systemImage: "checkmark") + .font(.caption) + .foregroundStyle(.secondary) + } + Button(role: .destructive) { + Task { await clearCache() } + } label: { + if isClearingCache { + ProgressView().controlSize(.small) + } else { + Text("Clear") + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + if let storageSummary { + Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .disabled(!isAdvancedOptionsEnabled || isClearingCache) + .opacity(isAdvancedOptionsEnabled ? 1 : 0.4) + .frame(maxWidth: 480, alignment: .leading) + } + .confirmationDialog( + "Enable Advanced Options?", + isPresented: $isShowingAdvancedWarning, + titleVisibility: .visible + ) { + Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true } + Button("Cancel", role: .cancel) {} + } message: { + Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.") + } + } + + /// Never writes `true` directly — turning the toggle on only opens the + /// warning dialog; only that dialog's own "Enable" button actually sets + /// it. Turning off doesn't need confirmation. + private var advancedOptionsBinding: Binding { + Binding( + get: { isAdvancedOptionsEnabled }, + set: { newValue in + if newValue { + isShowingAdvancedWarning = true + } else { + isAdvancedOptionsEnabled = false + } + } + ) + } + + private func comingSoonRow(_ title: String) -> some View { + HStack { + Text(title) + Spacer() + Text("Coming Soon") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(true) + .opacity(0.5) + } + + // MARK: - About + + private var aboutDetail: some View { + VStack(spacing: 16) { + sectionHeader + AboutInfoView() + .frame(maxWidth: 420) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + + // MARK: - Helpers + + private func labeledRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + } + .font(.callout) + } + + private func formattedBytes(_ bytes: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + + private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String { + if summary.errors.isEmpty { + return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections." + } + return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")." + } + + private func refreshSyncState() async { + guard let cachingClient = session.cachingClient else { return } + storageSummary = await cachingClient.cacheStorageSummary() + pendingOperations = await cachingClient.pendingOperations() + } + + private func runFullSync() async { + guard let cachingClient = session.cachingClient, !isSyncing else { return } + isSyncing = true + defer { isSyncing = false } + lastFullSyncSummary = await cachingClient.performFullSync() + await refreshSyncState() + } + + private func retrySync() async { + guard let cachingClient = session.cachingClient, !isSyncing else { return } + isSyncing = true + defer { isSyncing = false } + lastFlushSummary = await cachingClient.flushPendingOperations() + await refreshSyncState() + } + + private func clearCache() async { + guard let cachingClient = session.cachingClient else { return } + isClearingCache = true + defer { isClearingCache = false } + await cachingClient.clearCache() + await refreshSyncState() + didClearCache = true + Task { + try? await Task.sleep(for: .seconds(2)) + didClearCache = false + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionRowView.swift b/Outpost/Features/Collections/CollectionRowView.swift index 913b9e0..6eb363e 100644 --- a/Outpost/Features/Collections/CollectionRowView.swift +++ b/Outpost/Features/Collections/CollectionRowView.swift @@ -10,7 +10,7 @@ struct CollectionRowView: View { } icon: { if let emoji = collection.emojiIcon { Text(emoji) - } else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) { + } else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) { Image(systemName: symbolName) .foregroundStyle(tintColor) } else { diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 3a7d250..ad42a31 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -4,6 +4,8 @@ import OutlineKit struct ContentView_macOS: View { @Environment(SessionStore.self) private var session + @Environment(AppNavigation.self) private var navigation + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false /// The landing state — no collection selected yet is what Home actually /// means, so this starts `true` rather than auto-selecting the first /// collection the way this used to work. @@ -26,11 +28,73 @@ struct ContentView_macOS: View { globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) } + /// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its + /// own (that's `@Bindable`'s job, and introducing one in `body` would + /// mean restructuring it away from a single implicit-return expression) + /// — a manually-built `Binding` over the same reference is simpler here. + private var selectedSettingsSectionBinding: Binding { + Binding( + get: { navigation.selectedSettingsSection }, + set: { navigation.selectedSettingsSection = $0 } + ) + } + var body: some View { + // A totally separate top-level branch, not content swapped inside + // one persistent `NavigationSplitView` — that was tried first (an + // `if/else` inside a single split view, then an explicit `.id()` on + // just the detail pane) and neither reliably replaced the detail + // pane's content on macOS: `NavigationSplitView` bridges to + // `NSSplitViewController`, and swapping a `NavigationStack` with + // real push history for a plain view inside one persisting instance + // doesn't propagate the way plain SwiftUI identity rules would + // suggest. A different `if` branch is a genuinely different view + // hierarchy, so there's no existing split view instance for AppKit + // to get confused about reusing. + if navigation.isShowingSettings { + settingsContent + } else { + mainContent + } + } + + private var settingsContent: some View { + NavigationSplitView { + SettingsSidebarList( + selection: selectedSettingsSectionBinding, + onDone: { navigation.isShowingSettings = false } + ) + .navigationSplitViewColumnWidth(min: 220, ideal: 260) + } detail: { + SettingsView(section: navigation.selectedSettingsSection ?? .appearance) + } + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .navigation) { + HStack(spacing: 6) { + Image(systemName: "gearshape.fill") + Text("Settings") + } + .font(.headline) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + } + } + } + + private var mainContent: some View { NavigationSplitView { VStack(spacing: 0) { SidebarSearchField(text: $globalSearchQuery) Divider() + if isOfflineModeEnabled { + OfflineBanner(isManual: true) + Divider() + } else if !session.networkMonitor.isOnline { + OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true }) + Divider() + } sidebar AccountFooter() } @@ -92,6 +156,7 @@ struct ContentView_macOS: View { isContextualSearchExpanded = false selectedCollection = nil isShowingHome = true + navigation.isShowingSettings = false replaceDocumentPath(with: []) } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index d99df2d..97e9e9c 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -12,6 +12,7 @@ import OutlineKit struct DocumentReaderView: View { @Environment(SessionStore.self) private var session @Environment(StarStore.self) private var starStore + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @State private var viewModel: DocumentReaderViewModel let apiClient: OutlineAPIClient @@ -56,6 +57,15 @@ struct DocumentReaderView: View { self.onDocumentCreated = onDocumentCreated } + /// New Document, editing, pin/star/subscribe, and Full Width all queue + /// and sync later (see `CachingOutlineAPIClient`) — everything else here + /// (sharing, permissions, move/archive/delete/duplicate/templatize, + /// history, insights, export) hits the server directly with no offline + /// path, so it's disabled rather than left to fail confusingly on tap. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { @@ -82,6 +92,7 @@ struct DocumentReaderView: View { NativeTextViewWrapper( text: $viewModel.text, configuration: .init(heightBehavior: .fitsContent), + documentId: viewModel.documentId, isEditable: viewModel.isEditing ) @@ -110,7 +121,8 @@ struct DocumentReaderView: View { } label: { Image(systemName: "square.and.arrow.up") } - .help("Share") + .help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection") + .disabled(!isEffectivelyOnline) .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } @@ -235,7 +247,8 @@ struct DocumentReaderView: View { viewModel.isPinned, viewModel.isInsightsEnabled ?? false, viewModel.isFullWidth, - viewModel.isEditing + viewModel.isEditing, + isEffectivelyOnline ].map(String.init).joined(separator: "-") } @@ -305,27 +318,33 @@ struct DocumentReaderView: View { Button("Permissions…") { isShowingShareSheet = true } + .disabled(!isEffectivelyOnline) Divider() Button("Templatize") { Task { await templatize() } } + .disabled(!isEffectivelyOnline) Button("Duplicate") { Task { await duplicate() } } + .disabled(!isEffectivelyOnline) Button("Unpublish") { isShowingUnpublishConfirmation = true } + .disabled(!isEffectivelyOnline) Button("Archive…") { isShowingArchiveConfirmation = true } + .disabled(!isEffectivelyOnline) Divider() Button("Move") { isShowingMoveSheet = true } + .disabled(!isEffectivelyOnline) // Multipart file upload is its own subsystem — deferred rather than // half-built here. Button("Import Document…") {} @@ -342,9 +361,13 @@ struct DocumentReaderView: View { Button("History") { isShowingHistorySheet = true } + .disabled(!isEffectivelyOnline) Button("Insights") { isShowingInsightsSheet = true } + .disabled(!isEffectivelyOnline) + // Present/Search in Document both read `documents.info`, which is + // read-through cached — they work offline on whatever's cached. Button("Present") { isShowingPresentSheet = true } @@ -354,6 +377,7 @@ struct DocumentReaderView: View { Button("Download") { Task { await download() } } + .disabled(!isEffectivelyOnline) Button("Copy") { Task { await copyMarkdown() } } @@ -370,6 +394,7 @@ struct DocumentReaderView: View { get: { viewModel.isInsightsEnabled ?? false }, set: { _ in Task { await toggleInsights() } } )) + .disabled(!isEffectivelyOnline) // Confirmed against a live server: there's no per-document embeds // field. Only a workspace-level setting exists, and that's not // reachable via the API either (no `team.update` endpoint in the @@ -386,6 +411,7 @@ struct DocumentReaderView: View { Button("Delete…", role: .destructive) { isShowingDeleteConfirmation = true } + .disabled(!isEffectivelyOnline) } private func star() async { diff --git a/Outpost/Features/Collections/OfflineBanner.swift b/Outpost/Features/Collections/OfflineBanner.swift new file mode 100644 index 0000000..24cafbc --- /dev/null +++ b/Outpost/Features/Collections/OfflineBanner.swift @@ -0,0 +1,27 @@ +#if os(macOS) +import SwiftUI + +/// Shown at the top of the sidebar whenever the app is offline — either for +/// real (`NetworkMonitor` reports no connection) or because the user turned +/// on the manual "Offline Mode" toggle. Cached collections/documents keep +/// browsing working either way, but the user should know they might be +/// looking at stale data. +struct OfflineBanner: View { + /// Whether this is the user's own "Offline Mode" toggle rather than a + /// real dropped connection — same banner slot, different explanation. + var isManual: Bool = false + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content") + .font(.callout) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift new file mode 100644 index 0000000..9bd51de --- /dev/null +++ b/Outpost/Features/Collections/OfflineConnectionPromptBanner.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import SwiftUI + +/// Shown in the sidebar when the network is actually down and the user +/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change +/// any fetch behavior on its own — reads still try live and fall back to +/// cache the normal way (see `CachingOutlineAPIClient`) until the user +/// actually taps the button here, same as `RemoteChangesBanner` never +/// auto-refreshes on their behalf either. +struct OfflineConnectionPromptBanner: View { + let onEnableOfflineMode: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text("No connection") + .font(.callout) + Spacer(minLength: 8) + Button("Turn On Offline Mode", action: onEnableOfflineMode) + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index d1888ce..56543ab 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -57,25 +57,33 @@ final class HomeViewModel { /// Fetches fresh pinned docs and the current tab's documents to compare /// against what's displayed, without replacing either. Bails silently /// on a fetch failure rather than treating it as "changed" — a - /// transient network hiccup shouldn't pop the refresh banner. + /// transient network hiccup (or, offline, `listPins` failing outright — + /// it isn't one of the cached endpoints) shouldn't pop the refresh + /// banner. This needs the *throwing* pinned-fetch specifically: the + /// plain `fetchPinned()` used elsewhere collapses any failure to `[]`, + /// which used to read here as "pins changed" against whatever was + /// already displayed and falsely popped the banner on every offline + /// poll. func checkForRemoteChanges(tab: HomeTab) async { - async let freshPinned = fetchPinned() + async let freshPinnedTask = fetchPinnedThrowing() guard let freshTab = try? await fetch(tab: tab) else { return } - let pinned = await freshPinned + guard let pinned = try? await freshPinnedTask else { return } if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { hasRemoteChanges = true } } + private func fetchPinned() async -> [OutlineDocument] { + (try? await fetchPinnedThrowing()) ?? [] + } + /// `pins.list` only returns pin records, not the documents themselves — /// fetches each pinned document individually. Pins are a small curated /// set (unlike a full collection tree), so the N+1 here is acceptable /// where it wouldn't be in the sidebar. - private func fetchPinned() async -> [OutlineDocument] { - guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { - return [] - } + private func fetchPinnedThrowing() async throws -> [OutlineDocument] { + let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) var documents: [OutlineDocument] = [] for pin in pins { if let document = try? await apiClient.documentInfo(id: pin.documentId) { diff --git a/Outpost/OutpostApp.swift b/Outpost/OutpostApp.swift index 49caa0c..884c94e 100644 --- a/Outpost/OutpostApp.swift +++ b/Outpost/OutpostApp.swift @@ -14,6 +14,7 @@ import AppKit @main struct OutpostApp: App { @State private var session = SessionStore() + @State private var navigation = AppNavigation() @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system #if os(macOS) @@ -25,6 +26,7 @@ struct OutpostApp: App { WindowGroup { RootView() .environment(session) + .environment(navigation) #if os(iOS) .preferredColorScheme(appearance.colorScheme) #endif @@ -41,6 +43,16 @@ struct OutpostApp: App { openWindow(id: "about") } } + // No `Settings {}` scene anymore — Settings renders inside the + // root window (see `AppNavigation`), not a separate popup, so + // ⌘, has to be wired up by hand instead of coming for free. + CommandGroup(replacing: .appSettings) { + Button("Settings…") { + navigation.isShowingSettings = true + } + .keyboardShortcut(",") + .disabled(!session.isSignedIn) + } CommandGroup(after: .appSettings) { Divider() Button("Log Out…") { @@ -63,11 +75,6 @@ struct OutpostApp: App { .disablesFullScreen() } .windowResizability(.contentSize) - - Settings { - PreferencesView() - .environment(session) - } #endif } diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift new file mode 100644 index 0000000..6804aef --- /dev/null +++ b/Outpost/Root/AppNavigation.swift @@ -0,0 +1,40 @@ +import Observation + +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case appearance, account, offlineSync, advanced, about + + var id: String { rawValue } + + var title: String { + switch self { + case .appearance: return "Appearance" + case .account: return "Account" + case .offlineSync: return "Offline & Sync" + case .advanced: return "Advanced" + case .about: return "About" + } + } + + var icon: String { + switch self { + case .appearance: return "paintbrush" + case .account: return "person.crop.circle" + case .offlineSync: return "arrow.triangle.2.circlepath" + case .advanced: return "wrench.and.screwdriver" + case .about: return "info.circle" + } + } +} + +/// Cross-cutting UI state that doesn't belong to any one screen. Lives at +/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach +/// it) and is read wherever something needs to open Settings (the profile +/// menu) or render it — as a swap of the *existing* sidebar/detail panes in +/// `ContentView_macOS`, not a separate popup window or an overlay that hides +/// the sidebar. +@Observable +@MainActor +final class AppNavigation { + var isShowingSettings = false + var selectedSettingsSection: SettingsSection? = .appearance +} diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 5b25277..5fd90d4 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -5,6 +5,19 @@ struct RootView: View { @Environment(SessionStore.self) private var session @State private var welcomeName: String? @State private var starStore = StarStore() + @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + + /// Real connectivity is only half of "can talk to the server" — the + /// manual Offline Mode toggle is the other half. Syncing needs to + /// resume on either one clearing, not just a real reconnect: turning + /// the toggle off while the network had been up the whole time never + /// changes `networkMonitor.isOnline`, so keying the flush task off that + /// alone left pending operations stuck until the next real network + /// blip. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } var body: some View { ZStack { @@ -32,6 +45,48 @@ struct RootView: View { starStore.reset() } } + // Replay whatever queued up while offline the moment either signal + // clears — a real reconnect, or the user turning Offline Mode back + // off — no need to wait for the user to open Settings and hit Retry. + // Also catches Full Local Sync back up immediately, rather than + // leaving it to wait out the rest of the periodic loop below. + // + // The flush itself gets a retry loop with a cooloff, not just one + // attempt: a single transient failure (one bad operation, a blip + // mid-flush) used to leave everything else stuck until the user + // manually hit Retry or connectivity changed again. Keeps retrying + // every 5 minutes for as long as *anything* is still pending and + // this signal stays on — stops on its own once the queue is empty, + // and `.task(id:)` cancels/restarts it automatically if + // isEffectivelyOnline flips again in the meantime. + .task(id: isEffectivelyOnline) { + guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return } + if isFullLocalSyncEnabled { + _ = await cachingClient.performFullSync() + } + while !Task.isCancelled { + _ = await cachingClient.flushPendingOperations() + let remaining = await cachingClient.pendingOperations() + guard !remaining.isEmpty else { break } + try? await Task.sleep(for: .seconds(300)) + } + } + // Fully automatic — this is the only place Full Local Sync actually + // runs from (Settings' "Sync Now" is just an on-demand nudge at the + // same call). Syncs immediately whenever this task (re)starts, which + // covers both "just switched on" and "was already on at launch" — + // `.task(id:)` restarts on either, an `@AppStorage`-backed toggle + // changing anywhere updates every view reading that key — then every + // 20 minutes after, for as long as it stays enabled. + .task(id: isFullLocalSyncEnabled) { + guard isFullLocalSyncEnabled else { return } + while !Task.isCancelled { + if isEffectivelyOnline, let cachingClient = session.cachingClient { + _ = await cachingClient.performFullSync() + } + try? await Task.sleep(for: .seconds(1200)) + } + } } private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index d8563fe..d815378 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -17,6 +17,16 @@ final class SessionStore { var teamName: String? var teamAvatarURL: URL? private(set) var apiClient: OutlineAPIClient? + /// Same object as `apiClient` when the offline cache is available — kept + /// as a separately-typed reference so Settings can reach cache/sync-queue + /// specific methods (`flushPendingOperations`, `performFullSync`, etc.) + /// without downcasting the protocol-typed `apiClient` everywhere. + private(set) var cachingClient: CachingOutlineAPIClient? + let networkMonitor = NetworkMonitor() + /// `nil` only if the on-disk SwiftData store failed to open (e.g. disk + /// full) — in that case `apiClient` falls back to talking to the server + /// directly with no offline cache, rather than failing sign-in outright. + private let cacheStore: OfflineCacheStore? var serverURL: URL? { defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) @@ -26,23 +36,32 @@ final class SessionStore { self.tokenStore = tokenStore self.defaults = defaults self.isSignedIn = (try? tokenStore.token()) != nil + self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) if isSignedIn, let serverURL { - apiClient = LiveOutlineAPIClient( - configuration: OutlineConfiguration(baseURL: serverURL), - tokenStore: tokenStore - ) + (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) } } func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - apiClient = LiveOutlineAPIClient( + (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + apply(user: user, team: team, serverURL: serverURL) + isSignedIn = true + } + + private static func makeAPIClient( + serverURL: URL, + tokenStore: TokenStoring, + cache: OfflineCacheStore? + ) -> (OutlineAPIClient, CachingOutlineAPIClient?) { + let live = LiveOutlineAPIClient( configuration: OutlineConfiguration(baseURL: serverURL), tokenStore: tokenStore ) - apply(user: user, team: team, serverURL: serverURL) - isSignedIn = true + guard let cache else { return (live, nil) } + let caching = CachingOutlineAPIClient(live: live, cache: cache) + return (caching, caching) } func signOut() { @@ -55,6 +74,7 @@ final class SessionStore { teamName = nil teamAvatarURL = nil apiClient = nil + cachingClient = nil } /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this diff --git a/Outpost/Support/NetworkMonitor.swift b/Outpost/Support/NetworkMonitor.swift new file mode 100644 index 0000000..35fc64d --- /dev/null +++ b/Outpost/Support/NetworkMonitor.swift @@ -0,0 +1,36 @@ +import Foundation +import Network +import Observation + +/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to +/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual +/// request failures rather than pre-checking reachability. +@Observable +@MainActor +final class NetworkMonitor { + private(set) var isOnline = true + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.outpost.network-monitor") + + init() { + // Weak on the outer closure (it's held by `monitor` for the object's + // whole lifetime, so a strong capture here would be a retain cycle), + // then unwrapped once into a plain strong local — the inner `Task` + // closure capturing that local `self` is what a nested closure needs + // to be consistent about capture semantics with its enclosing one. + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + + let online = path.status == .satisfied + Task { @MainActor in + self.isOnline = online + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +}