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? 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: []) } }