Files
sst/Sources/SstData/History.swift
T

188 lines
6.1 KiB
Swift

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<Void, Never>?
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
}
}