import Foundation import Observation import SwiftData private struct Saved: Codable { struct Composer: Codable { var text: String var images: Int } var composer: [String: Composer] = [:] var checked: [String: [String]] = [:] var texts: [String: String] = [:] } final class DraftSend { let key: String let text: String let images: Int init(key: String, text: String, images: Int) { self.key = key self.text = text self.images = images } } func draftOrigin(_ origin: URL) -> String { let c = URLComponents(url: origin, resolvingAgainstBaseURL: false)! let scheme = (c.scheme ?? "https").lowercased() return "\(scheme)://\((c.host ?? "").lowercased()):\(c.port ?? (scheme == "http" ? 80 : 443))" } @MainActor @Observable final class Drafts { private var composerTexts: [String: String] = [:] private var composerImages: [String: Int] = [:] private var sending: [DraftSend] = [] private var lostImages: [String: Int] = [:] private var checked: [String: [String]] = [:] private var texts: [String: String] = [:] @ObservationIgnored private let scope: String @ObservationIgnored private var timer: Task? @ObservationIgnored private var dirty = false init(origin: URL, login: String) { scope = "\(draftOrigin(origin)).\(login)" load(Self.read(scope)) } private func load(_ saved: Saved) { composerTexts = saved.composer.filter { !$0.value.text.isEmpty }.mapValues(\.text) lostImages = saved.composer.reduce(into: [:]) { lost, d in let n = d.value.images - (composerImages[d.key] ?? 0) if n > 0 { lost[d.key] = n } } checked = saved.checked texts = saved.texts } func reload() { if !dirty && sending.isEmpty { load(Self.read(scope)) } } private static func records(_ scope: String) -> [DraftRecord] { fetchRecords(FetchDescriptor(predicate: #Predicate { $0.scope == scope }, sortBy: [SortDescriptor(\.saved, order: .reverse)])) } private static func read(_ scope: String) -> Saved { guard let record = records(scope).first else { return Saved() } do { return try JSONDecoder().decode(Saved.self, from: record.json) } catch { log.error("read drafts: \(String(describing: error), privacy: .public)") return Saved() } } func prune(_ store: Store) { var changed = false for key in Set(composerTexts.keys).union(composerImages.keys).union(lostImages.keys) { let taskId = key.hasPrefix("message:") ? String(key.dropFirst("message:".count)) : key if taskId != "lead" && store.findTask(taskId)?.status != .active { composerTexts[key] = nil composerImages[key] = nil lostImages[key] = nil changed = true } } for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) { checked[id] = nil texts[id] = nil changed = true } if changed { save() } } private func save() { dirty = true timer?.cancel() timer = Task { [weak self] in try? await Task.sleep(for: .milliseconds(400)) if !Task.isCancelled { self?.flush() } } } func flush() { timer?.cancel() timer = nil guard dirty else { return } dirty = false var out = Saved(checked: checked, texts: texts) let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys) for key in keys { let inFlight = sending.filter { $0.key == key } let text = (inFlight.map(\.text) + [composerTexts[key] ?? ""]).filter { !$0.isEmpty }.joined(separator: "\n\n") let images = (composerImages[key] ?? 0) + inFlight.reduce(0) { $0 + $1.images } + (lostImages[key] ?? 0) if !text.isEmpty || images > 0 { out.composer[key] = Saved.Composer(text: text, images: images) } } do { let json = try JSONEncoder().encode(out) let records = Self.records(scope) if records.isEmpty { persistence.insert(DraftRecord(scope: scope, json: json)) } else { records[0].json = json records[0].saved = Date() for record in records.dropFirst() { persistence.delete(record) } } saveRecords() } catch { log.error("save drafts: \(String(describing: error), privacy: .public)") } } func composerText(_ key: String) -> String { composerTexts[key] ?? "" } func setComposerText(_ key: String, _ text: String) { if (composerTexts[key] ?? "") == text { return } composerTexts[key] = text.isEmpty ? nil : text save() } func setComposerImages(_ key: String, _ n: Int) { if (composerImages[key] ?? 0) == n { return } composerImages[key] = n > 0 ? n : nil save() } func lostImageCount(_ key: String) -> Int { lostImages[key] ?? 0 } func forgetLostImages(_ key: String) { lostImages[key] = nil save() } func startSend(_ key: String, _ text: String, _ images: Int) -> DraftSend { let send = DraftSend(key: key, text: text, images: images) sending.append(send) composerTexts[key] = nil dirty = true return send } func endSend(_ send: DraftSend, _ ok: Bool) { sending.removeAll { $0 === send } if ok { lostImages[send.key] = nil } else { restoreText(send.key, send.text) } dirty = true flush() } func restoreText(_ key: String, _ text: String) { let joined = [text, composerTexts[key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n") if !joined.isEmpty { composerTexts[key] = joined } save() } func checkedAnswer(_ id: String) -> [String] { checked[id] ?? [] } func setCheckedAnswer(_ id: String, _ options: [String]) { checked[id] = options save() } func hasTextAnswer(_ id: String) -> Bool { texts[id] != nil } func textAnswer(_ id: String) -> String? { texts[id] } func setTextAnswer(_ id: String, _ text: String) { texts[id] = text save() } func dropTextAnswer(_ id: String) { texts[id] = nil save() } func clearAnswer(_ id: String) { checked[id] = nil texts[id] = nil dirty = true flush() } }