import Foundation import SstCore public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable { public var id: UUID public var date: Date public var duration: TimeInterval public var rawText: String public var finalText: String public var corrections: [AppliedCorrection] public init( id: UUID = UUID(), date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection] = [] ) { self.id = id self.date = date self.duration = duration self.rawText = rawText self.finalText = finalText self.corrections = corrections } public init(result: DictationResult) { self.init( date: result.date, duration: result.duration, rawText: result.rawText, finalText: result.finalText, corrections: result.corrections) } } /// Newest first, stored as one JSON file; saves are debounced. @MainActor public final class HistoryStore { public enum LoadOutcome: Equatable { case empty case loaded case recoveredCorrupt(backup: URL) /// The file exists but cannot be read; it is left alone and nothing is written over it. case unreadable(String) } private struct Document: Codable { var version: Int var entries: [HistoryEntry] } public private(set) var entries: [HistoryEntry] = [] public private(set) var lastError: String? public var onChange: (() -> Void)? private let fileURL: URL private var readOnly = false private var saveTask: Task? public init(fileURL: URL) { self.fileURL = fileURL } private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } private static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } @discardableResult public func load() -> LoadOutcome { let data: Data do { data = try Data(contentsOf: fileURL) } catch { if !FileManager.default.fileExists(atPath: fileURL.path) { entries = [] onChange?() return .empty } readOnly = true lastError = error.localizedDescription return .unreadable(error.localizedDescription) } do { entries = try Self.makeDecoder().decode(Document.self, from: data).entries onChange?() return .loaded } catch { let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json") try? FileManager.default.moveItem(at: fileURL, to: backup) entries = [] onChange?() return .recoveredCorrupt(backup: backup) } } public func add(_ entry: HistoryEntry) { entries.insert(entry, at: 0) changed() } public func remove(id: UUID) { entries.removeAll { $0.id == id } changed() } /// Replaces the final text of an entry. The highlighted corrections no longer match an edited text, so they are dropped. public func updateText(id: UUID, to text: String) { guard let index = entries.firstIndex(where: { $0.id == id }), entries[index].finalText != text else { return } entries[index].finalText = text entries[index].corrections = [] changed() } public func removeAll() { entries = [] changed() } /// Writes immediately (also call this when the app quits). public func flush() { saveTask?.cancel() saveTask = nil guard !readOnly else { return } do { try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries)) try data.write(to: fileURL, options: .atomic) lastError = nil } catch { lastError = error.localizedDescription } } private func changed() { onChange?() saveTask?.cancel() saveTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } self?.flush() } } } public enum HistorySearch { static func fold(_ text: String) -> String { text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) } /// Every query word must occur in the final or the original text (case- and diacritic-insensitive). public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] { let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init) guard !words.isEmpty else { return entries } return entries.filter { entry in let haystack = fold(entry.finalText + " " + entry.rawText) return words.allSatisfy { haystack.contains($0) } } } } public struct HistoryDay: Identifiable, Equatable, Sendable { public let day: Date public let entries: [HistoryEntry] public var id: Date { day } } public enum HistoryGrouping { /// Groups newest-first entries by calendar day, keeping their order. public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] { var days: [HistoryDay] = [] for entry in entries { let start = calendar.startOfDay(for: entry.date) if let last = days.last, last.day == start { days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry]) } else { days.append(HistoryDay(day: start, entries: [entry])) } } return days } }