TeraAddStore.swift (26704B)
1 import Foundation 2 3 @MainActor 4 final class TeraAddStore: ObservableObject { 5 @Published private(set) var schemas: [TeraAddSchema] = [] 6 @Published private(set) var drafts: [TeraDraftStatus] = [] 7 @Published private(set) var activeDraft: TeraDraftStatus? 8 @Published private(set) var form: TeraAddForm { 9 didSet { 10 if form != oldValue { 11 message = nil 12 protection.editingChanged() 13 composer.observeEditing(form, isEditable: isFormEditable, isRevision: revisionTarget != nil) 14 } 15 } 16 } 17 18 @Published private(set) var composerState: TeraComposerSaveState = .idle 19 let submissions: TeraSubmissionStore 20 let recovery: TeraDraftRecoveryStore 21 let protection = TeraEditingProtection() 22 @Published private(set) var mediaRecoveryMessage: String? 23 @Published private(set) var state: TeraAddLoadState = .idle 24 var mediaSupport: TeraAddMediaSupport { 25 mediaAccess.support 26 } 27 28 private let mediaAccess: TeraMediaAccessStore 29 @Published private(set) var blossomConfiguration: TeraBlossomConfigurationStatus? 30 @Published private(set) var blossomEvidence: TeraBlossomEvidence? 31 @Published private(set) var isCheckingBlossom = false 32 @Published private(set) var isWorking = false 33 @Published private(set) var message: String? 34 @Published private(set) var lastFailureCode: String? 35 @Published private(set) var observationState: TeraRuntimeObservationState = .inactive 36 37 private let runtimeClient: TeraRuntimeClient 38 private let composer: TeraComposerAutosave 39 private let media: (any TeraAddMediaHandling)? 40 private let observationDelay: @Sendable (UInt32) async throws -> Void 41 private let identifier: @Sendable () -> String 42 private let clock: TeraClock 43 private var generation = TeraSessionGeneration.initial 44 private var operationGeneration: TeraSessionGeneration? 45 private var operationTask: Task<Void, Never>? 46 private let observation = TeraStoreObservation() 47 private var configuration: TeraPresentationConfiguration? 48 private var draftsGeneration = TeraSessionGeneration.initial 49 private var appliedDraftsGeneration = TeraSessionGeneration.initial 50 private var probeGeneration = TeraSessionGeneration.initial 51 private var blossomGeneration = TeraSessionGeneration.initial 52 let revisionPreparation: TeraRevisionPreparation 53 private var revisionTarget: TeraRevisionTarget? 54 private var revisionOperationID: String? 55 private var activePublicKey: String? 56 57 init( 58 runtimeClient: TeraRuntimeClient, 59 media: (any TeraAddMediaHandling)? = nil, 60 initialType: TeraAddCommandType = .createUpdate, 61 identifier: @escaping @Sendable () -> String = { 62 UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() 63 }, 64 clock: TeraClock = .system, 65 observationDelay: @escaping @Sendable (UInt32) async throws -> Void = 66 TeraRuntimeObservationBackoff.sleep 67 ) { 68 self.runtimeClient = runtimeClient 69 revisionPreparation = TeraRevisionPreparation(client: runtimeClient, media: media) 70 recovery = TeraDraftRecoveryStore(client: runtimeClient, media: media) 71 composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient).protectingMedia(media)) 72 submissions = TeraSubmissionStore(client: runtimeClient, composer: composer, media: media) 73 self.media = media 74 mediaAccess = TeraMediaAccessStore(media: media) 75 self.identifier = identifier 76 self.clock = clock 77 self.observationDelay = observationDelay 78 form = TeraAddPresentation.newForm( 79 type: initialType, 80 identifier: identifier, 81 clock: clock 82 ) 83 mediaAccess.changed = { [weak self] in self?.objectWillChange.send() } 84 submissions.changed = { [weak self] in self?.objectWillChange.send() } 85 composer.stateChanged = { [weak self] in self?.composerState = $0 } 86 protection.cancelled = { [weak self] in guard let self else { return }; generation = generation.invalidated() } 87 } 88 89 var savedComposer: TeraComposerDraft? { 90 composer.acknowledged 91 } 92 93 func updateForm<Value>(_ keyPath: WritableKeyPath<TeraAddForm, Value>, _ value: Value) { 94 guard isFormEditable else { return } 95 generation = generation.invalidated() 96 form[keyPath: keyPath] = value 97 } 98 99 func newDraft(type: TeraAddCommandType? = nil) { 100 guard canCreateNewComposer else { return } 101 if needsEditingPreservation { 102 protection.schedule(kind: .editing, save: preservation(), apply: replacement { store, _ in store.replaceWithNew(type: type) }) 103 } else { 104 replaceWithNew(type: type) 105 } 106 } 107 108 private func replaceWithNew(type: TeraAddCommandType?) { 109 guard submissions.canReplaceEditing else { return } 110 submissions.newAction() 111 composer.reset(scope: composer.scope) 112 generation = generation.invalidated() 113 activeDraft = nil 114 resetRevision() 115 form = TeraAddPresentation.newForm(type: type ?? form.commandType, identifier: identifier, clock: clock) 116 message = nil 117 } 118 119 func reopen(_ draft: TeraDraftStatus) { 120 guard !isWorking, !submissions.isWorking, !protection.isWorking, !protection.failed else { return } 121 if needsEditingPreservation { 122 protection.schedule(kind: .reopen, save: preservation(), apply: replacement { store, _ in store.replaceWithLegacy(draft) }) 123 } else { 124 replaceWithLegacy(draft) 125 } 126 } 127 128 private func replaceWithLegacy(_ draft: TeraDraftStatus) { 129 guard !submissions.isWorking else { return } 130 guard let snapshot = draft.form else { 131 message = "This operation has no editable Add form." 132 return 133 } 134 generation = generation.invalidated() 135 activeDraft = draft 136 composer.reset(scope: composer.scope) 137 revisionTarget = nil 138 revisionPreparation.reset() 139 revisionOperationID = draft.isRevision ? draft.id : nil 140 form = snapshot 141 message = draft.isEditable ? "Draft reopened." : draft.honestSummary 142 } 143 144 func reopenSaved(_ selection: TeraDraftRecoverySelection) async -> Bool { 145 await replaceEditing(kind: .reopen) { store, requested in 146 let recovered = try await store.recovery.load(selection) 147 try store.ensureCurrent(requested) 148 switch recovered { 149 case let .composer(draft): 150 try store.composer.restore(draft) 151 store.activeDraft = nil 152 store.resetRevision() 153 store.form = draft.form.editingValue 154 store.message = "Saved editing reopened." 155 case let .legacy(draft): 156 store.composer.reset(scope: store.composer.scope) 157 store.revisionTarget = nil 158 store.revisionPreparation.reset() 159 store.revisionOperationID = draft.isRevision ? draft.id : nil 160 try store.accept(draft, generation: requested) 161 store.message = draft.honestSummary 162 } 163 } 164 } 165 166 func importPhotos() async { 167 await prepareMedia(capture: false) 168 } 169 170 private func prepareMedia(capture: Bool) async { 171 guard canAddMedia, media != nil else { 172 message = "Photo intake is unavailable." 173 return 174 } 175 await perform { requestedGeneration in 176 let remaining = self.mediaLimit - self.form.media.count 177 let prepared = try await self.mediaAccess.prepare(limit: remaining, capture: capture) 178 try self.ensureCurrent(requestedGeneration) 179 self.form.media.append(contentsOf: prepared.prefix(remaining)) 180 self.message = "Photo prepared. Add descriptive text before publishing." 181 } 182 } 183 184 func checkPhotoService() async { 185 guard !isCheckingBlossom, !Task.isCancelled else { return } 186 guard blossomConfiguration != nil else { 187 message = "No photo service is configured for the current network profile." 188 return 189 } 190 probeGeneration = probeGeneration.invalidated() 191 let probe = probeGeneration 192 blossomGeneration = blossomGeneration.invalidated() 193 let serviceRequest = blossomGeneration 194 let requestedGeneration = generation 195 isCheckingBlossom = true 196 defer { 197 if probe == probeGeneration { 198 isCheckingBlossom = false 199 } 200 } 201 do { 202 let evidence = try await runtimeClient.probeBlossom() 203 try ensureCurrent(requestedGeneration) 204 let support = try await mediaAccess.inspect() 205 try ensureCurrent(requestedGeneration) 206 guard probe == probeGeneration, serviceRequest == blossomGeneration else { return } 207 blossomEvidence = evidence 208 mediaAccess.accept(support) 209 message = "Photo service is reachable." 210 } catch { 211 guard isCurrent(requestedGeneration), probe == probeGeneration, 212 serviceRequest == blossomGeneration else { return } 213 guard await refreshBlossomSnapshot(), isCurrent(requestedGeneration), 214 probe == probeGeneration else { return } 215 message = TeraAddPresentation.message(for: error) 216 } 217 } 218 219 func removeMedia(id: String) { 220 guard isFormEditable else { return } 221 generation = generation.invalidated() 222 form.media.removeAll(where: { $0.id == id }) 223 } 224 225 func updateMediaAlt(id: String, alt: String) { 226 guard isFormEditable, 227 let index = form.media.firstIndex(where: { $0.id == id }) 228 else { return } 229 generation = generation.invalidated() 230 form.media[index].alt = alt 231 } 232 233 func save(locale: Locale = .current) async { 234 if canSave, !protection.isWorking, revisionTarget != nil, isFormEditable { 235 form = TeraFoodDecimalEntry.form(form, locale: locale) 236 } 237 await perform { requestedGeneration in 238 if self.revisionTarget != nil { 239 _ = try await self.saveCurrentForm(generation: requestedGeneration) 240 } else { 241 guard self.isFormEditable else { throw TeraComposerAcknowledgment.unconfirmed } 242 _ = try await self.composer.save(TeraComposerForm(editing: self.form)) 243 } 244 try self.ensureCurrent(requestedGeneration) 245 self.message = "Draft saved on this device." 246 } 247 } 248 249 func submit(locale: Locale = .current) async { 250 // Only a new editable request may translate entry text. Existing capture, 251 // retry, revision operation and signed bytes retain their original inputs. 252 if canSubmit, !protection.isWorking, isFormEditable, !submissions.usesCurrentAction, activeDraft?.isRevision != true { 253 form = TeraFoodDecimalEntry.form(form, locale: locale) 254 } 255 if activeDraft == nil, revisionTarget == nil { 256 guard canSubmit else { return } 257 await submissions.submit(form: form) 258 return 259 } 260 await perform { requested in 261 let legacy = TeraLegacySubmission( 262 runtimeClient: self.runtimeClient, media: self.media, 263 revisionID: { self.revisionOperationID }, 264 initial: { 265 if let active = self.activeDraft, active.isRevision || !active.isEditable { 266 return active 267 } 268 return try await self.saveCurrentForm(generation: requested) 269 }, 270 ensure: { try self.ensureCurrent(requested) }, 271 acceptDraft: { try self.accept($0, generation: requested) }, 272 acceptRevision: { try self.accept($0, generation: requested) }, 273 message: { self.message = $0 }, 274 refreshMedia: { await self.refreshBlossomSnapshot() } 275 ) 276 try await legacy.submit() 277 } 278 } 279 280 func retry(id: String) async { 281 await replaceEditing { store, requestedGeneration in 282 var current = try await store.runtimeClient.draftStatus(id: id) 283 try store.ensureCurrent(requestedGeneration) 284 guard current.coordinateWritable else { 285 store.message = current.honestSummary 286 return 287 } 288 if current.state == .draft || current.state == .mediaPreparing 289 || current.state == .readyToSign 290 { 291 store.composer.reset(scope: store.composer.scope) 292 store.activeDraft = current 293 store.revisionOperationID = current.isRevision ? current.id : nil 294 if let form = current.form { 295 store.form = form 296 } 297 store.message = "Review the draft before submitting again." 298 return 299 } 300 if current.isRevision { 301 let revision = try await store.runtimeClient.advanceRevision( 302 operationID: current.id 303 ) 304 try store.ensureCurrent(requestedGeneration) 305 store.composer.reset(scope: store.composer.scope) 306 try store.accept(revision, generation: requestedGeneration) 307 store.message = revision.honestSummary 308 return 309 } 310 if current.canAdvance { 311 current = try await store.runtimeClient.advanceDraft( 312 id: current.id, 313 expectedRevision: current.revision 314 ) 315 } 316 try store.ensureCurrent(requestedGeneration) 317 store.composer.reset(scope: store.composer.scope) 318 try store.accept(current, generation: requestedGeneration) 319 store.message = current.honestSummary 320 } 321 } 322 323 func cancel(id: String) async { 324 await replaceEditing { store, requestedGeneration in 325 let current = try await store.runtimeClient.draftStatus(id: id) 326 try store.ensureCurrent(requestedGeneration) 327 guard current.state.canCancel else { return } 328 if current.isRevision { 329 let cancelled = try await store.runtimeClient.cancelRevision( 330 operationID: current.id 331 ) 332 try store.ensureCurrent(requestedGeneration) 333 store.composer.reset(scope: store.composer.scope) 334 try store.accept(cancelled, generation: requestedGeneration) 335 store.message = cancelled.honestSummary 336 return 337 } 338 let cancelled = try await store.runtimeClient.cancelAddIntent( 339 id: current.id, 340 expectedRevision: current.revision 341 ) 342 try store.ensureCurrent(requestedGeneration) 343 store.composer.reset(scope: store.composer.scope) 344 try store.accept(cancelled, generation: requestedGeneration) 345 store.message = "Local work was cancelled. Any already-published relay effect is preserved." 346 } 347 } 348 349 func retractAndRevise(_ card: TeraTodayCard) async { 350 await replaceEditing { store, requestedGeneration in 351 let editing = try await TeraAddCardIntents.revision(card, author: store.activePublicKey, client: store.runtimeClient) 352 try store.ensureCurrent(requestedGeneration) 353 store.revisionTarget = editing.target 354 store.revisionPreparation.reset() 355 store.composer.reset(scope: store.composer.scope) 356 store.revisionOperationID = nil 357 store.activeDraft = nil 358 store.form = editing.form 359 store.message = "Review the lossless revised copy before publishing." 360 } 361 } 362 363 func retract(_ card: TeraTodayCard) async { 364 await replaceEditing { store, requestedGeneration in 365 let request = try TeraAddCardIntents.retraction(card, author: store.activePublicKey, identifier: store.identifier) 366 var status = try await store.runtimeClient.saveRetractionDraft( 367 id: request.id, input: request.input, 368 authoredAtUnixSeconds: store.clock.unixSeconds(), 369 persistedAtUnixMilliseconds: store.clock.unixMilliseconds() 370 ) 371 try store.ensureCurrent(requestedGeneration) 372 store.composer.reset(scope: store.composer.scope) 373 try store.accept(status, generation: requestedGeneration) 374 status = try await store.runtimeClient.queueAddIntent( 375 id: status.id, 376 expectedRevision: status.revision 377 ) 378 try store.accept(status, generation: requestedGeneration) 379 if status.canAdvance { 380 status = try await store.runtimeClient.advanceDraft( 381 id: status.id, 382 expectedRevision: status.revision 383 ) 384 try store.accept(status, generation: requestedGeneration) 385 } 386 store.message = status.honestSummary 387 } 388 } 389 390 private func reloadDrafts() async { 391 let requestedGeneration = generation 392 draftsGeneration = draftsGeneration.invalidated() 393 let request = draftsGeneration 394 do { 395 let loaded = try await runtimeClient.draftHeads(limit: 100) 396 guard isCurrent(requestedGeneration), request == draftsGeneration else { return } 397 appliedDraftsGeneration = try appliedDraftsGeneration.next() 398 drafts = TeraAddPresentation.sorted(loaded) 399 if let activeDraft, let current = loaded.first(where: { $0.id == activeDraft.id }), 400 current.revision >= activeDraft.revision 401 { 402 self.activeDraft = current 403 } 404 } catch { 405 guard isCurrent(requestedGeneration), request == draftsGeneration else { return } 406 message = TeraAddPresentation.message(for: error) 407 } 408 } 409 410 @discardableResult 411 private func refreshBlossomSnapshot() async -> Bool { 412 let requestedGeneration = generation 413 blossomGeneration = blossomGeneration.invalidated() 414 let request = blossomGeneration 415 let snapshot = try? await runtimeClient.snapshot() 416 guard isCurrent(requestedGeneration), request == blossomGeneration else { return false } 417 if let snapshot, configuration == TeraPresentationConfiguration(snapshot: snapshot) { 418 blossomConfiguration = snapshot.blossomConfiguration 419 blossomEvidence = snapshot.blossomEvidence 420 } 421 return true 422 } 423 424 private func startObservation() { 425 observation.start( 426 client: runtimeClient, buffer: (capacity: 16, delay: observationDelay), 427 state: { [weak self] in self?.observationState = $0 }, 428 accepts: { [weak self] in $0.matches(context: self?.configuration?.context) }, 429 refresh: { [weak self] batch in 430 guard let self else { return } 431 let requested = generation 432 if batch.contains(anyOf: [.drafts, .media]) { 433 submissions.inventory.start() 434 await reloadDrafts() 435 } 436 guard isCurrent(requested) else { return } 437 if batch.contains(anyOf: [.media, .settings]) { 438 await refreshBlossomSnapshot() 439 } 440 } 441 ) 442 } 443 444 private func isCurrent(_ requested: TeraSessionGeneration) -> Bool { 445 requested == generation && generation.isActive && !Task.isCancelled 446 } 447 448 private func ensureCurrent(_ requested: TeraSessionGeneration) throws { 449 guard isCurrent(requested) else { throw CancellationError() } 450 } 451 452 private func accept(_ status: TeraDraftStatus, generation requested: TeraSessionGeneration) throws { 453 try ensureCurrent(requested) 454 draftsGeneration = draftsGeneration.invalidated() 455 appliedDraftsGeneration = try appliedDraftsGeneration.next() 456 activeDraft = status 457 if let form = status.form { 458 self.form = form 459 } 460 drafts.removeAll(where: { $0.id == status.id }) 461 drafts.append(status) 462 drafts = TeraAddPresentation.sorted(drafts) 463 } 464 465 private func accept(_ status: TeraRevisionStatus, generation requested: TeraSessionGeneration) throws { 466 try ensureCurrent(requested) 467 revisionOperationID = status.operationID 468 try accept(status.replacement, generation: requested) 469 if let retraction = status.retraction { 470 drafts.removeAll(where: { $0.id == retraction.id }) 471 drafts.append(retraction) 472 drafts = TeraAddPresentation.sorted(drafts) 473 } 474 } 475 476 private func perform(_ operation: @escaping (TeraSessionGeneration) async throws -> Void) async { 477 guard operationTask == nil, !Task.isCancelled else { return } 478 generation = generation.invalidated() 479 let requestedGeneration = generation 480 operationGeneration = requestedGeneration 481 isWorking = true 482 message = nil 483 lastFailureCode = nil 484 let task = Task { @MainActor [weak self] in 485 guard let self, !Task.isCancelled else { return } 486 await execute(operation, generation: requestedGeneration) 487 } 488 operationTask = task 489 await task.value 490 } 491 492 private func execute( 493 _ operation: @escaping (TeraSessionGeneration) async throws -> Void, 494 generation requestedGeneration: TeraSessionGeneration 495 ) async { 496 defer { 497 if operationGeneration == requestedGeneration { 498 isWorking = false 499 operationGeneration = nil 500 operationTask = nil 501 } 502 } 503 do { 504 try ensureCurrent(requestedGeneration) 505 try await operation(requestedGeneration) 506 } catch is CancellationError { 507 if isCurrent(requestedGeneration) { 508 message = TeraUserMessages.text(.operationCancelled) 509 } 510 } catch { 511 if isCurrent(requestedGeneration) { 512 await refreshBlossomSnapshot() 513 guard isCurrent(requestedGeneration) else { return } 514 message = TeraAddPresentation.message(for: error) 515 lastFailureCode = TeraAddPresentation.failure(for: error)?.code 516 } 517 } 518 } 519 } 520 521 /// Lifecycle remains in the same lexical owner so private callback guards and 522 /// editing authority cannot be bypassed by a second controller. 523 extension TeraAddStore { 524 func capturePhoto() async { 525 await prepareMedia(capture: true) 526 } 527 528 func recheckMediaAccess() async { 529 guard !isWorking else { return } 530 await mediaAccess.recheck() 531 } 532 533 func configure(snapshot: TeraRuntimeSnapshot) { 534 let updated = TeraPresentationConfiguration(snapshot: snapshot) 535 let scope = TeraComposerScope(authorPublicKey: updated.publicKey, localNetworkID: updated.context.id) 536 let scopeChanged = composer.scope != scope 537 recovery.configure(scope: scope) 538 submissions.configure(scope: scope, context: updated.context) 539 if scopeChanged { 540 composer.reset(scope: scope) 541 } 542 if let configuration, configuration != updated { 543 stop() 544 schemas = [] 545 drafts = [] 546 if scopeChanged { 547 activeDraft = nil 548 resetRevision() 549 form = TeraAddPresentation.newForm(type: form.commandType, identifier: identifier, clock: clock) 550 } 551 state = .idle 552 mediaAccess.accept(.unavailable) 553 mediaRecoveryMessage = nil 554 message = nil 555 lastFailureCode = nil 556 } 557 blossomGeneration = blossomGeneration.invalidated() 558 configuration = updated 559 activePublicKey = snapshot.identity.publicKeyHex 560 blossomConfiguration = snapshot.blossomConfiguration 561 blossomEvidence = snapshot.blossomEvidence 562 } 563 564 func start() async { 565 guard !Task.isCancelled, await !(recovery.transfers.resumeIfObserving(observation.isActive)) else { return } 566 composer.resume() 567 submissions.start() 568 startObservation() 569 guard operationGeneration == nil else { return } 570 message = nil 571 state = .loading 572 generation = generation.invalidated() 573 let requestedGeneration = generation 574 draftsGeneration = draftsGeneration.invalidated() 575 let draftRequest = appliedDraftsGeneration 576 let serviceConfiguration = configuration 577 do { 578 let loaded = try await TeraAddStartupSnapshot.load(client: runtimeClient, support: mediaAccess.inspect) { schemas in 579 guard self.isCurrent(requestedGeneration) else { return } 580 self.schemas = schemas 581 self.state = .ready 582 self.recovery.start() 583 } 584 guard isCurrent(requestedGeneration) else { return } 585 if serviceConfiguration == configuration { 586 mediaRecoveryMessage = loaded.mediaMessage 587 } 588 await recovery.transfers.reconcile() 589 try ensureCurrent(requestedGeneration) 590 schemas = loaded.schemas 591 if draftRequest == appliedDraftsGeneration { 592 appliedDraftsGeneration = try appliedDraftsGeneration.next() 593 drafts = TeraAddPresentation.sorted(loaded.drafts) 594 } 595 if serviceConfiguration == configuration { 596 mediaAccess.accept(loaded.support) 597 } 598 state = .ready 599 await submissions.refreshSelected() 600 } catch { 601 guard isCurrent(requestedGeneration) else { return } 602 state = .failed(TeraAddPresentation.message(for: error)) 603 } 604 } 605 606 func stop() { 607 mediaAccess.invalidate() 608 submissions.stop() 609 protection.cancel() 610 recovery.stop() 611 composer.stop() 612 generation = generation.invalidated() 613 operationGeneration = nil 614 probeGeneration = probeGeneration.invalidated() 615 isCheckingBlossom = false 616 operationTask?.cancel() 617 operationTask = nil 618 observation.stop() 619 observationState = .stopped 620 isWorking = false 621 } 622 623 func suspend() { 624 recovery.stop() 625 observation.stop() 626 observationState = .stopped 627 } 628 } 629 630 private extension TeraAddStore { 631 func resetRevision() { 632 revisionTarget = nil 633 revisionPreparation.reset() 634 revisionOperationID = nil 635 } 636 637 func saveCurrentForm(generation requestedGeneration: TeraSessionGeneration) async throws -> TeraDraftStatus { 638 if let target = revisionTarget { 639 let revision = try await revisionPreparation.prepare(target: target, form: form, identifier: identifier, 640 ensureCurrent: { try self.ensureCurrent(requestedGeneration) }) 641 try ensureCurrent(requestedGeneration) 642 revisionOperationID = revision.operationID 643 revisionTarget = nil 644 revisionPreparation.reset() 645 try accept(revision, generation: requestedGeneration) 646 return revision.replacement 647 } 648 guard isFormEditable else { throw TeraComposerAcknowledgment.unconfirmed } 649 let opened = try await TeraOpenedMedia.open(form.media, using: media) 650 defer { opened.close() } 651 try ensureCurrent(requestedGeneration) 652 let input = TeraAddRuntimeInput(form: form, media: opened.handles) 653 let status = try await runtimeClient.saveAddIntent( 654 input: input, 655 existingDraftID: activeDraft?.isRevision == true ? nil : activeDraft?.id, 656 expectedRevision: activeDraft?.isRevision == true ? nil : activeDraft?.revision 657 ) 658 try accept(status, generation: requestedGeneration) 659 return status 660 } 661 662 var needsEditingPreservation: Bool { 663 revisionTarget != nil || (isFormEditable && (composer.isDirty || submissions.hasAction)) 664 } 665 666 func preservation() -> () async -> Bool { 667 let scope = composer.scope 668 return { [weak self] in 669 guard let self, composer.scope == scope else { return false } 670 guard needsEditingPreservation else { return true } 671 let editing = form 672 do { 673 try await submissions.preserveForReplacement(currentForm: { self.form }) 674 } catch { 675 message = TeraAddPresentation.message(for: error) 676 return false 677 } 678 await save() 679 return composer.scope == scope && form == editing && revisionTarget == nil && !composer.isDirty 680 } 681 } 682 683 func replacement(_ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) -> () async -> Bool { 684 let scope = composer.scope 685 return { [weak self] in 686 guard let self, composer.scope == scope else { return false } 687 var applied = false 688 await perform { requested in 689 try await operation(self, requested) 690 applied = true 691 } 692 return applied 693 } 694 } 695 696 @discardableResult 697 func replaceEditing(kind: TeraEditingProtection.Kind = .editing, 698 _ operation: @escaping (TeraAddStore, TeraSessionGeneration) async throws -> Void) async -> Bool 699 { 700 await protection.replace(kind: kind, save: preservation(), apply: replacement(operation)) 701 } 702 }