Files
sst/Sources/SstData/DictionaryStore.swift
T

120 lines
4.2 KiB
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)?
/// Fired (after `onChange`) when the file was changed by something other than this store.
public var onExternalChange: (() -> 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).preservingIDs(from: file)
onChange?()
onExternalChange?()
}
/// 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: [])
}
}