field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }