55 KiB
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 naarhistory.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, Equatablemetid,kind: Kind(.term(String)|.correction(heard:wrote:)),source,wroteenum DictionaryLine { entry, comment, blank, invalid }struct DictionaryFilemetinit(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):
.target(name: "SstData", dependencies: ["SstCore"]),
en bij de testtargets:
.testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
- Step 2: Schrijf de falende tests
Tests/SstDataTests/Fakes.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:
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:
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:
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:
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 valtisWordterug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer vanswift test, wijzig de test niet stilletjes.
- Step 6: Commit
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) enSstCore.TextCorrecting/CorrectionResult/AppliedCorrection. -
Produces:
struct CorrectionEngine: TextCorrectingmetinit(entries: [DictionaryEntry], knowledge: WordKnowledge)encorrect(_:) -> CorrectionResult. -
Step 1: Schrijf de falende tests
Tests/SstDataTests/CorrectionEngineTests.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:
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
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:
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:
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:
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:
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
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 HistoryStoremetinit(fileURL:),entries(nieuwste eerst),load() -> LoadOutcome(.empty | .loaded | .recoveredCorrupt(backup:)),add(_:),remove(id:),removeAll(),flush(),onChange,lastErrorHistorySearch.filter(_:query:) -> [HistoryEntry]HistoryGrouping.byDay(_:calendar:) -> [HistoryDay](HistoryDay:day: Date,entries)
-
Step 1: Schrijf de falende tests
Tests/SstDataTests/HistoryTests.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:
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
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 DictionaryStoremetinit(fileURL:),fileURL,file,entries,load() throws,apply(_ mutate: (inout DictionaryFile) -> Void) throws,reloadIfChanged(),startWatching(),stopWatching(),onChange,static headerfinal class DictionaryCorrector: TextCorrectingmetinit(knowledge:),update(entries:),setEnabled(_:)
-
Step 1: Schrijf de falende tests
Tests/SstDataTests/DictionaryStoreTests.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:
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
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
DictationControlleren 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:)enDictionaryStore.applykomen 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.