From 7f4f05397f686e95cfb46ce82770b462bd037bc8 Mon Sep 17 00:00:00 2001 From: kagun <88010417+artin-kagun@users.noreply.github.com> Date: Tue, 5 May 2026 18:51:46 +0900 Subject: [PATCH 1/4] perf: cache large user dictionary lookup --- .../Configs/CustomCodableConfigItem.swift | 49 ++- .../Core/InputUtils/SegmentsManager.swift | 366 +++++++++++++++--- .../UserDictionaryIndexStore.swift | 241 ++++++++++++ .../UserDictionaryIndexStoreTests.swift | 211 ++++++++++ 4 files changed, 816 insertions(+), 51 deletions(-) create mode 100644 Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift create mode 100644 Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift diff --git a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift index d85462d5..52ddd953 100644 --- a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift +++ b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift @@ -11,9 +11,18 @@ import enum KanaKanjiConverterModuleWithDefaultDictionary.LearningType protocol CustomCodableConfigItem: ConfigItem { static var `default`: Value { get } + static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool } extension CustomCodableConfigItem { + static var revisionKey: String { + "\(Self.key).revision" + } + + static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { + false + } + public var value: Value { get { guard let data = UserDefaults.standard.data(forKey: Self.key) else { @@ -30,8 +39,14 @@ extension CustomCodableConfigItem { } nonmutating set { do { + let oldValue = UserDefaults.standard.data(forKey: Self.key).flatMap { + try? JSONDecoder().decode(Value.self, from: $0) + } let encoded = try JSONEncoder().encode(newValue) UserDefaults.standard.set(encoded, forKey: Self.key) + if Self.shouldIncrementRevision(oldValue: oldValue, newValue: newValue) { + UserDefaults.standard.set(UserDefaults.standard.integer(forKey: Self.revisionKey) + 1, forKey: Self.revisionKey) + } } catch { print(#file, #line, error) } @@ -67,8 +82,8 @@ extension Config { extension Config { public struct UserDictionaryEntry: Sendable, Codable, Identifiable { - public init(word: String, reading: String, hint: String? = nil) { - self.id = UUID() + public init(id: UUID = UUID(), word: String, reading: String, hint: String? = nil) { + self.id = id self.word = word self.reading = reading self.hint = hint @@ -108,6 +123,21 @@ extension Config { .init(word: "azooKey", reading: "あずーきー", hint: "アプリ") ]) public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.user_dictionary_temporal2" + + static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { + Self.revisionSignature(oldValue?.items ?? Self.default.items) != Self.revisionSignature(newValue.items) + } + + private static func revisionSignature(_ items: [UserDictionaryEntry]) -> [String] { + items.map { item in + [ + item.id.uuidString, + item.word, + item.reading, + item.hint ?? "" + ].joined(separator: "\u{1F}") + } + } } public struct SystemUserDictionary: CustomCodableConfigItem { @@ -124,6 +154,21 @@ extension Config { public static let `default`: Value = .init(items: []) public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.system_user_dictionary" + + static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { + Self.revisionSignature(oldValue?.items ?? Self.default.items) != Self.revisionSignature(newValue.items) + } + + private static func revisionSignature(_ items: [UserDictionaryEntry]) -> [String] { + items.map { item in + [ + item.id.uuidString, + item.word, + item.reading, + item.hint ?? "" + ].joined(separator: "\u{1F}") + } + } } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 098d09da..81cc2509 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -1,6 +1,54 @@ import Foundation import KanaKanjiConverterModuleWithDefaultDictionary +private final class UserDictionaryIndexBuildRegistry: @unchecked Sendable { + struct Key: Hashable, Sendable { + var directoryPath: String + var userRevision: Int + var systemRevision: Int + } + + static let shared = UserDictionaryIndexBuildRegistry() + + private let lock = NSLock() + private var inProgressKeys: Set = [] + private var inProgressDirectoryPaths: Set = [] + private var retryNotBeforeByKey: [Key: Date] = [:] + private let retryInterval: TimeInterval = 30 + + func start(_ key: Key) -> Bool { + lock.lock() + defer { + lock.unlock() + } + if inProgressKeys.contains(key) || inProgressDirectoryPaths.contains(key.directoryPath) { + return false + } + if let retryNotBefore = retryNotBeforeByKey[key], + retryNotBefore > Date() { + return false + } + retryNotBeforeByKey.removeValue(forKey: key) + inProgressKeys.insert(key) + inProgressDirectoryPaths.insert(key.directoryPath) + return true + } + + func finish(_ key: Key, failed: Bool) { + lock.lock() + defer { + lock.unlock() + } + inProgressKeys.remove(key) + inProgressDirectoryPaths.remove(key.directoryPath) + if failed { + retryNotBeforeByKey[key] = Date().addingTimeInterval(retryInterval) + } else { + retryNotBeforeByKey.removeValue(forKey: key) + } + } +} + public final class SegmentsManager { public init( kanaKanjiConverter: KanaKanjiConverter, @@ -36,12 +84,6 @@ public final class SegmentsManager { private var liveConversionEnabled: Bool { Config.LiveConversion().value } - private var userDictionary: Config.UserDictionary.Value { - Config.UserDictionary().value - } - private var systemUserDictionary: Config.SystemUserDictionary.Value { - Config.SystemUserDictionary().value - } private var zenzaiPersonalizationLevel: Config.ZenzaiPersonalizationLevel.Value { Config.ZenzaiPersonalizationLevel().value } @@ -64,6 +106,30 @@ public final class SegmentsManager { private var suggestSelectionIndex: Int? private var backspaceAdjustedPredictionCandidate: PredictionCandidate? private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? + private var userDictionaryCache: UserDictionaryCache? + private var userDictionaryIndexNeedsReload = true + + private var userDictionaryIndexDirectoryURL: URL { + self.applicationDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) + } + + private struct DynamicUserDictionaryEntry: Sendable { + var deduplicationKey: String + var ruby: String + var element: DicdataElement + } + + private struct UserDictionaryCache { + var userRevision: Int + var systemRevision: Int + var userEntryCount: Int + var systemEntryCount: Int + var userEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] + var systemEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] + var dynamicFallbackEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] + var hasIndexedDictionary: Bool + var hasLoadedIndexMetadata: Bool + } public struct PredictionCandidate: Sendable, Equatable { public var displayText: String @@ -80,6 +146,242 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } + private func currentUserDictionaryCache() -> UserDictionaryCache { + let userRevision = UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey) + let systemRevision = UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey) + if let userDictionaryCache, + userDictionaryCache.userRevision == userRevision, + userDictionaryCache.systemRevision == systemRevision { + if !userDictionaryCache.hasLoadedIndexMetadata { + let allEntries = Self.entries(from: userDictionaryCache.userEntriesByFirstRubyCharacter) + + Self.entries(from: userDictionaryCache.systemEntriesByFirstRubyCharacter) + let indexState = self.currentUserDictionaryIndexState( + userRevision: userRevision, + systemRevision: systemRevision, + entries: allEntries + ) + if indexState.hasLoadedIndexMetadata { + var cache = userDictionaryCache + cache.dynamicFallbackEntriesByFirstRubyCharacter = Self.entriesByFirstRubyCharacter(indexState.dynamicFallbackEntries) + cache.hasIndexedDictionary = indexState.hasIndexedDictionary + cache.hasLoadedIndexMetadata = true + self.userDictionaryCache = cache + self.userDictionaryIndexNeedsReload = true + return cache + } + } + return userDictionaryCache + } + + let userEntries = Config.UserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DynamicUserDictionaryEntry( + deduplicationKey: "user:\(item.id.uuidString)", + ruby: ruby, + element: .init(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ) + } + let systemEntries = Config.SystemUserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DynamicUserDictionaryEntry( + deduplicationKey: "system:\(item.id.uuidString)", + ruby: ruby, + element: .init(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ) + } + let allEntries = userEntries + systemEntries + let indexState = self.currentUserDictionaryIndexState( + userRevision: userRevision, + systemRevision: systemRevision, + entries: allEntries + ) + let cache = UserDictionaryCache( + userRevision: userRevision, + systemRevision: systemRevision, + userEntryCount: userEntries.count, + systemEntryCount: systemEntries.count, + userEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(userEntries), + systemEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(systemEntries), + dynamicFallbackEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(indexState.dynamicFallbackEntries), + hasIndexedDictionary: indexState.hasIndexedDictionary, + hasLoadedIndexMetadata: indexState.hasLoadedIndexMetadata + ) + self.userDictionaryCache = cache + self.appendDebugMessage("userDictionaryCount: \(cache.userEntryCount)") + self.appendDebugMessage("systemUserDictionaryCount: \(cache.systemEntryCount)") + return cache + } + + private func currentUserDictionaryIndexState( + userRevision: Int, + systemRevision: Int, + entries: [DynamicUserDictionaryEntry] + ) -> (hasIndexedDictionary: Bool, dynamicFallbackEntries: [DynamicUserDictionaryEntry], hasLoadedIndexMetadata: Bool) { + let store = UserDictionaryIndexStore(directoryURL: self.userDictionaryIndexDirectoryURL) + if let metadata = store.metadata(), + metadata.userRevision == userRevision, + metadata.systemRevision == systemRevision, + store.hasUsableIndex(for: metadata) { + let dynamicFallbackEntries = self.dynamicFallbackEntriesForIndexedDictionary( + entries: entries, + skippedEntryCount: metadata.skippedEntryCount + ) + return (metadata.indexedEntryCount > 0, dynamicFallbackEntries, true) + } + + self.startUserDictionaryIndexBuildIfNeeded( + userRevision: userRevision, + systemRevision: systemRevision, + entries: entries + ) + return (false, entries, false) + } + + private func dynamicFallbackEntriesForIndexedDictionary( + entries: [DynamicUserDictionaryEntry], + skippedEntryCount: Int + ) -> [DynamicUserDictionaryEntry] { + guard skippedEntryCount > 0 else { + return [] + } + do { + let supportedCharacters = try UserDictionaryIndexStore.supportedCharacters() + return entries.filter { + !UserDictionaryIndexStore.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) + } + } catch { + self.appendDebugMessage("userDictionaryIndexFallbackError: \(error)") + return entries + } + } + + private func startUserDictionaryIndexBuildIfNeeded( + userRevision: Int, + systemRevision: Int, + entries: [DynamicUserDictionaryEntry] + ) { + let directoryURL = self.userDictionaryIndexDirectoryURL + let registryKey = UserDictionaryIndexBuildRegistry.Key( + directoryPath: directoryURL.path, + userRevision: userRevision, + systemRevision: systemRevision + ) + guard UserDictionaryIndexBuildRegistry.shared.start(registryKey) else { + return + } + + DispatchQueue.global(qos: .utility).async { [directoryURL, entries, registryKey] in + var didFail = false + do { + _ = try UserDictionaryIndexStore(directoryURL: directoryURL).rebuild( + entries: entries.map(\.element), + userRevision: userRevision, + systemRevision: systemRevision + ) + } catch { + didFail = true + } + UserDictionaryIndexBuildRegistry.shared.finish(registryKey, failed: didFail) + } + } + + private func dynamicUserDictionary(for queryRuby: String) -> [DicdataElement] { + let cache = self.currentUserDictionaryCache() + guard !queryRuby.isEmpty else { + return [] + } + let entriesByFirstRubyCharacter = if cache.hasIndexedDictionary { + cache.dynamicFallbackEntriesByFirstRubyCharacter + } else { + Self.mergeEntriesByFirstRubyCharacter( + cache.userEntriesByFirstRubyCharacter, + cache.systemEntriesByFirstRubyCharacter + ) + } + var elements: [DicdataElement] = [] + var seenKeys: Set = [] + for suffixStart in queryRuby.indices { + let suffix = String(queryRuby[suffixStart...]) + guard let firstRubyCharacter = suffix.first else { + continue + } + let entries = entriesByFirstRubyCharacter[firstRubyCharacter] ?? [] + for entry in entries where Self.dynamicUserDictionaryEntryRuby(entry.ruby, matchesQuerySuffix: suffix) { + if seenKeys.insert(entry.deduplicationKey).inserted { + elements.append(entry.element) + } + } + } + return elements + } + + private static func entriesByFirstRubyCharacter( + _ entries: [DynamicUserDictionaryEntry] + ) -> [Character: [DynamicUserDictionaryEntry]] { + entries.reduce(into: [Character: [DynamicUserDictionaryEntry]]()) { result, entry in + guard let firstRubyCharacter = entry.ruby.first else { + return + } + result[firstRubyCharacter, default: []].append(entry) + } + } + + private static func entries( + from entriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] + ) -> [DynamicUserDictionaryEntry] { + entriesByFirstRubyCharacter.values.flatMap(\.self) + } + + private static func mergeEntriesByFirstRubyCharacter( + _ lhs: [Character: [DynamicUserDictionaryEntry]], + _ rhs: [Character: [DynamicUserDictionaryEntry]] + ) -> [Character: [DynamicUserDictionaryEntry]] { + rhs.reduce(into: lhs) { result, item in + result[item.key, default: []].append(contentsOf: item.value) + } + } + + static func shouldIncludeDynamicUserDictionaryEntry(ruby entryRuby: String, for queryRuby: String) -> Bool { + guard !entryRuby.isEmpty, !queryRuby.isEmpty else { + return false + } + return queryRuby.indices.contains { suffixStart in + let suffix = String(queryRuby[suffixStart...]) + return Self.dynamicUserDictionaryEntryRuby(entryRuby, matchesQuerySuffix: suffix) + } + } + + private static func dynamicUserDictionaryEntryRuby(_ entryRuby: String, matchesQuerySuffix suffix: String) -> Bool { + entryRuby.hasPrefix(suffix) || suffix.hasPrefix(entryRuby) + } + + private static func makeDynamicShortcuts() -> [DicdataElement] { + [ + ("M/d", -18, DateTemplateLiteral.CalendarType.western), + ("yyyy/MM/dd", -18.1, .western), + ("yyyy-MM-dd", -18.2, .western), + ("M月d日(E)", -18.3, .western), + ("yyyy年M月d日", -18.4, .western), + ("Gyyyy年M月d日", -18.5, .japanese), + ("E曜日", -18.6, .western) + ].flatMap { (format, value: PValue, type) in + [ + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) + ] + } + [ + .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) + ] + } + public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in @@ -480,50 +782,16 @@ public final class SegmentsManager { self.kanaKanjiConverter.stopComposition() return } - // ユーザ辞書情報の更新 - var userDictionary: [DicdataElement] = userDictionary.items.map { - .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - self.appendDebugMessage("userDictionaryCount: \(userDictionary.count)") - let systemUserDictionary: [DicdataElement] = systemUserDictionary.items.map { - .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - self.appendDebugMessage("systemUserDictionaryCount: \(systemUserDictionary.count)") - userDictionary.append(contentsOf: consume systemUserDictionary) - - /// 日付・時刻変換を事前に入れておく - let dynamicShortcuts: [DicdataElement] = - [ - ("M/d", -18, DateTemplateLiteral.CalendarType.western), - ("yyyy/MM/dd", -18.1, .western), - ("yyyy-MM-dd", -18.2, .western), - ("M月d日(E)", -18.3, .western), - ("yyyy年M月d日", -18.4, .western), - ("Gyyyy年M月d日", -18.5, .japanese), - ("E曜日", -18.6, .western) - ].flatMap { (format, value: PValue, type) in - [ - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) - ] - } + [ - // 月 - .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - // 年 - .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - // 時刻 - .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) - ] - - self.kanaKanjiConverter.importDynamicUserDictionary(consume userDictionary, shortcuts: dynamicShortcuts) - let prefixComposingText = self.composingText.prefixToCursorPosition() + let queryRuby = prefixComposingText.convertTarget.toKatakana() + let userDictionary = self.dynamicUserDictionary(for: queryRuby) + self.kanaKanjiConverter.updateUserDictionaryURL( + self.userDictionaryIndexDirectoryURL, + forceReload: self.userDictionaryIndexNeedsReload + ) + self.userDictionaryIndexNeedsReload = false + self.kanaKanjiConverter.importDynamicUserDictionary(userDictionary, shortcuts: Self.makeDynamicShortcuts()) + let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) let result = self.kanaKanjiConverter.requestCandidates( prefixComposingText, diff --git a/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift b/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift new file mode 100644 index 00000000..26711f14 --- /dev/null +++ b/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift @@ -0,0 +1,241 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +public struct UserDictionaryIndexBuildResult: Sendable, Equatable { + public var indexedEntryCount: Int + public var skippedEntryCount: Int + public var totalEntryCount: Int +} + +public struct UserDictionaryIndexSummary: Sendable, Equatable { + public var userRevision: Int + public var systemRevision: Int + public var indexedEntryCount: Int + public var skippedEntryCount: Int + public var totalEntryCount: Int + public var updatedAt: Date? +} + +public enum UserDictionaryIndexStatus: Sendable, Equatable { + case notBuilt(entryCount: Int) + case ready(UserDictionaryIndexSummary) + case needsRebuild(currentEntryCount: Int, existing: UserDictionaryIndexSummary?) +} + +public enum UserDictionaryIndexController { + public static func indexDirectoryURL(applicationDirectoryURL: URL) -> URL { + applicationDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) + } + + public static func currentStatus(applicationDirectoryURL: URL) -> UserDictionaryIndexStatus { + Self.status( + directoryURL: Self.indexDirectoryURL(applicationDirectoryURL: applicationDirectoryURL), + currentUserRevision: UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey), + currentSystemRevision: UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey), + entryCount: Self.currentEntries().count + ) + } + + public static func rebuild(applicationDirectoryURL: URL) throws -> UserDictionaryIndexBuildResult { + let entries = Self.currentEntries() + let result = try UserDictionaryIndexStore( + directoryURL: Self.indexDirectoryURL(applicationDirectoryURL: applicationDirectoryURL) + ).rebuild( + entries: entries, + userRevision: UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey), + systemRevision: UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey) + ) + return .init( + indexedEntryCount: result.indexedEntryCount, + skippedEntryCount: result.skippedEntryCount, + totalEntryCount: entries.count + ) + } + + static func status( + directoryURL: URL, + currentUserRevision: Int, + currentSystemRevision: Int, + entryCount: Int + ) -> UserDictionaryIndexStatus { + let store = UserDictionaryIndexStore(directoryURL: directoryURL) + guard let metadata = store.metadata() else { + return .notBuilt(entryCount: entryCount) + } + let summary = UserDictionaryIndexSummary( + userRevision: metadata.userRevision, + systemRevision: metadata.systemRevision, + indexedEntryCount: metadata.indexedEntryCount, + skippedEntryCount: metadata.skippedEntryCount, + totalEntryCount: metadata.indexedEntryCount + metadata.skippedEntryCount, + updatedAt: Self.metadataModificationDate(directoryURL: directoryURL) + ) + if metadata.userRevision == currentUserRevision, + metadata.systemRevision == currentSystemRevision, + store.hasUsableIndex(for: metadata) { + return .ready(summary) + } + return .needsRebuild(currentEntryCount: entryCount, existing: summary) + } + + private static func currentEntries() -> [DicdataElement] { + let userEntries = Config.UserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + let systemEntries = Config.SystemUserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + return userEntries + systemEntries + } + + private static func metadataModificationDate(directoryURL: URL) -> Date? { + let metadataURL = directoryURL.appendingPathComponent("metadata.json", isDirectory: false) + let attributes = try? FileManager.default.attributesOfItem(atPath: metadataURL.path) + return attributes?[.modificationDate] as? Date + } +} + +struct UserDictionaryIndexStore { + enum BuildError: Error { + case missingCharIDFile + } + + struct RebuildResult { + var indexedEntryCount: Int + var skippedEntryCount: Int + } + + struct Metadata: Codable, Equatable { + var userRevision: Int + var systemRevision: Int + var indexedEntryCount: Int + var skippedEntryCount: Int + } + + let directoryURL: URL + + private var metadataURL: URL { + directoryURL.appendingPathComponent("metadata.json", isDirectory: false) + } + + func metadata() -> Metadata? { + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + return try? JSONDecoder().decode(Metadata.self, from: data) + } + + func hasUsableIndex(for metadata: Metadata) -> Bool { + guard metadata.indexedEntryCount > 0 else { + return true + } + let requiredFileNames = [ + "user.louds", + "user.loudschars2", + "user0.loudstxt3" + ] + return requiredFileNames.allSatisfy { + FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0, isDirectory: false).path) + } + } + + func rebuild(entries: [DicdataElement], userRevision: Int, systemRevision: Int) throws -> RebuildResult { + let fileManager = FileManager.default + let parentURL = directoryURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + + let temporaryURL = parentURL.appendingPathComponent( + "\(directoryURL.lastPathComponent).building-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + + do { + let indexableEntries: [DicdataElement] + if entries.isEmpty { + indexableEntries = [] + } else { + guard let charIDFileURL = Self.defaultCharIDFileURL() else { + throw BuildError.missingCharIDFile + } + let supportedCharacters = try Self.supportedCharacters(from: charIDFileURL) + indexableEntries = entries.filter { + Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) + } + guard !indexableEntries.isEmpty else { + if fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.removeItem(at: directoryURL) + } + try Self.writeMetadata( + .init(userRevision: userRevision, systemRevision: systemRevision, indexedEntryCount: 0, skippedEntryCount: entries.count), + to: temporaryURL + ) + try fileManager.moveItem(at: temporaryURL, to: directoryURL) + return .init(indexedEntryCount: 0, skippedEntryCount: entries.count) + } + try DictionaryBuilder.exportDictionary( + entries: indexableEntries, + to: temporaryURL, + baseName: "user", + shardByFirstCharacter: false, + charIDFileURL: charIDFileURL + ) + } + + if fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.removeItem(at: directoryURL) + } + let result = RebuildResult(indexedEntryCount: indexableEntries.count, skippedEntryCount: entries.count - indexableEntries.count) + try Self.writeMetadata( + .init( + userRevision: userRevision, + systemRevision: systemRevision, + indexedEntryCount: result.indexedEntryCount, + skippedEntryCount: result.skippedEntryCount + ), + to: temporaryURL + ) + try fileManager.moveItem(at: temporaryURL, to: directoryURL) + return result + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + static func supportedCharacters() throws -> Set { + guard let charIDFileURL = Self.defaultCharIDFileURL() else { + throw BuildError.missingCharIDFile + } + return try Self.supportedCharacters(from: charIDFileURL) + } + + static func canIndex(ruby: String, supportedCharacters: Set) -> Bool { + !ruby.isEmpty && ruby.allSatisfy { supportedCharacters.contains($0) } + } + + private static func defaultCharIDFileURL() -> URL? { + _ = DicdataStore.withDefaultDictionary(preloadDictionary: false) + return (Bundle.allBundles + Bundle.allFrameworks) + .lazy + .compactMap(\.resourceURL) + .map { + $0.appendingPathComponent("Dictionary/louds/charID.chid", isDirectory: false) + } + .first { + FileManager.default.fileExists(atPath: $0.path) + } + } + + private static func supportedCharacters(from charIDFileURL: URL) throws -> Set { + let text = try String(contentsOf: charIDFileURL, encoding: .utf8) + return Set(text) + } + + private static func writeMetadata(_ metadata: Metadata, to directoryURL: URL) throws { + let data = try JSONEncoder().encode(metadata) + try data.write(to: directoryURL.appendingPathComponent("metadata.json", isDirectory: false)) + } +} diff --git a/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift b/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift new file mode 100644 index 00000000..1465c316 --- /dev/null +++ b/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift @@ -0,0 +1,211 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@Test func dynamicUserDictionaryFilteringKeepsConvertibleEntries() { + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "コーエン")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "コーエンマコーレー")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "アカイコーエン")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "アカイコーエンマコーレーデス")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "カン", for: "アカン")) +} + +@Test func dynamicUserDictionaryFilteringDropsUnrelatedEntries() { + #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "セイソクレツ", for: "コーエン")) + #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "", for: "コーエン")) + #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエン", for: "")) +} + +@Test func rebuildUserDictionaryIndexWritesSearchFiles() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + _ = try UserDictionaryIndexStore(directoryURL: directory).rebuild( + entries: [ + .init(word: "Cohen-Macaulay", ruby: "コーエンマコーレー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), + .init(word: "正則列", ruby: "セイソクレツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ], + userRevision: 12, + systemRevision: 34 + ) + + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.louds").path)) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.loudschars2").path)) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user0.loudstxt3").path)) + #expect(UserDictionaryIndexStore(directoryURL: directory).metadata() == .init( + userRevision: 12, + systemRevision: 34, + indexedEntryCount: 2, + skippedEntryCount: 0 + )) +} + +@Test func userDictionaryIndexReportsSkippedUnsupportedReadings() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + let result = try UserDictionaryIndexStore(directoryURL: directory).rebuild( + entries: [ + .init(word: "unsupported", ruby: "\u{10FFFF}", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ], + userRevision: 56, + systemRevision: 78 + ) + + #expect(result.indexedEntryCount == 0) + #expect(result.skippedEntryCount == 1) + #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.louds").path)) + #expect(UserDictionaryIndexStore(directoryURL: directory).metadata() == .init( + userRevision: 56, + systemRevision: 78, + indexedEntryCount: 0, + skippedEntryCount: 1 + )) +} + +@Test func userDictionaryIndexRequiresFilesWhenMetadataReportsIndexedEntries() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + let store = UserDictionaryIndexStore(directoryURL: directory) + let metadata = UserDictionaryIndexStore.Metadata( + userRevision: 90, + systemRevision: 12, + indexedEntryCount: 1, + skippedEntryCount: 0 + ) + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(metadata) + try data.write(to: directory.appendingPathComponent("metadata.json", isDirectory: false)) + + #expect(!store.hasUsableIndex(for: metadata)) + + for fileName in ["user.louds", "user.loudschars2", "user0.loudstxt3"] { + try Data().write(to: directory.appendingPathComponent(fileName, isDirectory: false)) + } + + #expect(store.hasUsableIndex(for: metadata)) +} + +@Test func userDictionaryIndexStatusReportsReadyAndStaleCaches() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: directory) + } + + #expect(UserDictionaryIndexController.status( + directoryURL: directory, + currentUserRevision: 1, + currentSystemRevision: 2, + entryCount: 3 + ) == .notBuilt(entryCount: 3)) + + _ = try UserDictionaryIndexStore(directoryURL: directory).rebuild( + entries: [ + .init(word: "Cohen-Macaulay", ruby: "コーエンマコーレー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ], + userRevision: 1, + systemRevision: 2 + ) + + switch UserDictionaryIndexController.status( + directoryURL: directory, + currentUserRevision: 1, + currentSystemRevision: 2, + entryCount: 1 + ) { + case .ready(let summary): + #expect(summary.indexedEntryCount == 1) + #expect(summary.skippedEntryCount == 0) + default: + Issue.record("Expected a ready user dictionary index") + } + + switch UserDictionaryIndexController.status( + directoryURL: directory, + currentUserRevision: 2, + currentSystemRevision: 2, + entryCount: 4 + ) { + case .needsRebuild(let currentEntryCount, let existing): + #expect(currentEntryCount == 4) + #expect(existing?.indexedEntryCount == 1) + default: + Issue.record("Expected a stale user dictionary index") + } +} + +@Test func userDictionaryRevisionTracksEntryChanges() { + let defaults = UserDefaults.standard + let key = Config.UserDictionary.key + let revisionKey = Config.UserDictionary.revisionKey + let oldData = defaults.data(forKey: key) + let oldRevision = defaults.object(forKey: revisionKey) + defer { + if let oldData { + defaults.set(oldData, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + if let oldRevision { + defaults.set(oldRevision, forKey: revisionKey) + } else { + defaults.removeObject(forKey: revisionKey) + } + } + + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: revisionKey) + + var value = Config.UserDictionary.default + Config.UserDictionary().value = value + #expect(defaults.integer(forKey: revisionKey) == 0) + + value.items[0].hint = "コメント変更" + Config.UserDictionary().value = value + #expect(defaults.integer(forKey: revisionKey) == 1) +} + +@Test func systemUserDictionaryRevisionIgnoresLastUpdateOnlyChanges() { + let defaults = UserDefaults.standard + let key = Config.SystemUserDictionary.key + let revisionKey = Config.SystemUserDictionary.revisionKey + let oldData = defaults.data(forKey: key) + let oldRevision = defaults.object(forKey: revisionKey) + defer { + if let oldData { + defaults.set(oldData, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + if let oldRevision { + defaults.set(oldRevision, forKey: revisionKey) + } else { + defaults.removeObject(forKey: revisionKey) + } + } + + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: revisionKey) + + var value = Config.SystemUserDictionary.default + value.lastUpdate = .now + Config.SystemUserDictionary().value = value + #expect(defaults.integer(forKey: revisionKey) == 0) + + value.items.append(.init(word: "正則列", reading: "せいそくれつ")) + Config.SystemUserDictionary().value = value + #expect(defaults.integer(forKey: revisionKey) == 1) +} From 638077f94de93d4c3a1cef17cbc07a5121d56e4a Mon Sep 17 00:00:00 2001 From: kagun <88010417+artin-kagun@users.noreply.github.com> Date: Fri, 8 May 2026 20:00:10 +0900 Subject: [PATCH 2/4] Revert "perf: cache large user dictionary lookup" This reverts commit 4d3bf82e8f02b9696f8a43723d4e4a572906d18c. --- .../Configs/CustomCodableConfigItem.swift | 49 +-- .../Core/InputUtils/SegmentsManager.swift | 366 +++--------------- .../UserDictionaryIndexStore.swift | 241 ------------ .../UserDictionaryIndexStoreTests.swift | 211 ---------- 4 files changed, 51 insertions(+), 816 deletions(-) delete mode 100644 Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift delete mode 100644 Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift diff --git a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift index 52ddd953..d85462d5 100644 --- a/Core/Sources/Core/Configs/CustomCodableConfigItem.swift +++ b/Core/Sources/Core/Configs/CustomCodableConfigItem.swift @@ -11,18 +11,9 @@ import enum KanaKanjiConverterModuleWithDefaultDictionary.LearningType protocol CustomCodableConfigItem: ConfigItem { static var `default`: Value { get } - static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool } extension CustomCodableConfigItem { - static var revisionKey: String { - "\(Self.key).revision" - } - - static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { - false - } - public var value: Value { get { guard let data = UserDefaults.standard.data(forKey: Self.key) else { @@ -39,14 +30,8 @@ extension CustomCodableConfigItem { } nonmutating set { do { - let oldValue = UserDefaults.standard.data(forKey: Self.key).flatMap { - try? JSONDecoder().decode(Value.self, from: $0) - } let encoded = try JSONEncoder().encode(newValue) UserDefaults.standard.set(encoded, forKey: Self.key) - if Self.shouldIncrementRevision(oldValue: oldValue, newValue: newValue) { - UserDefaults.standard.set(UserDefaults.standard.integer(forKey: Self.revisionKey) + 1, forKey: Self.revisionKey) - } } catch { print(#file, #line, error) } @@ -82,8 +67,8 @@ extension Config { extension Config { public struct UserDictionaryEntry: Sendable, Codable, Identifiable { - public init(id: UUID = UUID(), word: String, reading: String, hint: String? = nil) { - self.id = id + public init(word: String, reading: String, hint: String? = nil) { + self.id = UUID() self.word = word self.reading = reading self.hint = hint @@ -123,21 +108,6 @@ extension Config { .init(word: "azooKey", reading: "あずーきー", hint: "アプリ") ]) public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.user_dictionary_temporal2" - - static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { - Self.revisionSignature(oldValue?.items ?? Self.default.items) != Self.revisionSignature(newValue.items) - } - - private static func revisionSignature(_ items: [UserDictionaryEntry]) -> [String] { - items.map { item in - [ - item.id.uuidString, - item.word, - item.reading, - item.hint ?? "" - ].joined(separator: "\u{1F}") - } - } } public struct SystemUserDictionary: CustomCodableConfigItem { @@ -154,21 +124,6 @@ extension Config { public static let `default`: Value = .init(items: []) public static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.system_user_dictionary" - - static func shouldIncrementRevision(oldValue: Value?, newValue: Value) -> Bool { - Self.revisionSignature(oldValue?.items ?? Self.default.items) != Self.revisionSignature(newValue.items) - } - - private static func revisionSignature(_ items: [UserDictionaryEntry]) -> [String] { - items.map { item in - [ - item.id.uuidString, - item.word, - item.reading, - item.hint ?? "" - ].joined(separator: "\u{1F}") - } - } } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 81cc2509..098d09da 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -1,54 +1,6 @@ import Foundation import KanaKanjiConverterModuleWithDefaultDictionary -private final class UserDictionaryIndexBuildRegistry: @unchecked Sendable { - struct Key: Hashable, Sendable { - var directoryPath: String - var userRevision: Int - var systemRevision: Int - } - - static let shared = UserDictionaryIndexBuildRegistry() - - private let lock = NSLock() - private var inProgressKeys: Set = [] - private var inProgressDirectoryPaths: Set = [] - private var retryNotBeforeByKey: [Key: Date] = [:] - private let retryInterval: TimeInterval = 30 - - func start(_ key: Key) -> Bool { - lock.lock() - defer { - lock.unlock() - } - if inProgressKeys.contains(key) || inProgressDirectoryPaths.contains(key.directoryPath) { - return false - } - if let retryNotBefore = retryNotBeforeByKey[key], - retryNotBefore > Date() { - return false - } - retryNotBeforeByKey.removeValue(forKey: key) - inProgressKeys.insert(key) - inProgressDirectoryPaths.insert(key.directoryPath) - return true - } - - func finish(_ key: Key, failed: Bool) { - lock.lock() - defer { - lock.unlock() - } - inProgressKeys.remove(key) - inProgressDirectoryPaths.remove(key.directoryPath) - if failed { - retryNotBeforeByKey[key] = Date().addingTimeInterval(retryInterval) - } else { - retryNotBeforeByKey.removeValue(forKey: key) - } - } -} - public final class SegmentsManager { public init( kanaKanjiConverter: KanaKanjiConverter, @@ -84,6 +36,12 @@ public final class SegmentsManager { private var liveConversionEnabled: Bool { Config.LiveConversion().value } + private var userDictionary: Config.UserDictionary.Value { + Config.UserDictionary().value + } + private var systemUserDictionary: Config.SystemUserDictionary.Value { + Config.SystemUserDictionary().value + } private var zenzaiPersonalizationLevel: Config.ZenzaiPersonalizationLevel.Value { Config.ZenzaiPersonalizationLevel().value } @@ -106,30 +64,6 @@ public final class SegmentsManager { private var suggestSelectionIndex: Int? private var backspaceAdjustedPredictionCandidate: PredictionCandidate? private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? - private var userDictionaryCache: UserDictionaryCache? - private var userDictionaryIndexNeedsReload = true - - private var userDictionaryIndexDirectoryURL: URL { - self.applicationDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) - } - - private struct DynamicUserDictionaryEntry: Sendable { - var deduplicationKey: String - var ruby: String - var element: DicdataElement - } - - private struct UserDictionaryCache { - var userRevision: Int - var systemRevision: Int - var userEntryCount: Int - var systemEntryCount: Int - var userEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] - var systemEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] - var dynamicFallbackEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] - var hasIndexedDictionary: Bool - var hasLoadedIndexMetadata: Bool - } public struct PredictionCandidate: Sendable, Equatable { public var displayText: String @@ -146,242 +80,6 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } - private func currentUserDictionaryCache() -> UserDictionaryCache { - let userRevision = UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey) - let systemRevision = UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey) - if let userDictionaryCache, - userDictionaryCache.userRevision == userRevision, - userDictionaryCache.systemRevision == systemRevision { - if !userDictionaryCache.hasLoadedIndexMetadata { - let allEntries = Self.entries(from: userDictionaryCache.userEntriesByFirstRubyCharacter) - + Self.entries(from: userDictionaryCache.systemEntriesByFirstRubyCharacter) - let indexState = self.currentUserDictionaryIndexState( - userRevision: userRevision, - systemRevision: systemRevision, - entries: allEntries - ) - if indexState.hasLoadedIndexMetadata { - var cache = userDictionaryCache - cache.dynamicFallbackEntriesByFirstRubyCharacter = Self.entriesByFirstRubyCharacter(indexState.dynamicFallbackEntries) - cache.hasIndexedDictionary = indexState.hasIndexedDictionary - cache.hasLoadedIndexMetadata = true - self.userDictionaryCache = cache - self.userDictionaryIndexNeedsReload = true - return cache - } - } - return userDictionaryCache - } - - let userEntries = Config.UserDictionary().value.items.map { item in - let ruby = item.reading.toKatakana() - return DynamicUserDictionaryEntry( - deduplicationKey: "user:\(item.id.uuidString)", - ruby: ruby, - element: .init(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ) - } - let systemEntries = Config.SystemUserDictionary().value.items.map { item in - let ruby = item.reading.toKatakana() - return DynamicUserDictionaryEntry( - deduplicationKey: "system:\(item.id.uuidString)", - ruby: ruby, - element: .init(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ) - } - let allEntries = userEntries + systemEntries - let indexState = self.currentUserDictionaryIndexState( - userRevision: userRevision, - systemRevision: systemRevision, - entries: allEntries - ) - let cache = UserDictionaryCache( - userRevision: userRevision, - systemRevision: systemRevision, - userEntryCount: userEntries.count, - systemEntryCount: systemEntries.count, - userEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(userEntries), - systemEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(systemEntries), - dynamicFallbackEntriesByFirstRubyCharacter: Self.entriesByFirstRubyCharacter(indexState.dynamicFallbackEntries), - hasIndexedDictionary: indexState.hasIndexedDictionary, - hasLoadedIndexMetadata: indexState.hasLoadedIndexMetadata - ) - self.userDictionaryCache = cache - self.appendDebugMessage("userDictionaryCount: \(cache.userEntryCount)") - self.appendDebugMessage("systemUserDictionaryCount: \(cache.systemEntryCount)") - return cache - } - - private func currentUserDictionaryIndexState( - userRevision: Int, - systemRevision: Int, - entries: [DynamicUserDictionaryEntry] - ) -> (hasIndexedDictionary: Bool, dynamicFallbackEntries: [DynamicUserDictionaryEntry], hasLoadedIndexMetadata: Bool) { - let store = UserDictionaryIndexStore(directoryURL: self.userDictionaryIndexDirectoryURL) - if let metadata = store.metadata(), - metadata.userRevision == userRevision, - metadata.systemRevision == systemRevision, - store.hasUsableIndex(for: metadata) { - let dynamicFallbackEntries = self.dynamicFallbackEntriesForIndexedDictionary( - entries: entries, - skippedEntryCount: metadata.skippedEntryCount - ) - return (metadata.indexedEntryCount > 0, dynamicFallbackEntries, true) - } - - self.startUserDictionaryIndexBuildIfNeeded( - userRevision: userRevision, - systemRevision: systemRevision, - entries: entries - ) - return (false, entries, false) - } - - private func dynamicFallbackEntriesForIndexedDictionary( - entries: [DynamicUserDictionaryEntry], - skippedEntryCount: Int - ) -> [DynamicUserDictionaryEntry] { - guard skippedEntryCount > 0 else { - return [] - } - do { - let supportedCharacters = try UserDictionaryIndexStore.supportedCharacters() - return entries.filter { - !UserDictionaryIndexStore.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) - } - } catch { - self.appendDebugMessage("userDictionaryIndexFallbackError: \(error)") - return entries - } - } - - private func startUserDictionaryIndexBuildIfNeeded( - userRevision: Int, - systemRevision: Int, - entries: [DynamicUserDictionaryEntry] - ) { - let directoryURL = self.userDictionaryIndexDirectoryURL - let registryKey = UserDictionaryIndexBuildRegistry.Key( - directoryPath: directoryURL.path, - userRevision: userRevision, - systemRevision: systemRevision - ) - guard UserDictionaryIndexBuildRegistry.shared.start(registryKey) else { - return - } - - DispatchQueue.global(qos: .utility).async { [directoryURL, entries, registryKey] in - var didFail = false - do { - _ = try UserDictionaryIndexStore(directoryURL: directoryURL).rebuild( - entries: entries.map(\.element), - userRevision: userRevision, - systemRevision: systemRevision - ) - } catch { - didFail = true - } - UserDictionaryIndexBuildRegistry.shared.finish(registryKey, failed: didFail) - } - } - - private func dynamicUserDictionary(for queryRuby: String) -> [DicdataElement] { - let cache = self.currentUserDictionaryCache() - guard !queryRuby.isEmpty else { - return [] - } - let entriesByFirstRubyCharacter = if cache.hasIndexedDictionary { - cache.dynamicFallbackEntriesByFirstRubyCharacter - } else { - Self.mergeEntriesByFirstRubyCharacter( - cache.userEntriesByFirstRubyCharacter, - cache.systemEntriesByFirstRubyCharacter - ) - } - var elements: [DicdataElement] = [] - var seenKeys: Set = [] - for suffixStart in queryRuby.indices { - let suffix = String(queryRuby[suffixStart...]) - guard let firstRubyCharacter = suffix.first else { - continue - } - let entries = entriesByFirstRubyCharacter[firstRubyCharacter] ?? [] - for entry in entries where Self.dynamicUserDictionaryEntryRuby(entry.ruby, matchesQuerySuffix: suffix) { - if seenKeys.insert(entry.deduplicationKey).inserted { - elements.append(entry.element) - } - } - } - return elements - } - - private static func entriesByFirstRubyCharacter( - _ entries: [DynamicUserDictionaryEntry] - ) -> [Character: [DynamicUserDictionaryEntry]] { - entries.reduce(into: [Character: [DynamicUserDictionaryEntry]]()) { result, entry in - guard let firstRubyCharacter = entry.ruby.first else { - return - } - result[firstRubyCharacter, default: []].append(entry) - } - } - - private static func entries( - from entriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] - ) -> [DynamicUserDictionaryEntry] { - entriesByFirstRubyCharacter.values.flatMap(\.self) - } - - private static func mergeEntriesByFirstRubyCharacter( - _ lhs: [Character: [DynamicUserDictionaryEntry]], - _ rhs: [Character: [DynamicUserDictionaryEntry]] - ) -> [Character: [DynamicUserDictionaryEntry]] { - rhs.reduce(into: lhs) { result, item in - result[item.key, default: []].append(contentsOf: item.value) - } - } - - static func shouldIncludeDynamicUserDictionaryEntry(ruby entryRuby: String, for queryRuby: String) -> Bool { - guard !entryRuby.isEmpty, !queryRuby.isEmpty else { - return false - } - return queryRuby.indices.contains { suffixStart in - let suffix = String(queryRuby[suffixStart...]) - return Self.dynamicUserDictionaryEntryRuby(entryRuby, matchesQuerySuffix: suffix) - } - } - - private static func dynamicUserDictionaryEntryRuby(_ entryRuby: String, matchesQuerySuffix suffix: String) -> Bool { - entryRuby.hasPrefix(suffix) || suffix.hasPrefix(entryRuby) - } - - private static func makeDynamicShortcuts() -> [DicdataElement] { - [ - ("M/d", -18, DateTemplateLiteral.CalendarType.western), - ("yyyy/MM/dd", -18.1, .western), - ("yyyy-MM-dd", -18.2, .western), - ("M月d日(E)", -18.3, .western), - ("yyyy年M月d日", -18.4, .western), - ("Gyyyy年M月d日", -18.5, .japanese), - ("E曜日", -18.6, .western) - ].flatMap { (format, value: PValue, type) in - [ - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) - ] - } + [ - .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) - ] - } - public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in @@ -782,16 +480,50 @@ public final class SegmentsManager { self.kanaKanjiConverter.stopComposition() return } - let prefixComposingText = self.composingText.prefixToCursorPosition() - let queryRuby = prefixComposingText.convertTarget.toKatakana() - let userDictionary = self.dynamicUserDictionary(for: queryRuby) - self.kanaKanjiConverter.updateUserDictionaryURL( - self.userDictionaryIndexDirectoryURL, - forceReload: self.userDictionaryIndexNeedsReload - ) - self.userDictionaryIndexNeedsReload = false - self.kanaKanjiConverter.importDynamicUserDictionary(userDictionary, shortcuts: Self.makeDynamicShortcuts()) + // ユーザ辞書情報の更新 + var userDictionary: [DicdataElement] = userDictionary.items.map { + .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + self.appendDebugMessage("userDictionaryCount: \(userDictionary.count)") + let systemUserDictionary: [DicdataElement] = systemUserDictionary.items.map { + .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + self.appendDebugMessage("systemUserDictionaryCount: \(systemUserDictionary.count)") + userDictionary.append(contentsOf: consume systemUserDictionary) + + /// 日付・時刻変換を事前に入れておく + let dynamicShortcuts: [DicdataElement] = + [ + ("M/d", -18, DateTemplateLiteral.CalendarType.western), + ("yyyy/MM/dd", -18.1, .western), + ("yyyy-MM-dd", -18.2, .western), + ("M月d日(E)", -18.3, .western), + ("yyyy年M月d日", -18.4, .western), + ("Gyyyy年M月d日", -18.5, .japanese), + ("E曜日", -18.6, .western) + ].flatMap { (format, value: PValue, type) in + [ + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) + ] + } + [ + // 月 + .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + // 年 + .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + // 時刻 + .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) + ] + + self.kanaKanjiConverter.importDynamicUserDictionary(consume userDictionary, shortcuts: dynamicShortcuts) + let prefixComposingText = self.composingText.prefixToCursorPosition() let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) let result = self.kanaKanjiConverter.requestCandidates( prefixComposingText, diff --git a/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift b/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift deleted file mode 100644 index 26711f14..00000000 --- a/Core/Sources/Core/UserDictionary/UserDictionaryIndexStore.swift +++ /dev/null @@ -1,241 +0,0 @@ -import Foundation -import KanaKanjiConverterModuleWithDefaultDictionary - -public struct UserDictionaryIndexBuildResult: Sendable, Equatable { - public var indexedEntryCount: Int - public var skippedEntryCount: Int - public var totalEntryCount: Int -} - -public struct UserDictionaryIndexSummary: Sendable, Equatable { - public var userRevision: Int - public var systemRevision: Int - public var indexedEntryCount: Int - public var skippedEntryCount: Int - public var totalEntryCount: Int - public var updatedAt: Date? -} - -public enum UserDictionaryIndexStatus: Sendable, Equatable { - case notBuilt(entryCount: Int) - case ready(UserDictionaryIndexSummary) - case needsRebuild(currentEntryCount: Int, existing: UserDictionaryIndexSummary?) -} - -public enum UserDictionaryIndexController { - public static func indexDirectoryURL(applicationDirectoryURL: URL) -> URL { - applicationDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) - } - - public static func currentStatus(applicationDirectoryURL: URL) -> UserDictionaryIndexStatus { - Self.status( - directoryURL: Self.indexDirectoryURL(applicationDirectoryURL: applicationDirectoryURL), - currentUserRevision: UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey), - currentSystemRevision: UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey), - entryCount: Self.currentEntries().count - ) - } - - public static func rebuild(applicationDirectoryURL: URL) throws -> UserDictionaryIndexBuildResult { - let entries = Self.currentEntries() - let result = try UserDictionaryIndexStore( - directoryURL: Self.indexDirectoryURL(applicationDirectoryURL: applicationDirectoryURL) - ).rebuild( - entries: entries, - userRevision: UserDefaults.standard.integer(forKey: Config.UserDictionary.revisionKey), - systemRevision: UserDefaults.standard.integer(forKey: Config.SystemUserDictionary.revisionKey) - ) - return .init( - indexedEntryCount: result.indexedEntryCount, - skippedEntryCount: result.skippedEntryCount, - totalEntryCount: entries.count - ) - } - - static func status( - directoryURL: URL, - currentUserRevision: Int, - currentSystemRevision: Int, - entryCount: Int - ) -> UserDictionaryIndexStatus { - let store = UserDictionaryIndexStore(directoryURL: directoryURL) - guard let metadata = store.metadata() else { - return .notBuilt(entryCount: entryCount) - } - let summary = UserDictionaryIndexSummary( - userRevision: metadata.userRevision, - systemRevision: metadata.systemRevision, - indexedEntryCount: metadata.indexedEntryCount, - skippedEntryCount: metadata.skippedEntryCount, - totalEntryCount: metadata.indexedEntryCount + metadata.skippedEntryCount, - updatedAt: Self.metadataModificationDate(directoryURL: directoryURL) - ) - if metadata.userRevision == currentUserRevision, - metadata.systemRevision == currentSystemRevision, - store.hasUsableIndex(for: metadata) { - return .ready(summary) - } - return .needsRebuild(currentEntryCount: entryCount, existing: summary) - } - - private static func currentEntries() -> [DicdataElement] { - let userEntries = Config.UserDictionary().value.items.map { item in - let ruby = item.reading.toKatakana() - return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - let systemEntries = Config.SystemUserDictionary().value.items.map { item in - let ruby = item.reading.toKatakana() - return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - return userEntries + systemEntries - } - - private static func metadataModificationDate(directoryURL: URL) -> Date? { - let metadataURL = directoryURL.appendingPathComponent("metadata.json", isDirectory: false) - let attributes = try? FileManager.default.attributesOfItem(atPath: metadataURL.path) - return attributes?[.modificationDate] as? Date - } -} - -struct UserDictionaryIndexStore { - enum BuildError: Error { - case missingCharIDFile - } - - struct RebuildResult { - var indexedEntryCount: Int - var skippedEntryCount: Int - } - - struct Metadata: Codable, Equatable { - var userRevision: Int - var systemRevision: Int - var indexedEntryCount: Int - var skippedEntryCount: Int - } - - let directoryURL: URL - - private var metadataURL: URL { - directoryURL.appendingPathComponent("metadata.json", isDirectory: false) - } - - func metadata() -> Metadata? { - guard let data = try? Data(contentsOf: metadataURL) else { - return nil - } - return try? JSONDecoder().decode(Metadata.self, from: data) - } - - func hasUsableIndex(for metadata: Metadata) -> Bool { - guard metadata.indexedEntryCount > 0 else { - return true - } - let requiredFileNames = [ - "user.louds", - "user.loudschars2", - "user0.loudstxt3" - ] - return requiredFileNames.allSatisfy { - FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0, isDirectory: false).path) - } - } - - func rebuild(entries: [DicdataElement], userRevision: Int, systemRevision: Int) throws -> RebuildResult { - let fileManager = FileManager.default - let parentURL = directoryURL.deletingLastPathComponent() - try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) - - let temporaryURL = parentURL.appendingPathComponent( - "\(directoryURL.lastPathComponent).building-\(UUID().uuidString)", - isDirectory: true - ) - try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) - - do { - let indexableEntries: [DicdataElement] - if entries.isEmpty { - indexableEntries = [] - } else { - guard let charIDFileURL = Self.defaultCharIDFileURL() else { - throw BuildError.missingCharIDFile - } - let supportedCharacters = try Self.supportedCharacters(from: charIDFileURL) - indexableEntries = entries.filter { - Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) - } - guard !indexableEntries.isEmpty else { - if fileManager.fileExists(atPath: directoryURL.path) { - try fileManager.removeItem(at: directoryURL) - } - try Self.writeMetadata( - .init(userRevision: userRevision, systemRevision: systemRevision, indexedEntryCount: 0, skippedEntryCount: entries.count), - to: temporaryURL - ) - try fileManager.moveItem(at: temporaryURL, to: directoryURL) - return .init(indexedEntryCount: 0, skippedEntryCount: entries.count) - } - try DictionaryBuilder.exportDictionary( - entries: indexableEntries, - to: temporaryURL, - baseName: "user", - shardByFirstCharacter: false, - charIDFileURL: charIDFileURL - ) - } - - if fileManager.fileExists(atPath: directoryURL.path) { - try fileManager.removeItem(at: directoryURL) - } - let result = RebuildResult(indexedEntryCount: indexableEntries.count, skippedEntryCount: entries.count - indexableEntries.count) - try Self.writeMetadata( - .init( - userRevision: userRevision, - systemRevision: systemRevision, - indexedEntryCount: result.indexedEntryCount, - skippedEntryCount: result.skippedEntryCount - ), - to: temporaryURL - ) - try fileManager.moveItem(at: temporaryURL, to: directoryURL) - return result - } catch { - try? fileManager.removeItem(at: temporaryURL) - throw error - } - } - - static func supportedCharacters() throws -> Set { - guard let charIDFileURL = Self.defaultCharIDFileURL() else { - throw BuildError.missingCharIDFile - } - return try Self.supportedCharacters(from: charIDFileURL) - } - - static func canIndex(ruby: String, supportedCharacters: Set) -> Bool { - !ruby.isEmpty && ruby.allSatisfy { supportedCharacters.contains($0) } - } - - private static func defaultCharIDFileURL() -> URL? { - _ = DicdataStore.withDefaultDictionary(preloadDictionary: false) - return (Bundle.allBundles + Bundle.allFrameworks) - .lazy - .compactMap(\.resourceURL) - .map { - $0.appendingPathComponent("Dictionary/louds/charID.chid", isDirectory: false) - } - .first { - FileManager.default.fileExists(atPath: $0.path) - } - } - - private static func supportedCharacters(from charIDFileURL: URL) throws -> Set { - let text = try String(contentsOf: charIDFileURL, encoding: .utf8) - return Set(text) - } - - private static func writeMetadata(_ metadata: Metadata, to directoryURL: URL) throws { - let data = try JSONEncoder().encode(metadata) - try data.write(to: directoryURL.appendingPathComponent("metadata.json", isDirectory: false)) - } -} diff --git a/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift b/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift deleted file mode 100644 index 1465c316..00000000 --- a/Core/Tests/CoreTests/UserDictionaryTests/UserDictionaryIndexStoreTests.swift +++ /dev/null @@ -1,211 +0,0 @@ -@testable import Core -import Foundation -import KanaKanjiConverterModuleWithDefaultDictionary -import Testing - -@Test func dynamicUserDictionaryFilteringKeepsConvertibleEntries() { - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "コーエン")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "コーエンマコーレー")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "アカイコーエン")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエンマコーレー", for: "アカイコーエンマコーレーデス")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "カン", for: "アカン")) -} - -@Test func dynamicUserDictionaryFilteringDropsUnrelatedEntries() { - #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "セイソクレツ", for: "コーエン")) - #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "", for: "コーエン")) - #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コーエン", for: "")) -} - -@Test func rebuildUserDictionaryIndexWritesSearchFiles() throws { - let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) - defer { - try? FileManager.default.removeItem(at: directory) - } - - _ = try UserDictionaryIndexStore(directoryURL: directory).rebuild( - entries: [ - .init(word: "Cohen-Macaulay", ruby: "コーエンマコーレー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), - .init(word: "正則列", ruby: "セイソクレツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ], - userRevision: 12, - systemRevision: 34 - ) - - #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.louds").path)) - #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.loudschars2").path)) - #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("user0.loudstxt3").path)) - #expect(UserDictionaryIndexStore(directoryURL: directory).metadata() == .init( - userRevision: 12, - systemRevision: 34, - indexedEntryCount: 2, - skippedEntryCount: 0 - )) -} - -@Test func userDictionaryIndexReportsSkippedUnsupportedReadings() throws { - let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) - defer { - try? FileManager.default.removeItem(at: directory) - } - - let result = try UserDictionaryIndexStore(directoryURL: directory).rebuild( - entries: [ - .init(word: "unsupported", ruby: "\u{10FFFF}", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ], - userRevision: 56, - systemRevision: 78 - ) - - #expect(result.indexedEntryCount == 0) - #expect(result.skippedEntryCount == 1) - #expect(!FileManager.default.fileExists(atPath: directory.appendingPathComponent("user.louds").path)) - #expect(UserDictionaryIndexStore(directoryURL: directory).metadata() == .init( - userRevision: 56, - systemRevision: 78, - indexedEntryCount: 0, - skippedEntryCount: 1 - )) -} - -@Test func userDictionaryIndexRequiresFilesWhenMetadataReportsIndexedEntries() throws { - let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) - defer { - try? FileManager.default.removeItem(at: directory) - } - - let store = UserDictionaryIndexStore(directoryURL: directory) - let metadata = UserDictionaryIndexStore.Metadata( - userRevision: 90, - systemRevision: 12, - indexedEntryCount: 1, - skippedEntryCount: 0 - ) - - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(metadata) - try data.write(to: directory.appendingPathComponent("metadata.json", isDirectory: false)) - - #expect(!store.hasUsableIndex(for: metadata)) - - for fileName in ["user.louds", "user.loudschars2", "user0.loudstxt3"] { - try Data().write(to: directory.appendingPathComponent(fileName, isDirectory: false)) - } - - #expect(store.hasUsableIndex(for: metadata)) -} - -@Test func userDictionaryIndexStatusReportsReadyAndStaleCaches() throws { - let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("azookey-user-dictionary-index-\(UUID().uuidString)", isDirectory: true) - defer { - try? FileManager.default.removeItem(at: directory) - } - - #expect(UserDictionaryIndexController.status( - directoryURL: directory, - currentUserRevision: 1, - currentSystemRevision: 2, - entryCount: 3 - ) == .notBuilt(entryCount: 3)) - - _ = try UserDictionaryIndexStore(directoryURL: directory).rebuild( - entries: [ - .init(word: "Cohen-Macaulay", ruby: "コーエンマコーレー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ], - userRevision: 1, - systemRevision: 2 - ) - - switch UserDictionaryIndexController.status( - directoryURL: directory, - currentUserRevision: 1, - currentSystemRevision: 2, - entryCount: 1 - ) { - case .ready(let summary): - #expect(summary.indexedEntryCount == 1) - #expect(summary.skippedEntryCount == 0) - default: - Issue.record("Expected a ready user dictionary index") - } - - switch UserDictionaryIndexController.status( - directoryURL: directory, - currentUserRevision: 2, - currentSystemRevision: 2, - entryCount: 4 - ) { - case .needsRebuild(let currentEntryCount, let existing): - #expect(currentEntryCount == 4) - #expect(existing?.indexedEntryCount == 1) - default: - Issue.record("Expected a stale user dictionary index") - } -} - -@Test func userDictionaryRevisionTracksEntryChanges() { - let defaults = UserDefaults.standard - let key = Config.UserDictionary.key - let revisionKey = Config.UserDictionary.revisionKey - let oldData = defaults.data(forKey: key) - let oldRevision = defaults.object(forKey: revisionKey) - defer { - if let oldData { - defaults.set(oldData, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - if let oldRevision { - defaults.set(oldRevision, forKey: revisionKey) - } else { - defaults.removeObject(forKey: revisionKey) - } - } - - defaults.removeObject(forKey: key) - defaults.removeObject(forKey: revisionKey) - - var value = Config.UserDictionary.default - Config.UserDictionary().value = value - #expect(defaults.integer(forKey: revisionKey) == 0) - - value.items[0].hint = "コメント変更" - Config.UserDictionary().value = value - #expect(defaults.integer(forKey: revisionKey) == 1) -} - -@Test func systemUserDictionaryRevisionIgnoresLastUpdateOnlyChanges() { - let defaults = UserDefaults.standard - let key = Config.SystemUserDictionary.key - let revisionKey = Config.SystemUserDictionary.revisionKey - let oldData = defaults.data(forKey: key) - let oldRevision = defaults.object(forKey: revisionKey) - defer { - if let oldData { - defaults.set(oldData, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - if let oldRevision { - defaults.set(oldRevision, forKey: revisionKey) - } else { - defaults.removeObject(forKey: revisionKey) - } - } - - defaults.removeObject(forKey: key) - defaults.removeObject(forKey: revisionKey) - - var value = Config.SystemUserDictionary.default - value.lastUpdate = .now - Config.SystemUserDictionary().value = value - #expect(defaults.integer(forKey: revisionKey) == 0) - - value.items.append(.init(word: "正則列", reading: "せいそくれつ")) - Config.SystemUserDictionary().value = value - #expect(defaults.integer(forKey: revisionKey) == 1) -} From a5600d5a4241bf61f13baf996f96d238530e412c Mon Sep 17 00:00:00 2001 From: kagun <88010417+artin-kagun@users.noreply.github.com> Date: Fri, 8 May 2026 18:05:29 +0900 Subject: [PATCH 3/4] perf: export user dictionary on save --- .../Core/InputUtils/SegmentsManager.swift | 166 +++++++++---- .../CompiledUserDictionaryStore.swift | 218 ++++++++++++++++++ .../CompiledUserDictionaryStoreTests.swift | 92 ++++++++ azooKeyMac/AppDelegate.swift | 30 +++ azooKeyMac/Windows/ConfigWindow.swift | 17 ++ .../Windows/UserDictionaryEditorWindow.swift | 29 +++ 6 files changed, 502 insertions(+), 50 deletions(-) create mode 100644 Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift create mode 100644 Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 098d09da..5ea7ef09 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -36,12 +36,6 @@ public final class SegmentsManager { private var liveConversionEnabled: Bool { Config.LiveConversion().value } - private var userDictionary: Config.UserDictionary.Value { - Config.UserDictionary().value - } - private var systemUserDictionary: Config.SystemUserDictionary.Value { - Config.SystemUserDictionary().value - } private var zenzaiPersonalizationLevel: Config.ZenzaiPersonalizationLevel.Value { Config.ZenzaiPersonalizationLevel().value } @@ -64,6 +58,19 @@ public final class SegmentsManager { private var suggestSelectionIndex: Int? private var backspaceAdjustedPredictionCandidate: PredictionCandidate? private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? + private var didLoadCompiledUserDictionaryState = false + private var compiledUserDictionaryModificationDate: Date? + private var fallbackUserDictionaryEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] = [:] + + private var compiledUserDictionaryDirectoryURL: URL { + CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: self.azooKeyMemoryDir) + } + + private struct DynamicUserDictionaryEntry: Sendable { + var deduplicationKey: String + var ruby: String + var element: DicdataElement + } public struct PredictionCandidate: Sendable, Equatable { public var displayText: String @@ -80,6 +87,99 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } + private func reloadCompiledUserDictionaryIfNeeded() -> Bool { + let modificationDate = CompiledUserDictionaryStore.modificationDate(memoryDirectoryURL: self.azooKeyMemoryDir) + guard !self.didLoadCompiledUserDictionaryState || self.compiledUserDictionaryModificationDate != modificationDate else { + return false + } + self.didLoadCompiledUserDictionaryState = true + self.compiledUserDictionaryModificationDate = modificationDate + let fallbackEntries = CompiledUserDictionaryStore.fallbackEntries(memoryDirectoryURL: self.azooKeyMemoryDir) + .enumerated() + .map { offset, entry in + DynamicUserDictionaryEntry( + deduplicationKey: "fallback:\(offset):\(entry.ruby):\(entry.word)", + ruby: entry.ruby, + element: entry + ) + } + self.fallbackUserDictionaryEntriesByFirstRubyCharacter = Self.entriesByFirstRubyCharacter(fallbackEntries) + return true + } + + private func dynamicFallbackUserDictionary(for queryRuby: String) -> [DicdataElement] { + guard !queryRuby.isEmpty else { + return [] + } + var elements: [DicdataElement] = [] + var seenKeys: Set = [] + for suffixStart in queryRuby.indices { + let suffix = String(queryRuby[suffixStart...]) + guard let firstRubyCharacter = suffix.first else { + continue + } + let entries = self.fallbackUserDictionaryEntriesByFirstRubyCharacter[firstRubyCharacter] ?? [] + for entry in entries where Self.dynamicUserDictionaryEntryRuby(entry.ruby, matchesQuerySuffix: suffix) { + if seenKeys.insert(entry.deduplicationKey).inserted { + elements.append(entry.element) + } + } + } + return elements + } + + private static func entriesByFirstRubyCharacter( + _ entries: [DynamicUserDictionaryEntry] + ) -> [Character: [DynamicUserDictionaryEntry]] { + entries.reduce(into: [Character: [DynamicUserDictionaryEntry]]()) { result, entry in + guard let firstRubyCharacter = entry.ruby.first else { + return + } + result[firstRubyCharacter, default: []].append(entry) + } + } + + static func shouldIncludeDynamicUserDictionaryEntry(ruby entryRuby: String, for queryRuby: String) -> Bool { + guard !entryRuby.isEmpty, !queryRuby.isEmpty else { + return false + } + return queryRuby.indices.contains { suffixStart in + let suffix = String(queryRuby[suffixStart...]) + return Self.dynamicUserDictionaryEntryRuby(entryRuby, matchesQuerySuffix: suffix) + } + } + + private static func dynamicUserDictionaryEntryRuby(_ entryRuby: String, matchesQuerySuffix suffix: String) -> Bool { + entryRuby.hasPrefix(suffix) || suffix.hasPrefix(entryRuby) + } + + private static func makeDynamicShortcuts() -> [DicdataElement] { + [ + ("M/d", -18, DateTemplateLiteral.CalendarType.western), + ("yyyy/MM/dd", -18.1, .western), + ("yyyy-MM-dd", -18.2, .western), + ("M月d日(E)", -18.3, .western), + ("yyyy年M月d日", -18.4, .western), + ("Gyyyy年M月d日", -18.5, .japanese), + ("E曜日", -18.6, .western) + ].flatMap { (format, value: PValue, type) in + [ + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) + ] + } + [ + .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) + ] + } + public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in @@ -185,7 +285,7 @@ public final class SegmentsManager { fullWidthRomanCandidate: true, learningType: Config.Learning().value.learningType, memoryDirectoryURL: self.azooKeyMemoryDir, - sharedContainerURL: self.azooKeyMemoryDir, + sharedContainerURL: self.compiledUserDictionaryDirectoryURL, textReplacer: .withDefaultEmojiDictionary(), specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), @@ -480,50 +580,16 @@ public final class SegmentsManager { self.kanaKanjiConverter.stopComposition() return } - // ユーザ辞書情報の更新 - var userDictionary: [DicdataElement] = userDictionary.items.map { - .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - self.appendDebugMessage("userDictionaryCount: \(userDictionary.count)") - let systemUserDictionary: [DicdataElement] = systemUserDictionary.items.map { - .init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - self.appendDebugMessage("systemUserDictionaryCount: \(systemUserDictionary.count)") - userDictionary.append(contentsOf: consume systemUserDictionary) - - /// 日付・時刻変換を事前に入れておく - let dynamicShortcuts: [DicdataElement] = - [ - ("M/d", -18, DateTemplateLiteral.CalendarType.western), - ("yyyy/MM/dd", -18.1, .western), - ("yyyy-MM-dd", -18.2, .western), - ("M月d日(E)", -18.3, .western), - ("yyyy年M月d日", -18.4, .western), - ("Gyyyy年M月d日", -18.5, .japanese), - ("E曜日", -18.6, .western) - ].flatMap { (format, value: PValue, type) in - [ - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) - ] - } + [ - // 月 - .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - // 年 - .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - // 時刻 - .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) - ] - - self.kanaKanjiConverter.importDynamicUserDictionary(consume userDictionary, shortcuts: dynamicShortcuts) - let prefixComposingText = self.composingText.prefixToCursorPosition() + let shouldReloadUserDictionary = self.reloadCompiledUserDictionaryIfNeeded() + let queryRuby = prefixComposingText.convertTarget.toKatakana() + let userDictionary = self.dynamicFallbackUserDictionary(for: queryRuby) + self.kanaKanjiConverter.updateUserDictionaryURL( + self.compiledUserDictionaryDirectoryURL, + forceReload: shouldReloadUserDictionary + ) + self.kanaKanjiConverter.importDynamicUserDictionary(userDictionary, shortcuts: Self.makeDynamicShortcuts()) + let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) let result = self.kanaKanjiConverter.requestCandidates( prefixComposingText, diff --git a/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift new file mode 100644 index 00000000..f811c66f --- /dev/null +++ b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift @@ -0,0 +1,218 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +public struct CompiledUserDictionaryExportResult: Sendable, Equatable { + public var indexedEntryCount: Int + public var fallbackEntryCount: Int + public var totalEntryCount: Int +} + +public enum CompiledUserDictionaryStore { + public static func directoryURL(memoryDirectoryURL: URL) -> URL { + memoryDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) + } + + public static func exportCurrentDictionaries(memoryDirectoryURL: URL) throws -> CompiledUserDictionaryExportResult { + let entries = Self.currentEntries() + return try UserDictionaryIndexStore( + directoryURL: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) + ).rebuild(entries: entries) + } + + public static func fallbackEntries(memoryDirectoryURL: URL) -> [DicdataElement] { + UserDictionaryIndexStore( + directoryURL: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) + ).fallbackEntries() + } + + public static func modificationDate(memoryDirectoryURL: URL) -> Date? { + let metadataURL = Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) + .appendingPathComponent("metadata.json", isDirectory: false) + let attributes = try? FileManager.default.attributesOfItem(atPath: metadataURL.path) + return attributes?[.modificationDate] as? Date + } + + private static func currentEntries() -> [DicdataElement] { + let userEntries = Config.UserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + let systemEntries = Config.SystemUserDictionary().value.items.map { item in + let ruby = item.reading.toKatakana() + return DicdataElement(word: item.word, ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + return userEntries + systemEntries + } +} + +struct UserDictionaryIndexStore { + enum BuildError: Error { + case missingCharIDFile + } + + struct Metadata: Codable, Equatable { + var indexedEntryCount: Int + var fallbackEntryCount: Int + } + + private struct FallbackEntry: Codable, Equatable { + var word: String + var ruby: String + } + + let directoryURL: URL + + private var metadataURL: URL { + directoryURL.appendingPathComponent("metadata.json", isDirectory: false) + } + + private var fallbackURL: URL { + directoryURL.appendingPathComponent("fallback.json", isDirectory: false) + } + + func metadata() -> Metadata? { + guard let data = try? Data(contentsOf: metadataURL) else { + return nil + } + return try? JSONDecoder().decode(Metadata.self, from: data) + } + + func hasCompiledDictionary() -> Bool { + let requiredFileNames = [ + "user.louds", + "user.loudschars2", + "user0.loudstxt3" + ] + return requiredFileNames.allSatisfy { + FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0, isDirectory: false).path) + } + } + + func fallbackEntries() -> [DicdataElement] { + guard let data = try? Data(contentsOf: fallbackURL), + let entries = try? JSONDecoder().decode([FallbackEntry].self, from: data) else { + return [] + } + return entries.map { + DicdataElement(word: $0.word, ruby: $0.ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + } + } + + @discardableResult + func rebuild(entries: [DicdataElement]) throws -> CompiledUserDictionaryExportResult { + let fileManager = FileManager.default + let parentURL = directoryURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + + let temporaryURL = parentURL.appendingPathComponent( + "\(directoryURL.lastPathComponent).building-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + + do { + let indexableEntries: [DicdataElement] + let fallbackEntries: [DicdataElement] + if entries.isEmpty { + indexableEntries = [] + fallbackEntries = [] + } else { + guard let charIDFileURL = Self.defaultCharIDFileURL() else { + throw BuildError.missingCharIDFile + } + let supportedCharacters = try Self.supportedCharacters(from: charIDFileURL) + indexableEntries = entries.filter { + Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) + } + fallbackEntries = entries.filter { + !Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) + } + if !indexableEntries.isEmpty { + try DictionaryBuilder.exportDictionary( + entries: indexableEntries, + to: temporaryURL, + baseName: "user", + shardByFirstCharacter: false, + charIDFileURL: charIDFileURL + ) + } + } + + try Self.writeFallbackEntries(fallbackEntries, to: temporaryURL) + try Self.writeMetadata( + .init(indexedEntryCount: indexableEntries.count, fallbackEntryCount: fallbackEntries.count), + to: temporaryURL + ) + if fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.removeItem(at: directoryURL) + } + try fileManager.moveItem(at: temporaryURL, to: directoryURL) + return .init( + indexedEntryCount: indexableEntries.count, + fallbackEntryCount: fallbackEntries.count, + totalEntryCount: entries.count + ) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + static func supportedCharacters() throws -> Set { + guard let charIDFileURL = Self.defaultCharIDFileURL() else { + throw BuildError.missingCharIDFile + } + return try Self.supportedCharacters(from: charIDFileURL) + } + + static func canIndex(ruby: String, supportedCharacters: Set) -> Bool { + !ruby.isEmpty && ruby.allSatisfy { supportedCharacters.contains($0) } + } + + private static func defaultCharIDFileURL() -> URL? { + _ = DicdataStore.withDefaultDictionary(preloadDictionary: false) + let fileManager = FileManager.default + var resourceURLs = (Bundle.allBundles + Bundle.allFrameworks).compactMap(\.resourceURL) + + if let mainResourceURL = Bundle.main.resourceURL, + let enumerator = fileManager.enumerator( + at: mainResourceURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + for case let url as URL in enumerator where url.pathExtension == "bundle" { + if let bundle = Bundle(url: url), let resourceURL = bundle.resourceURL { + resourceURLs.append(resourceURL) + } else { + resourceURLs.append(url.appendingPathComponent("Contents/Resources", isDirectory: true)) + } + } + } + + return resourceURLs.lazy + .map { + $0.appendingPathComponent("Dictionary/louds/charID.chid", isDirectory: false) + } + .first { + fileManager.fileExists(atPath: $0.path) + } + } + + private static func supportedCharacters(from charIDFileURL: URL) throws -> Set { + let text = try String(contentsOf: charIDFileURL, encoding: .utf8) + return Set(text) + } + + private static func writeFallbackEntries(_ entries: [DicdataElement], to directoryURL: URL) throws { + let fallbackEntries = entries.map { + FallbackEntry(word: $0.word, ruby: $0.ruby) + } + let data = try JSONEncoder().encode(fallbackEntries) + try data.write(to: directoryURL.appendingPathComponent("fallback.json", isDirectory: false)) + } + + private static func writeMetadata(_ metadata: Metadata, to directoryURL: URL) throws { + let data = try JSONEncoder().encode(metadata) + try data.write(to: directoryURL.appendingPathComponent("metadata.json", isDirectory: false)) + } +} diff --git a/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift new file mode 100644 index 00000000..1d078b4d --- /dev/null +++ b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift @@ -0,0 +1,92 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@Test func rebuildCompiledUserDictionaryWritesSearchFiles() throws { + let directoryURL = try makeTemporaryDirectoryURL() + let store = UserDictionaryIndexStore(directoryURL: directoryURL) + let entries = [ + DicdataElement(word: "テスト単語", ruby: "テスト", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), + DicdataElement(word: "辞書単語", ruby: "ジショ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ] + + let result = try store.rebuild(entries: entries) + + #expect(result.indexedEntryCount == 2) + #expect(result.fallbackEntryCount == 0) + #expect(result.totalEntryCount == 2) + #expect(store.hasCompiledDictionary()) + #expect(FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("metadata.json").path)) + #expect(store.metadata() == .init(indexedEntryCount: 2, fallbackEntryCount: 0)) + #expect(store.fallbackEntries().isEmpty) +} + +@Test func rebuildCompiledUserDictionaryStoresUnsupportedReadingsAsFallback() throws { + let directoryURL = try makeTemporaryDirectoryURL() + let store = UserDictionaryIndexStore(directoryURL: directoryURL) + let entries = [ + DicdataElement(word: "外字単語", ruby: "\u{10FFFF}", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ] + + let result = try store.rebuild(entries: entries) + let fallbackEntries = store.fallbackEntries() + + #expect(result.indexedEntryCount == 0) + #expect(result.fallbackEntryCount == 1) + #expect(result.totalEntryCount == 1) + #expect(!store.hasCompiledDictionary()) + #expect(fallbackEntries.map(\.word) == ["外字単語"]) + #expect(fallbackEntries.map(\.ruby) == ["\u{10FFFF}"]) +} + +@Test func userDictionaryIndexabilityUsesDefaultCharIDCharacters() throws { + let supportedCharacters = try UserDictionaryIndexStore.supportedCharacters() + + #expect(UserDictionaryIndexStore.canIndex(ruby: "テスト", supportedCharacters: supportedCharacters)) + #expect(!UserDictionaryIndexStore.canIndex(ruby: "", supportedCharacters: supportedCharacters)) + #expect(!UserDictionaryIndexStore.canIndex(ruby: "\u{10FFFF}", supportedCharacters: supportedCharacters)) +} + +@Test func fallbackDynamicUserDictionaryFilteringKeepsRelevantReadings() { + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コウエン", for: "コウ")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コウ", for: "コウエン")) + #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "エン", for: "コウエン")) + #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "スウガク", for: "カガク")) +} + +@MainActor +@Test func compiledUserDictionaryCandidatesUseExportDirectory() throws { + let memoryURL = try makeTemporaryDirectoryURL() + let dictionaryURL = CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: memoryURL) + let store = UserDictionaryIndexStore(directoryURL: dictionaryURL) + try store.rebuild(entries: [ + DicdataElement(word: "コーシー", ruby: "コーシー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), + DicdataElement(word: "Cauchy", ruby: "コーシー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ]) + + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: memoryURL, + containerURL: nil, + context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition("こーしー", inputStyle: .direct) + manager.requestSetCandidateWindowState(visible: true) + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _), .composing(let candidates, _): + let candidateTexts = candidates.map(\.text) + #expect(candidateTexts.contains("コーシー")) + #expect(candidateTexts.contains("Cauchy")) + case .hidden: + Issue.record("candidate window is hidden") + } +} + +private func makeTemporaryDirectoryURL() throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("CompiledUserDictionaryStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL +} diff --git a/azooKeyMac/AppDelegate.swift b/azooKeyMac/AppDelegate.swift index 1a7fd659..860ddff5 100644 --- a/azooKeyMac/AppDelegate.swift +++ b/azooKeyMac/AppDelegate.swift @@ -35,6 +35,35 @@ class AppDelegate: NSObject, NSApplicationDelegate { var userDictionaryEditorWindowController: NSWindowController? var kanaKanjiConverter = KanaKanjiConverter.withDefaultDictionary() + private var userDictionaryMemoryDirectoryURL: URL { + let applicationSupportDirectoryURL: URL + if #available(macOS 13, *) { + applicationSupportDirectoryURL = URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + } else { + applicationSupportDirectoryURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("azooKey", isDirectory: true) + } + return applicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) + } + + private func exportInitialUserDictionaryIfNeeded() { + let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL + Task.detached(priority: .utility) { + guard CompiledUserDictionaryStore.modificationDate(memoryDirectoryURL: memoryDirectoryURL) == nil else { + return + } + do { + _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + } catch { + print("Failed to export compiled user dictionary: \(error)") + } + } + } + private static func buildSwiftUIWindow( _ view: some View, contentRect: NSRect = NSRect(x: 0, y: 0, width: 400, height: 300), @@ -89,6 +118,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { Task { await Config.OpenAiApiKey.loadFromKeychain() } + self.exportInitialUserDictionaryIfNeeded() // Check if mainMenu exists, or create it if NSApp.mainMenu == nil { diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index d9bcd4a3..bf90fdd8 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -74,6 +74,10 @@ struct ConfigWindow: View { } } + private var userDictionaryMemoryDirectoryURL: URL { + self.azooKeyApplicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) + } + private var debugTypoCorrectionModelDirectoryURL: URL { DebugTypoCorrectionWeights.modelDirectoryURL( azooKeyApplicationSupportDirectoryURL: self.azooKeyApplicationSupportDirectoryURL @@ -148,6 +152,17 @@ struct ConfigWindow: View { } } + private func exportUserDictionary() { + let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL + Task.detached(priority: .utility) { + do { + _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + } catch { + print("Failed to export compiled user dictionary: \(error)") + } + } + } + private func getErrorMessage(for error: OpenAIError) -> String { switch error { case .invalidURL: @@ -429,6 +444,7 @@ struct ConfigWindow: View { } self.systemUserDictionary.value.lastUpdate = .now self.systemUserDictionaryUpdateMessage = .successfulUpdate + self.exportUserDictionary() } catch { self.systemUserDictionaryUpdateMessage = .error(error) } @@ -437,6 +453,7 @@ struct ConfigWindow: View { self.systemUserDictionary.value.lastUpdate = nil self.systemUserDictionary.value.items = [] self.systemUserDictionaryUpdateMessage = nil + self.exportUserDictionary() } } } label: { diff --git a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift index cddbc6d0..b681eb2e 100644 --- a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift +++ b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift @@ -50,6 +50,32 @@ struct UserDictionaryEditorWindow: View { self.$userDictionary.wrappedValue = value } + private var userDictionaryMemoryDirectoryURL: URL { + let applicationSupportDirectoryURL: URL + if #available(macOS 13, *) { + applicationSupportDirectoryURL = URL.applicationSupportDirectory + .appending(path: "azooKey", directoryHint: .isDirectory) + } else { + applicationSupportDirectoryURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("azooKey", isDirectory: true) + } + return applicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) + } + + private func exportUserDictionary() { + let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL + Task.detached(priority: .utility) { + do { + _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + } catch { + print("Failed to export compiled user dictionary: \(error)") + } + } + } + var body: some View { VStack { Text("ユーザ辞書の設定") @@ -81,6 +107,7 @@ struct UserDictionaryEditorWindow: View { Spacer() Button("完了", systemImage: "checkmark") { self.editTargetID = nil + self.exportUserDictionary() } Spacer() } @@ -107,6 +134,7 @@ struct UserDictionaryEditorWindow: View { value.items.append(undoItem) } self.undoItem = nil + self.exportUserDictionary() } } Spacer() @@ -130,6 +158,7 @@ struct UserDictionaryEditorWindow: View { value.items.remove(at: itemIndex) } } + self.exportUserDictionary() } .buttonStyle(.bordered) .labelStyle(.iconOnly) From 829f1beb52ff1d423a71670cd4eaae0bfd55a959 Mon Sep 17 00:00:00 2001 From: kagun <88010417+artin-kagun@users.noreply.github.com> Date: Mon, 11 May 2026 15:34:57 +0900 Subject: [PATCH 4/4] Refine compiled user dictionary export on save --- .../Core/InputUtils/SegmentsManager.swift | 149 ++++-------------- .../CompiledUserDictionaryStore.swift | 145 +++-------------- .../CompiledUserDictionaryStoreTests.swift | 60 +++---- azooKeyMac/AppDelegate.swift | 28 +++- azooKeyMac/Windows/ConfigWindow.swift | 13 +- .../Windows/UserDictionaryEditorWindow.swift | 24 +-- 6 files changed, 105 insertions(+), 314 deletions(-) diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 5ea7ef09..b2a97a58 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -58,19 +58,6 @@ public final class SegmentsManager { private var suggestSelectionIndex: Int? private var backspaceAdjustedPredictionCandidate: PredictionCandidate? private var backspaceTypoCorrectionLock: BackspaceTypoCorrectionLock? - private var didLoadCompiledUserDictionaryState = false - private var compiledUserDictionaryModificationDate: Date? - private var fallbackUserDictionaryEntriesByFirstRubyCharacter: [Character: [DynamicUserDictionaryEntry]] = [:] - - private var compiledUserDictionaryDirectoryURL: URL { - CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: self.azooKeyMemoryDir) - } - - private struct DynamicUserDictionaryEntry: Sendable { - var deduplicationKey: String - var ruby: String - var element: DicdataElement - } public struct PredictionCandidate: Sendable, Equatable { public var displayText: String @@ -87,99 +74,6 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } - private func reloadCompiledUserDictionaryIfNeeded() -> Bool { - let modificationDate = CompiledUserDictionaryStore.modificationDate(memoryDirectoryURL: self.azooKeyMemoryDir) - guard !self.didLoadCompiledUserDictionaryState || self.compiledUserDictionaryModificationDate != modificationDate else { - return false - } - self.didLoadCompiledUserDictionaryState = true - self.compiledUserDictionaryModificationDate = modificationDate - let fallbackEntries = CompiledUserDictionaryStore.fallbackEntries(memoryDirectoryURL: self.azooKeyMemoryDir) - .enumerated() - .map { offset, entry in - DynamicUserDictionaryEntry( - deduplicationKey: "fallback:\(offset):\(entry.ruby):\(entry.word)", - ruby: entry.ruby, - element: entry - ) - } - self.fallbackUserDictionaryEntriesByFirstRubyCharacter = Self.entriesByFirstRubyCharacter(fallbackEntries) - return true - } - - private func dynamicFallbackUserDictionary(for queryRuby: String) -> [DicdataElement] { - guard !queryRuby.isEmpty else { - return [] - } - var elements: [DicdataElement] = [] - var seenKeys: Set = [] - for suffixStart in queryRuby.indices { - let suffix = String(queryRuby[suffixStart...]) - guard let firstRubyCharacter = suffix.first else { - continue - } - let entries = self.fallbackUserDictionaryEntriesByFirstRubyCharacter[firstRubyCharacter] ?? [] - for entry in entries where Self.dynamicUserDictionaryEntryRuby(entry.ruby, matchesQuerySuffix: suffix) { - if seenKeys.insert(entry.deduplicationKey).inserted { - elements.append(entry.element) - } - } - } - return elements - } - - private static func entriesByFirstRubyCharacter( - _ entries: [DynamicUserDictionaryEntry] - ) -> [Character: [DynamicUserDictionaryEntry]] { - entries.reduce(into: [Character: [DynamicUserDictionaryEntry]]()) { result, entry in - guard let firstRubyCharacter = entry.ruby.first else { - return - } - result[firstRubyCharacter, default: []].append(entry) - } - } - - static func shouldIncludeDynamicUserDictionaryEntry(ruby entryRuby: String, for queryRuby: String) -> Bool { - guard !entryRuby.isEmpty, !queryRuby.isEmpty else { - return false - } - return queryRuby.indices.contains { suffixStart in - let suffix = String(queryRuby[suffixStart...]) - return Self.dynamicUserDictionaryEntryRuby(entryRuby, matchesQuerySuffix: suffix) - } - } - - private static func dynamicUserDictionaryEntryRuby(_ entryRuby: String, matchesQuerySuffix suffix: String) -> Bool { - entryRuby.hasPrefix(suffix) || suffix.hasPrefix(entryRuby) - } - - private static func makeDynamicShortcuts() -> [DicdataElement] { - [ - ("M/d", -18, DateTemplateLiteral.CalendarType.western), - ("yyyy/MM/dd", -18.1, .western), - ("yyyy-MM-dd", -18.2, .western), - ("M月d日(E)", -18.3, .western), - ("yyyy年M月d日", -18.4, .western), - ("Gyyyy年M月d日", -18.5, .japanese), - ("E曜日", -18.6, .western) - ].flatMap { (format, value: PValue, type) in - [ - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), - .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) - ] - } + [ - .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), - .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), - .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) - ] - } - public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in @@ -285,7 +179,7 @@ public final class SegmentsManager { fullWidthRomanCandidate: true, learningType: Config.Learning().value.learningType, memoryDirectoryURL: self.azooKeyMemoryDir, - sharedContainerURL: self.compiledUserDictionaryDirectoryURL, + sharedContainerURL: CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: self.azooKeyMemoryDir), textReplacer: .withDefaultEmojiDictionary(), specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), @@ -580,16 +474,39 @@ public final class SegmentsManager { self.kanaKanjiConverter.stopComposition() return } - let prefixComposingText = self.composingText.prefixToCursorPosition() - let shouldReloadUserDictionary = self.reloadCompiledUserDictionaryIfNeeded() - let queryRuby = prefixComposingText.convertTarget.toKatakana() - let userDictionary = self.dynamicFallbackUserDictionary(for: queryRuby) - self.kanaKanjiConverter.updateUserDictionaryURL( - self.compiledUserDictionaryDirectoryURL, - forceReload: shouldReloadUserDictionary - ) - self.kanaKanjiConverter.importDynamicUserDictionary(userDictionary, shortcuts: Self.makeDynamicShortcuts()) + /// 日付・時刻変換を事前に入れておく + let dynamicShortcuts: [DicdataElement] = + [ + ("M/d", -18, DateTemplateLiteral.CalendarType.western), + ("yyyy/MM/dd", -18.1, .western), + ("yyyy-MM-dd", -18.2, .western), + ("M月d日(E)", -18.3, .western), + ("yyyy年M月d日", -18.4, .western), + ("Gyyyy年M月d日", -18.5, .japanese), + ("E曜日", -18.6, .western) + ].flatMap { (format, value: PValue, type) in + [ + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "1", deltaUnit: 60 * 60 * 24).export(), ruby: "アシタ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), + .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "2", deltaUnit: 60 * 60 * 24).export(), ruby: "アサッテ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value) + ] + } + [ + // 月 + .init(word: DateTemplateLiteral(format: "MM月", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コンゲツ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + // 年 + .init(word: DateTemplateLiteral(format: "yyyy年", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "Gyyyy年", type: .japanese, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "コトシ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + // 時刻 + .init(word: DateTemplateLiteral(format: "HH:mm", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18), + .init(word: DateTemplateLiteral(format: "HH時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.1), + .init(word: DateTemplateLiteral(format: "aK時mm分", type: .western, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "イマ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18.2) + ] + self.kanaKanjiConverter.importDynamicUserDictionary([], shortcuts: dynamicShortcuts) + + let prefixComposingText = self.composingText.prefixToCursorPosition() let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) let result = self.kanaKanjiConverter.requestCandidates( prefixComposingText, diff --git a/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift index f811c66f..de4c40c0 100644 --- a/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift +++ b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift @@ -1,35 +1,24 @@ import Foundation import KanaKanjiConverterModuleWithDefaultDictionary -public struct CompiledUserDictionaryExportResult: Sendable, Equatable { - public var indexedEntryCount: Int - public var fallbackEntryCount: Int - public var totalEntryCount: Int -} - public enum CompiledUserDictionaryStore { - public static func directoryURL(memoryDirectoryURL: URL) -> URL { - memoryDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) + enum BuildError: Error { + case missingCharIDFile } - public static func exportCurrentDictionaries(memoryDirectoryURL: URL) throws -> CompiledUserDictionaryExportResult { - let entries = Self.currentEntries() - return try UserDictionaryIndexStore( - directoryURL: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) - ).rebuild(entries: entries) + public static func directoryURL(memoryDirectoryURL: URL) -> URL { + memoryDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) } - public static func fallbackEntries(memoryDirectoryURL: URL) -> [DicdataElement] { - UserDictionaryIndexStore( + public static func exportCurrentDictionaries(memoryDirectoryURL: URL) throws { + try Self.rebuild( + entries: Self.currentEntries(), directoryURL: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) - ).fallbackEntries() + ) } - public static func modificationDate(memoryDirectoryURL: URL) -> Date? { - let metadataURL = Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) - .appendingPathComponent("metadata.json", isDirectory: false) - let attributes = try? FileManager.default.attributesOfItem(atPath: metadataURL.path) - return attributes?[.modificationDate] as? Date + public static func hasExportedDictionary(memoryDirectoryURL: URL) -> Bool { + Self.hasCompiledDictionary(at: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL)) } private static func currentEntries() -> [DicdataElement] { @@ -43,63 +32,14 @@ public enum CompiledUserDictionaryStore { } return userEntries + systemEntries } -} - -struct UserDictionaryIndexStore { - enum BuildError: Error { - case missingCharIDFile - } - - struct Metadata: Codable, Equatable { - var indexedEntryCount: Int - var fallbackEntryCount: Int - } - - private struct FallbackEntry: Codable, Equatable { - var word: String - var ruby: String - } - - let directoryURL: URL - - private var metadataURL: URL { - directoryURL.appendingPathComponent("metadata.json", isDirectory: false) - } - private var fallbackURL: URL { - directoryURL.appendingPathComponent("fallback.json", isDirectory: false) - } - - func metadata() -> Metadata? { - guard let data = try? Data(contentsOf: metadataURL) else { - return nil - } - return try? JSONDecoder().decode(Metadata.self, from: data) - } - - func hasCompiledDictionary() -> Bool { - let requiredFileNames = [ - "user.louds", - "user.loudschars2", - "user0.loudstxt3" - ] - return requiredFileNames.allSatisfy { + static func hasCompiledDictionary(at directoryURL: URL) -> Bool { + ["user.louds", "user.loudschars2", "user0.loudstxt3"].allSatisfy { FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0, isDirectory: false).path) } } - func fallbackEntries() -> [DicdataElement] { - guard let data = try? Data(contentsOf: fallbackURL), - let entries = try? JSONDecoder().decode([FallbackEntry].self, from: data) else { - return [] - } - return entries.map { - DicdataElement(word: $0.word, ruby: $0.ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - } - } - - @discardableResult - func rebuild(entries: [DicdataElement]) throws -> CompiledUserDictionaryExportResult { + static func rebuild(entries: [DicdataElement], directoryURL: URL, charIDFileURL: URL? = nil) throws { let fileManager = FileManager.default let parentURL = directoryURL.deletingLastPathComponent() try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) @@ -111,21 +51,13 @@ struct UserDictionaryIndexStore { try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) do { - let indexableEntries: [DicdataElement] - let fallbackEntries: [DicdataElement] - if entries.isEmpty { - indexableEntries = [] - fallbackEntries = [] - } else { - guard let charIDFileURL = Self.defaultCharIDFileURL() else { + if !entries.isEmpty { + guard let charIDFileURL = charIDFileURL ?? Self.defaultCharIDFileURL() else { throw BuildError.missingCharIDFile } - let supportedCharacters = try Self.supportedCharacters(from: charIDFileURL) - indexableEntries = entries.filter { - Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) - } - fallbackEntries = entries.filter { - !Self.canIndex(ruby: $0.ruby, supportedCharacters: supportedCharacters) + let supportedCharacters = Set(try String(contentsOf: charIDFileURL, encoding: .utf8)) + let indexableEntries = entries.filter { + !$0.ruby.isEmpty && $0.ruby.allSatisfy(supportedCharacters.contains) } if !indexableEntries.isEmpty { try DictionaryBuilder.exportDictionary( @@ -138,38 +70,17 @@ struct UserDictionaryIndexStore { } } - try Self.writeFallbackEntries(fallbackEntries, to: temporaryURL) - try Self.writeMetadata( - .init(indexedEntryCount: indexableEntries.count, fallbackEntryCount: fallbackEntries.count), - to: temporaryURL - ) if fileManager.fileExists(atPath: directoryURL.path) { try fileManager.removeItem(at: directoryURL) } try fileManager.moveItem(at: temporaryURL, to: directoryURL) - return .init( - indexedEntryCount: indexableEntries.count, - fallbackEntryCount: fallbackEntries.count, - totalEntryCount: entries.count - ) } catch { try? fileManager.removeItem(at: temporaryURL) throw error } } - static func supportedCharacters() throws -> Set { - guard let charIDFileURL = Self.defaultCharIDFileURL() else { - throw BuildError.missingCharIDFile - } - return try Self.supportedCharacters(from: charIDFileURL) - } - - static func canIndex(ruby: String, supportedCharacters: Set) -> Bool { - !ruby.isEmpty && ruby.allSatisfy { supportedCharacters.contains($0) } - } - - private static func defaultCharIDFileURL() -> URL? { + static func defaultCharIDFileURL() -> URL? { _ = DicdataStore.withDefaultDictionary(preloadDictionary: false) let fileManager = FileManager.default var resourceURLs = (Bundle.allBundles + Bundle.allFrameworks).compactMap(\.resourceURL) @@ -197,22 +108,4 @@ struct UserDictionaryIndexStore { fileManager.fileExists(atPath: $0.path) } } - - private static func supportedCharacters(from charIDFileURL: URL) throws -> Set { - let text = try String(contentsOf: charIDFileURL, encoding: .utf8) - return Set(text) - } - - private static func writeFallbackEntries(_ entries: [DicdataElement], to directoryURL: URL) throws { - let fallbackEntries = entries.map { - FallbackEntry(word: $0.word, ruby: $0.ruby) - } - let data = try JSONEncoder().encode(fallbackEntries) - try data.write(to: directoryURL.appendingPathComponent("fallback.json", isDirectory: false)) - } - - private static func writeMetadata(_ metadata: Metadata, to directoryURL: URL) throws { - let data = try JSONEncoder().encode(metadata) - try data.write(to: directoryURL.appendingPathComponent("metadata.json", isDirectory: false)) - } } diff --git a/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift index 1d078b4d..9cab0127 100644 --- a/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift +++ b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift @@ -5,65 +5,44 @@ import Testing @Test func rebuildCompiledUserDictionaryWritesSearchFiles() throws { let directoryURL = try makeTemporaryDirectoryURL() - let store = UserDictionaryIndexStore(directoryURL: directoryURL) + let charIDFileURL = try makeTemporaryCharIDFileURL(characters: "\0テストジショ") let entries = [ DicdataElement(word: "テスト単語", ruby: "テスト", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), DicdataElement(word: "辞書単語", ruby: "ジショ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) ] - let result = try store.rebuild(entries: entries) + try CompiledUserDictionaryStore.rebuild(entries: entries, directoryURL: directoryURL, charIDFileURL: charIDFileURL) - #expect(result.indexedEntryCount == 2) - #expect(result.fallbackEntryCount == 0) - #expect(result.totalEntryCount == 2) - #expect(store.hasCompiledDictionary()) - #expect(FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("metadata.json").path)) - #expect(store.metadata() == .init(indexedEntryCount: 2, fallbackEntryCount: 0)) - #expect(store.fallbackEntries().isEmpty) + #expect(CompiledUserDictionaryStore.hasCompiledDictionary(at: directoryURL)) + #expect(!FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("metadata.json").path)) + #expect(!FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("fallback.json").path)) } -@Test func rebuildCompiledUserDictionaryStoresUnsupportedReadingsAsFallback() throws { +@Test func rebuildCompiledUserDictionarySkipsUnsupportedReadings() throws { let directoryURL = try makeTemporaryDirectoryURL() - let store = UserDictionaryIndexStore(directoryURL: directoryURL) + let charIDFileURL = try makeTemporaryCharIDFileURL(characters: "\0テスト") let entries = [ DicdataElement(word: "外字単語", ruby: "\u{10FFFF}", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) ] - let result = try store.rebuild(entries: entries) - let fallbackEntries = store.fallbackEntries() + try CompiledUserDictionaryStore.rebuild(entries: entries, directoryURL: directoryURL, charIDFileURL: charIDFileURL) - #expect(result.indexedEntryCount == 0) - #expect(result.fallbackEntryCount == 1) - #expect(result.totalEntryCount == 1) - #expect(!store.hasCompiledDictionary()) - #expect(fallbackEntries.map(\.word) == ["外字単語"]) - #expect(fallbackEntries.map(\.ruby) == ["\u{10FFFF}"]) -} - -@Test func userDictionaryIndexabilityUsesDefaultCharIDCharacters() throws { - let supportedCharacters = try UserDictionaryIndexStore.supportedCharacters() - - #expect(UserDictionaryIndexStore.canIndex(ruby: "テスト", supportedCharacters: supportedCharacters)) - #expect(!UserDictionaryIndexStore.canIndex(ruby: "", supportedCharacters: supportedCharacters)) - #expect(!UserDictionaryIndexStore.canIndex(ruby: "\u{10FFFF}", supportedCharacters: supportedCharacters)) -} - -@Test func fallbackDynamicUserDictionaryFilteringKeepsRelevantReadings() { - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コウエン", for: "コウ")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "コウ", for: "コウエン")) - #expect(SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "エン", for: "コウエン")) - #expect(!SegmentsManager.shouldIncludeDynamicUserDictionaryEntry(ruby: "スウガク", for: "カガク")) + #expect(!CompiledUserDictionaryStore.hasCompiledDictionary(at: directoryURL)) + #expect(!FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("metadata.json").path)) + #expect(!FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("fallback.json").path)) } @MainActor @Test func compiledUserDictionaryCandidatesUseExportDirectory() throws { + guard CompiledUserDictionaryStore.defaultCharIDFileURL() != nil else { + return + } let memoryURL = try makeTemporaryDirectoryURL() let dictionaryURL = CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: memoryURL) - let store = UserDictionaryIndexStore(directoryURL: dictionaryURL) - try store.rebuild(entries: [ + try CompiledUserDictionaryStore.rebuild(entries: [ DicdataElement(word: "コーシー", ruby: "コーシー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5), DicdataElement(word: "Cauchy", ruby: "コーシー", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) - ]) + ], directoryURL: dictionaryURL) let manager = SegmentsManager( kanaKanjiConverter: .withDefaultDictionary(), @@ -90,3 +69,10 @@ private func makeTemporaryDirectoryURL() throws -> URL { try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) return directoryURL } + +private func makeTemporaryCharIDFileURL(characters: String) throws -> URL { + let directoryURL = try makeTemporaryDirectoryURL() + let fileURL = directoryURL.appendingPathComponent("charID.chid", isDirectory: false) + try characters.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL +} diff --git a/azooKeyMac/AppDelegate.swift b/azooKeyMac/AppDelegate.swift index 860ddff5..3063eabe 100644 --- a/azooKeyMac/AppDelegate.swift +++ b/azooKeyMac/AppDelegate.swift @@ -53,17 +53,41 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func exportInitialUserDictionaryIfNeeded() { let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL Task.detached(priority: .utility) { - guard CompiledUserDictionaryStore.modificationDate(memoryDirectoryURL: memoryDirectoryURL) == nil else { + guard !CompiledUserDictionaryStore.hasExportedDictionary(memoryDirectoryURL: memoryDirectoryURL) else { return } do { - _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + await MainActor.run { + self.reloadUserDictionary(memoryDirectoryURL: memoryDirectoryURL) + } } catch { print("Failed to export compiled user dictionary: \(error)") } } } + func exportUserDictionaryAndReloadConverter() { + let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL + Task.detached(priority: .utility) { + do { + try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) + await MainActor.run { + self.reloadUserDictionary(memoryDirectoryURL: memoryDirectoryURL) + } + } catch { + print("Failed to export compiled user dictionary: \(error)") + } + } + } + + private func reloadUserDictionary(memoryDirectoryURL: URL) { + self.kanaKanjiConverter.updateUserDictionaryURL( + CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: memoryDirectoryURL), + forceReload: true + ) + } + private static func buildSwiftUIWindow( _ view: some View, contentRect: NSRect = NSRect(x: 0, y: 0, width: 400, height: 300), diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index bf90fdd8..3dfd884f 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -74,10 +74,6 @@ struct ConfigWindow: View { } } - private var userDictionaryMemoryDirectoryURL: URL { - self.azooKeyApplicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) - } - private var debugTypoCorrectionModelDirectoryURL: URL { DebugTypoCorrectionWeights.modelDirectoryURL( azooKeyApplicationSupportDirectoryURL: self.azooKeyApplicationSupportDirectoryURL @@ -153,13 +149,8 @@ struct ConfigWindow: View { } private func exportUserDictionary() { - let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL - Task.detached(priority: .utility) { - do { - _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) - } catch { - print("Failed to export compiled user dictionary: \(error)") - } + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.exportUserDictionaryAndReloadConverter() } } diff --git a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift index b681eb2e..29b84d5f 100644 --- a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift +++ b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift @@ -50,29 +50,9 @@ struct UserDictionaryEditorWindow: View { self.$userDictionary.wrappedValue = value } - private var userDictionaryMemoryDirectoryURL: URL { - let applicationSupportDirectoryURL: URL - if #available(macOS 13, *) { - applicationSupportDirectoryURL = URL.applicationSupportDirectory - .appending(path: "azooKey", directoryHint: .isDirectory) - } else { - applicationSupportDirectoryURL = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first! - .appendingPathComponent("azooKey", isDirectory: true) - } - return applicationSupportDirectoryURL.appendingPathComponent("memory", isDirectory: true) - } - private func exportUserDictionary() { - let memoryDirectoryURL = self.userDictionaryMemoryDirectoryURL - Task.detached(priority: .utility) { - do { - _ = try CompiledUserDictionaryStore.exportCurrentDictionaries(memoryDirectoryURL: memoryDirectoryURL) - } catch { - print("Failed to export compiled user dictionary: \(error)") - } + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.exportUserDictionaryAndReloadConverter() } }