From 57eb455edb99dcbcaea1abaaf8b817e778ae47fe Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:13:58 +0200 Subject: [PATCH] feat: add history store, search and day grouping Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstData/History.swift | 164 ++++++++++++++++++++++++++ Tests/SstDataTests/HistoryTests.swift | 119 +++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 Sources/SstData/History.swift create mode 100644 Tests/SstDataTests/HistoryTests.swift diff --git a/Sources/SstData/History.swift b/Sources/SstData/History.swift new file mode 100644 index 0000000..d97caf3 --- /dev/null +++ b/Sources/SstData/History.swift @@ -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? + + 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 + } +} diff --git a/Tests/SstDataTests/HistoryTests.swift b/Tests/SstDataTests/HistoryTests.swift new file mode 100644 index 0000000..13c06e2 --- /dev/null +++ b/Tests/SstDataTests/HistoryTests.swift @@ -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) + } +}