Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.
Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.
Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.
8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
574 lines
24 KiB
Swift
574 lines
24 KiB
Swift
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
|
|
|
|
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 createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
|
try await live.createDocument(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] = []
|
|
do {
|
|
collections = try await listCollections(offset: 0, limit: 250)
|
|
} catch {
|
|
errors.append(errorDescription(error))
|
|
}
|
|
|
|
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<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
|
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 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)
|
|
// 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 .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)
|
|
}
|
|
}
|