Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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] =
[
Expand Down Expand Up @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions Core/Sources/Core/UserDictionary/CompiledUserDictionaryStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions azooKeyMac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions azooKeyMac/Windows/ConfigWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -429,6 +435,7 @@ struct ConfigWindow: View {
}
self.systemUserDictionary.value.lastUpdate = .now
self.systemUserDictionaryUpdateMessage = .successfulUpdate
self.exportUserDictionary()
} catch {
self.systemUserDictionaryUpdateMessage = .error(error)
}
Expand All @@ -437,6 +444,7 @@ struct ConfigWindow: View {
self.systemUserDictionary.value.lastUpdate = nil
self.systemUserDictionary.value.items = []
self.systemUserDictionaryUpdateMessage = nil
self.exportUserDictionary()
}
}
} label: {
Expand Down
9 changes: 9 additions & 0 deletions azooKeyMac/Windows/UserDictionaryEditorWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("ユーザ辞書の設定")
Expand Down Expand Up @@ -81,6 +87,7 @@ struct UserDictionaryEditorWindow: View {
Spacer()
Button("完了", systemImage: "checkmark") {
self.editTargetID = nil
self.exportUserDictionary()
}
Spacer()
}
Expand All @@ -107,6 +114,7 @@ struct UserDictionaryEditorWindow: View {
value.items.append(undoItem)
}
self.undoItem = nil
self.exportUserDictionary()
}
}
Spacer()
Expand All @@ -130,6 +138,7 @@ struct UserDictionaryEditorWindow: View {
value.items.remove(at: itemIndex)
}
}
self.exportUserDictionary()
}
.buttonStyle(.bordered)
.labelStyle(.iconOnly)
Expand Down
Loading