243 lines
6.9 KiB
Swift
243 lines
6.9 KiB
Swift
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<Void, Never>?
|
|
@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()
|
|
}
|
|
}
|