Read-only for now, per instruction to defer key creation/revocation. Both confirmed against live network captures.
714 lines
30 KiB
Swift
714 lines
30 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
|
|
|
|
/// The one exception to "no new tree structure while offline" —
|
|
/// synthesizes a document under a `pending-<uuid>` 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()
|
|
}
|
|
|
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
|
try await live.createAttachment(request)
|
|
}
|
|
|
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
|
try await live.uploadAttachmentFile(result, fileData: fileData)
|
|
}
|
|
|
|
public func deleteAttachment(id: String) async throws {
|
|
try await live.deleteAttachment(id: id)
|
|
}
|
|
|
|
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
|
try await live.updateUserAvatar(request)
|
|
}
|
|
|
|
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
|
try await live.updateUserName(request)
|
|
}
|
|
|
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
|
try await live.updateUserLanguage(request)
|
|
}
|
|
|
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
|
try await live.updateUserPreferences(request)
|
|
}
|
|
|
|
public func deleteAccount() async throws {
|
|
try await live.deleteAccount()
|
|
}
|
|
|
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await live.subscribeToNotifications(eventType: eventType)
|
|
}
|
|
|
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
|
}
|
|
|
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
|
try await live.listApiKeys(request)
|
|
}
|
|
|
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
|
try await live.installationInfo()
|
|
}
|
|
|
|
// 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<T: Codable>(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)
|
|
}
|
|
}
|