Files
sst/docs/superpowers/plans/2026-10-01-app-2-data.md
T

1414 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.