TeraDraftRecoveryStore.swift (5909B)
1 import Foundation 2 3 /// One reader and one pending selection request. Only the current bounded 4 /// composer and legacy pages are retained; continuation never grows an archive. 5 @MainActor 6 final class TeraDraftRecoveryStore: ObservableObject { 7 let restore: TeraRestoreRecoveryStore 8 let transfers: TeraNativeRepairStore 9 let revisions: TeraRevisionDetailStore 10 static let pageSize: UInt16 = 100 11 @Published private(set) var composers: [TeraComposerListEntry] = [] 12 @Published private(set) var legacy: [TeraLegacyDraftListEntry] = [] 13 @Published private(set) var composerCursor: String? 14 @Published private(set) var legacyCursor: String? 15 @Published private(set) var composerError: String? 16 @Published private(set) var legacyError: String? 17 @Published private(set) var isLoading = false 18 private(set) var scope: TeraComposerScope? 19 private let client: TeraRuntimeClient 20 private let composer: TeraComposerPersistence 21 private var generation = TeraSessionGeneration.initial 22 private var task: Task<Void, Never>? 23 private var pending: Request? 24 25 private enum Request { case first, composers(String), legacy(String) } 26 27 init(client: TeraRuntimeClient, media: (any TeraAddMediaHandling)? = nil) { 28 restore = TeraRestoreRecoveryStore(client: client) 29 transfers = TeraNativeRepairStore(client: client, media: media) 30 self.client = client 31 revisions = TeraRevisionDetailStore(client: client) 32 composer = TeraComposerPersistence(client: client).protectingMedia(media) 33 } 34 35 deinit { task?.cancel() } 36 37 func configure(scope: TeraComposerScope) { 38 guard self.scope != scope else { return } 39 stop() 40 self.scope = scope 41 transfers.configure(author: scope.authorPublicKey) 42 revisions.configure(author: scope.authorPublicKey) 43 composers = [] 44 legacy = [] 45 composerCursor = nil 46 legacyCursor = nil 47 composerError = nil 48 legacyError = nil 49 } 50 51 func stop() { 52 restore.invalidate() 53 transfers.stop() 54 revisions.stop() 55 generation = generation.invalidated() 56 pending = nil 57 task?.cancel() 58 isLoading = false 59 } 60 61 func start() { 62 schedule(.first) 63 } 64 65 func load(_ selection: TeraDraftRecoverySelection) async throws -> TeraRecoveredDraft { 66 guard let scope else { throw TeraComposerAcknowledgment.unconfirmed } 67 let requested = generation 68 let result: TeraRecoveredDraft 69 switch selection { 70 case let .composer(id): 71 let draft = try await composer.load(scope, id) 72 guard draft.scope == scope, draft.id == id else { throw TeraComposerAcknowledgment.unconfirmed } 73 result = .composer(draft) 74 case let .legacy(id): 75 let draft = try await client.draftStatus(id: id) 76 guard draft.authorPublicKey == scope.authorPublicKey, draft.id == id, draft.form != nil else { 77 throw TeraComposerAcknowledgment.unconfirmed 78 } 79 result = .legacy(draft) 80 } 81 guard isCurrent(requested) else { throw CancellationError() } 82 return result 83 } 84 85 func moreComposers() { 86 guard let composerCursor, !isLoading else { return } 87 schedule(.composers(composerCursor)) 88 } 89 90 func moreLegacy() { 91 guard let legacyCursor, !isLoading else { return } 92 schedule(.legacy(legacyCursor)) 93 } 94 95 private func schedule(_ request: Request) { 96 guard !Task.isCancelled, generation.isActive, scope != nil else { return } 97 pending = request 98 isLoading = true 99 startWorker() 100 } 101 102 private func startWorker() { 103 guard task == nil, let request = pending, let scope, generation.isActive else { return } 104 pending = nil 105 let requested = generation 106 isLoading = true 107 task = Task { [weak self] in 108 guard let self else { return } 109 await load(request, scope: scope, generation: requested) 110 task = nil 111 isLoading = false 112 startWorker() 113 } 114 } 115 116 private func load(_ request: Request, scope: TeraComposerScope, generation: TeraSessionGeneration) async { 117 switch request { 118 case .first: 119 await loadComposers(scope: scope, cursor: nil, generation: generation) 120 guard isCurrent(generation) else { return } 121 await loadLegacy(scope: scope, cursor: nil, generation: generation) 122 case let .composers(cursor): 123 await loadComposers(scope: scope, cursor: cursor, generation: generation) 124 case let .legacy(cursor): 125 await loadLegacy(scope: scope, cursor: cursor, generation: generation) 126 } 127 } 128 129 private func loadComposers(scope: TeraComposerScope, cursor: String?, generation: TeraSessionGeneration) async { 130 do { 131 let page = try await client.listComposers(scope: scope, limit: Self.pageSize, cursor: cursor) 132 guard isCurrent(generation) else { return } 133 guard page.scope == scope, page.entries.count <= Int(Self.pageSize) else { 134 throw TeraComposerAcknowledgment.unconfirmed 135 } 136 composers = page.entries 137 composerCursor = page.nextCursor 138 composerError = nil 139 } catch { 140 guard isCurrent(generation) else { return } 141 composerError = "Saved editing could not be listed. Keep current changes and retry the list." 142 } 143 } 144 145 private func loadLegacy(scope: TeraComposerScope, cursor: String?, generation: TeraSessionGeneration) async { 146 do { 147 let page = try await client.legacyDraftPage(limit: Self.pageSize, cursor: cursor) 148 guard isCurrent(generation) else { return } 149 guard page.authorPublicKey == scope.authorPublicKey, page.entries.count <= Int(Self.pageSize) else { 150 throw TeraComposerAcknowledgment.unconfirmed 151 } 152 legacy = page.entries 153 legacyCursor = page.nextCursor 154 legacyError = nil 155 } catch { 156 guard isCurrent(generation) else { return } 157 legacyError = "Saved operations could not be listed. Editing is still available; retry the list." 158 } 159 } 160 161 private func isCurrent(_ requested: TeraSessionGeneration) -> Bool { 162 generation == requested && generation.isActive && !Task.isCancelled 163 } 164 }