feat: add history store, search and day grouping
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
|
entries = []
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
entries = try Self.makeDecoder().decode(Document.self, from: data).entries
|
||||||
|
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 = []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeAll() {
|
||||||
|
entries = []
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes immediately (also call this when the app quits).
|
||||||
|
public func flush() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = nil
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import SstCore
|
||||||
|
@testable import SstData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct HistoryStoreTests {
|
||||||
|
func tempFile() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry {
|
||||||
|
HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func newestEntryComesFirstAndSurvivesARoundTrip() {
|
||||||
|
let url = tempFile()
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
store.add(entry("eerste", at: 0))
|
||||||
|
store.add(HistoryEntry(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code",
|
||||||
|
corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)]))
|
||||||
|
store.flush()
|
||||||
|
|
||||||
|
let reloaded = HistoryStore(fileURL: url)
|
||||||
|
#expect(reloaded.load() == .loaded)
|
||||||
|
#expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"])
|
||||||
|
#expect(reloaded.entries == store.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileIsEmpty() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
#expect(store.load() == .empty)
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removeAndRemoveAll() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
let a = entry("a", at: 0), b = entry("b", at: 1)
|
||||||
|
store.add(a); store.add(b)
|
||||||
|
store.remove(id: a.id)
|
||||||
|
#expect(store.entries.map(\.finalText) == ["b"])
|
||||||
|
store.removeAll()
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws {
|
||||||
|
let url = tempFile()
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try "dit is geen json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let store = HistoryStore(fileURL: url)
|
||||||
|
guard case .recoveredCorrupt(let backup) = store.load() else {
|
||||||
|
Issue.record("expected recoveredCorrupt"); return
|
||||||
|
}
|
||||||
|
#expect(FileManager.default.fileExists(atPath: backup.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(store.entries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func onChangeFiresForEdits() {
|
||||||
|
let store = HistoryStore(fileURL: tempFile())
|
||||||
|
var calls = 0
|
||||||
|
store.onChange = { calls += 1 }
|
||||||
|
store.add(entry("a", at: 0))
|
||||||
|
store.removeAll()
|
||||||
|
#expect(calls == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func entryFromDictationResultKeepsEverything() {
|
||||||
|
let result = DictationResult(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final",
|
||||||
|
corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)])
|
||||||
|
let e = HistoryEntry(result: result)
|
||||||
|
#expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistorySearchTests {
|
||||||
|
let entries = [
|
||||||
|
HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"),
|
||||||
|
HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Test func emptyQueryReturnsEverything() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: " ").count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ignoresCaseAndDiacritics() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "CAFE").count == 1)
|
||||||
|
#expect(HistorySearch.filter(entries, query: "café").count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func allWordsMustMatchInAnyOrder() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "handig claude").count == 1)
|
||||||
|
#expect(HistorySearch.filter(entries, query: "handig café").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func searchesTheOriginalTextToo() {
|
||||||
|
#expect(HistorySearch.filter(entries, query: "cloud").count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HistoryGroupingTests {
|
||||||
|
@Test func groupsByDayKeepingOrder() {
|
||||||
|
var calendar = Calendar(identifier: .gregorian)
|
||||||
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
func at(_ day: Int, _ hour: Int) -> Date {
|
||||||
|
calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))!
|
||||||
|
}
|
||||||
|
let entries = [at(2, 15), at(2, 9), at(1, 20)].map {
|
||||||
|
HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x")
|
||||||
|
}
|
||||||
|
let days = HistoryGrouping.byDay(entries, calendar: calendar)
|
||||||
|
#expect(days.count == 2)
|
||||||
|
#expect(days[0].entries.count == 2)
|
||||||
|
#expect(days[1].entries.count == 1)
|
||||||
|
#expect(days[0].day > days[1].day)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user