// SpeechHistoryStore.swift // OSGKeyboard · Shared // // Observable store for voice transcription history. Mirrored through // iCloud KVS when settings sync is enabled. import Combine import Foundation @MainActor public final class SpeechHistoryStore: ObservableObject { public static let shared = SpeechHistoryStore() @Published public private(set) var entries: [SpeechHistoryEntry] = [] public let defaults: UserDefaults private var payload: SyncedSpeechHistory = .empty public init(defaults: UserDefaults = .standard) { self.defaults = defaults reloadFromDisk() NotificationCenter.default.addObserver( forName: .speechHistoryDidSyncFromCloud, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.reloadFromDisk() } } } public func append(text: String, engineMode: String? = nil) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } rebaseOnPersistedStateBeforeMutation() let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode) payload.entries.insert(entry, at: 0) payload.trimEntries() payload.updatedAt = Date() applyPayload(postCloudPush: true) } public func delete(id: UUID) { rebaseOnPersistedStateBeforeMutation() guard payload.entries.contains(where: { $0.id == id }) else { return } payload.deletedEntryIDs[id] = Date() payload.entries.removeAll { $0.id == id } payload.updatedAt = Date() payload.pruneTombstonesIfNeeded() applyPayload(postCloudPush: true) } /// Deletes every entry whose `createdAt` falls on the given calendar day (local). public func deleteEntries(on day: Date) { rebaseOnPersistedStateBeforeMutation() let calendar = Calendar.current let start = calendar.startOfDay(for: day) guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { return } let matching = payload.entries.filter { $0.createdAt >= start && $0.createdAt < end } guard !matching.isEmpty else { return } let now = Date() for entry in matching { payload.deletedEntryIDs[entry.id] = now } payload.entries.removeAll { $0.createdAt >= start && $0.createdAt < end } payload.updatedAt = now payload.pruneTombstonesIfNeeded() applyPayload(postCloudPush: true) } public func clearAll() { rebaseOnPersistedStateBeforeMutation() payload.recordClearAll() payload.updatedAt = Date() payload.pruneTombstonesIfNeeded() applyPayload(postCloudPush: true) } /// Cloud pulls write the merged history to disk but only *schedule* the /// in-memory reload (the notification observer hops through a Task). /// Mutating a stale snapshot and saving it wholesale would erase whatever /// that merge just brought in — always rebase on the persisted state /// before mutating. private func rebaseOnPersistedStateBeforeMutation() { let disk = SpeechHistoryStorage.load(from: defaults) guard disk != payload else { return } payload = SyncedSpeechHistory.merge(local: payload, remote: disk) } public func snapshot() -> SyncedSpeechHistory { payload } public func apply(_ history: SyncedSpeechHistory) { payload = history entries = history.entries.sorted { $0.createdAt > $1.createdAt } } public func reloadFromDisk() { payload = SpeechHistoryStorage.load(from: defaults) entries = payload.entries.sorted { $0.createdAt > $1.createdAt } } /// Entries grouped by calendar day (newest day first). public var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] { let calendar = Calendar.current var buckets: [Date: [SpeechHistoryEntry]] = [:] for entry in entries { let day = calendar.startOfDay(for: entry.createdAt) buckets[day, default: []].append(entry) } return buckets.keys.sorted(by: >).map { day in (day, buckets[day]!.sorted { $0.createdAt > $1.createdAt }) } } private func applyPayload(postCloudPush: Bool) { entries = payload.entries.sorted { $0.createdAt > $1.createdAt } SpeechHistoryStorage.save(payload, to: defaults) guard postCloudPush else { return } Task { try? await SpeechHistoryCloudSync.shared.pushLocalIfEnabled() } } }