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 22ba337e..02e60e71 100644 --- a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift +++ b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift @@ -33,6 +33,32 @@ struct UserDictionaryEditorWindow: View { self.userDictionary.value.items.count >= 50 } + 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("ユーザ辞書の設定") @@ -64,6 +90,7 @@ struct UserDictionaryEditorWindow: View { Spacer() Button("完了", systemImage: "checkmark") { self.editTargetID = nil + self.exportUserDictionary() } Spacer() } @@ -86,6 +113,7 @@ struct UserDictionaryEditorWindow: View { Button("元に戻す", systemImage: "arrow.uturn.backward") { self.userDictionary.value.items.append(undoItem) self.undoItem = nil + self.exportUserDictionary() } } Spacer() @@ -108,6 +136,7 @@ struct UserDictionaryEditorWindow: View { }) { self.undoItem = self.userDictionary.value.items[itemIndex] self.userDictionary.value.items.remove(at: itemIndex) + self.exportUserDictionary() } } .buttonStyle(.bordered)