Skip to content

Commit 9c706f5

Browse files
Merge pull request #183 from azooKey/feat/import_system_user_dictionary
feat: システムユーザ辞書の読み込みをサポート
2 parents d887bdd + 4dd11b9 commit 9c706f5

6 files changed

Lines changed: 254 additions & 34 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import Foundation
2+
import SQLite3
3+
#if canImport(AppKit)
4+
import AppKit
5+
#endif
6+
7+
/// macOSのユーザ辞書データを取り出すためのヘルパー
8+
///
9+
/// - note:
10+
/// `bash`コマンドとしては次の通りのものに対応
11+
/// ```bash
12+
/// sqlite3 -header -csv ~/Library/KeyboardServices/TextReplacements.db "SELECT ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY"
13+
/// ```
14+
public enum SystemUserDictionaryHelper: Sendable {
15+
#if canImport(AppKit)
16+
/// Delegate that allows the user to choose **only** a directory named "KeyboardServices".
17+
private final class KeyboardServicesDirectoryDelegate: NSObject, NSOpenSavePanelDelegate {
18+
private let allowedFolderName = "KeyboardServices"
19+
20+
/// Controls which items are enabled in the open panel.
21+
func panel(_ sender: Any, shouldEnable url: URL) -> Bool {
22+
// Enable selection only for the target directory itself.
23+
url.hasDirectoryPath && url.lastPathComponent == allowedFolderName
24+
}
25+
26+
/// Validates the final URL when the user presses “Open”.
27+
func panel(_ sender: Any, validate url: URL) throws {
28+
guard url.lastPathComponent == allowedFolderName else {
29+
throw NSError(
30+
domain: NSCocoaErrorDomain,
31+
code: NSUserCancelledError,
32+
userInfo: [NSLocalizedDescriptionKey: "KeyboardServicesという名前のフォルダのみ選択できます。"]
33+
)
34+
}
35+
}
36+
}
37+
38+
/// A shared delegate instance that remains alive for the lifetime of the open panel.
39+
@MainActor private static let keyboardServicesDelegate = KeyboardServicesDirectoryDelegate()
40+
#endif
41+
42+
public struct Entry: Sendable {
43+
public let shortcut: String
44+
public let phrase: String
45+
}
46+
47+
public enum FetchError: Sendable, Error {
48+
case fileNotExist(String)
49+
case fileNotReadable(String)
50+
case failedToOpenDatabase(status: Int32)
51+
case failedToPrepareStatement(status: Int32)
52+
}
53+
54+
@MainActor static func promptUserForTextReplacementDirectory() -> URL? {
55+
let panel = NSOpenPanel()
56+
panel.title = "システムのユーザ辞書ディレクトリ(KeyboardServices)を選択してください"
57+
panel.message = "システムのユーザ辞書ディレクトリ(KeyboardServices)を選択してください"
58+
panel.canChooseDirectories = true
59+
panel.canChooseFiles = false
60+
panel.allowsMultipleSelection = false
61+
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/KeyboardServices")
62+
#if canImport(AppKit)
63+
panel.delegate = keyboardServicesDelegate
64+
#endif
65+
66+
let response = panel.runModal()
67+
return response == .OK ? panel.url : nil
68+
}
69+
70+
@MainActor public static func fetchEntries() throws(FetchError) -> [Entry] {
71+
let userName = NSUserName()
72+
var dbPath = "/Users/\(userName)/Library/KeyboardServices/TextReplacements.db"
73+
guard FileManager.default.fileExists(atPath: dbPath) else {
74+
throw .fileNotExist(dbPath)
75+
}
76+
// 事前に取得を試みる
77+
print("dbPath", dbPath)
78+
79+
let url: URL
80+
let needStop: Bool
81+
if !FileManager.default.isReadableFile(atPath: dbPath) {
82+
#if canImport(AppKit)
83+
guard let authorizedURL = Self.promptUserForTextReplacementDirectory(), authorizedURL.startAccessingSecurityScopedResource() else {
84+
throw FetchError.fileNotReadable(dbPath)
85+
}
86+
needStop = true
87+
url = authorizedURL
88+
#else
89+
throw FetchError.fileNotReadable(dbPath)
90+
#endif
91+
} else {
92+
needStop = false
93+
url = URL(fileURLWithPath: dbPath)
94+
}
95+
defer {
96+
if needStop {
97+
url.stopAccessingSecurityScopedResource()
98+
}
99+
}
100+
101+
var db: OpaquePointer?
102+
103+
let openStatus = sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY, nil)
104+
guard openStatus == SQLITE_OK else {
105+
print("Failed to open database")
106+
throw .failedToOpenDatabase(status: openStatus)
107+
}
108+
109+
defer {
110+
sqlite3_close(db)
111+
}
112+
113+
let query = "SELECT ZSHORTCUT, ZPHRASE FROM ZTEXTREPLACEMENTENTRY"
114+
var statement: OpaquePointer?
115+
116+
let prepareStatus = sqlite3_prepare_v2(db, query, -1, &statement, nil)
117+
guard prepareStatus == SQLITE_OK else {
118+
print("Failed to prepare statement")
119+
throw .failedToPrepareStatement(status: prepareStatus)
120+
}
121+
122+
defer {
123+
sqlite3_finalize(statement)
124+
}
125+
126+
var entries: [Entry] = []
127+
while sqlite3_step(statement) == SQLITE_ROW {
128+
if let shortcutC = sqlite3_column_text(statement, 0),
129+
let phraseC = sqlite3_column_text(statement, 1) {
130+
let shortcut = String(cString: shortcutC)
131+
let phrase = String(cString: phraseC)
132+
entries.append(Entry(shortcut: shortcut, phrase: phrase))
133+
}
134+
}
135+
136+
return entries
137+
}
138+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@testable import Core
2+
import Testing
3+
4+
@Test func testSystemUserDictionaryHelper() async throws {
5+
let entries = try await SystemUserDictionaryHelper.fetchEntries()
6+
print(entries)
7+
// always true
8+
#expect(entries.count >= 0)
9+
}

azooKeyMac/Configs/CustomCodableConfigItem.swift

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -64,45 +64,57 @@ extension Config {
6464
}
6565

6666
extension Config {
67-
struct UserDictionary: CustomCodableConfigItem {
68-
var items: Value = Self.default
69-
70-
struct Value: Codable {
71-
var items: [Item]
67+
struct UserDictionaryEntry: Sendable, Codable, Identifiable {
68+
init(word: String, reading: String, hint: String? = nil) {
69+
self.id = UUID()
70+
self.word = word
71+
self.reading = reading
72+
self.hint = hint
7273
}
7374

74-
struct Item: Codable, Identifiable {
75-
init(word: String, reading: String, hint: String? = nil) {
76-
self.id = UUID()
77-
self.word = word
78-
self.reading = reading
79-
self.hint = hint
80-
}
81-
82-
var id: UUID
83-
var word: String
84-
var reading: String
85-
var hint: String?
75+
var id: UUID
76+
var word: String
77+
var reading: String
78+
var hint: String?
8679

87-
var nonNullHint: String {
88-
get {
89-
hint ?? ""
90-
}
91-
set {
92-
if newValue.isEmpty {
93-
hint = nil
94-
} else {
95-
hint = newValue
96-
}
80+
var nonNullHint: String {
81+
get {
82+
hint ?? ""
83+
}
84+
set {
85+
if newValue.isEmpty {
86+
hint = nil
87+
} else {
88+
hint = newValue
9789
}
9890
}
9991
}
92+
}
93+
94+
struct UserDictionary: CustomCodableConfigItem {
95+
var items: Value = Self.default
96+
97+
struct Value: Codable {
98+
var items: [UserDictionaryEntry]
99+
}
100100

101101
static let `default`: Value = .init(items: [
102102
.init(word: "azooKey", reading: "あずーきー", hint: "アプリ")
103103
])
104104
static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.user_dictionary_temporal2"
105105
}
106+
107+
struct SystemUserDictionary: CustomCodableConfigItem {
108+
var items: Value = Self.default
109+
110+
struct Value: Codable {
111+
var lastUpdate: Date?
112+
var items: [UserDictionaryEntry]
113+
}
114+
115+
static let `default`: Value = .init(items: [])
116+
static let key: String = "dev.ensan.inputmethod.azooKeyMac.preference.system_user_dictionary"
117+
}
106118
}
107119

108120
extension Config {

azooKeyMac/InputController/SegmentsManager.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ final class SegmentsManager {
1919
private var userDictionary: Config.UserDictionary.Value {
2020
Config.UserDictionary().value
2121
}
22+
private var systemUserDictionary: Config.SystemUserDictionary.Value {
23+
Config.SystemUserDictionary().value
24+
}
2225
private var zenzaiPersonalizationLevel: Config.ZenzaiPersonalizationLevel.Value {
2326
Config.ZenzaiPersonalizationLevel().value
2427
}
@@ -329,10 +332,17 @@ final class SegmentsManager {
329332
return
330333
}
331334
// ユーザ辞書情報の更新
332-
self.kanaKanjiConverter.sendToDicdataStore(.importDynamicUserDict(userDictionary.items.map {
335+
var userDictionary: [DicdataElement] = userDictionary.items.map {
336+
.init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5)
337+
}
338+
self.appendDebugMessage("userDictionaryCount: \(userDictionary.count)")
339+
let systemUserDictionary: [DicdataElement] = systemUserDictionary.items.map {
333340
.init(word: $0.word, ruby: $0.reading.toKatakana(), cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -5)
334-
}))
335-
self.appendDebugMessage("userDictionaryCount: \(self.userDictionary.items.count)")
341+
}
342+
self.appendDebugMessage("systemUserDictionaryCount: \(systemUserDictionary.count)")
343+
userDictionary.append(contentsOf: consume systemUserDictionary)
344+
345+
self.kanaKanjiConverter.sendToDicdataStore(.importDynamicUserDict(consume userDictionary))
336346

337347
let prefixComposingText = self.composingText.prefixToCursorPosition()
338348
let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: 30)

azooKeyMac/Windows/ConfigWindow.swift

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,20 @@ struct ConfigWindow: View {
1818
@ConfigState private var inferenceLimit = Config.ZenzaiInferenceLimit()
1919
@ConfigState private var debugWindow = Config.DebugWindow()
2020
@ConfigState private var userDictionary = Config.UserDictionary()
21+
@ConfigState private var systemUserDictionary = Config.SystemUserDictionary()
2122

2223
@State private var zenzaiHelpPopover = false
2324
@State private var zenzaiProfileHelpPopover = false
2425
@State private var zenzaiInferenceLimitHelpPopover = false
2526
@State private var openAiApiKeyPopover = false
2627
@State private var connectionTestInProgress = false
2728
@State private var connectionTestResult: String?
29+
@State private var systemUserDictionaryUpdateMessage: SystemUserDictionaryUpdateMessage?
30+
31+
private enum SystemUserDictionaryUpdateMessage {
32+
case error(any Error)
33+
case successfulUpdate
34+
}
2835

2936
private func getErrorMessage(for error: OpenAIError) -> String {
3037
switch error {
@@ -168,8 +175,52 @@ struct ConfigWindow: View {
168175
Toggle("「、」「。」の代わりに「,」「.」を入力", isOn: $typeCommaAndPeriod)
169176
Toggle("スペースは常に半角を入力", isOn: $typeHalfSpace)
170177
Divider()
171-
Button("ユーザ辞書を編集する") {
172-
(NSApplication.shared.delegate as? AppDelegate)!.openUserDictionaryEditorWindow()
178+
LabeledContent {
179+
HStack {
180+
Button("編集") {
181+
(NSApplication.shared.delegate as? AppDelegate)!.openUserDictionaryEditorWindow()
182+
}
183+
Spacer()
184+
Text("\(self.userDictionary.value.items.count)件のアイテム")
185+
}
186+
} label: {
187+
Text("azooKeyユーザ辞書")
188+
}
189+
LabeledContent {
190+
Button("読み込む") {
191+
do {
192+
let systemUserDictionaryEntries = try SystemUserDictionaryHelper.fetchEntries()
193+
self.systemUserDictionary.value.items = systemUserDictionaryEntries.map {
194+
.init(word: $0.phrase, reading: $0.shortcut)
195+
}
196+
self.systemUserDictionary.value.lastUpdate = .now
197+
self.systemUserDictionaryUpdateMessage = .successfulUpdate
198+
} catch {
199+
self.systemUserDictionaryUpdateMessage = .error(error)
200+
}
201+
}
202+
Button("リセット") {
203+
self.systemUserDictionary.value.lastUpdate = nil
204+
self.systemUserDictionary.value.items = []
205+
self.systemUserDictionaryUpdateMessage = nil
206+
}
207+
Spacer()
208+
switch self.systemUserDictionaryUpdateMessage {
209+
case .none:
210+
if let updated = self.systemUserDictionary.value.lastUpdate {
211+
let date = updated.formatted(date: .omitted, time: .omitted)
212+
Text("最終更新: \(updated) / \(self.systemUserDictionary.value.items.count)件のアイテム")
213+
} else {
214+
Text("未設定")
215+
}
216+
case .error(let error):
217+
Text("読み込みエラー: \(error.localizedDescription)")
218+
case .successfulUpdate:
219+
Text("読み込みに成功しました / \(self.systemUserDictionary.value.items.count)件のアイテム")
220+
}
221+
222+
} label: {
223+
Text("システムのユーザ辞書")
173224
}
174225
Divider()
175226
Toggle("(開発者用)デバッグウィンドウを有効化", isOn: $debugWindow)

azooKeyMac/Windows/UserDictionaryEditorWindow.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ struct UserDictionaryEditorWindow: View {
1212
@ConfigState private var userDictionary = Config.UserDictionary()
1313

1414
@State private var editTargetID: UUID?
15-
@State private var undoItem: Config.UserDictionary.Item?
15+
@State private var undoItem: Config.UserDictionaryEntry?
1616

1717
@ViewBuilder
1818
private func helpButton(helpContent: LocalizedStringKey, isPresented: Binding<Bool>) -> some View {
@@ -71,7 +71,7 @@ struct UserDictionaryEditorWindow: View {
7171
HStack {
7272
Spacer()
7373
Button("追加", systemImage: "plus") {
74-
let newItem = Config.UserDictionary.Item(word: "", reading: "", hint: nil)
74+
let newItem = Config.UserDictionaryEntry(word: "", reading: "", hint: nil)
7575
self.userDictionary.value.items.append(newItem)
7676
self.editTargetID = newItem.id
7777
self.undoItem = nil

0 commit comments

Comments
 (0)