// RimePersonalDictionaryExporter.swift // OSGKeyboard · Shared // // Turns PersonalDictionary into an osg_personal Rime table that the // main osg_pinyin dictionary imports. High weight keeps same-code // personal hits above the baseline lexicon. import CryptoKit import Foundation public enum RimePersonalDictionaryExporter { public static let dictionaryName = "osg_personal" /// Above any baseline `osg_pinyin` weight so same-code hits pin to top. public static let pinWeight = 50_000_000 public struct Entry: Equatable, Sendable { public let text: String public let code: String public let weight: Int public init(text: String, code: String, weight: Int = RimePersonalDictionaryExporter.pinWeight) { self.text = text self.code = code self.weight = weight } } /// Builds Rime rows for Chinese (pinyin-coded), English (latin-coded), /// and Latin aliases that should surface the canonical term. public static func entries( from dictionary: PersonalDictionary, annotator: RimePinyinAnnotator ) -> [Entry] { var seen = Set() var rows: [Entry] = [] func append(text: String, code: String) { let key = "\(text)\t\(code)" guard seen.insert(key).inserted else { return } rows.append(Entry(text: text, code: code)) } for entry in dictionary.effectiveEntries { let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines) guard !term.isEmpty else { continue } if let code = annotator.code(for: term) { append(text: term, code: code) } // Latin aliases → alternate codes for the canonical term // (e.g. brand English → 中文专名). Chinese ASR aliases stay out. for alias in entry.aliases { let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } guard !RimePinyinAnnotator.containsCJK(trimmed) else { continue } let latin = RimePinyinAnnotator.latinSpellerCode(trimmed) guard !latin.isEmpty else { continue } append(text: term, code: latin) } } return rows.sorted { if $0.text != $1.text { return $0.text.localizedStandardCompare($1.text) == .orderedAscending } return $0.code < $1.code } } public static func yaml( from dictionary: PersonalDictionary, annotator: RimePinyinAnnotator ) -> String { yaml(entries: entries(from: dictionary, annotator: annotator)) } public static func yaml(entries: [Entry]) -> String { var lines: [String] = [ "# Generated by OSGKeyboard from PersonalDictionary. Do not edit.", "---", "name: \(dictionaryName)", "version: \"1.0\"", "sort: by_weight", "columns:", " - text", " - code", " - weight", "..." ] for entry in entries { lines.append("\(entry.text)\t\(entry.code)\t\(entry.weight)") } lines.append("") return lines.joined(separator: "\n") } public static func fingerprint(of yaml: String) -> String { let digest = SHA256.hash(data: Data(yaml.utf8)) return digest.map { String(format: "%02x", $0) }.joined() } /// Injects `import_tables: [osg_personal]` into a baseline dict header. public static func injectingImportTables(into baselineYAML: String) -> String { if baselineYAML.contains("import_tables:") { return baselineYAML } let needle = "use_preset_vocabulary: false\n" let injection = """ use_preset_vocabulary: false import_tables: - \(dictionaryName) """ if let range = baselineYAML.range(of: needle) { return baselineYAML.replacingCharacters(in: range, with: injection) } // Fallback: insert after the `---` document start block's name line. let nameNeedle = "name: osg_pinyin\n" if let range = baselineYAML.range(of: nameNeedle) { let injectionAfterName = """ name: osg_pinyin import_tables: - \(dictionaryName) """ return baselineYAML.replacingCharacters(in: range, with: injectionAfterName) } return baselineYAML } }