Files
openless/Sources/OpenLessPersistence/DictionaryStore.swift
T

120 lines
4.3 KiB
Swift

import Foundation
import OpenLessCore
public final class DictionaryStore: @unchecked Sendable {
private let fileURL: URL
private let lock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init() {
encoder.dateEncodingStrategy = .iso8601
decoder.dateDecodingStrategy = .iso8601
let dir = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("OpenLess", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("dictionary.json")
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: "[]".data(using: .utf8))
}
}
public func all() -> [DictionaryEntry] {
lock.lock()
defer { lock.unlock() }
return loadAll()
}
public func enabledEntries() -> [DictionaryEntry] {
all().filter { $0.enabled && !$0.trimmedPhrase.isEmpty }
}
public func upsert(_ entry: DictionaryEntry) {
lock.lock()
defer { lock.unlock() }
var entries = loadAll()
var updated = entry
updated.phrase = updated.trimmedPhrase
updated.updatedAt = Date()
if let index = entries.firstIndex(where: { $0.id == updated.id }) {
entries[index] = updated
} else {
entries.insert(updated, at: 0)
}
saveLocked(entries)
}
public func delete(id: UUID) {
lock.lock()
defer { lock.unlock() }
let entries = loadAll().filter { $0.id != id }
saveLocked(entries)
}
@discardableResult
public func learnTerms(from text: String) -> [DictionaryEntry] {
lock.lock()
defer { lock.unlock() }
var entries = loadAll()
let existing = Set(entries.map { $0.trimmedPhrase.lowercased() })
let candidates = Self.extractLearnableTerms(from: text)
.filter { !existing.contains($0.lowercased()) }
.prefix(8)
let learned = candidates.map {
DictionaryEntry(
phrase: $0,
category: .learned,
notes: "自动从历史输入中学习,可作为后续 ASR 热词和语义判断候选。",
enabled: true,
source: .automatic
)
}
if !learned.isEmpty {
entries.insert(contentsOf: learned, at: 0)
saveLocked(entries)
}
return Array(learned)
}
private func loadAll() -> [DictionaryEntry] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
return (try? decoder.decode([DictionaryEntry].self, from: data)) ?? []
}
private func saveLocked(_ entries: [DictionaryEntry]) {
let sorted = entries.sorted { lhs, rhs in
if lhs.enabled != rhs.enabled { return lhs.enabled && !rhs.enabled }
return lhs.updatedAt > rhs.updatedAt
}
if let data = try? encoder.encode(sorted) {
try? data.write(to: fileURL, options: .atomic)
}
}
private static func extractLearnableTerms(from text: String) -> [String] {
let pattern = #"[A-Za-z][A-Za-z0-9_.+#-]{2,}"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
let matches = regex.matches(in: text, range: range)
let stopwords: Set<String> = [
"and", "the", "for", "with", "from", "this", "that", "you", "your",
"http", "https", "www", "com", "app", "api", "json", "true", "false"
]
var result: [String] = []
var seen = Set<String>()
for match in matches {
guard let tokenRange = Range(match.range, in: text) else { continue }
let token = String(text[tokenRange])
let lower = token.lowercased()
let hasSignal = token.contains { $0.isUppercase } || token.contains { $0.isNumber } || token.contains(".") || token.contains("+") || token.contains("#")
guard hasSignal, !stopwords.contains(lower), seen.insert(lower).inserted else { continue }
result.append(token)
}
return result
}
}