Files

323 lines
9.4 KiB
Swift
Raw Permalink Normal View History

2026-10-16 23:30:57 -07:00
import Foundation
import Observation
import SwiftData
2026-10-16 23:30:57 -07:00
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] = [:]
2026-10-18 07:30:00 -07:00
var comments: [String: [LineComment]]? = nil
}
struct LineAnchor: Codable, Hashable {
let path: String
let line: Int
let old: Bool
let quote: String
}
struct LineComment: Codable {
let anchor: LineAnchor
var text: String
2026-10-16 23:30:57 -07:00
}
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] = [:]
2026-10-18 07:30:00 -07:00
private var comments: [String: [LineComment]] = [:]
@ObservationIgnored private let scope: String
2026-10-16 23:30:57 -07:00
@ObservationIgnored private var timer: Task<Void, Never>?
@ObservationIgnored private var dirty = false
2026-10-16 23:30:57 -07:00
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
2026-10-16 23:30:57 -07:00
}
}
checked = saved.checked
texts = saved.texts
2026-10-18 07:30:00 -07:00
comments = saved.comments ?? [:]
2026-10-16 23:30:57 -07:00
}
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 {
2026-10-16 23:30:57 -07:00
return Saved()
}
do {
return try JSONDecoder().decode(Saved.self, from: record.json)
2026-10-16 23:30:57 -07:00
} 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
}
}
2026-10-18 07:30:00 -07:00
for taskId in comments.keys where store.findTask(taskId)?.status != .active {
comments[taskId] = nil
changed = true
}
2026-10-16 23:30:57 -07:00
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
2026-10-16 23:30:57 -07:00
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
2026-10-18 07:30:00 -07:00
var out = Saved(checked: checked, texts: texts, comments: comments)
2026-10-16 23:30:57 -07:00
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()
2026-10-16 23:30:57 -07:00
} 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
2026-10-16 23:30:57 -07:00
return send
}
func endSend(_ send: DraftSend, _ ok: Bool) {
sending.removeAll { $0 === send }
if ok {
lostImages[send.key] = nil
} else {
restoreText(send.key, send.text)
2026-10-16 23:30:57 -07:00
}
dirty = true
2026-10-16 23:30:57 -07:00
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()
}
2026-10-16 23:30:57 -07:00
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()
}
2026-10-18 07:30:00 -07:00
func lineComments(_ taskId: String) -> [LineComment] {
comments[taskId] ?? []
}
func lineComment(_ taskId: String, _ anchor: LineAnchor) -> LineComment? {
comments[taskId]?.first { $0.anchor == anchor }
}
func toggleLineComment(_ taskId: String, _ anchor: LineAnchor) {
var list = comments[taskId] ?? []
if let i = list.firstIndex(where: { $0.anchor == anchor }) {
if !trimmed(list[i].text).isEmpty {
return
}
list.remove(at: i)
} else {
list.append(LineComment(anchor: anchor, text: ""))
}
comments[taskId] = list.isEmpty ? nil : list
save()
}
func setLineComment(_ taskId: String, _ anchor: LineAnchor, _ text: String) {
guard let i = comments[taskId]?.firstIndex(where: { $0.anchor == anchor }), comments[taskId]![i].text != text else {
return
}
comments[taskId]![i].text = text
save()
}
func dropLineComment(_ taskId: String, _ anchor: LineAnchor) {
comments[taskId]?.removeAll { $0.anchor == anchor }
if comments[taskId]?.isEmpty == true {
comments[taskId] = nil
}
save()
}
func takeLineComments(_ taskId: String) -> [LineComment] {
let all = comments[taskId] ?? []
let ready = all.filter { !trimmed($0.text).isEmpty }
let rest = all.filter { trimmed($0.text).isEmpty }
comments[taskId] = rest.isEmpty ? nil : rest
dirty = true
flush()
return ready
}
func restoreLineComments(_ taskId: String, _ back: [LineComment]) {
var list = back
for c in comments[taskId] ?? [] {
if let i = list.firstIndex(where: { $0.anchor == c.anchor }) {
list[i].text = [list[i].text, c.text].filter { !trimmed($0).isEmpty }.joined(separator: "\n\n")
} else {
list.append(c)
}
}
comments[taskId] = list
save()
}
2026-10-16 23:30:57 -07:00
func clearAnswer(_ id: String) {
checked[id] = nil
texts[id] = nil
dirty = true
2026-10-16 23:30:57 -07:00
flush()
}
}