diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 098d09da..b2a97a58 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 } @@ -185,7 +179,7 @@ public final class SegmentsManager { fullWidthRomanCandidate: true, learningType: Config.Learning().value.learningType, memoryDirectoryURL: self.azooKeyMemoryDir, - sharedContainerURL: self.azooKeyMemoryDir, + sharedContainerURL: CompiledUserDictionaryStore.directoryURL(memoryDirectoryURL: self.azooKeyMemoryDir), textReplacer: .withDefaultEmojiDictionary(), specialCandidateProviders: KanaKanjiConverter.defaultSpecialCandidateProviders, zenzaiMode: self.zenzaiMode(leftSideContext: leftSideContext, requestRichCandidates: requestRichCandidates), @@ -480,17 +474,6 @@ 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] = [ @@ -521,7 +504,7 @@ public final class SegmentsManager { .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) + self.kanaKanjiConverter.importDynamicUserDictionary([], shortcuts: dynamicShortcuts) let prefixComposingText = self.composingText.prefixToCursorPosition() let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30) diff --git a/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift new file mode 100644 index 00000000..de4c40c0 --- /dev/null +++ b/Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift @@ -0,0 +1,111 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +public enum CompiledUserDictionaryStore { + enum BuildError: Error { + case missingCharIDFile + } + + public static func directoryURL(memoryDirectoryURL: URL) -> URL { + memoryDirectoryURL.appendingPathComponent("UserDictionary", isDirectory: true) + } + + public static func exportCurrentDictionaries(memoryDirectoryURL: URL) throws { + try Self.rebuild( + entries: Self.currentEntries(), + directoryURL: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL) + ) + } + + public static func hasExportedDictionary(memoryDirectoryURL: URL) -> Bool { + Self.hasCompiledDictionary(at: Self.directoryURL(memoryDirectoryURL: memoryDirectoryURL)) + } + + 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 + } + + static func hasCompiledDictionary(at directoryURL: URL) -> Bool { + ["user.louds", "user.loudschars2", "user0.loudstxt3"].allSatisfy { + FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0, isDirectory: false).path) + } + } + + 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) + + let temporaryURL = parentURL.appendingPathComponent( + "\(directoryURL.lastPathComponent).building-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: temporaryURL, withIntermediateDirectories: true) + + do { + if !entries.isEmpty { + guard let charIDFileURL = charIDFileURL ?? Self.defaultCharIDFileURL() else { + throw BuildError.missingCharIDFile + } + 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( + entries: indexableEntries, + to: temporaryURL, + baseName: "user", + shardByFirstCharacter: false, + charIDFileURL: charIDFileURL + ) + } + } + + if fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.removeItem(at: directoryURL) + } + try fileManager.moveItem(at: temporaryURL, to: directoryURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + } + + 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) + } + } +} diff --git a/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift new file mode 100644 index 00000000..9cab0127 --- /dev/null +++ b/Core/Tests/CoreTests/UserDictionaryTests/CompiledUserDictionaryStoreTests.swift @@ -0,0 +1,78 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@Test func rebuildCompiledUserDictionaryWritesSearchFiles() throws { + let directoryURL = try makeTemporaryDirectoryURL() + 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) + ] + + try CompiledUserDictionaryStore.rebuild(entries: entries, directoryURL: directoryURL, charIDFileURL: charIDFileURL) + + #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 rebuildCompiledUserDictionarySkipsUnsupportedReadings() throws { + let directoryURL = try makeTemporaryDirectoryURL() + let charIDFileURL = try makeTemporaryCharIDFileURL(characters: "\0テスト") + let entries = [ + DicdataElement(word: "外字単語", ruby: "\u{10FFFF}", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5) + ] + + try CompiledUserDictionaryStore.rebuild(entries: entries, directoryURL: directoryURL, charIDFileURL: charIDFileURL) + + #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) + 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(), + 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 +} + +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 1a7fd659..3063eabe 100644 --- a/azooKeyMac/AppDelegate.swift +++ b/azooKeyMac/AppDelegate.swift @@ -35,6 +35,59 @@ 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.hasExportedDictionary(memoryDirectoryURL: memoryDirectoryURL) else { + return + } + do { + 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), @@ -89,6 +142,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..3dfd884f 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -148,6 +148,12 @@ struct ConfigWindow: View { } } + private func exportUserDictionary() { + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.exportUserDictionaryAndReloadConverter() + } + } + private func getErrorMessage(for error: OpenAIError) -> String { switch error { case .invalidURL: @@ -429,6 +435,7 @@ struct ConfigWindow: View { } self.systemUserDictionary.value.lastUpdate = .now self.systemUserDictionaryUpdateMessage = .successfulUpdate + self.exportUserDictionary() } catch { self.systemUserDictionaryUpdateMessage = .error(error) } @@ -437,6 +444,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..29b84d5f 100644 --- a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift +++ b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift @@ -50,6 +50,12 @@ struct UserDictionaryEditorWindow: View { self.$userDictionary.wrappedValue = value } + private func exportUserDictionary() { + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.exportUserDictionaryAndReloadConverter() + } + } + var body: some View { VStack { Text("ユーザ辞書の設定") @@ -81,6 +87,7 @@ struct UserDictionaryEditorWindow: View { Spacer() Button("完了", systemImage: "checkmark") { self.editTargetID = nil + self.exportUserDictionary() } Spacer() } @@ -107,6 +114,7 @@ struct UserDictionaryEditorWindow: View { value.items.append(undoItem) } self.undoItem = nil + self.exportUserDictionary() } } Spacer() @@ -130,6 +138,7 @@ struct UserDictionaryEditorWindow: View { value.items.remove(at: itemIndex) } } + self.exportUserDictionary() } .buttonStyle(.bordered) .labelStyle(.iconOnly)