1414 lines
55 KiB
Markdown
1414 lines
55 KiB
Markdown
# App fase 2 — SstData (woordenboek, correcties, geschiedenis) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** De gegevenslaag van de app: het woordenboek (bestand, correctieronde, waarschuwingen, voorrangslijst) en de transcriptiegeschiedenis (opslag, zoeken, groeperen), allemaal los van de interface en volledig getest.
|
||
|
||
**Architecture:** Eén nieuw target `SstData` dat alleen van `SstCore` afhangt (voor `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`). Pure logica (`DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistorySearch`, `HistoryGrouping`) is hardware- en UI-vrij; bestandstoegang zit in `DictionaryStore` en `HistoryStore`. Woordkennis zit achter het protocol `WordKnowledge` zodat tests deterministisch zijn.
|
||
|
||
**Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, Foundation, NaturalLanguage, AppKit (spellingcontrole).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Woordenboek — het ontwerp in detail", "Geschiedenis")
|
||
|
||
**Voorwaarde:** fase 1 (`2026-10-01-app-1-engine-hooks.md`) is afgerond: `AppliedCorrection`, `CorrectionResult`, `TextCorrecting` en `DictationResult` bestaan in `SstCore`.
|
||
|
||
## Global Constraints
|
||
|
||
- Dictionary-bestand: UTF-8, één invoer per regel; `#` = opmerking; `gehoord => schrijf` = correctiepaar; een regel zonder `=>` is een term. Opmerkingen, lege regels en volgorde blijven bij opslaan behouden; onleesbare regels blijven staan (`invalid`).
|
||
- Correctie: hele woorden, hoofdletter-onafhankelijk, één doorgang van links naar rechts, langste overeenkomst eerst (gelijke lengte: eerste in het bestand), vervangen tekst wordt niet opnieuw gescand, vervanging is letterlijk. Delen van een bron worden verbonden door nul of meer spaties/koppeltekens (`[\s\-‐‑–]*`). Woordgrenzen: niet direct naast een letter of cijfer (`\p{L}`, `\p{N}`).
|
||
- Een term van één woord dat een gewoon woord is (spellingcontrole of Engelse woordenlijst) krijgt géén automatische hoofdletterregel (wel voorrang bij het model).
|
||
- Voorrangslijst: eerst termen (bestandsvolgorde), dan unieke rechterkanten van paren; maximaal 24 woorden.
|
||
- Geschiedenis: JSON `{ "version": 1, "entries": [...] }`, ISO-8601 datums, nieuwste eerst, atomisch schrijven met 300 ms debounce; beschadigd bestand wordt hernoemd naar `history.corrupt-<datum>.json`.
|
||
- Zoeken: hoofdletter- en diakriet-ongevoelig; alle zoekwoorden moeten voorkomen in de uiteindelijke of oorspronkelijke tekst.
|
||
- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>` (tweede `-m`).
|
||
|
||
## Bestandsstructuur
|
||
|
||
```
|
||
Package.swift (wijzig) target SstData + SstDataTests
|
||
Sources/SstData/WordKnowledge.swift (nieuw) WordKnowledge, EntryKey, CommonWords, SystemWordKnowledge
|
||
Sources/SstData/DictionaryFile.swift (nieuw) DictionaryEntry, DictionaryLine, DictionaryFile
|
||
Sources/SstData/CorrectionEngine.swift (nieuw)
|
||
Sources/SstData/EntryWarnings.swift (nieuw)
|
||
Sources/SstData/VocabularyBuilder.swift (nieuw)
|
||
Sources/SstData/History.swift (nieuw) HistoryEntry, HistoryStore, HistorySearch, HistoryGrouping
|
||
Sources/SstData/DictionaryStore.swift (nieuw) DictionaryStore, DictionaryCorrector
|
||
Tests/SstDataTests/Fakes.swift, DictionaryFileTests.swift, SystemWordKnowledgeTests.swift,
|
||
CorrectionEngineTests.swift, EntryWarningsTests.swift, VocabularyBuilderTests.swift,
|
||
HistoryTests.swift, DictionaryStoreTests.swift
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Target, woordkennis en `DictionaryFile`
|
||
|
||
**Files:**
|
||
- Modify: `Package.swift`
|
||
- Create: `Sources/SstData/WordKnowledge.swift`, `Sources/SstData/DictionaryFile.swift`
|
||
- Test: `Tests/SstDataTests/Fakes.swift`, `Tests/SstDataTests/DictionaryFileTests.swift`, `Tests/SstDataTests/SystemWordKnowledgeTests.swift`
|
||
|
||
**Interfaces:**
|
||
- Produces (gebruikt door Task 2–5 en de latere fasen):
|
||
- `protocol WordKnowledge { func isWord(_:) -> Bool; func isFunctionWord(_:) -> Bool }`
|
||
- `final class SystemWordKnowledge: WordKnowledge` (`init()`)
|
||
- `enum EntryKey { static func parts(_:) -> [String]; static func key(_:) -> String }` (intern)
|
||
- `struct DictionaryEntry: Identifiable, Equatable` met `id`, `kind: Kind` (`.term(String)` | `.correction(heard:wrote:)`), `source`, `wrote`
|
||
- `enum DictionaryLine { entry, comment, blank, invalid }`
|
||
- `struct DictionaryFile` met `init(parsing:)`, `serialized()`, `entries`, `append(_:) -> UUID`, `update(id:kind:)`, `remove(id:)`
|
||
|
||
- [ ] **Step 1: Targets toevoegen**
|
||
|
||
In `Package.swift`, voeg bij `targets` toe (naast de bestaande regels):
|
||
|
||
```swift
|
||
.target(name: "SstData", dependencies: ["SstCore"]),
|
||
```
|
||
|
||
en bij de testtargets:
|
||
|
||
```swift
|
||
.testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
|
||
```
|
||
|
||
- [ ] **Step 2: Schrijf de falende tests**
|
||
|
||
`Tests/SstDataTests/Fakes.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
@testable import SstData
|
||
|
||
/// Deterministic stand-in for the macOS spell checker.
|
||
struct FakeKnowledge: WordKnowledge {
|
||
var words: Set<String> = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"]
|
||
var functionWords: Set<String> = ["in", "de", "the", "of"]
|
||
func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) }
|
||
func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) }
|
||
}
|
||
|
||
func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) }
|
||
func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) }
|
||
```
|
||
|
||
`Tests/SstDataTests/DictionaryFileTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
@testable import SstData
|
||
|
||
struct DictionaryFileTests {
|
||
let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n"
|
||
|
||
@Test func parsesTermsPairsCommentsAndBlanks() {
|
||
let file = DictionaryFile(parsing: sample)
|
||
#expect(file.lines.count == 4)
|
||
#expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")])
|
||
#expect(file.lines[0] == .comment("# Opmerking"))
|
||
#expect(file.lines[2] == .blank)
|
||
}
|
||
|
||
@Test func roundTripKeepsCommentsBlankLinesAndOrder() {
|
||
#expect(DictionaryFile(parsing: sample).serialized() == sample)
|
||
}
|
||
|
||
@Test func normalisesSpacingAroundTheArrow() {
|
||
#expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n")
|
||
#expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")])
|
||
}
|
||
|
||
@Test func unreadableLinesAreKeptAsInvalid() {
|
||
let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n")
|
||
#expect(file.lines[0] == .invalid("oops =>"))
|
||
#expect(file.lines[1] == .invalid("=> nothing"))
|
||
#expect(file.serialized() == "oops =>\n=> nothing\nok\n")
|
||
}
|
||
|
||
@Test func handlesWindowsLineEndingsAndMissingFinalNewline() {
|
||
let file = DictionaryFile(parsing: "a\r\nb => c")
|
||
#expect(file.entries == [term("a"), pair("b", "c")])
|
||
#expect(file.serialized() == "a\nb => c\n")
|
||
}
|
||
|
||
@Test func emptyTextIsAValidEmptyFile() {
|
||
#expect(DictionaryFile(parsing: "").entries.isEmpty)
|
||
#expect(DictionaryFile(parsing: "").serialized() == "")
|
||
}
|
||
|
||
@Test func appendUpdateAndRemove() {
|
||
var file = DictionaryFile(parsing: "# c\n")
|
||
let id = file.append(.term("Vercel"))
|
||
#expect(file.entries == [term("Vercel")])
|
||
file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel"))
|
||
#expect(file.entries == [pair("versel", "Vercel")])
|
||
file.remove(id: id)
|
||
#expect(file.entries.isEmpty)
|
||
#expect(file.serialized() == "# c\n")
|
||
}
|
||
}
|
||
```
|
||
|
||
`Tests/SstDataTests/SystemWordKnowledgeTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
@testable import SstData
|
||
|
||
struct SystemWordKnowledgeTests {
|
||
let knowledge = SystemWordKnowledge()
|
||
|
||
@Test func recognisesEnglishAndDutchWords() {
|
||
#expect(knowledge.isWord("cloud"))
|
||
#expect(knowledge.isWord("Cloud"))
|
||
#expect(knowledge.isWord("vandaag"))
|
||
}
|
||
|
||
@Test func doesNotTreatProductNamesAsWords() {
|
||
#expect(!knowledge.isWord("vercel"))
|
||
#expect(!knowledge.isWord("supabase"))
|
||
#expect(!knowledge.isWord("anthropic"))
|
||
}
|
||
|
||
@Test func functionWordsComeFromTheBuiltInList() {
|
||
#expect(knowledge.isFunctionWord("de"))
|
||
#expect(knowledge.isFunctionWord("The"))
|
||
#expect(!knowledge.isFunctionWord("vercel"))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Draai de tests, moeten falen**
|
||
|
||
Run: `swift test --filter SstDataTests`
|
||
Expected: compilatiefouten (`cannot find 'DictionaryFile'`, `cannot find type 'WordKnowledge'`).
|
||
|
||
- [ ] **Step 4: Implementatie**
|
||
|
||
`Sources/SstData/WordKnowledge.swift`:
|
||
|
||
```swift
|
||
import AppKit
|
||
import Foundation
|
||
import NaturalLanguage
|
||
|
||
public protocol WordKnowledge {
|
||
/// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not.
|
||
func isWord(_ word: String) -> Bool
|
||
/// A very frequent function word ("in", "de", "the", "of").
|
||
func isFunctionWord(_ word: String) -> Bool
|
||
}
|
||
|
||
/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings.
|
||
enum EntryKey {
|
||
static func parts(_ source: String) -> [String] {
|
||
source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init)
|
||
}
|
||
|
||
/// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source.
|
||
static func key(_ source: String) -> String {
|
||
parts(source).joined().lowercased()
|
||
}
|
||
}
|
||
|
||
/// The most frequent Dutch and English function words.
|
||
enum CommonWords {
|
||
static let english: Set<String> = [
|
||
"a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but",
|
||
"by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him",
|
||
"his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not",
|
||
"now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than",
|
||
"that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very",
|
||
"was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your",
|
||
]
|
||
static let dutch: Set<String> = [
|
||
"aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door",
|
||
"dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is",
|
||
"ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu",
|
||
"of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van",
|
||
"veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei",
|
||
"zich", "zij", "zijn", "zo", "zou",
|
||
]
|
||
static let all: Set<String> = english.union(dutch)
|
||
}
|
||
|
||
/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not).
|
||
/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list.
|
||
public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable {
|
||
private let englishWords = NLEmbedding.wordEmbedding(for: .english)
|
||
|
||
public init() {}
|
||
|
||
public func isWord(_ word: String) -> Bool {
|
||
let lowered = word.lowercased()
|
||
guard !lowered.isEmpty else { return false }
|
||
if let englishWords {
|
||
if englishWords.contains(lowered) { return true }
|
||
} else if spellChecks(lowered, language: "en") {
|
||
return true
|
||
}
|
||
return spellChecks(lowered, language: "nl")
|
||
}
|
||
|
||
public func isFunctionWord(_ word: String) -> Bool {
|
||
CommonWords.all.contains(word.lowercased())
|
||
}
|
||
|
||
private func spellChecks(_ word: String, language: String) -> Bool {
|
||
NSSpellChecker.shared.checkSpelling(
|
||
of: word, startingAt: 0, language: language, wrap: false,
|
||
inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound
|
||
}
|
||
}
|
||
```
|
||
|
||
`Sources/SstData/DictionaryFile.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
public struct DictionaryEntry: Identifiable, Equatable, Sendable {
|
||
public enum Kind: Equatable, Sendable {
|
||
case term(String)
|
||
case correction(heard: String, wrote: String)
|
||
}
|
||
|
||
public let id: UUID
|
||
public var kind: Kind
|
||
|
||
public init(id: UUID = UUID(), kind: Kind) {
|
||
self.id = id
|
||
self.kind = kind
|
||
}
|
||
|
||
/// Equality ignores `id`: two entries with the same content are the same entry.
|
||
public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind }
|
||
|
||
/// The text the correction pass listens for (a term listens for itself).
|
||
public var source: String {
|
||
switch kind {
|
||
case .term(let term): term
|
||
case .correction(let heard, _): heard
|
||
}
|
||
}
|
||
|
||
/// The text that ends up in the transcript.
|
||
public var wrote: String {
|
||
switch kind {
|
||
case .term(let term): term
|
||
case .correction(_, let wrote): wrote
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum DictionaryLine: Equatable, Sendable {
|
||
case entry(DictionaryEntry)
|
||
case comment(String)
|
||
case blank
|
||
case invalid(String)
|
||
}
|
||
|
||
/// The dictionary as a list of lines, so comments, blank lines and order survive an edit.
|
||
public struct DictionaryFile: Equatable, Sendable {
|
||
public var lines: [DictionaryLine]
|
||
|
||
public init(lines: [DictionaryLine] = []) {
|
||
self.lines = lines
|
||
}
|
||
|
||
public init(parsing text: String) {
|
||
var raw = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||
.map(String.init)
|
||
if raw.last == "" { raw.removeLast() }
|
||
lines = raw.map(Self.parseLine)
|
||
}
|
||
|
||
static func parseLine(_ line: String) -> DictionaryLine {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.isEmpty { return .blank }
|
||
if trimmed.hasPrefix("#") { return .comment(line) }
|
||
if let arrow = trimmed.range(of: "=>") {
|
||
let heard = trimmed[..<arrow.lowerBound].trimmingCharacters(in: .whitespaces)
|
||
let wrote = trimmed[arrow.upperBound...].trimmingCharacters(in: .whitespaces)
|
||
guard !heard.isEmpty, !wrote.isEmpty else { return .invalid(line) }
|
||
return .entry(DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)))
|
||
}
|
||
return .entry(DictionaryEntry(kind: .term(trimmed)))
|
||
}
|
||
|
||
public func serialized() -> String {
|
||
let out = lines.map { line -> String in
|
||
switch line {
|
||
case .entry(let entry):
|
||
switch entry.kind {
|
||
case .term(let term): return term
|
||
case .correction(let heard, let wrote): return "\(heard) => \(wrote)"
|
||
}
|
||
case .comment(let text), .invalid(let text): return text
|
||
case .blank: return ""
|
||
}
|
||
}
|
||
return out.isEmpty ? "" : out.joined(separator: "\n") + "\n"
|
||
}
|
||
|
||
public var entries: [DictionaryEntry] {
|
||
lines.compactMap { line in
|
||
if case .entry(let entry) = line { return entry }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID {
|
||
let entry = DictionaryEntry(kind: kind)
|
||
lines.append(.entry(entry))
|
||
return entry.id
|
||
}
|
||
|
||
public mutating func update(id: UUID, kind: DictionaryEntry.Kind) {
|
||
guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return }
|
||
lines[index] = .entry(DictionaryEntry(id: id, kind: kind))
|
||
}
|
||
|
||
public mutating func remove(id: UUID) {
|
||
lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Draai de tests**
|
||
|
||
Run: `swift test --filter SstDataTests`
|
||
Expected: alle tests PASS (7 `DictionaryFile`, 3 `SystemWordKnowledge`).
|
||
|
||
> De Engelse woordenlijst komt uit `NLEmbedding.wordEmbedding(for: .english)`. Is die op de machine niet beschikbaar, dan valt `isWord` terug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer van `swift test`, wijzig de test niet stilletjes.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add Package.swift Sources/SstData Tests/SstDataTests
|
||
git commit -m "feat: add SstData with DictionaryFile and word knowledge" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `CorrectionEngine`
|
||
|
||
**Files:**
|
||
- Create: `Sources/SstData/CorrectionEngine.swift`
|
||
- Test: `Tests/SstDataTests/CorrectionEngineTests.swift`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 (`DictionaryEntry`, `WordKnowledge`, `EntryKey`) en `SstCore.TextCorrecting`/`CorrectionResult`/`AppliedCorrection`.
|
||
- Produces: `struct CorrectionEngine: TextCorrecting` met `init(entries: [DictionaryEntry], knowledge: WordKnowledge)` en `correct(_:) -> CorrectionResult`.
|
||
|
||
- [ ] **Step 1: Schrijf de falende tests**
|
||
|
||
`Tests/SstDataTests/CorrectionEngineTests.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Testing
|
||
import SstCore
|
||
@testable import SstData
|
||
|
||
struct CorrectionEngineTests {
|
||
let knowledge = FakeKnowledge()
|
||
|
||
func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine {
|
||
CorrectionEngine(entries: entries, knowledge: knowledge)
|
||
}
|
||
|
||
@Test func replacesAPairAndReportsIt() {
|
||
let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag")
|
||
#expect(result.text == "ik gebruik Claude Code vandaag")
|
||
#expect(result.corrections.count == 1)
|
||
let c = result.corrections[0]
|
||
#expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code")
|
||
#expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code")
|
||
}
|
||
|
||
@Test func findsGluedHyphenatedAndSpacedVariants() {
|
||
let e = engine([pair("cloud code", "Claude Code")])
|
||
for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] {
|
||
#expect(e.correct(variant).text == "Claude Code", "variant \(variant)")
|
||
}
|
||
}
|
||
|
||
@Test func neverTouchesRealWordsThatMerelyStartTheSame() {
|
||
let e = engine([pair("cloud code", "Claude Code")])
|
||
#expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig")
|
||
#expect(e.correct("cloud codex").text == "cloud codex")
|
||
#expect(e.correct("de cloud en de code").text == "de cloud en de code")
|
||
#expect(e.correct("the cloud").corrections.isEmpty)
|
||
}
|
||
|
||
@Test func respectsPunctuationBoundaries() {
|
||
let e = engine([pair("cloud code", "Claude Code")])
|
||
#expect(e.correct("(cloud code)").text == "(Claude Code)")
|
||
#expect(e.correct("cloud code.").text == "Claude Code.")
|
||
#expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code")
|
||
}
|
||
|
||
@Test func longestMatchWins() {
|
||
let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")])
|
||
#expect(e.correct("cloud code review nu").text == "Claude Code Review nu")
|
||
#expect(e.correct("cloud-code-review").text == "Claude Code Review")
|
||
}
|
||
|
||
@Test func replacedTextIsNeverRescanned() {
|
||
let e = engine([pair("a", "b"), pair("b", "c")])
|
||
#expect(e.correct("a b").text == "b c")
|
||
}
|
||
|
||
@Test func termsFixCasingAndSpacing() {
|
||
let e = engine([term("Anthropic"), term("Claude Code")])
|
||
#expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic")
|
||
#expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code")
|
||
}
|
||
|
||
@Test func termAlreadyRightIsNotReportedAsACorrection() {
|
||
let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude")
|
||
#expect(result.text == "Anthropic bouwt Claude")
|
||
#expect(result.corrections.isEmpty)
|
||
}
|
||
|
||
@Test func singleWordTermThatIsARealWordGetsNoCasingRule() {
|
||
let e = engine([term("Reef"), term("Slack")])
|
||
#expect(e.correct("het reef en slack").text == "het reef en slack")
|
||
}
|
||
|
||
@Test func firstEntryWinsOnDuplicateSources() {
|
||
let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")])
|
||
#expect(e.correct("cloudcode").text == "Claude Code")
|
||
}
|
||
|
||
@Test func replacementIsLiteral() {
|
||
let e = engine([pair("price", "US$1 \\0 $1")])
|
||
#expect(e.correct("the price").text == "the US$1 \\0 $1")
|
||
}
|
||
|
||
@Test func handlesDiacritics() {
|
||
let e = engine([term("Café")])
|
||
#expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek")
|
||
}
|
||
|
||
@Test func offsetsAreRelativeToTheCorrectedText() {
|
||
let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a")
|
||
#expect(result.text == "xxx y xxx")
|
||
#expect(result.corrections.map(\.start) == [0, 4, 6])
|
||
#expect(result.corrections.map(\.length) == [3, 1, 3])
|
||
}
|
||
|
||
@Test func emptyDictionaryAndEmptyTextAreNoOps() {
|
||
#expect(engine([]).correct("hallo").text == "hallo")
|
||
#expect(engine([pair("a", "b")]).correct("").text == "")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Draai de tests, moeten falen**
|
||
|
||
Run: `swift test --filter CorrectionEngineTests`
|
||
Expected: compilatiefout `cannot find 'CorrectionEngine' in scope`.
|
||
|
||
- [ ] **Step 3: Implementatie**
|
||
|
||
`Sources/SstData/CorrectionEngine.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import SstCore
|
||
|
||
/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned.
|
||
public struct CorrectionEngine: TextCorrecting {
|
||
private struct Rule {
|
||
let source: String
|
||
let wrote: String
|
||
}
|
||
|
||
private let rules: [Rule]
|
||
private let regex: NSRegularExpression?
|
||
|
||
public init(entries: [DictionaryEntry], knowledge: WordKnowledge) {
|
||
var seen = Set<String>()
|
||
var built: [(rule: Rule, order: Int)] = []
|
||
for (order, entry) in entries.enumerated() {
|
||
let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let key = EntryKey.key(source)
|
||
guard !key.isEmpty, !seen.contains(key) else { continue }
|
||
// A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text.
|
||
if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue }
|
||
seen.insert(key)
|
||
built.append((Rule(source: source, wrote: entry.wrote), order))
|
||
}
|
||
let ordered = built.sorted { a, b in
|
||
let la = a.rule.source.filter(\.isLetter).count
|
||
let lb = b.rule.source.filter(\.isLetter).count
|
||
return la != lb ? la > lb : a.order < b.order
|
||
}.map(\.rule)
|
||
rules = ordered
|
||
if ordered.isEmpty {
|
||
regex = nil
|
||
} else {
|
||
let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|")
|
||
regex = try? NSRegularExpression(
|
||
pattern: "(?<![\\p{L}\\p{N}])(?:\(alternatives))(?![\\p{L}\\p{N}])",
|
||
options: [.caseInsensitive]
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Parts joined by "zero or more spaces/hyphens", so "cloud code" also finds "cloudcode" and "Cloud-Code".
|
||
private static func pattern(_ source: String) -> String {
|
||
EntryKey.parts(source)
|
||
.map { NSRegularExpression.escapedPattern(for: $0) }
|
||
.joined(separator: "[\\s\\-‐‑–]*")
|
||
}
|
||
|
||
public func correct(_ text: String) -> CorrectionResult {
|
||
guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) }
|
||
let ns = text as NSString
|
||
var output = ""
|
||
var outputLength = 0
|
||
var last = 0
|
||
var applied: [AppliedCorrection] = []
|
||
|
||
for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
|
||
let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
||
output += gap
|
||
outputLength += gap.utf16.count
|
||
|
||
let heard = ns.substring(with: match.range)
|
||
let group = (1..<match.numberOfRanges).first { match.range(at: $0).location != NSNotFound } ?? 1
|
||
let rule = rules[group - 1]
|
||
|
||
output += rule.wrote
|
||
if heard != rule.wrote {
|
||
applied.append(AppliedCorrection(
|
||
heard: heard, wrote: rule.wrote, rule: rule.source,
|
||
start: outputLength, length: rule.wrote.utf16.count))
|
||
}
|
||
outputLength += rule.wrote.utf16.count
|
||
last = match.range.location + match.range.length
|
||
}
|
||
output += ns.substring(from: last)
|
||
return CorrectionResult(text: output, corrections: applied)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Draai de tests**
|
||
|
||
Run: `swift test --filter CorrectionEngineTests`
|
||
Expected: 14 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Sources/SstData Tests/SstDataTests
|
||
git commit -m "feat: add CorrectionEngine (whole-word, longest-first, single pass)" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `EntryWarnings` en `VocabularyBuilder`
|
||
|
||
**Files:**
|
||
- Create: `Sources/SstData/EntryWarnings.swift`, `Sources/SstData/VocabularyBuilder.swift`
|
||
- Test: `Tests/SstDataTests/EntryWarningsTests.swift`, `Tests/SstDataTests/VocabularyBuilderTests.swift`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1.
|
||
- Produces:
|
||
- `struct EntryWarning { level: Level (.note < .warning < .error); message: String }`
|
||
- `EntryWarnings.check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning]` (meest ernstig eerst; `others` = alle andere invoeren)
|
||
- `VocabularyBuilder.build(from: [DictionaryEntry], maxTerms: Int = 24) -> [String]`
|
||
|
||
- [ ] **Step 1: Schrijf de falende tests**
|
||
|
||
`Tests/SstDataTests/EntryWarningsTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
@testable import SstData
|
||
|
||
struct EntryWarningsTests {
|
||
let knowledge = FakeKnowledge()
|
||
|
||
func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] {
|
||
EntryWarnings.check(kind, among: others, knowledge: knowledge)
|
||
}
|
||
|
||
@Test func singleCommonWordIsAWarning() {
|
||
let w = check(.correction(heard: "cloud", wrote: "Claude"))
|
||
#expect(w.contains { $0.level == .warning && $0.message.contains("cloud") })
|
||
}
|
||
|
||
@Test func veryShortSourceIsAWarning() {
|
||
#expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning })
|
||
}
|
||
|
||
@Test func functionWordInsideAPhraseIsAWarning() {
|
||
#expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning })
|
||
}
|
||
|
||
@Test func gluedFormThatIsARealWordIsAWarning() {
|
||
let w = check(.correction(heard: "some thing", wrote: "Something"))
|
||
#expect(w.contains { $0.level == .warning && $0.message.contains("something") })
|
||
}
|
||
|
||
@Test func phraseOfCommonWordsOnlyGetsANote() {
|
||
let w = check(.correction(heard: "cloud code", wrote: "Claude Code"))
|
||
#expect(w.count == 1)
|
||
#expect(w[0].level == .note)
|
||
}
|
||
|
||
@Test func distinctiveEntriesGetNoWarnings() {
|
||
#expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty)
|
||
#expect(check(.term("Supabase")).isEmpty)
|
||
}
|
||
|
||
@Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() {
|
||
let others = [pair("cloud code", "Claude Code")]
|
||
let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others)
|
||
#expect(w.first?.level == .error)
|
||
}
|
||
|
||
@Test func termThatIsARealWordGetsANoteAboutBiasOnly() {
|
||
let w = check(.term("Reef"))
|
||
#expect(w.count == 1)
|
||
#expect(w[0].level == .note)
|
||
}
|
||
|
||
@Test func emptySourceIsAnError() {
|
||
#expect(check(.term(" ")).first?.level == .error)
|
||
}
|
||
|
||
@Test func warningsAreSortedMostSevereFirst() {
|
||
let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")])
|
||
#expect(w.map(\.level) == w.map(\.level).sorted(by: >))
|
||
}
|
||
}
|
||
```
|
||
|
||
`Tests/SstDataTests/VocabularyBuilderTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
@testable import SstData
|
||
|
||
struct VocabularyBuilderTests {
|
||
@Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() {
|
||
let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")]
|
||
#expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"])
|
||
}
|
||
|
||
@Test func listIsCapped() {
|
||
let entries = (1...40).map { term("Woord\($0)") }
|
||
#expect(VocabularyBuilder.build(from: entries).count == 24)
|
||
#expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5)
|
||
}
|
||
|
||
@Test func emptyDictionaryGivesNoVocabulary() {
|
||
#expect(VocabularyBuilder.build(from: []).isEmpty)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Draai de tests, moeten falen**
|
||
|
||
Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"`
|
||
Expected: compilatiefouten (`cannot find 'EntryWarnings'`, `cannot find 'VocabularyBuilder'`).
|
||
|
||
- [ ] **Step 3: Implementatie**
|
||
|
||
`Sources/SstData/EntryWarnings.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
public struct EntryWarning: Equatable, Sendable {
|
||
public enum Level: Int, Comparable, Sendable {
|
||
case note = 0, warning, error
|
||
public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue }
|
||
}
|
||
|
||
public let level: Level
|
||
public let message: String
|
||
|
||
public init(level: Level, message: String) {
|
||
self.level = level
|
||
self.message = message
|
||
}
|
||
}
|
||
|
||
/// Flags dictionary entries that are likely to corrupt normal text.
|
||
public enum EntryWarnings {
|
||
/// - Parameter others: all entries except the one being checked.
|
||
public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] {
|
||
let source: String
|
||
let isCorrection: Bool
|
||
switch kind {
|
||
case .term(let term): (source, isCorrection) = (term, false)
|
||
case .correction(let heard, _): (source, isCorrection) = (heard, true)
|
||
}
|
||
|
||
let parts = EntryKey.parts(source)
|
||
guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] }
|
||
|
||
var result: [EntryWarning] = []
|
||
if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) {
|
||
result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt."))
|
||
}
|
||
|
||
if !isCorrection {
|
||
if parts.count == 1, knowledge.isWord(source) {
|
||
result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel."))
|
||
}
|
||
return result.sorted { $0.level > $1.level }
|
||
}
|
||
|
||
if parts.count == 1, knowledge.isWord(parts[0]) {
|
||
result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen."))
|
||
}
|
||
if source.filter(\.isLetter).count < 3 {
|
||
result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen."))
|
||
}
|
||
if parts.count > 1 {
|
||
var flagged = false
|
||
if let common = parts.first(where: { knowledge.isFunctionWord($0) }) {
|
||
result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken."))
|
||
flagged = true
|
||
}
|
||
let glued = parts.joined()
|
||
if knowledge.isWord(glued) {
|
||
result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen."))
|
||
flagged = true
|
||
}
|
||
if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) {
|
||
result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen."))
|
||
}
|
||
}
|
||
return result.sorted { $0.level > $1.level }
|
||
}
|
||
}
|
||
```
|
||
|
||
`Sources/SstData/VocabularyBuilder.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
/// The short word list handed to the speech engine as context.
|
||
public enum VocabularyBuilder {
|
||
/// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words.
|
||
public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] {
|
||
var out: [String] = []
|
||
var seen = Set<String>()
|
||
func add(_ word: String) {
|
||
let key = word.lowercased()
|
||
guard !word.isEmpty, !seen.contains(key) else { return }
|
||
seen.insert(key)
|
||
out.append(word)
|
||
}
|
||
for entry in entries { if case .term(let term) = entry.kind { add(term) } }
|
||
for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } }
|
||
return Array(out.prefix(maxTerms))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Draai de tests**
|
||
|
||
Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"`
|
||
Expected: 10 + 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Sources/SstData Tests/SstDataTests
|
||
git commit -m "feat: add EntryWarnings and VocabularyBuilder" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Geschiedenis — opslag, zoeken, groeperen
|
||
|
||
**Files:**
|
||
- Create: `Sources/SstData/History.swift`
|
||
- Test: `Tests/SstDataTests/HistoryTests.swift`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SstCore.DictationResult`, `SstCore.AppliedCorrection`.
|
||
- Produces:
|
||
- `struct HistoryEntry: Codable, Equatable, Identifiable` (`id`, `date`, `duration`, `rawText`, `finalText`, `corrections`; `init(result:)`)
|
||
- `@MainActor final class HistoryStore` met `init(fileURL:)`, `entries` (nieuwste eerst), `load() -> LoadOutcome` (`.empty | .loaded | .recoveredCorrupt(backup:)`), `add(_:)`, `remove(id:)`, `removeAll()`, `flush()`, `onChange`, `lastError`
|
||
- `HistorySearch.filter(_:query:) -> [HistoryEntry]`
|
||
- `HistoryGrouping.byDay(_:calendar:) -> [HistoryDay]` (`HistoryDay`: `day: Date`, `entries`)
|
||
|
||
- [ ] **Step 1: Schrijf de falende tests**
|
||
|
||
`Tests/SstDataTests/HistoryTests.swift`:
|
||
|
||
```swift
|
||
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)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Draai de tests, moeten falen**
|
||
|
||
Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"`
|
||
Expected: compilatiefout `cannot find 'HistoryStore' in scope`.
|
||
|
||
- [ ] **Step 3: Implementatie**
|
||
|
||
`Sources/SstData/History.swift`:
|
||
|
||
```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)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Draai de tests**
|
||
|
||
Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"`
|
||
Expected: 6 + 4 + 1 tests PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Sources/SstData Tests/SstDataTests
|
||
git commit -m "feat: add history store, search and day grouping" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `DictionaryStore` en `DictionaryCorrector`
|
||
|
||
**Files:**
|
||
- Create: `Sources/SstData/DictionaryStore.swift`
|
||
- Test: `Tests/SstDataTests/DictionaryStoreTests.swift`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 (`DictionaryFile`), Task 2 (`CorrectionEngine`).
|
||
- Produces:
|
||
- `@MainActor final class DictionaryStore` met `init(fileURL:)`, `fileURL`, `file`, `entries`, `load() throws`, `apply(_ mutate: (inout DictionaryFile) -> Void) throws`, `reloadIfChanged()`, `startWatching()`, `stopWatching()`, `onChange`, `static header`
|
||
- `final class DictionaryCorrector: TextCorrecting` met `init(knowledge:)`, `update(entries:)`, `setEnabled(_:)`
|
||
|
||
- [ ] **Step 1: Schrijf de falende tests**
|
||
|
||
`Tests/SstDataTests/DictionaryStoreTests.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Testing
|
||
import SstCore
|
||
@testable import SstData
|
||
|
||
@MainActor
|
||
struct DictionaryStoreTests {
|
||
func tempFile() -> URL {
|
||
FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt")
|
||
}
|
||
|
||
@Test func loadCreatesAFileWithAHeaderWhenMissing() throws {
|
||
let url = tempFile()
|
||
let store = DictionaryStore(fileURL: url)
|
||
try store.load()
|
||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||
#expect(store.entries.isEmpty)
|
||
#expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek"))
|
||
}
|
||
|
||
@Test func applyWritesAtomicallyAndKeepsComments() throws {
|
||
let url = tempFile()
|
||
let store = DictionaryStore(fileURL: url)
|
||
try store.load()
|
||
try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) }
|
||
let text = try String(contentsOf: url, encoding: .utf8)
|
||
#expect(text.hasPrefix("# Sst woordenboek"))
|
||
#expect(text.hasSuffix("cloud code => Claude Code\n"))
|
||
#expect(store.entries == [pair("cloud code", "Claude Code")])
|
||
}
|
||
|
||
@Test func externalEditIsPickedUp() throws {
|
||
let url = tempFile()
|
||
let store = DictionaryStore(fileURL: url)
|
||
try store.load()
|
||
var changes = 0
|
||
store.onChange = { changes += 1 }
|
||
try "Vercel\n".write(to: url, atomically: true, encoding: .utf8)
|
||
store.reloadIfChanged()
|
||
#expect(store.entries == [term("Vercel")])
|
||
#expect(changes == 1)
|
||
store.reloadIfChanged() // unchanged text: no second notification
|
||
#expect(changes == 1)
|
||
}
|
||
|
||
@Test func ourOwnWriteDoesNotTriggerAReload() throws {
|
||
let store = DictionaryStore(fileURL: tempFile())
|
||
try store.load()
|
||
try store.apply { $0.append(.term("Supabase")) }
|
||
var changes = 0
|
||
store.onChange = { changes += 1 }
|
||
store.reloadIfChanged()
|
||
#expect(changes == 0)
|
||
}
|
||
|
||
@Test func watcherNoticesAnEditorSavingTheFile() async throws {
|
||
let url = tempFile()
|
||
let store = DictionaryStore(fileURL: url)
|
||
try store.load()
|
||
var changed = false
|
||
store.onChange = { changed = true }
|
||
store.startWatching()
|
||
try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8)
|
||
for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) }
|
||
store.stopWatching()
|
||
#expect(changed)
|
||
#expect(store.entries == [term("Anthropic")])
|
||
}
|
||
}
|
||
|
||
struct DictionaryCorrectorTests {
|
||
@Test func correctsWhenEnabledAndPassesThroughWhenDisabled() {
|
||
let corrector = DictionaryCorrector(knowledge: FakeKnowledge())
|
||
corrector.update(entries: [pair("cloud code", "Claude Code")])
|
||
#expect(corrector.correct("cloud code").text == "Claude Code")
|
||
corrector.setEnabled(false)
|
||
#expect(corrector.correct("cloud code").text == "cloud code")
|
||
corrector.setEnabled(true)
|
||
corrector.update(entries: [])
|
||
#expect(corrector.correct("cloud code").text == "cloud code")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Draai de tests, moeten falen**
|
||
|
||
Run: `swift test --filter "DictionaryStoreTests|DictionaryCorrectorTests"`
|
||
Expected: compilatiefout `cannot find 'DictionaryStore' in scope`.
|
||
|
||
- [ ] **Step 3: Implementatie**
|
||
|
||
`Sources/SstData/DictionaryStore.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import SstCore
|
||
|
||
/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor.
|
||
@MainActor
|
||
public final class DictionaryStore {
|
||
public static let header = """
|
||
# Sst woordenboek
|
||
# Eén invoer per regel. Een los woord is een term (voorrang bij het model).
|
||
# 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code
|
||
|
||
"""
|
||
|
||
public let fileURL: URL
|
||
public private(set) var file = DictionaryFile()
|
||
public var onChange: (() -> Void)?
|
||
|
||
private var lastText = ""
|
||
private var watcher: DispatchSourceFileSystemObject?
|
||
private var debounce: Task<Void, Never>?
|
||
|
||
public init(fileURL: URL) {
|
||
self.fileURL = fileURL
|
||
}
|
||
|
||
public var entries: [DictionaryEntry] { file.entries }
|
||
|
||
/// Reads the file; creates it with a short header when it does not exist yet.
|
||
public func load() throws {
|
||
try FileManager.default.createDirectory(
|
||
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||
try Self.header.write(to: fileURL, atomically: true, encoding: .utf8)
|
||
}
|
||
let text = try String(contentsOf: fileURL, encoding: .utf8)
|
||
lastText = text
|
||
file = DictionaryFile(parsing: text)
|
||
onChange?()
|
||
}
|
||
|
||
/// Applies an edit and writes the file straight away.
|
||
public func apply(_ mutate: (inout DictionaryFile) -> Void) throws {
|
||
var updated = file
|
||
mutate(&updated)
|
||
let text = updated.serialized()
|
||
try text.write(to: fileURL, atomically: true, encoding: .utf8)
|
||
file = updated
|
||
lastText = text
|
||
onChange?()
|
||
}
|
||
|
||
/// Re-reads the file when its text differs from what we last wrote or read.
|
||
public func reloadIfChanged() {
|
||
guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return }
|
||
lastText = text
|
||
file = DictionaryFile(parsing: text)
|
||
onChange?()
|
||
}
|
||
|
||
/// Watches the folder (editors replace files instead of editing them in place).
|
||
public func startWatching() {
|
||
guard watcher == nil else { return }
|
||
let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY)
|
||
guard descriptor >= 0 else { return }
|
||
let source = DispatchSource.makeFileSystemObjectSource(
|
||
fileDescriptor: descriptor, eventMask: .write, queue: .main)
|
||
source.setEventHandler { [weak self] in
|
||
MainActor.assumeIsolated { self?.scheduleReload() }
|
||
}
|
||
source.setCancelHandler { close(descriptor) }
|
||
source.resume()
|
||
watcher = source
|
||
}
|
||
|
||
public func stopWatching() {
|
||
watcher?.cancel()
|
||
watcher = nil
|
||
debounce?.cancel()
|
||
}
|
||
|
||
private func scheduleReload() {
|
||
debounce?.cancel()
|
||
debounce = Task { [weak self] in
|
||
try? await Task.sleep(for: .milliseconds(200))
|
||
guard !Task.isCancelled else { return }
|
||
self?.reloadIfChanged()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes.
|
||
public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private let knowledge: WordKnowledge
|
||
private var engine: CorrectionEngine
|
||
private var enabled = true
|
||
|
||
public init(knowledge: WordKnowledge) {
|
||
self.knowledge = knowledge
|
||
self.engine = CorrectionEngine(entries: [], knowledge: knowledge)
|
||
}
|
||
|
||
public func update(entries: [DictionaryEntry]) {
|
||
let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge)
|
||
lock.withLock { engine = rebuilt }
|
||
}
|
||
|
||
public func setEnabled(_ value: Bool) {
|
||
lock.withLock { enabled = value }
|
||
}
|
||
|
||
public func correct(_ text: String) -> CorrectionResult {
|
||
let (current, isOn) = lock.withLock { (engine, enabled) }
|
||
return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: [])
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Draai alle tests**
|
||
|
||
Run: `swift test`
|
||
Expected: alle tests PASS (engine-tests uit fase 1 + 54 in `SstDataTests`). De test `watcherNoticesAnEditorSavingTheFile` wacht maximaal 4 s; faalt hij sporadisch, draai hem apart opnieuw voordat je de implementatie wijzigt.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add Sources/SstData Tests/SstDataTests
|
||
git commit -m "feat: add DictionaryStore with file watching and DictionaryCorrector" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
- **Spec-dekking:** bestandsformaat en behoud van opmerkingen/volgorde/ongeldige regels (Task 1), alle correctieregels uit de spec — hele woorden, glue/koppelteken, langste eerst, geen ketens, termen die gewone woorden zijn, letterlijke vervanging, Cloudflare/cloud codex (Task 2), waarschuwingstabel (Task 3), voorrangslijst met limiet 24 (Task 3), geschiedenis met zoeken, groeperen, beschadigd bestand (Task 4), bestandsmonitor en uit/aan-schakelaar (Task 5). Niet in deze fase: de plaats van de bestanden in Application Support, de koppeling aan `DictationController` en de UI (fase 4 en 5).
|
||
- **Placeholders:** geen; alle code is uit een werkende, geteste versie overgenomen.
|
||
- **Typeconsistentie:** `DictionaryEntry.Kind` (`.term`, `.correction(heard:wrote:)`), `CorrectionEngine(entries:knowledge:)`, `EntryWarnings.check(_:among:knowledge:)`, `HistoryEntry(result:)` en `DictionaryStore.apply` komen overeen met wat Task 2–5 en de latere fasen gebruiken.
|
||
- **Afwijking van de spec (bewust):** "gewoon woord" gebruikt de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden) plus de Nederlandse spellingcontrole, niet de Engelse spellingcontrole: die rekent "anthropic" tot de woorden en zou jouw eigen voorbeeldterm onterecht uitsluiten van de hoofdletterregel.
|