diff --git a/sumire-keyboard.xcodeproj/project.pbxproj b/sumire-keyboard.xcodeproj/project.pbxproj index 23eface..af16d94 100644 --- a/sumire-keyboard.xcodeproj/project.pbxproj +++ b/sumire-keyboard.xcodeproj/project.pbxproj @@ -61,11 +61,6 @@ path = "sumire-keyboard"; sourceTree = ""; }; - C00000000000000000000001 /* sumire-keyboardShared */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "sumire-keyboardShared"; - sourceTree = ""; - }; A4EBDFB42F93627000764A31 /* sumire-keyboardTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "sumire-keyboardTests"; @@ -78,15 +73,18 @@ }; B10000000000000000000005 /* sumire-keyboardKeyboard */ = { isa = PBXFileSystemSynchronizedRootGroup; - explicitFileTypes = { - }; explicitFolders = ( KanaKanjiResources, ); path = "sumire-keyboardKeyboard"; sourceTree = ""; }; - C10000000000000000000001 /* KanaKanjiCore */ = { + C00000000000000000000001 /* sumire-keyboardShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "sumire-keyboardShared"; + sourceTree = ""; + }; + C10000000000000000000001 /* sumire-keyboardKeyboard/KanaKanjiCore */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "sumire-keyboardKeyboard/KanaKanjiCore"; sourceTree = ""; @@ -132,7 +130,7 @@ C00000000000000000000001 /* sumire-keyboardShared */, B10000000000000000000005 /* sumire-keyboardKeyboard */, A4EBDFB42F93627000764A31 /* sumire-keyboardTests */, - C10000000000000000000001 /* KanaKanjiCore */, + C10000000000000000000001 /* sumire-keyboardKeyboard/KanaKanjiCore */, A4EBDFBE2F93627000764A31 /* sumire-keyboardUITests */, A4EBDFA52F93626E00764A31 /* Products */, ); @@ -169,6 +167,7 @@ fileSystemSynchronizedGroups = ( A4EBDFA62F93626E00764A31 /* sumire-keyboard */, C00000000000000000000001 /* sumire-keyboardShared */, + C10000000000000000000001 /* sumire-keyboardKeyboard/KanaKanjiCore */, ); name = "sumire-keyboard"; packageProductDependencies = ( @@ -192,7 +191,7 @@ ); fileSystemSynchronizedGroups = ( A4EBDFB42F93627000764A31 /* sumire-keyboardTests */, - C10000000000000000000001 /* KanaKanjiCore */, + C10000000000000000000001 /* sumire-keyboardKeyboard/KanaKanjiCore */, ); name = "sumire-keyboardTests"; packageProductDependencies = ( diff --git a/sumire-keyboard.xcodeproj/xcuserdata/kazuma.xcuserdatad/xcschemes/xcschememanagement.plist b/sumire-keyboard.xcodeproj/xcuserdata/kazuma.xcuserdatad/xcschemes/xcschememanagement.plist index 5df7b0b..8b09538 100644 --- a/sumire-keyboard.xcodeproj/xcuserdata/kazuma.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/sumire-keyboard.xcodeproj/xcuserdata/kazuma.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ sumire-keyboardKeyboard.xcscheme_^#shared#^_ orderHint - 1 + 0 diff --git a/sumire-keyboard/ContentView.swift b/sumire-keyboard/ContentView.swift index d54dcfb..36ec324 100644 --- a/sumire-keyboard/ContentView.swift +++ b/sumire-keyboard/ContentView.swift @@ -38,6 +38,12 @@ struct ContentView: View { ) private var usesHalfWidthSpace = false + @AppStorage( + KeyboardSettings.Keys.predictiveConversionStartLength, + store: KeyboardSettings.defaults + ) + private var predictiveConversionStartLength = KeyboardSettings.defaultPredictiveConversionStartLength + @State private var keyboards: [KeyboardSettings.SumireKeyboard] = [] @State private var currentKeyboardID = "" @State private var keyboardEditorRoute: KeyboardEditorRoute? @@ -61,6 +67,7 @@ struct ContentView: View { keyboardListSection japaneseFlickSection conversionSection + dictionaryManagementSection sharedSettingsSection } .navigationTitle("Sumire Keyboard") @@ -157,6 +164,26 @@ struct ContentView: View { Text(usesHalfWidthSpace ? "半角" : "全角") .foregroundStyle(.secondary) } + + Stepper( + "学習辞書の予測変換を開始する文字数: \(predictiveConversionStartLength)文字", + value: $predictiveConversionStartLength, + in: 1...10 + ) + + Text("入力文字数が指定した数に達してから、学習辞書の予測候補を表示します。ユーザー辞書と完全一致候補には影響しません。") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + private var dictionaryManagementSection: some View { + Section("辞書") { + NavigationLink { + DictionaryManagementView() + } label: { + Label("辞書管理", systemImage: "books.vertical") + } } } diff --git a/sumire-keyboard/DictionaryManagementView.swift b/sumire-keyboard/DictionaryManagementView.swift new file mode 100644 index 0000000..c91365d --- /dev/null +++ b/sumire-keyboard/DictionaryManagementView.swift @@ -0,0 +1,754 @@ +import Combine +import SwiftUI + +@MainActor +final class DictionaryManagementViewModel: ObservableObject { + let repositories: DictionaryRepositories? + @Published var errorMessage: String? + + init() { + do { + repositories = try DictionaryRepositoryContainer.makeDefault() + } catch { + repositories = nil + errorMessage = error.localizedDescription + } + } +} + +@MainActor +final class LearningDictionaryManagementViewModel: ObservableObject { + @Published var entries: [LearningDictionaryEntry] = [] + @Published var query = "" + @Published var isLoading = false + @Published var errorMessage: String? + + private let queryRepository: any LearningDictionaryQueryRepository + private let editorRepository: any LearningDictionaryEditorRepository + + init( + queryRepository: any LearningDictionaryQueryRepository, + editorRepository: any LearningDictionaryEditorRepository + ) { + self.queryRepository = queryRepository + self.editorRepository = editorRepository + } + + func load() async { + isLoading = true + defer { + isLoading = false + } + + do { + entries = try await queryRepository.searchForManagementUI( + query: query, + limit: 200, + offset: 0 + ) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func save(_ draft: DictionaryEntryDraft, editing entry: LearningDictionaryEntry?) async { + do { + let now = Date() + let nextEntry = LearningDictionaryEntry( + id: entry?.id ?? UUID(), + reading: draft.reading, + word: draft.word, + score: draft.score, + leftId: draft.leftId, + rightId: draft.rightId, + updatedAt: now + ) + if entry == nil { + try await editorRepository.add(nextEntry) + } else { + try await editorRepository.update(nextEntry) + } + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + func delete(_ entry: LearningDictionaryEntry) async { + do { + try await editorRepository.delete(id: entry.id) + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + func deleteAll() async { + do { + try await editorRepository.deleteAll() + await load() + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +final class UserDictionaryManagementViewModel: ObservableObject { + @Published var entries: [UserDictionaryEntry] = [] + @Published var query = "" + @Published var isLoading = false + @Published var buildState = UserDictionaryBuildState(status: .idle, updatedAt: Date()) + @Published var successMessage: String? + @Published var errorMessage: String? + + private let queryRepository: any UserDictionaryManagementQueryRepository + private let editorRepository: any UserDictionaryEditorRepository + private let loudsBuilder: any UserDictionaryLoudsBuilder + private let loudsValidator: any UserDictionaryLoudsValidator + private let artifactPublisher: any UserDictionaryArtifactPublisher + private let buildStateRepository: any UserDictionaryBuildStateRepository + + var isBuilding: Bool { + switch buildState.status { + case .building, .validating: + return true + case .idle, .ready, .failed: + return false + } + } + + init( + queryRepository: any UserDictionaryManagementQueryRepository, + editorRepository: any UserDictionaryEditorRepository, + loudsBuilder: any UserDictionaryLoudsBuilder = FileUserDictionaryLoudsBuilder(), + loudsValidator: any UserDictionaryLoudsValidator = FileUserDictionaryLoudsValidator(), + artifactPublisher: any UserDictionaryArtifactPublisher = FileUserDictionaryArtifactPublisher(), + buildStateRepository: any UserDictionaryBuildStateRepository = FileUserDictionaryBuildStateRepository() + ) { + self.queryRepository = queryRepository + self.editorRepository = editorRepository + self.loudsBuilder = loudsBuilder + self.loudsValidator = loudsValidator + self.artifactPublisher = artifactPublisher + self.buildStateRepository = buildStateRepository + } + + func load() async { + isLoading = true + defer { + isLoading = false + } + + do { + entries = try await queryRepository.searchForManagementUI( + query: query, + limit: 200, + offset: 0 + ) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func save(_ draft: DictionaryEntryDraft, editing entry: UserDictionaryEntry?) async { + do { + let now = Date() + let nextEntry = UserDictionaryEntry( + id: entry?.id ?? UUID(), + reading: draft.reading, + word: draft.word, + score: draft.score, + leftId: draft.leftId, + rightId: draft.rightId, + updatedAt: now + ) + if entry == nil { + try await editorRepository.add(nextEntry) + } else { + try await editorRepository.update(nextEntry) + } + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + func delete(_ entry: UserDictionaryEntry) async { + do { + try await editorRepository.delete(id: entry.id) + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + func deleteAll() async { + do { + try await editorRepository.deleteAll() + await load() + } catch { + errorMessage = error.localizedDescription + } + } + + func loadBuildState() async { + do { + buildState = try await buildStateRepository.load() + } catch { + errorMessage = error.localizedDescription + } + } + + func buildUserDictionary() async { + let startedAt = Date() + successMessage = nil + errorMessage = nil + buildState = UserDictionaryBuildState(status: .building, updatedAt: startedAt) + + do { + try await buildStateRepository.save(buildState) + let allEntries = try await queryRepository.allEntries() + let artifacts = try await loudsBuilder.build(from: allEntries) + + buildState = UserDictionaryBuildState(status: .validating, updatedAt: Date()) + try await buildStateRepository.save(buildState) + try await loudsValidator.validate(artifacts) + try await artifactPublisher.publish(artifacts) + + let readyState = UserDictionaryBuildState( + status: .ready, + updatedAt: Date(), + artifactVersion: artifacts.directoryURL.lastPathComponent + ) + buildState = readyState + try await buildStateRepository.save(readyState) + successMessage = "LOUDS のビルドが完了しました。" + await load() + } catch { + let message = error.localizedDescription + let failedState = UserDictionaryBuildState( + status: .failed(message), + updatedAt: Date(), + artifactVersion: buildState.artifactVersion, + lastErrorMessage: message + ) + buildState = failedState + try? await buildStateRepository.save(failedState) + errorMessage = message + } + } +} + +struct DictionaryManagementView: View { + @StateObject private var viewModel = DictionaryManagementViewModel() + + var body: some View { + Form { + if let errorMessage = viewModel.errorMessage { + Section("エラー") { + Text(errorMessage) + .foregroundStyle(.red) + } + } + + Section("辞書") { + if let repositories = viewModel.repositories { + NavigationLink { + LearningDictionaryManagementView( + viewModel: LearningDictionaryManagementViewModel( + queryRepository: repositories.learningRepository, + editorRepository: repositories.learningRepository + ) + ) + } label: { + Label("学習辞書", systemImage: "clock.arrow.circlepath") + } + + NavigationLink { + UserDictionaryManagementView( + viewModel: UserDictionaryManagementViewModel( + queryRepository: repositories.userRepository, + editorRepository: repositories.userRepository + ) + ) + } label: { + Label("ユーザー辞書", systemImage: "person.text.rectangle") + } + } else { + Text("辞書データベースを開けませんでした。") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("辞書管理") + } +} + +struct LearningDictionaryManagementView: View { + @StateObject var viewModel: LearningDictionaryManagementViewModel + @State private var editorRoute: LearningEditorRoute? + @State private var showsDeleteAllConfirmation = false + + init(viewModel: LearningDictionaryManagementViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + List { + if viewModel.isLoading { + ProgressView() + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + ForEach(viewModel.entries) { entry in + DictionaryEntryRow( + reading: entry.reading, + word: entry.word, + score: entry.score, + leftId: entry.leftId, + rightId: entry.rightId, + updatedAt: entry.updatedAt + ) + .contentShape(Rectangle()) + .onTapGesture { + editorRoute = .edit(entry) + } + .swipeActions { + Button("削除", role: .destructive) { + Task { + await viewModel.delete(entry) + } + } + } + } + } + .navigationTitle("学習辞書") + .searchable(text: $viewModel.query, prompt: "読み・単語で検索") + .onSubmit(of: .search) { + Task { + await viewModel.load() + } + } + .onChange(of: viewModel.query) { + Task { + await viewModel.load() + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("全削除", role: .destructive) { + showsDeleteAllConfirmation = true + } + .disabled(viewModel.entries.isEmpty) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + editorRoute = .add + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("学習辞書を追加") + } + } + .confirmationDialog("学習辞書をすべて削除しますか", isPresented: $showsDeleteAllConfirmation) { + Button("全削除", role: .destructive) { + Task { + await viewModel.deleteAll() + } + } + } + .sheet(item: $editorRoute) { route in + DictionaryEntryEditorView( + title: route.title, + initialDraft: route.draft + ) { draft in + Task { + await viewModel.save(draft, editing: route.entry) + editorRoute = nil + } + } + } + .task { + await viewModel.load() + } + } +} + +struct UserDictionaryManagementView: View { + @StateObject var viewModel: UserDictionaryManagementViewModel + @State private var editorRoute: UserEditorRoute? + @State private var showsDeleteAllConfirmation = false + + init(viewModel: UserDictionaryManagementViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + List { + loudsBuildSection + + if viewModel.isLoading { + ProgressView() + } + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + ForEach(viewModel.entries) { entry in + DictionaryEntryRow( + reading: entry.reading, + word: entry.word, + score: entry.score, + leftId: entry.leftId, + rightId: entry.rightId, + updatedAt: entry.updatedAt + ) + .contentShape(Rectangle()) + .onTapGesture { + editorRoute = .edit(entry) + } + .swipeActions { + Button("削除", role: .destructive) { + Task { + await viewModel.delete(entry) + } + } + } + } + } + .navigationTitle("ユーザー辞書") + .searchable(text: $viewModel.query, prompt: "読み・単語で検索") + .onSubmit(of: .search) { + Task { + await viewModel.load() + } + } + .onChange(of: viewModel.query) { + Task { + await viewModel.load() + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("全削除", role: .destructive) { + showsDeleteAllConfirmation = true + } + .disabled(viewModel.entries.isEmpty) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + editorRoute = .add + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("ユーザー辞書を追加") + } + } + .confirmationDialog("ユーザー辞書をすべて削除しますか", isPresented: $showsDeleteAllConfirmation) { + Button("全削除", role: .destructive) { + Task { + await viewModel.deleteAll() + } + } + } + .sheet(item: $editorRoute) { route in + DictionaryEntryEditorView( + title: route.title, + initialDraft: route.draft + ) { draft in + Task { + await viewModel.save(draft, editing: route.entry) + editorRoute = nil + } + } + } + .task { + await viewModel.loadBuildState() + await viewModel.load() + } + } + + @ViewBuilder + private var loudsBuildSection: some View { + Section("LOUDS") { + Button { + Task { + await viewModel.buildUserDictionary() + } + } label: { + Label("LOUDS をビルド", systemImage: "hammer") + } + .disabled(viewModel.isBuilding) + + LabeledContent("状態") { + Text(buildStatusText) + .foregroundStyle(buildStatusColor) + } + + LabeledContent("最終更新") { + Text(viewModel.buildState.updatedAt, style: .date) + } + + if let artifactVersion = viewModel.buildState.artifactVersion { + LabeledContent("artifact") { + Text(artifactVersion) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let successMessage = viewModel.successMessage { + Text(successMessage) + .foregroundStyle(.green) + } + } + } + + private var buildStatusText: String { + switch viewModel.buildState.status { + case .idle: + return "idle" + case .building: + return "building" + case .validating: + return "validating" + case .ready: + return "ready" + case .failed(let message): + return "failed: \(message)" + } + } + + private var buildStatusColor: Color { + switch viewModel.buildState.status { + case .idle, .building, .validating: + return .secondary + case .ready: + return .green + case .failed: + return .red + } + } +} + +private struct DictionaryEntryRow: View { + let reading: String + let word: String + let score: Int + let leftId: Int + let rightId: Int + let updatedAt: Date + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(word) + .font(.body) + Spacer() + Text(reading) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Text("score \(score)") + Text("L \(leftId)") + Text("R \(rightId)") + Spacer() + Text(updatedAt, style: .date) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +struct DictionaryEntryDraft: Hashable { + var reading: String + var word: String + var score: Int + var leftId: Int + var rightId: Int + + var isValid: Bool { + reading.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && + word.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } +} + +private struct DictionaryEntryEditorView: View { + let title: String + let onSave: (DictionaryEntryDraft) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var draft: DictionaryEntryDraft + + init( + title: String, + initialDraft: DictionaryEntryDraft, + onSave: @escaping (DictionaryEntryDraft) -> Void + ) { + self.title = title + self.onSave = onSave + _draft = State(initialValue: initialDraft) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("読み", text: $draft.reading) + TextField("単語", text: $draft.word) + } + + Section { + LabeledContent("score") { + TextField("score", value: $draft.score, format: .number) + .multilineTextAlignment(.trailing) + .keyboardType(.numbersAndPunctuation) + } + LabeledContent("leftId") { + TextField("leftId", value: $draft.leftId, format: .number) + .multilineTextAlignment(.trailing) + .keyboardType(.numberPad) + } + LabeledContent("rightId") { + TextField("rightId", value: $draft.rightId, format: .number) + .multilineTextAlignment(.trailing) + .keyboardType(.numberPad) + } + } + } + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("キャンセル") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + onSave(draft) + } + .disabled(draft.isValid == false) + } + } + } + } +} + +private enum LearningEditorRoute: Identifiable { + case add + case edit(LearningDictionaryEntry) + + var id: String { + switch self { + case .add: + return "add" + case .edit(let entry): + return entry.id.uuidString + } + } + + var title: String { + switch self { + case .add: + return "学習辞書を追加" + case .edit: + return "学習辞書を編集" + } + } + + var entry: LearningDictionaryEntry? { + switch self { + case .add: + return nil + case .edit(let entry): + return entry + } + } + + var draft: DictionaryEntryDraft { + switch self { + case .add: + return DictionaryEntryDraft( + reading: "", + word: "", + score: DictionaryDefaultLexicalInfo.generalNoun.score, + leftId: DictionaryDefaultLexicalInfo.generalNoun.leftId, + rightId: DictionaryDefaultLexicalInfo.generalNoun.rightId + ) + case .edit(let entry): + return DictionaryEntryDraft( + reading: entry.reading, + word: entry.word, + score: entry.score, + leftId: entry.leftId, + rightId: entry.rightId + ) + } + } +} + +private enum UserEditorRoute: Identifiable { + case add + case edit(UserDictionaryEntry) + + var id: String { + switch self { + case .add: + return "add" + case .edit(let entry): + return entry.id.uuidString + } + } + + var title: String { + switch self { + case .add: + return "ユーザー辞書を追加" + case .edit: + return "ユーザー辞書を編集" + } + } + + var entry: UserDictionaryEntry? { + switch self { + case .add: + return nil + case .edit(let entry): + return entry + } + } + + var draft: DictionaryEntryDraft { + switch self { + case .add: + return DictionaryEntryDraft( + reading: "", + word: "", + score: DictionaryDefaultLexicalInfo.generalNoun.score, + leftId: DictionaryDefaultLexicalInfo.generalNoun.leftId, + rightId: DictionaryDefaultLexicalInfo.generalNoun.rightId + ) + case .edit(let entry): + return DictionaryEntryDraft( + reading: entry.reading, + word: entry.word, + score: entry.score, + leftId: entry.leftId, + rightId: entry.rightId + ) + } + } +} diff --git a/sumire-keyboardKeyboard/KanaKanjiCore/KanaKanjiConverter.swift b/sumire-keyboardKeyboard/KanaKanjiCore/KanaKanjiConverter.swift index 16a6f3f..b71f768 100644 --- a/sumire-keyboardKeyboard/KanaKanjiCore/KanaKanjiConverter.swift +++ b/sumire-keyboardKeyboard/KanaKanjiCore/KanaKanjiConverter.swift @@ -210,7 +210,9 @@ public struct KanaKanjiConverter: Sendable { let candidate = ConversionCandidate( text: entry.surface, reading: entry.yomi, - score: score + score: score, + leftId: entry.leftId, + rightId: entry.rightId ) if let current = bestByText[entry.surface] { @@ -363,7 +365,9 @@ public struct KanaKanjiConverter: Sendable { text: entry.surface, reading: entry.yomi, score: entry.cost + penaltyCost, - consumedLength: isFullMatch ? nil : match.length + consumedLength: isFullMatch ? nil : match.length, + leftId: entry.leftId, + rightId: entry.rightId ) if let current = bestByText[entry.surface] { @@ -568,7 +572,14 @@ public struct KanaKanjiConverter: Sendable { if seenSurfaces.insert(surface).inserted { let score = current.totalCost + (containsDigit(surface) ? 2000 : 0) - results.append(ConversionCandidate(text: surface, reading: yomi, score: score)) + let lexicalNode = singleLexicalNode(fromBOSState: current) + results.append(ConversionCandidate( + text: surface, + reading: yomi, + score: score, + leftId: lexicalNode?.leftId, + rightId: lexicalNode?.rightId + )) if results.count >= nBest { return results } @@ -668,6 +679,19 @@ public struct KanaKanjiConverter: Sendable { return result } + private func singleLexicalNode(fromBOSState state: State) -> Node? { + var result: Node? + var current = state.next + while let state = current, state.node.surface != "EOS" { + guard result == nil else { + return nil + } + result = state.node + current = state.next + } + return result + } + private func connectionCost(previousLeftId: Int, currentRightId: Int) -> Int { connectionMatrix?.cost(previousLeftId: previousLeftId, currentRightId: currentRightId) ?? 0 } diff --git a/sumire-keyboardKeyboard/KanaKanjiCore/Models.swift b/sumire-keyboardKeyboard/KanaKanjiCore/Models.swift index f75c8a9..da39b4b 100644 --- a/sumire-keyboardKeyboard/KanaKanjiCore/Models.swift +++ b/sumire-keyboardKeyboard/KanaKanjiCore/Models.swift @@ -21,12 +21,23 @@ public struct ConversionCandidate: Equatable, Sendable { public let reading: String public let score: Int public let consumedLength: Int? + public let leftId: Int? + public let rightId: Int? - public init(text: String, reading: String, score: Int, consumedLength: Int? = nil) { + public init( + text: String, + reading: String, + score: Int, + consumedLength: Int? = nil, + leftId: Int? = nil, + rightId: Int? = nil + ) { self.text = text self.reading = reading self.score = score self.consumedLength = consumedLength + self.leftId = leftId + self.rightId = rightId } } diff --git a/sumire-keyboardKeyboard/KanaKanjiCore/MozcArtifactIO.swift b/sumire-keyboardKeyboard/KanaKanjiCore/MozcArtifactIO.swift index e389263..72797bb 100644 --- a/sumire-keyboardKeyboard/KanaKanjiCore/MozcArtifactIO.swift +++ b/sumire-keyboardKeyboard/KanaKanjiCore/MozcArtifactIO.swift @@ -67,7 +67,16 @@ enum MozcArtifactIO { } static func writeDictionaryArtifacts(from sourceDirectory: URL, to outputDirectory: URL) throws { - let entries = try loadAllEntries(from: sourceDirectory) + try writeDictionaryArtifactsWithConnectionMatrix( + from: sourceDirectory, + to: outputDirectory + ) + } + + static func writeDictionaryArtifacts( + from entries: [DictionaryEntry], + to outputDirectory: URL + ) throws { try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) var grouped: [String: [DictionaryEntry]] = [:] @@ -104,6 +113,14 @@ enum MozcArtifactIO { let tokenArray = buildTokenArray(keys: keys, grouped: grouped, posIndexByPair: pos.indexByPair, tangoNodeIndexBySurface: tangoBuilt.nodeIndexByKey) try writeTokenArray(tokenArray, to: outputDirectory.appendingPathComponent("token_array.bin")) + } + + static func writeDictionaryArtifactsWithConnectionMatrix( + from sourceDirectory: URL, + to outputDirectory: URL + ) throws { + let entries = try loadAllEntries(from: sourceDirectory) + try writeDictionaryArtifacts(from: entries, to: outputDirectory) let connectionText = sourceDirectory.appendingPathComponent("connection_single_column.txt") if FileManager.default.fileExists(atPath: connectionText.path) { diff --git a/sumire-keyboardKeyboard/KeyboardViewController.swift b/sumire-keyboardKeyboard/KeyboardViewController.swift index cc1087e..1a60091 100644 --- a/sumire-keyboardKeyboard/KeyboardViewController.swift +++ b/sumire-keyboardKeyboard/KeyboardViewController.swift @@ -69,12 +69,37 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS case singleKanji case english case direct + case learning + case user + + var candidateSourceKind: CandidateSourceKind { + switch self { + case .main: + return .systemMain + case .auxiliary: + return .systemAuxiliary + case .fallback: + return .fallback + case .singleKanji: + return .systemSingleKanji + case .english: + return .systemEnglish + case .direct: + return .direct + case .learning: + return .learning + case .user: + return .user + } + } } private struct ConversionCandidateItem: Hashable, Sendable { let text: String + let reading: String let consumedReadingLength: Int let source: ConversionCandidateSource + let lexicalInfo: CandidateLexicalInfo? } private struct CandidateButtonConfiguration: Hashable { @@ -93,6 +118,26 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS let score: Int } + private struct SystemCandidateSource: CandidateSource { + let kind: CandidateSourceKind + let exactCandidates: [Candidate] + + func searchExact(reading: String, limit: Int) -> [Candidate] { + guard limit > 0 else { + return [] + } + return exactCandidates.prefix(limit).map { $0 } + } + + func searchCommonPrefix(inputReading: String, limit: Int) -> [Candidate] { + [] + } + + func searchPredictive(prefix: String, limit: Int) -> [Candidate] { + [] + } + } + private struct ConversionCandidateLookupKey: Equatable, Sendable { let targetText: String let language: PrecompositionLanguage @@ -1227,6 +1272,7 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS private var underlineRange: Range? private var kanaKanjiConverter: KanaKanjiConverter? private var englishEngine: EnglishEngine? + private var dictionaryRepositories: DictionaryRepositories? private var converterLoadFailureMessage: String? private var isLoadingKanaKanjiConverter = false private var isLoadingEnglishDictionary = false @@ -1333,6 +1379,7 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS super.viewDidLoad() _ = syncSumireKeyboardSettings() + dictionaryRepositories = try? DictionaryRepositoryContainer.makeDefault() configureInputStatusForCurrentSumireKeyboard() view.backgroundColor = KeyboardTheme.keyboardBackground view.clipsToBounds = false @@ -3389,8 +3436,20 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS } private func commitCandidateItem(_ candidate: ConversionCandidateItem) { + let committedSelection = CommittedSelection( + inputReading: conversionTargetText(), + candidateReading: candidate.reading, + word: candidate.text, + sourceKind: candidate.source.candidateSourceKind, + lexicalInfo: candidate.lexicalInfo, + committedAt: Date() + ) resetMultiTapState() - commitComposingText(candidate.text, consumedReadingLength: candidate.consumedReadingLength) + commitComposingText( + candidate.text, + consumedReadingLength: candidate.consumedReadingLength, + committedSelection: committedSelection + ) } @objc private func handleEnterLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -4965,7 +5024,11 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS } } - private func commitComposingText(_ text: String, consumedReadingLength: Int? = nil) { + private func commitComposingText( + _ text: String, + consumedReadingLength: Int? = nil, + committedSelection: CommittedSelection? = nil + ) { guard composingText.isEmpty == false else { return } @@ -5010,6 +5073,19 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS syncPrecompositionPhaseForCurrentText() } updatePreedit() + + guard let committedSelection, + let learningRepository = dictionaryRepositories?.learningRepository else { + return + } + + Task { + do { + try await learningRepository.recordCommittedSelection(committedSelection) + } catch { + NSLog("Failed to record learning dictionary selection: %@", error.localizedDescription) + } + } } private func commitRenderedComposingTextAsTyped() { @@ -5345,6 +5421,15 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS let auxiliaryConversionCandidateLimit = auxiliaryConversionCandidateLimit let singleKanjiConversionCandidateLimit = singleKanjiConversionCandidateLimit let conversionBeamWidth = conversionBeamWidth + let dictionaryCandidateSources: [any CandidateSource] + if let dictionaryRepositories { + dictionaryCandidateSources = [ + dictionaryRepositories.userCandidateSource, + dictionaryRepositories.learningCandidateSource + ] + } else { + dictionaryCandidateSources = [] + } DispatchQueue.global(qos: .userInitiated).async { let candidates = Self.buildCandidateItems( @@ -5356,7 +5441,8 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS auxiliaryConversionCandidateLimit: auxiliaryConversionCandidateLimit, singleKanjiConversionCandidateLimit: singleKanjiConversionCandidateLimit, conversionBeamWidth: conversionBeamWidth, - omissionSearchEnabled: key.omissionSearchEnabled + omissionSearchEnabled: key.omissionSearchEnabled, + dictionaryCandidateSources: dictionaryCandidateSources ) DispatchQueue.main.async { [weak self] in @@ -5405,26 +5491,31 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS auxiliaryConversionCandidateLimit: Int, singleKanjiConversionCandidateLimit: Int, conversionBeamWidth: Int, - omissionSearchEnabled: Bool + omissionSearchEnabled: Bool, + dictionaryCandidateSources: [any CandidateSource] = [] ) -> [ConversionCandidateItem] { - var seen = Set() - var candidates: [ConversionCandidateItem] = [] + var seenSystemText = Set() + var systemCandidates: [Candidate] = [] - func appendUnique( + func appendSystemCandidate( _ text: String, + reading: String = targetText, consumedReadingLength: Int = targetText.count, - source: ConversionCandidateSource + source: ConversionCandidateSource, + lexicalInfo: CandidateLexicalInfo? = nil ) { guard text.isEmpty == false, isDisplayableConversionCandidate(text), - seen.insert(text).inserted else { + seenSystemText.insert(text).inserted else { return } let consumedLength = min(max(consumedReadingLength, 0), targetText.count) - candidates.append(ConversionCandidateItem( - text: text, + systemCandidates.append(Candidate( + reading: reading, + word: text, consumedReadingLength: consumedLength, - source: source + sourceKind: source.candidateSourceKind, + lexicalInfo: lexicalInfo )) } @@ -5434,14 +5525,30 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS case .english: if let englishEngine { for candidate in englishEngine.getPrediction(input: targetText).prefix(conversionCandidateLimit) { - appendUnique(candidate.word, source: .english) + appendSystemCandidate( + candidate.word, + reading: candidate.reading, + source: .english + ) } } - appendUnique(targetText, source: .fallback) - return candidates + appendSystemCandidate(targetText, source: .fallback) + return mergeCandidateItems( + systemCandidates: systemCandidates, + dictionaryCandidateSources: dictionaryCandidateSources, + inputReading: targetText, + totalLimit: max(systemCandidates.count + dictionaryCandidateSources.count * conversionCandidateLimit, conversionCandidateLimit), + includesAuxiliaryCandidates: true + ) case .number: - appendUnique(targetText, source: .direct) - return candidates + appendSystemCandidate(targetText, source: .direct) + return mergeCandidateItems( + systemCandidates: systemCandidates, + dictionaryCandidateSources: [], + inputReading: targetText, + totalLimit: max(systemCandidates.count, conversionCandidateLimit), + includesAuxiliaryCandidates: false + ) } if let kanaKanjiConverter { @@ -5466,7 +5573,13 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS ) for candidate in kanaKanjiConverter.convert(targetText, options: mainOptions) { - appendUnique(candidate.text, source: .main) + appendSystemCandidate( + candidate.text, + reading: candidate.reading, + consumedReadingLength: candidate.consumedLength ?? candidate.reading.count, + source: .main, + lexicalInfo: lexicalInfo(from: candidate) + ) } let auxiliaryOptions = ConversionOptions( @@ -5488,8 +5601,10 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS ScoredConversionCandidateItem( item: ConversionCandidateItem( text: candidate.text, + reading: candidate.reading, consumedReadingLength: candidate.consumedLength ?? candidate.reading.count, - source: .auxiliary + source: .auxiliary, + lexicalInfo: lexicalInfo(from: candidate) ), score: candidate.score ) @@ -5502,28 +5617,126 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS )) for candidate in auxiliaryCandidates.sorted(by: scoredCandidateSort) { - appendUnique( + appendSystemCandidate( candidate.item.text, + reading: candidate.item.reading, consumedReadingLength: candidate.item.consumedReadingLength, - source: candidate.item.source + source: candidate.item.source, + lexicalInfo: candidate.item.lexicalInfo ) } for candidate in singleKanjiCandidates { - appendUnique(candidate.text, source: .singleKanji) + appendSystemCandidate( + candidate.text, + reading: candidate.reading, + consumedReadingLength: candidate.consumedLength ?? candidate.reading.count, + source: .singleKanji, + lexicalInfo: lexicalInfo(from: candidate) + ) } } else { for candidate in fallbackScoredCandidates(for: targetText, baseScore: ConversionOptions().unknownWordCost) .sorted(by: scoredCandidateSort) { - appendUnique( + appendSystemCandidate( candidate.item.text, + reading: candidate.item.reading, consumedReadingLength: candidate.item.consumedReadingLength, - source: candidate.item.source + source: candidate.item.source, + lexicalInfo: candidate.item.lexicalInfo ) } } - return candidates + return mergeCandidateItems( + systemCandidates: systemCandidates, + dictionaryCandidateSources: dictionaryCandidateSources, + inputReading: targetText, + totalLimit: max( + systemCandidates.count + dictionaryCandidateSources.count * conversionCandidateLimit, + conversionCandidateLimit + auxiliaryConversionCandidateLimit + singleKanjiConversionCandidateLimit + ), + includesAuxiliaryCandidates: true + ) + } + + nonisolated private static func mergeCandidateItems( + systemCandidates: [Candidate], + dictionaryCandidateSources: [any CandidateSource], + inputReading: String, + totalLimit: Int, + includesAuxiliaryCandidates: Bool + ) -> [ConversionCandidateItem] { + let effectiveLimit = max(totalLimit, 0) + guard effectiveLimit > 0 else { + return [] + } + + // システム候補を source kind ごとにグループ化して SystemCandidateSource に変換する。 + // こうすることで user/learning dictionary 候補と system 候補を単一のパイプラインに + // 通せるようになり、同じ reading+word を持つ候補が sourcePriority に基づいて + // 正しく残される(先入れ優先の appendUniqueCandidate によって user/learning 候補が + // 消える問題を解消する)。 + var systemByKind: [CandidateSourceKind: [Candidate]] = [:] + for candidate in systemCandidates { + systemByKind[candidate.sourceKind, default: []].append(candidate) + } + let systemSources: [any CandidateSource] = systemByKind.map { kind, candidates in + SystemCandidateSource(kind: kind, exactCandidates: candidates) + } + + // dictionaryCandidateSources (user, learning) を前に並べるが、 + // 最終的な順序は mergePolicy.sourcePriority が制御するため挿入順は影響しない。 + let allSources: [any CandidateSource] = dictionaryCandidateSources + systemSources + + let mergePolicy = CandidateMergePolicy( + sourcePriority: [.user, .learning, .systemMain, .systemAuxiliary, .systemSingleKanji, .systemEnglish, .fallback, .direct], + scoreStrategy: .max, + totalLimit: effectiveLimit, + includesAuxiliaryCandidates: includesAuxiliaryCandidates + ) + let pipeline = CandidatePipeline(sources: allSources, mergePolicy: mergePolicy) + + return pipeline.candidates(for: inputReading, limit: effectiveLimit).map { candidate in + ConversionCandidateItem( + text: candidate.word, + reading: candidate.reading, + consumedReadingLength: min(max(candidate.consumedReadingLength, 0), inputReading.count), + source: conversionCandidateSource(from: candidate.sourceKind), + lexicalInfo: candidate.lexicalInfo + ) + } + } + + nonisolated private static func lexicalInfo(from candidate: ConversionCandidate) -> CandidateLexicalInfo? { + guard let leftId = candidate.leftId, + let rightId = candidate.rightId else { + return nil + } + return CandidateLexicalInfo(score: candidate.score, leftId: leftId, rightId: rightId) + } + + nonisolated private static func conversionCandidateSource( + from sourceKind: CandidateSourceKind + ) -> ConversionCandidateSource { + switch sourceKind { + case .systemMain: + return .main + case .systemAuxiliary: + return .auxiliary + case .systemSingleKanji: + return .singleKanji + case .systemEnglish: + return .english + case .learning: + return .learning + case .user: + return .user + case .fallback: + return .fallback + case .direct: + return .direct + } } nonisolated private static func immediateCandidateItems( @@ -5538,8 +5751,10 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS case .english, .number: return [ConversionCandidateItem( text: targetText, + reading: targetText, consumedReadingLength: targetText.count, - source: language == .english ? .fallback : .direct + source: language == .english ? .fallback : .direct, + lexicalInfo: nil )] } } @@ -5552,24 +5767,30 @@ final class KeyboardViewController: UIInputViewController, UICollectionViewDataS ScoredConversionCandidateItem( item: ConversionCandidateItem( text: targetText, + reading: targetText, consumedReadingLength: targetText.count, - source: .fallback + source: .fallback, + lexicalInfo: nil ), score: baseScore ), ScoredConversionCandidateItem( item: ConversionCandidateItem( text: katakanaText(from: targetText), + reading: targetText, consumedReadingLength: targetText.count, - source: .fallback + source: .fallback, + lexicalInfo: nil ), score: baseScore + 100 ), ScoredConversionCandidateItem( item: ConversionCandidateItem( text: halfWidthKatakanaText(from: targetText), + reading: targetText, consumedReadingLength: targetText.count, - source: .fallback + source: .fallback, + lexicalInfo: nil ), score: baseScore + 200 ) diff --git a/sumire-keyboardShared/Dictionary/CandidatePipeline.swift b/sumire-keyboardShared/Dictionary/CandidatePipeline.swift new file mode 100644 index 0000000..9e731f0 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/CandidatePipeline.swift @@ -0,0 +1,173 @@ +import Foundation + +enum CandidateSourceKind: Hashable, Sendable { + case systemMain + case systemAuxiliary + case systemSingleKanji + case systemEnglish + case learning + case user + case fallback + case direct +} + +extension CandidateSourceKind { + static let systemDisplayOrder: [CandidateSourceKind] = [ + .systemMain, + .systemAuxiliary, + .systemSingleKanji, + .systemEnglish, + .fallback, + .direct + ] +} + +struct CandidateLexicalInfo: Hashable, Sendable { + let score: Int + let leftId: Int + let rightId: Int +} + +struct CandidateDedupKey: Hashable, Sendable { + let reading: String + let word: String +} + +struct Candidate: Hashable, Sendable { + let reading: String + let word: String + let consumedReadingLength: Int + let sourceKind: CandidateSourceKind + let lexicalInfo: CandidateLexicalInfo? + + var dedupKey: CandidateDedupKey { + CandidateDedupKey(reading: reading, word: word) + } +} + +protocol CandidateSource: Sendable { + var kind: CandidateSourceKind { get } + func searchExact(reading: String, limit: Int) -> [Candidate] + func searchCommonPrefix(inputReading: String, limit: Int) -> [Candidate] + func searchPredictive(prefix: String, limit: Int) -> [Candidate] +} + +enum CandidateScoreStrategy: Hashable, Sendable { + case max + case sourceWeighted([CandidateSourceKind: Int]) + case customNamed(String) +} + +struct CandidateMergePolicy: Hashable, Sendable { + let sourcePriority: [CandidateSourceKind] + let scoreStrategy: CandidateScoreStrategy + let totalLimit: Int + let includesAuxiliaryCandidates: Bool + + func dedupKey(for candidate: Candidate) -> CandidateDedupKey { + candidate.dedupKey + } + + func sourcePriorityRank(for kind: CandidateSourceKind) -> Int { + sourcePriority.firstIndex(of: kind) ?? sourcePriority.count + } +} + +struct CandidatePipeline: Sendable { + let sources: [any CandidateSource] + let mergePolicy: CandidateMergePolicy + + func candidates(for inputReading: String, limit: Int) -> [Candidate] { + let effectiveLimit = min(max(limit, 0), max(mergePolicy.totalLimit, 0)) + guard inputReading.isEmpty == false, effectiveLimit > 0 else { + return [] + } + + var collected: [Candidate] = [] + collected.reserveCapacity(effectiveLimit) + + for source in sources { + if source.kind == .systemAuxiliary, mergePolicy.includesAuxiliaryCandidates == false { + continue + } + + let remainingLimit = max(effectiveLimit - collected.count, 1) + collected.append(contentsOf: source.searchExact(reading: inputReading, limit: remainingLimit)) + + if source.kind != .systemMain, + source.kind != .systemEnglish, + source.kind != .fallback, + source.kind != .direct { + collected.append(contentsOf: source.searchCommonPrefix(inputReading: inputReading, limit: remainingLimit)) + collected.append(contentsOf: source.searchPredictive(prefix: inputReading, limit: remainingLimit)) + } + } + + return merge(collected).prefix(effectiveLimit).map { $0 } + } + + private func merge(_ candidates: [Candidate]) -> [Candidate] { + var bestByKey: [CandidateDedupKey: Candidate] = [:] + var firstIndexByKey: [CandidateDedupKey: Int] = [:] + + for (index, candidate) in candidates.enumerated() { + let key = mergePolicy.dedupKey(for: candidate) + if let current = bestByKey[key] { + if shouldReplace(current: current, with: candidate) { + bestByKey[key] = candidate + } + } else { + bestByKey[key] = candidate + firstIndexByKey[key] = index + } + } + + return bestByKey.values.sorted { lhs, rhs in + let lhsRank = mergePolicy.sourcePriorityRank(for: lhs.sourceKind) + let rhsRank = mergePolicy.sourcePriorityRank(for: rhs.sourceKind) + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + + let lhsScore = effectiveScore(for: lhs) + let rhsScore = effectiveScore(for: rhs) + if lhsScore != rhsScore { + return lhsScore < rhsScore + } + + let lhsIndex = firstIndexByKey[mergePolicy.dedupKey(for: lhs)] ?? Int.max + let rhsIndex = firstIndexByKey[mergePolicy.dedupKey(for: rhs)] ?? Int.max + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + + return lhs.word < rhs.word + } + } + + private func shouldReplace(current: Candidate, with candidate: Candidate) -> Bool { + let currentRank = mergePolicy.sourcePriorityRank(for: current.sourceKind) + let candidateRank = mergePolicy.sourcePriorityRank(for: candidate.sourceKind) + if currentRank != candidateRank { + return candidateRank < currentRank + } + + guard current.sourceKind == candidate.sourceKind else { + return false + } + + return effectiveScore(for: candidate) < effectiveScore(for: current) + } + + private func effectiveScore(for candidate: Candidate) -> Int { + let baseScore = candidate.lexicalInfo?.score ?? 0 + switch mergePolicy.scoreStrategy { + case .max: + return baseScore + case .sourceWeighted(let weights): + return baseScore + (weights[candidate.sourceKind] ?? 0) + case .customNamed: + return baseScore + } + } +} diff --git a/sumire-keyboardShared/Dictionary/DictionaryCandidateSources.swift b/sumire-keyboardShared/Dictionary/DictionaryCandidateSources.swift new file mode 100644 index 0000000..b1e68ba --- /dev/null +++ b/sumire-keyboardShared/Dictionary/DictionaryCandidateSources.swift @@ -0,0 +1,212 @@ +import Foundation + +// MARK: - DictionaryPredictiveSearchPolicy + +/// 辞書ソース別の予測変換ルールをまとめたヘルパー。 +/// +/// - ユーザー辞書は `allowsSystemStylePredictiveSearch` に従う(通常予測変換と同じ 3文字開始)。 +/// - 学習辞書は `allowsLearningPredictiveSearch` に従う(KeyboardSettings で可変)。 +/// - 読み長上限 `maxReadingLength` は両辞書共通: input.count < 6 → input.count + 2, それ以上 → nil。 +struct DictionaryPredictiveSearchPolicy: Sendable { + /// ユーザー辞書 / 通常予測変換の開始文字数(KanaKanjiConverter.predict() と同じ値)。 + static let defaultSystemPredictiveStartLength = 3 + + /// ユーザー辞書向け: 通常予測変換ルールで prefix / predictive 検索を許可するか。 + /// `predictiveConversionStartLength` 設定には依存しない。 + static func allowsSystemStylePredictiveSearch(input: String) -> Bool { + input.count >= defaultSystemPredictiveStartLength + } + + /// 学習辞書向け: `startLength` 以上の入力長で prefix / predictive 検索を許可するか。 + /// - Parameter startLength: `KeyboardSettings.predictiveConversionStartLength` を渡す。 + static func allowsLearningPredictiveSearch(input: String, startLength: Int) -> Bool { + input.count >= startLength + } + + /// 読み長の上限を返す(両辞書共通ルール、KanaKanjiConverter.predictiveMaxYomiLength と同じ)。 + /// - Returns: `input.count < 6` のとき `input.count + 2`、それ以上のとき `nil`(上限なし)。 + static func maxReadingLength(forInput input: String) -> Int? { + let inputLength = input.count + return inputLength < 6 ? inputLength + 2 : nil + } +} + +// MARK: - LearningDictionaryCandidateSource + +struct LearningDictionaryCandidateSource: CandidateSource { + let kind: CandidateSourceKind = .learning + private let store: SQLiteDictionaryStore + + init(store: SQLiteDictionaryStore) { + self.store = store + } + + func searchExact(reading: String, limit: Int) -> [Candidate] { + (try? store.searchLearningExact(reading: reading, limit: limit).map(Self.candidate(from:))) ?? [] + } + + /// prefix / predictive 検索: `KeyboardSettings.predictiveConversionStartLength` 文字未満では返さない。 + /// exact 検索 (`searchExact`) は設定値に関係なく常に動作する。 + func searchCommonPrefix(inputReading: String, limit: Int) -> [Candidate] { + guard DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch( + input: inputReading, + startLength: KeyboardSettings.predictiveConversionStartLength + ) else { + return [] + } + let maxReadingLength = DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: inputReading) + return (try? store.searchLearningPrefix( + prefix: inputReading, + limit: limit, + maxReadingLength: maxReadingLength + ).map(Self.candidate(from:))) ?? [] + } + + func searchPredictive(prefix: String, limit: Int) -> [Candidate] { + guard DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch( + input: prefix, + startLength: KeyboardSettings.predictiveConversionStartLength + ) else { + return [] + } + let maxReadingLength = DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: prefix) + return (try? store.searchLearningPrefix( + prefix: prefix, + limit: limit, + maxReadingLength: maxReadingLength + ).map(Self.candidate(from:))) ?? [] + } + + private static func candidate(from entry: LearningDictionaryEntry) -> Candidate { + Candidate( + reading: entry.reading, + word: entry.word, + consumedReadingLength: entry.reading.count, + sourceKind: .learning, + lexicalInfo: CandidateLexicalInfo(score: entry.score, leftId: entry.leftId, rightId: entry.rightId) + ) + } +} + +final class UserDictionaryCandidateSource: @unchecked Sendable, CandidateSource { + let kind: CandidateSourceKind = .user + private let store: SQLiteDictionaryStore + private let locator: UserDictionaryArtifactLocator + private let lock = NSLock() + private var cachedArtifact: UserDictionaryBinaryArtifact? + + init( + store: SQLiteDictionaryStore, + locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator() + ) { + self.store = store + self.locator = locator + } + + func searchExact(reading: String, limit: Int) -> [Candidate] { + let sqliteCandidates = (try? store.searchUserExact(reading: reading, limit: limit).map(Self.candidate(from:))) ?? [] + guard let artifact = currentArtifact() else { + return sqliteCandidates + } + let artifactCandidates = artifact.searchExact(reading: reading, limit: limit) + return Self.mergeDedup(sqlite: sqliteCandidates, artifact: artifactCandidates, limit: limit) + } + + /// prefix / predictive 検索: 通常予測変換ルール(3文字以上、読み長制限あり)に従う。 + /// `predictiveConversionStartLength` 設定には依存しない。 + /// exact 検索 (`searchExact`) は常に動作する。 + func searchCommonPrefix(inputReading: String, limit: Int) -> [Candidate] { + guard DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: inputReading) else { + return [] + } + let maxReadingLength = DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: inputReading) + let sqliteCandidates = (try? store.searchUserPrefix( + prefix: inputReading, + limit: limit, + maxReadingLength: maxReadingLength + ).map(Self.candidate(from:))) ?? [] + guard let artifact = currentArtifact() else { + return sqliteCandidates + } + let artifactCandidates = artifact.searchCommonPrefix( + inputReading: inputReading, + limit: limit, + maxReadingLength: maxReadingLength + ) + return Self.mergeDedup(sqlite: sqliteCandidates, artifact: artifactCandidates, limit: limit) + } + + func searchPredictive(prefix: String, limit: Int) -> [Candidate] { + guard DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: prefix) else { + return [] + } + let maxReadingLength = DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: prefix) + let sqliteCandidates = (try? store.searchUserPrefix( + prefix: prefix, + limit: limit, + maxReadingLength: maxReadingLength + ).map(Self.candidate(from:))) ?? [] + guard let artifact = currentArtifact() else { + return sqliteCandidates + } + let artifactCandidates = artifact.searchPredictive( + prefix: prefix, + limit: limit, + maxReadingLength: maxReadingLength + ) + return Self.mergeDedup(sqlite: sqliteCandidates, artifact: artifactCandidates, limit: limit) + } + + /// SQLite 側を優先して artifact 側とマージ・dedup する。 + /// SQLite に同じ reading+word がある場合は SQLite 候補を残し、artifact 候補を捨てる。 + /// これにより、artifact 作成後に SQLite へ追加された新規単語も即時反映される。 + private static func mergeDedup(sqlite: [Candidate], artifact: [Candidate], limit: Int) -> [Candidate] { + var seen = Set() + var result: [Candidate] = [] + result.reserveCapacity(min(sqlite.count + artifact.count, limit)) + + for candidate in sqlite { + guard result.count < limit else { break } + if seen.insert(candidate.dedupKey).inserted { + result.append(candidate) + } + } + + for candidate in artifact { + guard result.count < limit else { break } + if seen.insert(candidate.dedupKey).inserted { + result.append(candidate) + } + } + + return result + } + + private func currentArtifact() -> UserDictionaryBinaryArtifact? { + lock.lock() + defer { + lock.unlock() + } + + do { + let artifact = try UserDictionaryBinaryArtifact.loadCurrent(locator: locator) + if cachedArtifact?.version != artifact.version { + cachedArtifact = artifact + } + return cachedArtifact + } catch { + cachedArtifact = nil + return nil + } + } + + private static func candidate(from entry: UserDictionaryEntry) -> Candidate { + Candidate( + reading: entry.reading, + word: entry.word, + consumedReadingLength: entry.reading.count, + sourceKind: .user, + lexicalInfo: CandidateLexicalInfo(score: entry.score, leftId: entry.leftId, rightId: entry.rightId) + ) + } +} diff --git a/sumire-keyboardShared/Dictionary/DictionaryDefaultLexicalInfo.swift b/sumire-keyboardShared/Dictionary/DictionaryDefaultLexicalInfo.swift new file mode 100644 index 0000000..bf570de --- /dev/null +++ b/sumire-keyboardShared/Dictionary/DictionaryDefaultLexicalInfo.swift @@ -0,0 +1,21 @@ +import Foundation + +enum DictionaryDefaultLexicalIDs { + // id.def: 名詞,一般,*,*,*,*,* + // lexical 情報が取れない候補を user/learning dictionary に保存する際の default POS ID。 + static let generalNoun = 1851 +} + +enum DictionaryDefaultLexicalInfo { + static let generalNoun = CandidateLexicalInfo( + score: 0, + leftId: DictionaryDefaultLexicalIDs.generalNoun, + rightId: DictionaryDefaultLexicalIDs.generalNoun + ) +} + +extension Optional where Wrapped == CandidateLexicalInfo { + var resolvedForDictionarySave: CandidateLexicalInfo { + self ?? DictionaryDefaultLexicalInfo.generalNoun + } +} diff --git a/sumire-keyboardShared/Dictionary/DictionaryImportAndLoudsProtocols.swift b/sumire-keyboardShared/Dictionary/DictionaryImportAndLoudsProtocols.swift new file mode 100644 index 0000000..6f019a4 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/DictionaryImportAndLoudsProtocols.swift @@ -0,0 +1,101 @@ +import Foundation + +enum UserDictionarySourceType: Hashable, Sendable { + case plainText + case csv + case customNamed(String) +} + +protocol UserDictionaryImporter: Sendable { + var sourceType: UserDictionarySourceType { get } + func parse(data: Data) throws -> [RawImportedDictionaryRow] +} + +struct RawImportedDictionaryRow: Hashable, Sendable { + let reading: String? + let word: String? + let score: Int? + let leftId: Int? + let rightId: Int? + let sourceLineNumber: Int? +} + +struct NormalizedImportEntry: Hashable, Sendable { + let reading: String + let word: String + let score: Int + let leftId: Int + let rightId: Int +} + +struct ImportNormalizationResult: Sendable { + let entries: [NormalizedImportEntry] + let rejectedRows: [RawImportedDictionaryRow] + let mergedDuplicateCount: Int +} + +protocol ImportNormalizer: Sendable { + func normalize( + rows: [RawImportedDictionaryRow], + sourceType: UserDictionarySourceType + ) throws -> ImportNormalizationResult +} + +struct ImportPersistResult: Sendable { + let insertedCount: Int + let updatedCount: Int + let skippedCount: Int +} + +protocol UserDictionaryImportCoordinator: Sendable { + func importDictionary(data: Data, importer: any UserDictionaryImporter) async throws -> ImportPersistResult +} + +struct UserDictionaryLoudsArtifacts: Sendable { + let directoryURL: URL + let manifestURL: URL? +} + +enum UserDictionaryBuildStatus: Hashable, Sendable { + case idle + case building + case validating + case ready + case failed(String) +} + +struct UserDictionaryBuildState: Hashable, Sendable { + let status: UserDictionaryBuildStatus + let updatedAt: Date + let artifactVersion: String? + let lastErrorMessage: String? + + init( + status: UserDictionaryBuildStatus, + updatedAt: Date, + artifactVersion: String? = nil, + lastErrorMessage: String? = nil + ) { + self.status = status + self.updatedAt = updatedAt + self.artifactVersion = artifactVersion + self.lastErrorMessage = lastErrorMessage + } +} + +protocol UserDictionaryLoudsBuilder: Sendable { + func build(from entries: [UserDictionaryEntry]) async throws -> UserDictionaryLoudsArtifacts +} + +protocol UserDictionaryLoudsValidator: Sendable { + func validate(_ artifacts: UserDictionaryLoudsArtifacts) async throws +} + +protocol UserDictionaryArtifactPublisher: Sendable { + func publish(_ artifacts: UserDictionaryLoudsArtifacts) async throws +} + +protocol UserDictionaryBuildStateRepository: Sendable { + func load() async throws -> UserDictionaryBuildState + func save(_ state: UserDictionaryBuildState) async throws +} diff --git a/sumire-keyboardShared/Dictionary/DictionaryModels.swift b/sumire-keyboardShared/Dictionary/DictionaryModels.swift new file mode 100644 index 0000000..f18f565 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/DictionaryModels.swift @@ -0,0 +1,70 @@ +import Foundation + +typealias LearningDictionaryEntryID = UUID +typealias UserDictionaryEntryID = UUID + +struct LearningDictionaryEntry: Identifiable, Hashable, Sendable { + let id: LearningDictionaryEntryID + var reading: String + var word: String + var score: Int + var leftId: Int + var rightId: Int + var updatedAt: Date +} + +struct CommittedSelection: Sendable { + let inputReading: String + let candidateReading: String + let word: String + let sourceKind: CandidateSourceKind + let lexicalInfo: CandidateLexicalInfo? + let committedAt: Date +} + +protocol LearningDictionaryLearningRepository: Sendable { + func recordCommittedSelection(_ selection: CommittedSelection) async throws +} + +protocol LearningDictionaryQueryRepository: Sendable { + func searchExact(reading: String, limit: Int) async throws -> [LearningDictionaryEntry] + func searchCommonPrefix(inputReading: String, limit: Int) async throws -> [LearningDictionaryEntry] + func searchPredictive(prefix: String, limit: Int) async throws -> [LearningDictionaryEntry] + func searchForManagementUI(query: String, limit: Int, offset: Int) async throws -> [LearningDictionaryEntry] +} + +protocol LearningDictionaryEditorRepository: Sendable { + func add(_ entry: LearningDictionaryEntry) async throws + func update(_ entry: LearningDictionaryEntry) async throws + func delete(id: LearningDictionaryEntryID) async throws + func deleteAll() async throws +} + +struct UserDictionaryEntry: Identifiable, Hashable, Sendable { + let id: UserDictionaryEntryID + var reading: String + var word: String + var score: Int + var leftId: Int + var rightId: Int + var updatedAt: Date +} + +protocol UserDictionaryManagementQueryRepository: Sendable { + func searchForManagementUI(query: String, limit: Int, offset: Int) async throws -> [UserDictionaryEntry] + func count(query: String) async throws -> Int + func allEntries() async throws -> [UserDictionaryEntry] +} + +protocol UserDictionaryCandidateQueryRepository: Sendable { + func searchExact(reading: String, limit: Int) async throws -> [UserDictionaryEntry] + func searchCommonPrefix(inputReading: String, limit: Int) async throws -> [UserDictionaryEntry] + func searchPredictive(prefix: String, limit: Int) async throws -> [UserDictionaryEntry] +} + +protocol UserDictionaryEditorRepository: Sendable { + func add(_ entry: UserDictionaryEntry) async throws + func update(_ entry: UserDictionaryEntry) async throws + func delete(id: UserDictionaryEntryID) async throws + func deleteAll() async throws +} diff --git a/sumire-keyboardShared/Dictionary/DictionaryRepositoryContainer.swift b/sumire-keyboardShared/Dictionary/DictionaryRepositoryContainer.swift new file mode 100644 index 0000000..41edf12 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/DictionaryRepositoryContainer.swift @@ -0,0 +1,21 @@ +import Foundation + +enum DictionaryRepositoryContainer { + static func makeDefault() throws -> DictionaryRepositories { + try DictionaryRepositories(store: SQLiteDictionaryStore(databaseURL: sharedDatabaseURL())) + } + + static func sharedDatabaseURL() -> URL { + sharedDictionaryDirectoryURL().appendingPathComponent("sumire-dictionaries.sqlite") + } + + static func sharedDictionaryDirectoryURL() -> URL { + let fileManager = FileManager.default + let baseURL = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: KeyboardSettings.appGroupIdentifier + ) ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + + return baseURL + .appendingPathComponent("Dictionary", isDirectory: true) + } +} diff --git a/sumire-keyboardShared/Dictionary/SQLiteDatabase.swift b/sumire-keyboardShared/Dictionary/SQLiteDatabase.swift new file mode 100644 index 0000000..50584d1 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/SQLiteDatabase.swift @@ -0,0 +1,209 @@ +import Foundation +import SQLite3 + +enum SQLiteDatabaseError: Error, LocalizedError { + case openFailed(String) + case prepareFailed(String) + case stepFailed(String) + case bindFailed(String) + case invalidColumn(String) + + var errorDescription: String? { + switch self { + case .openFailed(let message): + return "SQLite open failed: \(message)" + case .prepareFailed(let message): + return "SQLite prepare failed: \(message)" + case .stepFailed(let message): + return "SQLite step failed: \(message)" + case .bindFailed(let message): + return "SQLite bind failed: \(message)" + case .invalidColumn(let message): + return "SQLite invalid column: \(message)" + } + } +} + +enum SQLiteValue: Sendable { + case text(String) + case int(Int) + case double(Double) + case null +} + +final class SQLiteStatement { + fileprivate let statement: OpaquePointer + + fileprivate init(statement: OpaquePointer) { + self.statement = statement + } + + func text(at index: Int32) throws -> String { + guard let cString = sqlite3_column_text(statement, index) else { + throw SQLiteDatabaseError.invalidColumn("Expected TEXT at column \(index).") + } + return String(cString: cString) + } + + func int(at index: Int32) -> Int { + Int(sqlite3_column_int64(statement, index)) + } + + func double(at index: Int32) -> Double { + sqlite3_column_double(statement, index) + } +} + +final class SQLiteDatabase: @unchecked Sendable { + private let url: URL + private let queue = DispatchQueue(label: "com.kazumaproject.sumire-keyboard.sqlite") + private let queueKey = DispatchSpecificKey() + private var connection: OpaquePointer? + + init(url: URL) throws { + self.url = url + queue.setSpecific(key: queueKey, value: ()) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + var database: OpaquePointer? + let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + let status = sqlite3_open_v2(url.path, &database, flags, nil) + guard status == SQLITE_OK, let database else { + let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" + if let database { + sqlite3_close(database) + } + throw SQLiteDatabaseError.openFailed(message) + } + connection = database + try execute("PRAGMA journal_mode = WAL") + try execute("PRAGMA foreign_keys = ON") + } + + deinit { + if let connection { + sqlite3_close(connection) + } + } + + func execute(_ sql: String, bindings: [SQLiteValue] = []) throws { + try syncOnQueue { + let statement = try prepare(sql) + defer { + sqlite3_finalize(statement) + } + try bind(bindings, to: statement) + let status = sqlite3_step(statement) + guard status == SQLITE_DONE || status == SQLITE_ROW else { + throw SQLiteDatabaseError.stepFailed(lastErrorMessage()) + } + } + } + + func query( + _ sql: String, + bindings: [SQLiteValue] = [], + map: (SQLiteStatement) throws -> T + ) throws -> [T] { + try syncOnQueue { + let statement = try prepare(sql) + defer { + sqlite3_finalize(statement) + } + try bind(bindings, to: statement) + + var results: [T] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_ROW { + results.append(try map(SQLiteStatement(statement: statement))) + } else if status == SQLITE_DONE { + return results + } else { + throw SQLiteDatabaseError.stepFailed(lastErrorMessage()) + } + } + } + } + + func transaction(_ work: () throws -> Void) throws { + try syncOnQueue { + try executeWithoutQueue("BEGIN IMMEDIATE") + do { + try work() + try executeWithoutQueue("COMMIT") + } catch { + try? executeWithoutQueue("ROLLBACK") + throw error + } + } + } + + private func syncOnQueue(_ work: () throws -> T) throws -> T { + if DispatchQueue.getSpecific(key: queueKey) != nil { + return try work() + } + return try queue.sync(execute: work) + } + + private func executeWithoutQueue(_ sql: String, bindings: [SQLiteValue] = []) throws { + let statement = try prepare(sql) + defer { + sqlite3_finalize(statement) + } + try bind(bindings, to: statement) + let status = sqlite3_step(statement) + guard status == SQLITE_DONE || status == SQLITE_ROW else { + throw SQLiteDatabaseError.stepFailed(lastErrorMessage()) + } + } + + private func prepare(_ sql: String) throws -> OpaquePointer { + guard let connection else { + throw SQLiteDatabaseError.openFailed("Database is closed.") + } + + var statement: OpaquePointer? + let status = sqlite3_prepare_v2(connection, sql, -1, &statement, nil) + guard status == SQLITE_OK, let statement else { + throw SQLiteDatabaseError.prepareFailed(lastErrorMessage()) + } + return statement + } + + private func bind(_ values: [SQLiteValue], to statement: OpaquePointer) throws { + for (offset, value) in values.enumerated() { + let index = Int32(offset + 1) + let status: Int32 + switch value { + case .text(let text): + status = sqlite3_bind_text(statement, index, text, -1, SQLiteDatabase.transientDestructor) + case .int(let int): + status = sqlite3_bind_int64(statement, index, sqlite3_int64(int)) + case .double(let double): + status = sqlite3_bind_double(statement, index, double) + case .null: + status = sqlite3_bind_null(statement, index) + } + + guard status == SQLITE_OK else { + throw SQLiteDatabaseError.bindFailed(lastErrorMessage()) + } + } + } + + private func lastErrorMessage() -> String { + guard let connection else { + return "Database is closed." + } + return String(cString: sqlite3_errmsg(connection)) + } + + private static let transientDestructor = unsafeBitCast( + -1, + to: sqlite3_destructor_type.self + ) +} diff --git a/sumire-keyboardShared/Dictionary/SQLiteDictionaryRepositories.swift b/sumire-keyboardShared/Dictionary/SQLiteDictionaryRepositories.swift new file mode 100644 index 0000000..6ae64ea --- /dev/null +++ b/sumire-keyboardShared/Dictionary/SQLiteDictionaryRepositories.swift @@ -0,0 +1,115 @@ +import Foundation + +final class SQLiteLearningDictionaryRepository: @unchecked Sendable, + LearningDictionaryLearningRepository, + LearningDictionaryQueryRepository, + LearningDictionaryEditorRepository { + private let store: SQLiteDictionaryStore + + init(store: SQLiteDictionaryStore) { + self.store = store + } + + func recordCommittedSelection(_ selection: CommittedSelection) async throws { + try store.recordCommittedSelection(selection) + } + + func searchExact(reading: String, limit: Int) async throws -> [LearningDictionaryEntry] { + try store.searchLearningExact(reading: reading, limit: limit) + } + + func searchCommonPrefix(inputReading: String, limit: Int) async throws -> [LearningDictionaryEntry] { + try store.searchLearningPrefix(prefix: inputReading, limit: limit) + } + + func searchPredictive(prefix: String, limit: Int) async throws -> [LearningDictionaryEntry] { + try store.searchLearningPrefix(prefix: prefix, limit: limit) + } + + func searchForManagementUI(query: String, limit: Int, offset: Int) async throws -> [LearningDictionaryEntry] { + try store.searchLearningForManagementUI(query: query, limit: limit, offset: offset) + } + + func add(_ entry: LearningDictionaryEntry) async throws { + try store.addLearning(entry) + } + + func update(_ entry: LearningDictionaryEntry) async throws { + try store.updateLearning(entry) + } + + func delete(id: LearningDictionaryEntryID) async throws { + try store.deleteLearning(id: id) + } + + func deleteAll() async throws { + try store.deleteAllLearning() + } +} + +final class SQLiteUserDictionaryRepository: @unchecked Sendable, + UserDictionaryManagementQueryRepository, + UserDictionaryCandidateQueryRepository, + UserDictionaryEditorRepository { + private let store: SQLiteDictionaryStore + + init(store: SQLiteDictionaryStore) { + self.store = store + } + + func searchForManagementUI(query: String, limit: Int, offset: Int) async throws -> [UserDictionaryEntry] { + try store.searchUserForManagementUI(query: query, limit: limit, offset: offset) + } + + func count(query: String) async throws -> Int { + try store.countUserEntries(query: query) + } + + func allEntries() async throws -> [UserDictionaryEntry] { + try store.allUserEntries() + } + + func searchExact(reading: String, limit: Int) async throws -> [UserDictionaryEntry] { + try store.searchUserExact(reading: reading, limit: limit) + } + + func searchCommonPrefix(inputReading: String, limit: Int) async throws -> [UserDictionaryEntry] { + try store.searchUserPrefix(prefix: inputReading, limit: limit) + } + + func searchPredictive(prefix: String, limit: Int) async throws -> [UserDictionaryEntry] { + try store.searchUserPrefix(prefix: prefix, limit: limit) + } + + func add(_ entry: UserDictionaryEntry) async throws { + try store.addUser(entry) + } + + func update(_ entry: UserDictionaryEntry) async throws { + try store.updateUser(entry) + } + + func delete(id: UserDictionaryEntryID) async throws { + try store.deleteUser(id: id) + } + + func deleteAll() async throws { + try store.deleteAllUser() + } +} + +struct DictionaryRepositories: Sendable { + let store: SQLiteDictionaryStore + let learningRepository: SQLiteLearningDictionaryRepository + let userRepository: SQLiteUserDictionaryRepository + let learningCandidateSource: LearningDictionaryCandidateSource + let userCandidateSource: UserDictionaryCandidateSource + + init(store: SQLiteDictionaryStore) { + self.store = store + self.learningRepository = SQLiteLearningDictionaryRepository(store: store) + self.userRepository = SQLiteUserDictionaryRepository(store: store) + self.learningCandidateSource = LearningDictionaryCandidateSource(store: store) + self.userCandidateSource = UserDictionaryCandidateSource(store: store) + } +} diff --git a/sumire-keyboardShared/Dictionary/SQLiteDictionaryStore.swift b/sumire-keyboardShared/Dictionary/SQLiteDictionaryStore.swift new file mode 100644 index 0000000..3578b54 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/SQLiteDictionaryStore.swift @@ -0,0 +1,509 @@ +import Foundation + +final class SQLiteDictionaryStore: @unchecked Sendable { + private let database: SQLiteDatabase + + init(databaseURL: URL) throws { + database = try SQLiteDatabase(url: databaseURL) + try migrate() + } + + init(database: SQLiteDatabase) throws { + self.database = database + try migrate() + } + + func recordCommittedSelection(_ selection: CommittedSelection) throws { + guard selection.candidateReading.count == selection.inputReading.count else { + return + } + + let lexicalInfo = selection.lexicalInfo.resolvedForDictionarySave + try recordLearningEntry( + reading: selection.candidateReading, + word: selection.word, + score: lexicalInfo.score, + leftId: lexicalInfo.leftId, + rightId: lexicalInfo.rightId, + updatedAt: selection.committedAt + ) + } + + func addLearning(_ entry: LearningDictionaryEntry) throws { + try upsertLearning(entry) + } + + func updateLearning(_ entry: LearningDictionaryEntry) throws { + try updateLearningEntry(entry) + } + + func deleteLearning(id: LearningDictionaryEntryID) throws { + try database.execute( + "DELETE FROM learning_dictionary_entries WHERE id = ?", + bindings: [.text(id.uuidString)] + ) + } + + func deleteAllLearning() throws { + try database.execute("DELETE FROM learning_dictionary_entries") + } + + func addUser(_ entry: UserDictionaryEntry) throws { + try upsertUser(entry) + } + + func updateUser(_ entry: UserDictionaryEntry) throws { + try updateUserEntry(entry) + } + + func deleteUser(id: UserDictionaryEntryID) throws { + try database.execute( + "DELETE FROM user_dictionary_entries WHERE id = ?", + bindings: [.text(id.uuidString)] + ) + } + + func deleteAllUser() throws { + try database.execute("DELETE FROM user_dictionary_entries") + } + + func searchLearningExact(reading: String, limit: Int) throws -> [LearningDictionaryEntry] { + guard reading.isEmpty == false, limit > 0 else { + return [] + } + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM learning_dictionary_entries + WHERE reading = ? + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(reading), .int(limit)], + map: Self.learningEntry(from:) + ) + } + + func searchLearningPrefix( + prefix: String, + limit: Int, + maxReadingLength: Int? = nil + ) throws -> [LearningDictionaryEntry] { + guard prefix.isEmpty == false, limit > 0 else { + return [] + } + if let maxReadingLength { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM learning_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' + AND length(reading) <= ? + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(Self.likePrefixPattern(prefix)), .int(maxReadingLength), .int(limit)], + map: Self.learningEntry(from:) + ) + } else { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM learning_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(Self.likePrefixPattern(prefix)), .int(limit)], + map: Self.learningEntry(from:) + ) + } + } + + func searchLearningForManagementUI(query: String, limit: Int, offset: Int) throws -> [LearningDictionaryEntry] { + let safeLimit = max(limit, 1) + let safeOffset = max(offset, 0) + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM learning_dictionary_entries + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + bindings: [.int(safeLimit), .int(safeOffset)], + map: Self.learningEntry(from:) + ) + } + + let pattern = Self.likeContainsPattern(query) + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM learning_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' OR word LIKE ? ESCAPE '\\' + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + bindings: [.text(pattern), .text(pattern), .int(safeLimit), .int(safeOffset)], + map: Self.learningEntry(from:) + ) + } + + func searchUserExact(reading: String, limit: Int) throws -> [UserDictionaryEntry] { + guard reading.isEmpty == false, limit > 0 else { + return [] + } + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + WHERE reading = ? + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(reading), .int(limit)], + map: Self.userEntry(from:) + ) + } + + func searchUserPrefix( + prefix: String, + limit: Int, + maxReadingLength: Int? = nil + ) throws -> [UserDictionaryEntry] { + guard prefix.isEmpty == false, limit > 0 else { + return [] + } + if let maxReadingLength { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' + AND length(reading) <= ? + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(Self.likePrefixPattern(prefix)), .int(maxReadingLength), .int(limit)], + map: Self.userEntry(from:) + ) + } else { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' + ORDER BY score ASC, updated_at DESC + LIMIT ? + """, + bindings: [.text(Self.likePrefixPattern(prefix)), .int(limit)], + map: Self.userEntry(from:) + ) + } + } + + func searchUserForManagementUI(query: String, limit: Int, offset: Int) throws -> [UserDictionaryEntry] { + let safeLimit = max(limit, 1) + let safeOffset = max(offset, 0) + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + bindings: [.int(safeLimit), .int(safeOffset)], + map: Self.userEntry(from:) + ) + } + + let pattern = Self.likeContainsPattern(query) + return try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' OR word LIKE ? ESCAPE '\\' + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + """, + bindings: [.text(pattern), .text(pattern), .int(safeLimit), .int(safeOffset)], + map: Self.userEntry(from:) + ) + } + + func allUserEntries() throws -> [UserDictionaryEntry] { + try database.query( + """ + SELECT id, reading, word, score, left_id, right_id, updated_at + FROM user_dictionary_entries + ORDER BY reading ASC, score ASC, updated_at DESC + """, + map: Self.userEntry(from:) + ) + } + + func countUserEntries(query: String) throws -> Int { + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return try database.query( + "SELECT COUNT(*) FROM user_dictionary_entries", + map: { $0.int(at: 0) } + ).first ?? 0 + } + + let pattern = Self.likeContainsPattern(query) + return try database.query( + """ + SELECT COUNT(*) + FROM user_dictionary_entries + WHERE reading LIKE ? ESCAPE '\\' OR word LIKE ? ESCAPE '\\' + """, + bindings: [.text(pattern), .text(pattern)], + map: { $0.int(at: 0) } + ).first ?? 0 + } + + private func migrate() throws { + try database.execute( + """ + CREATE TABLE IF NOT EXISTS learning_dictionary_entries ( + id TEXT PRIMARY KEY, + reading TEXT NOT NULL, + word TEXT NOT NULL, + score INTEGER NOT NULL, + left_id INTEGER NOT NULL, + right_id INTEGER NOT NULL, + updated_at REAL NOT NULL + ) + """ + ) + try database.execute( + """ + CREATE INDEX IF NOT EXISTS learning_dictionary_entries_reading_idx + ON learning_dictionary_entries(reading) + """ + ) + try database.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS learning_dictionary_entries_unique_logical_idx + ON learning_dictionary_entries(reading, word, left_id, right_id) + """ + ) + try database.execute( + """ + CREATE TABLE IF NOT EXISTS user_dictionary_entries ( + id TEXT PRIMARY KEY, + reading TEXT NOT NULL, + word TEXT NOT NULL, + score INTEGER NOT NULL, + left_id INTEGER NOT NULL, + right_id INTEGER NOT NULL, + updated_at REAL NOT NULL + ) + """ + ) + try database.execute( + """ + CREATE INDEX IF NOT EXISTS user_dictionary_entries_reading_idx + ON user_dictionary_entries(reading) + """ + ) + try database.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS user_dictionary_entries_unique_logical_idx + ON user_dictionary_entries(reading, word, left_id, right_id) + """ + ) + } + + private func recordLearningEntry( + reading: String, + word: String, + score: Int, + leftId: Int, + rightId: Int, + updatedAt: Date + ) throws { + let existing = try database.query( + """ + SELECT id, score + FROM learning_dictionary_entries + WHERE reading = ? AND word = ? AND left_id = ? AND right_id = ? + LIMIT 1 + """, + bindings: [.text(reading), .text(word), .int(leftId), .int(rightId)] + ) { statement in + (id: try statement.text(at: 0), score: statement.int(at: 1)) + }.first + + if let existing { + try database.execute( + """ + UPDATE learning_dictionary_entries + SET score = ?, updated_at = ? + WHERE id = ? + """, + bindings: [ + .int(existing.score - 500), + .double(updatedAt.timeIntervalSince1970), + .text(existing.id) + ] + ) + } else { + try database.execute( + """ + INSERT INTO learning_dictionary_entries + (id, reading, word, score, left_id, right_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + bindings: [ + .text(UUID().uuidString), + .text(reading), + .text(word), + .int(score), + .int(leftId), + .int(rightId), + .double(updatedAt.timeIntervalSince1970) + ] + ) + } + } + + private func upsertLearning(_ entry: LearningDictionaryEntry) throws { + try database.execute( + """ + INSERT INTO learning_dictionary_entries + (id, reading, word, score, left_id, right_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + reading = excluded.reading, + word = excluded.word, + score = excluded.score, + left_id = excluded.left_id, + right_id = excluded.right_id, + updated_at = excluded.updated_at + """, + bindings: Self.bindings(for: entry) + ) + } + + private func updateLearningEntry(_ entry: LearningDictionaryEntry) throws { + try database.execute( + """ + UPDATE learning_dictionary_entries + SET reading = ?, word = ?, score = ?, left_id = ?, right_id = ?, updated_at = ? + WHERE id = ? + """, + bindings: [ + .text(entry.reading), + .text(entry.word), + .int(entry.score), + .int(entry.leftId), + .int(entry.rightId), + .double(entry.updatedAt.timeIntervalSince1970), + .text(entry.id.uuidString) + ] + ) + } + + private func upsertUser(_ entry: UserDictionaryEntry) throws { + try database.execute( + """ + INSERT INTO user_dictionary_entries + (id, reading, word, score, left_id, right_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + reading = excluded.reading, + word = excluded.word, + score = excluded.score, + left_id = excluded.left_id, + right_id = excluded.right_id, + updated_at = excluded.updated_at + """, + bindings: Self.bindings(for: entry) + ) + } + + private func updateUserEntry(_ entry: UserDictionaryEntry) throws { + try database.execute( + """ + UPDATE user_dictionary_entries + SET reading = ?, word = ?, score = ?, left_id = ?, right_id = ?, updated_at = ? + WHERE id = ? + """, + bindings: [ + .text(entry.reading), + .text(entry.word), + .int(entry.score), + .int(entry.leftId), + .int(entry.rightId), + .double(entry.updatedAt.timeIntervalSince1970), + .text(entry.id.uuidString) + ] + ) + } + + private static func learningEntry(from statement: SQLiteStatement) throws -> LearningDictionaryEntry { + LearningDictionaryEntry( + id: UUID(uuidString: try statement.text(at: 0)) ?? UUID(), + reading: try statement.text(at: 1), + word: try statement.text(at: 2), + score: statement.int(at: 3), + leftId: statement.int(at: 4), + rightId: statement.int(at: 5), + updatedAt: Date(timeIntervalSince1970: statement.double(at: 6)) + ) + } + + private static func userEntry(from statement: SQLiteStatement) throws -> UserDictionaryEntry { + UserDictionaryEntry( + id: UUID(uuidString: try statement.text(at: 0)) ?? UUID(), + reading: try statement.text(at: 1), + word: try statement.text(at: 2), + score: statement.int(at: 3), + leftId: statement.int(at: 4), + rightId: statement.int(at: 5), + updatedAt: Date(timeIntervalSince1970: statement.double(at: 6)) + ) + } + + private static func bindings(for entry: LearningDictionaryEntry) -> [SQLiteValue] { + [ + .text(entry.id.uuidString), + .text(entry.reading), + .text(entry.word), + .int(entry.score), + .int(entry.leftId), + .int(entry.rightId), + .double(entry.updatedAt.timeIntervalSince1970) + ] + } + + private static func bindings(for entry: UserDictionaryEntry) -> [SQLiteValue] { + [ + .text(entry.id.uuidString), + .text(entry.reading), + .text(entry.word), + .int(entry.score), + .int(entry.leftId), + .int(entry.rightId), + .double(entry.updatedAt.timeIntervalSince1970) + ] + } + + private static func likePrefixPattern(_ value: String) -> String { + escapeLike(value) + "%" + } + + private static func likeContainsPattern(_ value: String) -> String { + "%" + escapeLike(value) + "%" + } + + private static func escapeLike(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + } +} diff --git a/sumire-keyboardShared/Dictionary/UserDictionaryLoudsArtifact.swift b/sumire-keyboardShared/Dictionary/UserDictionaryLoudsArtifact.swift new file mode 100644 index 0000000..5d141b2 --- /dev/null +++ b/sumire-keyboardShared/Dictionary/UserDictionaryLoudsArtifact.swift @@ -0,0 +1,384 @@ +import Foundation + +enum UserDictionaryArtifactError: Error, LocalizedError { + case artifactNotFound + case invalidArtifact(String) + + var errorDescription: String? { + switch self { + case .artifactNotFound: + return "User dictionary artifact was not found." + case .invalidArtifact(let message): + return "Invalid user dictionary artifact: \(message)" + } + } +} + +struct UserDictionaryArtifactLocator: Sendable { + let rootDirectoryURL: URL + + init(rootDirectoryURL: URL = DictionaryRepositoryContainer.sharedDictionaryDirectoryURL()) { + self.rootDirectoryURL = rootDirectoryURL + } + + var artifactsDirectoryURL: URL { + rootDirectoryURL.appendingPathComponent("UserDictionaryArtifacts", isDirectory: true) + } + + var buildsDirectoryURL: URL { + artifactsDirectoryURL.appendingPathComponent("builds", isDirectory: true) + } + + var currentManifestURL: URL { + artifactsDirectoryURL.appendingPathComponent("current-manifest.json") + } + + var buildStateURL: URL { + artifactsDirectoryURL.appendingPathComponent("build-state.json") + } + + func buildDirectoryURL(version: String) -> URL { + buildsDirectoryURL.appendingPathComponent(version, isDirectory: true) + } +} + +private struct UserDictionaryArtifactManifest: Codable { + let version: String + let builtAt: Double + let artifactsDirectoryRelativePath: String +} + +private struct PublishedUserDictionaryArtifact { + let version: String + let builtAt: Date + let artifactsDirectoryURL: URL + + static func loadCurrent( + locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator() + ) throws -> PublishedUserDictionaryArtifact { + let manifestData = try Data(contentsOf: locator.currentManifestURL) + let manifest = try JSONDecoder().decode(UserDictionaryArtifactManifest.self, from: manifestData) + let artifactsDirectoryURL = locator.artifactsDirectoryURL.appendingPathComponent( + manifest.artifactsDirectoryRelativePath, + isDirectory: true + ) + return PublishedUserDictionaryArtifact( + version: manifest.version, + builtAt: Date(timeIntervalSince1970: manifest.builtAt), + artifactsDirectoryURL: artifactsDirectoryURL + ) + } +} + +final class UserDictionaryBinaryArtifact: @unchecked Sendable { + let version: String + let builtAt: Date + private let dictionary: MozcDictionary + + init(version: String, builtAt: Date, dictionary: MozcDictionary) { + self.version = version + self.builtAt = builtAt + self.dictionary = dictionary + } + + static func loadCurrent( + locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator() + ) throws -> UserDictionaryBinaryArtifact { + let published = try PublishedUserDictionaryArtifact.loadCurrent(locator: locator) + return try load( + version: published.version, + builtAt: published.builtAt, + artifactsDirectoryURL: published.artifactsDirectoryURL + ) + } + + static func load( + version: String, + builtAt: Date, + artifactsDirectoryURL: URL + ) throws -> UserDictionaryBinaryArtifact { + guard MozcArtifactIO.containsDictionaryArtifacts(at: artifactsDirectoryURL) else { + throw UserDictionaryArtifactError.artifactNotFound + } + + let posTableURL = artifactsDirectoryURL.appendingPathComponent(MozcDictionary.posTableFileName) + guard FileManager.default.fileExists(atPath: posTableURL.path) else { + throw UserDictionaryArtifactError.invalidArtifact("POS table is missing.") + } + + let dictionary = try MozcDictionary(artifactsDirectory: artifactsDirectoryURL) + return UserDictionaryBinaryArtifact( + version: version, + builtAt: builtAt, + dictionary: dictionary + ) + } + + func searchExact(reading: String, limit: Int) -> [Candidate] { + guard reading.isEmpty == false, limit > 0 else { + return [] + } + + let matches = dictionary.prefixMatches( + in: Array(reading), + from: 0, + mode: .commonPrefix + ) + .filter { $0.length == reading.count } + + var entries: [DictionaryEntry] = [] + entries.reserveCapacity(limit) + for match in matches { + for entry in match.entries where entry.yomi == reading { + entries.append(entry) + if entries.count >= limit { + break + } + } + if entries.count >= limit { + break + } + } + + return entries + .sorted(by: Self.entrySort) + .prefix(limit) + .map(Self.candidate(from:)) + } + + /// - Parameter maxReadingLength: `nil` の場合は上限なし。 + /// `DictionaryPredictiveSearchPolicy.maxReadingLength(forInput:)` の値を渡す。 + func searchCommonPrefix(inputReading: String, limit: Int, maxReadingLength: Int? = nil) -> [Candidate] { + guard inputReading.isEmpty == false, limit > 0 else { + return [] + } + + return entries(forPrefix: inputReading, limit: limit, maxReadingLength: maxReadingLength) + } + + func searchPredictive(prefix: String, limit: Int, maxReadingLength: Int? = nil) -> [Candidate] { + guard prefix.isEmpty == false, limit > 0 else { + return [] + } + + return entries(forPrefix: prefix, limit: limit, maxReadingLength: maxReadingLength) + } + + private func entries(forPrefix prefix: String, limit: Int, maxReadingLength: Int? = nil) -> [Candidate] { + dictionary.predictiveEntries( + for: prefix, + predictivePrefixLength: prefix.count, + limit: limit, + maxYomiLength: maxReadingLength + ).map(Self.candidate(from:)) + } + + private static func candidate(from entry: DictionaryEntry) -> Candidate { + Candidate( + reading: entry.yomi, + word: entry.surface, + consumedReadingLength: entry.yomi.count, + sourceKind: .user, + lexicalInfo: CandidateLexicalInfo( + score: entry.cost, + leftId: entry.leftId, + rightId: entry.rightId + ) + ) + } + + private static func entrySort(_ lhs: DictionaryEntry, _ rhs: DictionaryEntry) -> Bool { + if lhs.cost != rhs.cost { + return lhs.cost < rhs.cost + } + if lhs.yomi.count != rhs.yomi.count { + return lhs.yomi.count > rhs.yomi.count + } + return lhs.surface < rhs.surface + } +} + +struct FileUserDictionaryLoudsBuilder: UserDictionaryLoudsBuilder { + let locator: UserDictionaryArtifactLocator + + init(locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator()) { + self.locator = locator + } + + func build(from entries: [UserDictionaryEntry]) async throws -> UserDictionaryLoudsArtifacts { + let version = Self.makeVersion() + let buildDirectoryURL = locator.buildDirectoryURL(version: version) + try FileManager.default.createDirectory(at: buildDirectoryURL, withIntermediateDirectories: true) + + let dictionaryEntries = entries + .filter { $0.reading.isEmpty == false && $0.word.isEmpty == false } + .map { + DictionaryEntry( + yomi: $0.reading, + leftId: $0.leftId, + rightId: $0.rightId, + cost: $0.score, + surface: $0.word + ) + } + + try MozcArtifactIO.writeDictionaryArtifacts(from: dictionaryEntries, to: buildDirectoryURL) + + let manifest = UserDictionaryArtifactManifest( + version: version, + builtAt: Date().timeIntervalSince1970, + artifactsDirectoryRelativePath: "builds/\(version)" + ) + let manifestURL = buildDirectoryURL.appendingPathComponent("manifest.json") + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(manifest).write(to: manifestURL, options: .atomic) + + return UserDictionaryLoudsArtifacts( + directoryURL: buildDirectoryURL, + manifestURL: manifestURL + ) + } + + private static func makeVersion() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: ".", with: "-") + } +} + +struct FileUserDictionaryLoudsValidator: UserDictionaryLoudsValidator { + func validate(_ artifacts: UserDictionaryLoudsArtifacts) async throws { + guard let manifestURL = artifacts.manifestURL else { + throw UserDictionaryArtifactError.invalidArtifact("Manifest URL is missing.") + } + + let manifestData = try Data(contentsOf: manifestURL) + let manifest = try JSONDecoder().decode(UserDictionaryArtifactManifest.self, from: manifestData) + let artifactsDirectoryURL = artifacts.directoryURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(manifest.artifactsDirectoryRelativePath, isDirectory: true) + + guard MozcArtifactIO.containsDictionaryArtifacts(at: artifactsDirectoryURL) else { + throw UserDictionaryArtifactError.invalidArtifact("Required dictionary artifact files are missing.") + } + + let posTableURL = artifactsDirectoryURL.appendingPathComponent(MozcDictionary.posTableFileName) + guard FileManager.default.fileExists(atPath: posTableURL.path) else { + throw UserDictionaryArtifactError.invalidArtifact("POS table file is missing.") + } + + _ = try UserDictionaryBinaryArtifact.load( + version: manifest.version, + builtAt: Date(timeIntervalSince1970: manifest.builtAt), + artifactsDirectoryURL: artifactsDirectoryURL + ) + } +} + +struct FileUserDictionaryArtifactPublisher: UserDictionaryArtifactPublisher { + let locator: UserDictionaryArtifactLocator + + init(locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator()) { + self.locator = locator + } + + func publish(_ artifacts: UserDictionaryLoudsArtifacts) async throws { + guard let manifestURL = artifacts.manifestURL else { + throw UserDictionaryArtifactError.invalidArtifact("Manifest URL is missing.") + } + try FileManager.default.createDirectory(at: locator.artifactsDirectoryURL, withIntermediateDirectories: true) + let data = try Data(contentsOf: manifestURL) + try data.write(to: locator.currentManifestURL, options: .atomic) + } +} + +final class FileUserDictionaryBuildStateRepository: @unchecked Sendable, UserDictionaryBuildStateRepository { + private struct StoredState: Codable { + let status: String + let failedMessage: String? + let updatedAt: Double + let artifactVersion: String? + let lastErrorMessage: String? + } + + private let locator: UserDictionaryArtifactLocator + private let queue = DispatchQueue(label: "com.kazumaproject.sumire-keyboard.user-dictionary-build-state") + + init(locator: UserDictionaryArtifactLocator = UserDictionaryArtifactLocator()) { + self.locator = locator + } + + func load() async throws -> UserDictionaryBuildState { + try queue.sync { + guard FileManager.default.fileExists(atPath: locator.buildStateURL.path) else { + return UserDictionaryBuildState(status: .idle, updatedAt: Date()) + } + let data = try Data(contentsOf: locator.buildStateURL) + let stored = try JSONDecoder().decode(StoredState.self, from: data) + return Self.state(from: stored) + } + } + + func save(_ state: UserDictionaryBuildState) async throws { + try queue.sync { + try FileManager.default.createDirectory(at: locator.artifactsDirectoryURL, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(Self.stored(from: state)) + try data.write(to: locator.buildStateURL, options: .atomic) + } + } + + private static func state(from stored: StoredState) -> UserDictionaryBuildState { + let status: UserDictionaryBuildStatus + switch stored.status { + case "building": + status = .building + case "validating": + status = .validating + case "ready": + status = .ready + case "failed": + status = .failed(stored.failedMessage ?? stored.lastErrorMessage ?? "Unknown error") + default: + status = .idle + } + return UserDictionaryBuildState( + status: status, + updatedAt: Date(timeIntervalSince1970: stored.updatedAt), + artifactVersion: stored.artifactVersion, + lastErrorMessage: stored.lastErrorMessage + ) + } + + private static func stored(from state: UserDictionaryBuildState) -> StoredState { + let status: String + let failedMessage: String? + switch state.status { + case .idle: + status = "idle" + failedMessage = nil + case .building: + status = "building" + failedMessage = nil + case .validating: + status = "validating" + failedMessage = nil + case .ready: + status = "ready" + failedMessage = nil + case .failed(let message): + status = "failed" + failedMessage = message + } + return StoredState( + status: status, + failedMessage: failedMessage, + updatedAt: state.updatedAt.timeIntervalSince1970, + artifactVersion: state.artifactVersion, + lastErrorMessage: state.lastErrorMessage + ) + } +} diff --git a/sumire-keyboardShared/KeyboardSettings.swift b/sumire-keyboardShared/KeyboardSettings.swift index 9e099ee..8628abc 100644 --- a/sumire-keyboardShared/KeyboardSettings.swift +++ b/sumire-keyboardShared/KeyboardSettings.swift @@ -17,6 +17,9 @@ enum KeyboardSettings { static let keyboardLeadingOffsetLandscape = "SumireKeyboardLeadingOffsetLandscape" static let keyboardHeightLandscape = "SumireKeyboardHeightLandscape" static let keyboardBottomMarginLandscape = "SumireKeyboardBottomMarginLandscape" + /// 学習辞書の prefix / predictive 検索を開始する入力文字数のキー。 + /// ユーザー辞書には適用しない。 + static let predictiveConversionStartLength = "SumireKeyboardPredictiveConversionStartLength" } enum JapaneseFlickInputMode: String, CaseIterable, Identifiable { @@ -144,6 +147,10 @@ enum KeyboardSettings { var bottomMargin: Double } + /// 学習辞書の予測変換開始文字数のデフォルト値。 + /// KanaKanjiConverter.predict() の開始条件 (input.count >= 3) と同じ値。 + static let defaultPredictiveConversionStartLength = 3 + static let appGroupIdentifier = "group.com.kazumaproject.sumire-keyboard" static let settingsURL = URL(string: "sumirekeyboard://settings") static let defaultKeyboardLeadingOffset: Double = 6 @@ -200,6 +207,22 @@ enum KeyboardSettings { } } + /// 学習辞書の prefix / predictive 検索を開始する入力文字数。 + /// 有効範囲: 1...10。未設定時は `defaultPredictiveConversionStartLength` (3) を返す。 + /// ユーザー辞書には適用しない(ユーザー辞書は通常予測変換ルール固定)。 + static var predictiveConversionStartLength: Int { + get { + guard defaults.object(forKey: Keys.predictiveConversionStartLength) != nil else { + return defaultPredictiveConversionStartLength + } + let value = defaults.integer(forKey: Keys.predictiveConversionStartLength) + return min(max(value, 1), 10) + } + set { + defaults.set(min(max(newValue, 1), 10), forKey: Keys.predictiveConversionStartLength) + } + } + static var usesHalfWidthSpace: Bool { get { guard defaults.object(forKey: Keys.usesHalfWidthSpace) != nil else { diff --git a/sumire-keyboardTests/DictionaryFeatureTests.swift b/sumire-keyboardTests/DictionaryFeatureTests.swift new file mode 100644 index 0000000..02880b2 --- /dev/null +++ b/sumire-keyboardTests/DictionaryFeatureTests.swift @@ -0,0 +1,681 @@ +import Foundation +import Testing +@testable import sumire_keyboard + +struct DictionaryFeatureTests { + @Test func candidatePipelineDeduplicatesByReadingAndWordUsingSourcePriority() { + let system = StubCandidateSource(kind: .systemMain, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 500, leftId: 1, rightId: 1) + ) + ]) + let user = StubCandidateSource(kind: .user, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .user, + lexicalInfo: CandidateLexicalInfo(score: 1, leftId: 2, rightId: 2) + ) + ]) + let pipeline = CandidatePipeline( + sources: [user, system], + mergePolicy: CandidateMergePolicy( + sourcePriority: [.systemMain, .user], + scoreStrategy: .max, + totalLimit: 10, + includesAuxiliaryCandidates: true + ) + ) + + let candidates = pipeline.candidates(for: "すみれ", limit: 10) + + #expect(candidates.count == 1) + #expect(candidates.first?.sourceKind == .systemMain) + } + + @Test func candidatePipelineAppliesTotalLimit() { + let source = StubCandidateSource(kind: .user, candidates: [ + Candidate(reading: "a", word: "a1", consumedReadingLength: 1, sourceKind: .user, lexicalInfo: nil), + Candidate(reading: "a", word: "a2", consumedReadingLength: 1, sourceKind: .user, lexicalInfo: nil), + Candidate(reading: "a", word: "a3", consumedReadingLength: 1, sourceKind: .user, lexicalInfo: nil) + ]) + let pipeline = CandidatePipeline( + sources: [source], + mergePolicy: CandidateMergePolicy( + sourcePriority: [.user], + scoreStrategy: .max, + totalLimit: 2, + includesAuxiliaryCandidates: true + ) + ) + + #expect(pipeline.candidates(for: "a", limit: 10).count == 2) + } + + @Test func sqliteUserRepositorySupportsCRUDAndPrefixSearch() async throws { + let repositories = try makeRepositories() + let userRepository = repositories.userRepository + let entry = UserDictionaryEntry( + id: UUID(), + reading: "すみれ", + word: "菫", + score: 100, + leftId: 10, + rightId: 20, + updatedAt: Date() + ) + + try await userRepository.add(entry) + #expect(try await userRepository.searchExact(reading: "すみれ", limit: 10).map(\.word) == ["菫"]) + #expect(try await userRepository.searchCommonPrefix(inputReading: "す", limit: 10).map(\.word) == ["菫"]) + + var updated = entry + updated.word = "すみれ" + try await userRepository.update(updated) + #expect(try await userRepository.searchExact(reading: "すみれ", limit: 10).map(\.word) == ["すみれ"]) + + try await userRepository.delete(id: entry.id) + #expect(try await userRepository.searchExact(reading: "すみれ", limit: 10).isEmpty) + + try await userRepository.add(entry) + try await userRepository.deleteAll() + #expect(try await userRepository.searchForManagementUI(query: "", limit: 10, offset: 0).isEmpty) + } + + @Test func learningRepositoryRecordsOnlyValidCommittedSelectionsAndUpdatesDuplicates() async throws { + let repositories = try makeRepositories() + let learningRepository = repositories.learningRepository + + try await learningRepository.recordCommittedSelection(CommittedSelection( + inputReading: "すみれ", + candidateReading: "す", + word: "菫", + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 10, leftId: 1, rightId: 2), + committedAt: Date() + )) + #expect(try await learningRepository.searchExact(reading: "す", limit: 10).isEmpty) + + let validSelection = CommittedSelection( + inputReading: "すみれ", + candidateReading: "すみれ", + word: "菫", + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 10, leftId: 1, rightId: 2), + committedAt: Date() + ) + try await learningRepository.recordCommittedSelection(validSelection) + try await learningRepository.recordCommittedSelection(validSelection) + + let entries = try await learningRepository.searchExact(reading: "すみれ", limit: 10) + #expect(entries.count == 1) + #expect(entries.first?.word == "菫") + #expect(entries.first?.score == -490) + } + + @Test func learningRepositoryResolvesMissingLexicalInfoToDefaultGeneralNoun() async throws { + let repositories = try makeRepositories() + let learningRepository = repositories.learningRepository + + try await learningRepository.recordCommittedSelection(CommittedSelection( + inputReading: "すみれ", + candidateReading: "すみれ", + word: "菫", + sourceKind: .systemMain, + lexicalInfo: nil, + committedAt: Date() + )) + + let entries = try await learningRepository.searchExact(reading: "すみれ", limit: 10) + let entry = try #require(entries.first) + #expect(entry.leftId == DictionaryDefaultLexicalIDs.generalNoun) + #expect(entry.rightId == DictionaryDefaultLexicalIDs.generalNoun) + #expect(entry.score == DictionaryDefaultLexicalInfo.generalNoun.score) + } + + @Test func learningRepositoryPreservesExistingLexicalInfo() async throws { + let repositories = try makeRepositories() + let learningRepository = repositories.learningRepository + + try await learningRepository.recordCommittedSelection(CommittedSelection( + inputReading: "すみれ", + candidateReading: "すみれ", + word: "菫", + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 77, leftId: 10, rightId: 20), + committedAt: Date() + )) + + let entries = try await learningRepository.searchExact(reading: "すみれ", limit: 10) + let entry = try #require(entries.first) + #expect(entry.leftId == 10) + #expect(entry.rightId == 20) + #expect(entry.score == 77) + } + + @Test func userDictionaryLoudsBuildPublishesSearchableArtifact() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let repositories = try makeRepositories(directory: directory) + let locator = UserDictionaryArtifactLocator(rootDirectoryURL: directory.appendingPathComponent("Dictionary", isDirectory: true)) + let userRepository = repositories.userRepository + + try await userRepository.add(UserDictionaryEntry( + id: UUID(), + reading: "すみれ", + word: "菫", + score: 100, + leftId: 1851, + rightId: 1851, + updatedAt: Date() + )) + try await userRepository.add(UserDictionaryEntry( + id: UUID(), + reading: "すみれいろ", + word: "菫色", + score: 90, + leftId: 1851, + rightId: 1851, + updatedAt: Date() + )) + + let builder = FileUserDictionaryLoudsBuilder(locator: locator) + let artifacts = try await builder.build(from: try await userRepository.allEntries()) + #expect(FileManager.default.fileExists(atPath: artifacts.directoryURL.path)) + #expect(MozcArtifactIO.containsDictionaryArtifacts(at: artifacts.directoryURL)) + #expect(FileManager.default.fileExists( + atPath: artifacts.directoryURL.appendingPathComponent(MozcDictionary.posTableFileName).path + )) + + try await FileUserDictionaryLoudsValidator().validate(artifacts) + try await FileUserDictionaryArtifactPublisher(locator: locator).publish(artifacts) + #expect(FileManager.default.fileExists(atPath: locator.currentManifestURL.path)) + + let source = UserDictionaryCandidateSource(store: repositories.store, locator: locator) + // exact match は常に動作する + #expect(source.searchExact(reading: "すみれ", limit: 10).map(\.word) == ["菫"]) + // 1文字入力では prefix 検索は動かない(通常予測変換ルール: input.count < 3 → 空を返す) + #expect(source.searchCommonPrefix(inputReading: "す", limit: 10).map(\.word).isEmpty) + // 3文字以上では prefix 検索が動く(maxReadingLength=5、"すみれ"=3文字は範囲内) + #expect(source.searchCommonPrefix(inputReading: "すみれ", limit: 10).map(\.word).contains("菫")) + // 2文字入力では predictive 検索は動かない + #expect(source.searchPredictive(prefix: "すみ", limit: 10).map(\.word).isEmpty) + // 4文字以上では predictive 検索が動く(maxReadingLength=6、"すみれいろ"=6文字は範囲内) + #expect(source.searchPredictive(prefix: "すみれい", limit: 10).map(\.word).contains("菫色")) + } + + // MARK: - Regression tests + + /// Test 1: artifact が存在していても、artifact 作成後に SQLite へ追加した単語が + /// UserDictionaryCandidateSource の検索結果に含まれること。 + @Test func userDictionaryCandidateSourceReturnsSQLiteResultEvenWhenArtifactExists() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let repositories = try makeRepositories(directory: directory) + let locator = UserDictionaryArtifactLocator( + rootDirectoryURL: directory.appendingPathComponent("Dictionary", isDirectory: true) + ) + + // artifact ビルド前に既存エントリを追加してビルド + let oldEntry = UserDictionaryEntry( + id: UUID(), reading: "てすと", word: "テスト", score: 100, + leftId: 1851, rightId: 1851, updatedAt: Date() + ) + try await repositories.userRepository.add(oldEntry) + + let builder = FileUserDictionaryLoudsBuilder(locator: locator) + let artifacts = try await builder.build(from: try await repositories.userRepository.allEntries()) + try await FileUserDictionaryLoudsValidator().validate(artifacts) + try await FileUserDictionaryArtifactPublisher(locator: locator).publish(artifacts) + + // artifact 作成後に新規エントリを SQLite だけに追加 + let newEntry = UserDictionaryEntry( + id: UUID(), reading: "しんき", word: "新規", score: 90, + leftId: 1851, rightId: 1851, updatedAt: Date() + ) + try await repositories.userRepository.add(newEntry) + + // artifact は存在するが、SQLite に追加した "新規" も検索結果に含まれるべき + let source = UserDictionaryCandidateSource(store: repositories.store, locator: locator) + let exactResults = source.searchExact(reading: "しんき", limit: 10) + #expect(exactResults.map(\.word).contains("新規"), + "artifact 作成後に SQLite へ追加したエントリが searchExact で返る必要があります") + + // searchCommonPrefix は 3文字以上から動く(通常予測変換ルール)。 + // "しんき" = 3文字なので検索が動き、SQLite の新規エントリが返るべき。 + let prefixResults = source.searchCommonPrefix(inputReading: "しんき", limit: 10) + #expect(prefixResults.map(\.word).contains("新規"), + "artifact 作成後に SQLite へ追加したエントリが searchCommonPrefix (3文字) で返る必要があります") + } + + /// Test 2: artifact に同じ reading+word がある場合、SQLite 側の候補が優先されること。 + /// (SQLite エントリの score が artifact エントリより低く設定されており、SQLite 優先なら score=1 が返る) + @Test func userDictionaryCandidateSourcePrioritizesSQLiteOverArtifactForSameDedupKey() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let repositories = try makeRepositories(directory: directory) + let locator = UserDictionaryArtifactLocator( + rootDirectoryURL: directory.appendingPathComponent("Dictionary", isDirectory: true) + ) + + // score=999 で artifact をビルド + let artifactEntry = UserDictionaryEntry( + id: UUID(), reading: "すみれ", word: "菫", score: 999, + leftId: 1851, rightId: 1851, updatedAt: Date() + ) + try await repositories.userRepository.add(artifactEntry) + let builder = FileUserDictionaryLoudsBuilder(locator: locator) + let artifacts = try await builder.build(from: try await repositories.userRepository.allEntries()) + try await FileUserDictionaryLoudsValidator().validate(artifacts) + try await FileUserDictionaryArtifactPublisher(locator: locator).publish(artifacts) + + // SQLite の同エントリを score=1 に更新(artifact 後の変更を想定) + var updated = artifactEntry + updated = UserDictionaryEntry( + id: artifactEntry.id, reading: "すみれ", word: "菫", score: 1, + leftId: 1851, rightId: 1851, updatedAt: Date() + ) + try await repositories.userRepository.update(updated) + + let source = UserDictionaryCandidateSource(store: repositories.store, locator: locator) + let results = source.searchExact(reading: "すみれ", limit: 10) + + // 重複は 1件に dedup されるべき + #expect(results.filter { $0.word == "菫" }.count == 1, + "同じ reading+word の候補は 1件に dedup されるべきです") + // SQLite 側 (score=1) が優先されているべき + let sumire = try #require(results.first { $0.word == "菫" }) + #expect(sumire.lexicalInfo?.score == 1, + "SQLite 側の候補 (score=1) が artifact 側 (score=999) より優先されるべきです") + } + + /// Test 3: learning 候補が system 候補より sourcePriority で優先され、 + /// system 候補と同じ reading+word を持っていても dedup で消されないこと。 + @Test func candidatePipelinePrefersLearningOverSystemForSameDedupKey() { + let system = StubCandidateSource(kind: .systemMain, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 500, leftId: 1, rightId: 1) + ) + ]) + let learning = StubCandidateSource(kind: .learning, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .learning, + lexicalInfo: CandidateLexicalInfo(score: 0, leftId: 1, rightId: 1) + ) + ]) + // 正しい優先順位: user > learning > systemMain + let pipeline = CandidatePipeline( + sources: [system, learning], + mergePolicy: CandidateMergePolicy( + sourcePriority: [.user, .learning, .systemMain, .systemAuxiliary, .systemSingleKanji, .systemEnglish, .fallback, .direct], + scoreStrategy: .max, + totalLimit: 10, + includesAuxiliaryCandidates: true + ) + ) + + let candidates = pipeline.candidates(for: "すみれ", limit: 10) + + // 重複は 1件にまとめられ、learning が優先されるべき + #expect(candidates.count == 1, + "同じ reading+word の候補は 1件に dedup されるべきです") + #expect(candidates.first?.sourceKind == .learning, + "learning は systemMain より sourcePriority が高いため learning 候補が残るべきです") + } + + /// Test 4 (user > learning > system): 同じ reading+word を持つ user/learning/system 候補が + /// 揃っているとき、user dictionary 候補が最優先で残ること。 + @Test func candidatePipelinePrefersUserOverLearningAndSystemForSameDedupKey() { + let system = StubCandidateSource(kind: .systemMain, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 500, leftId: 1, rightId: 1) + ) + ]) + let learning = StubCandidateSource(kind: .learning, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .learning, + lexicalInfo: CandidateLexicalInfo(score: 200, leftId: 1, rightId: 1) + ) + ]) + let user = StubCandidateSource(kind: .user, candidates: [ + Candidate( + reading: "すみれ", + word: "菫", + consumedReadingLength: 3, + sourceKind: .user, + lexicalInfo: CandidateLexicalInfo(score: 1, leftId: 1, rightId: 1) + ) + ]) + let pipeline = CandidatePipeline( + sources: [system, learning, user], + mergePolicy: CandidateMergePolicy( + sourcePriority: [.user, .learning, .systemMain, .systemAuxiliary, .systemSingleKanji, .systemEnglish, .fallback, .direct], + scoreStrategy: .max, + totalLimit: 10, + includesAuxiliaryCandidates: true + ) + ) + + let candidates = pipeline.candidates(for: "すみれ", limit: 10) + + #expect(candidates.count == 1, + "同じ reading+word の候補は 1件に dedup されるべきです") + #expect(candidates.first?.sourceKind == .user, + "user dictionary が最優先のため user 候補が残るべきです") + } + + // MARK: - Predictive search policy tests (pure function, no UserDefaults dependency) + + /// DictionaryPredictiveSearchPolicy のシステムスタイルルール検証。 + @Test func dictionaryPredictiveSearchPolicySystemStyleRequires3Chars() { + #expect(DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: "") == false) + #expect(DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: "じ") == false) + #expect(DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: "じい") == false) + #expect(DictionaryPredictiveSearchPolicy.allowsSystemStylePredictiveSearch(input: "じいじ") == true) + } + + /// DictionaryPredictiveSearchPolicy の学習辞書スタイルルール検証。 + @Test func dictionaryPredictiveSearchPolicyLearningStyleUsesStartLength() { + // startLength=3 + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じ", startLength: 3) == false) + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じい", startLength: 3) == false) + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じいじ", startLength: 3) == true) + // startLength=1: 1文字から許可 + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じ", startLength: 1) == true) + // startLength=4: 3文字では不許可 + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じいじ", startLength: 4) == false) + #expect(DictionaryPredictiveSearchPolicy.allowsLearningPredictiveSearch(input: "じいじい", startLength: 4) == true) + } + + /// maxReadingLength: input < 6 文字なら input + 2、6 文字以上なら nil。 + @Test func dictionaryPredictiveSearchPolicyMaxReadingLength() { + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あ") == 3) // 1+2=3 + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あい") == 4) // 2+2=4 + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あいう") == 5) // 3+2=5 + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あいうえ") == 6) // 4+2=6 + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あいうえお") == 7) // 5+2=7 + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あいうえおか") == nil) // 6→上限なし + #expect(DictionaryPredictiveSearchPolicy.maxReadingLength(forInput: "あいうえおかき") == nil) // 7→上限なし + } + + // MARK: - User dictionary predictive search suppression tests + + /// ユーザー辞書: 1〜2文字入力では prefix / predictive が動かず、exact match は動く。 + /// reading="じい" / word="自維" を登録して「じ」では出ないことを確認。 + @Test func userDictionaryCandidateSourceIgnoresPredictiveForShortInput() async throws { + let repositories = try makeRepositories() + let source = repositories.userCandidateSource + + try await repositories.userRepository.add(UserDictionaryEntry( + id: UUID(), reading: "じい", word: "自維", score: 3000, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + + // 1文字入力では predictive / prefix は空 + #expect( + source.searchPredictive(prefix: "じ", limit: 10).map(\.word).contains("自維") == false, + "1文字入力でユーザー辞書の predictive 候補が出てはいけません" + ) + #expect( + source.searchCommonPrefix(inputReading: "じ", limit: 10).map(\.word).contains("自維") == false, + "1文字入力でユーザー辞書の prefix 候補が出てはいけません" + ) + + // 2文字入力でも出ない + #expect( + source.searchPredictive(prefix: "じい", limit: 10).map(\.word).contains("自維") == false, + "2文字入力でユーザー辞書の predictive 候補が出てはいけません" + ) + #expect( + source.searchCommonPrefix(inputReading: "じい", limit: 10).map(\.word).contains("自維") == false, + "2文字入力でユーザー辞書の prefix 候補が出てはいけません" + ) + + // exact match は常に動く + #expect( + source.searchExact(reading: "じい", limit: 10).map(\.word) == ["自維"], + "exact match はショートカット設定に関係なく常に動く必要があります" + ) + } + + /// ユーザー辞書: predictiveConversionStartLength を変更してもユーザー辞書のルールは変わらない。 + @Test func userDictionaryCandidateSourceDoesNotUsePredictiveConversionStartLength() async throws { + let repositories = try makeRepositories() + let source = repositories.userCandidateSource + + try await repositories.userRepository.add(UserDictionaryEntry( + id: UUID(), reading: "じい", word: "自維", score: 3000, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + + // predictiveConversionStartLength を 1 にしてもユーザー辞書は 3文字未満で返さない + let key = KeyboardSettings.Keys.predictiveConversionStartLength + let original = KeyboardSettings.defaults.object(forKey: key) + defer { + if let original { + KeyboardSettings.defaults.set(original, forKey: key) + } else { + KeyboardSettings.defaults.removeObject(forKey: key) + } + } + KeyboardSettings.predictiveConversionStartLength = 1 + + #expect( + source.searchPredictive(prefix: "じ", limit: 10).map(\.word).contains("自維") == false, + "predictiveConversionStartLength=1 にしてもユーザー辞書の 1文字 predictive は出てはいけません" + ) + // ユーザー辞書の exact は相変わらず動く + #expect(source.searchExact(reading: "じい", limit: 10).map(\.word) == ["自維"]) + } + + /// ユーザー辞書: reading.count > input.count + 2 の候補は返さない(maxReadingLength 制限)。 + @Test func userDictionaryCandidateSourceFiltersLongReadingsByMaxLength() async throws { + let repositories = try makeRepositories() + let source = repositories.userCandidateSource + + // reading = "じい" (2文字): input "じいじ" (3文字) → maxReadingLength=5 → reading.count=2 <= 5 ✓ + // reading = "じいじいじ" (5文字): input "じい" (2文字) → 2文字未満なので prefix 自体が空 + // reading = "じいじいじ" (5文字): input "じいじ" (3文字) → maxReadingLength=5 → 5 <= 5 ✓ + // reading = "じいじいじい" (6文字): input "じいじ" (3文字) → maxReadingLength=5 → 6 > 5 ✗ + try await repositories.userRepository.add(UserDictionaryEntry( + id: UUID(), reading: "じいじいじい", word: "長い読み", score: 100, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + try await repositories.userRepository.add(UserDictionaryEntry( + id: UUID(), reading: "じいじいじ", word: "ちょうど", score: 100, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + + // input "じいじ" (3文字) → maxReadingLength = 3+2 = 5 + // "じいじいじ" (5文字) <= 5 → 返るべき + // "じいじいじい" (6文字) > 5 → 返らないべき + let results = source.searchCommonPrefix(inputReading: "じいじ", limit: 10) + #expect( + results.map(\.word).contains("ちょうど"), + "reading.count=5 <= maxReadingLength=5 なので返るべきです" + ) + #expect( + results.map(\.word).contains("長い読み") == false, + "reading.count=6 > maxReadingLength=5 なので返らないべきです" + ) + } + + // MARK: - Learning dictionary predictive search tests + + /// 学習辞書: predictiveConversionStartLength = 3 のとき 1〜2文字では prefix が出ない。 + @Test func learningDictionaryCandidateSourceRespectsStartLengthDefault() async throws { + let repositories = try makeRepositories() + try await repositories.learningRepository.recordCommittedSelection(CommittedSelection( + inputReading: "じい", + candidateReading: "じい", + word: "自維", + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 100, leftId: 1851, rightId: 1851), + committedAt: Date() + )) + let source = repositories.learningCandidateSource + + // startLength=3 に設定 + let key = KeyboardSettings.Keys.predictiveConversionStartLength + let original = KeyboardSettings.defaults.object(forKey: key) + defer { + if let original { + KeyboardSettings.defaults.set(original, forKey: key) + } else { + KeyboardSettings.defaults.removeObject(forKey: key) + } + } + KeyboardSettings.predictiveConversionStartLength = 3 + + // 1文字では出ない + #expect( + source.searchCommonPrefix(inputReading: "じ", limit: 10).map(\.word).contains("自維") == false, + "startLength=3 のとき 1文字入力で学習辞書の prefix 候補が出てはいけません" + ) + #expect( + source.searchPredictive(prefix: "じ", limit: 10).map(\.word).contains("自維") == false, + "startLength=3 のとき 1文字入力で学習辞書の predictive 候補が出てはいけません" + ) + // exact は出る + #expect(source.searchExact(reading: "じい", limit: 10).map(\.word) == ["自維"]) + } + + /// 学習辞書: predictiveConversionStartLength = 1 のとき 1文字から候補が出る。 + @Test func learningDictionaryCandidateSourceAllowsSearchWhenStartLengthIsOne() async throws { + let repositories = try makeRepositories() + try await repositories.learningRepository.recordCommittedSelection(CommittedSelection( + inputReading: "じい", + candidateReading: "じい", + word: "自維", + sourceKind: .systemMain, + lexicalInfo: CandidateLexicalInfo(score: 100, leftId: 1851, rightId: 1851), + committedAt: Date() + )) + let source = repositories.learningCandidateSource + + let key = KeyboardSettings.Keys.predictiveConversionStartLength + let original = KeyboardSettings.defaults.object(forKey: key) + defer { + if let original { + KeyboardSettings.defaults.set(original, forKey: key) + } else { + KeyboardSettings.defaults.removeObject(forKey: key) + } + } + KeyboardSettings.predictiveConversionStartLength = 1 + + // 1文字 "じ" で "じい" がヒット(maxReadingLength = 1+2=3、"じい".count=2 <= 3 ✓) + #expect( + source.searchCommonPrefix(inputReading: "じ", limit: 10).map(\.word).contains("自維"), + "startLength=1 のとき 1文字入力で学習辞書の prefix 候補が出るべきです" + ) + } + + /// 学習辞書: reading.count > input.count + 2 の候補は maxReadingLength 制限で返さない。 + @Test func learningDictionaryCandidateSourceFiltersLongReadingsByMaxLength() async throws { + let repositories = try makeRepositories() + // reading = "すみれいろ" (5文字) と "すみれいろのはな" (8文字) を学習エントリとして追加 + try await repositories.learningRepository.add(LearningDictionaryEntry( + id: UUID(), reading: "すみれいろ", word: "菫色", score: 0, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + try await repositories.learningRepository.add(LearningDictionaryEntry( + id: UUID(), reading: "すみれいろのはな", word: "菫色の花", score: 0, + leftId: 1851, rightId: 1851, updatedAt: Date() + )) + let source = repositories.learningCandidateSource + + let key = KeyboardSettings.Keys.predictiveConversionStartLength + let original = KeyboardSettings.defaults.object(forKey: key) + defer { + if let original { + KeyboardSettings.defaults.set(original, forKey: key) + } else { + KeyboardSettings.defaults.removeObject(forKey: key) + } + } + KeyboardSettings.predictiveConversionStartLength = 3 + + // input "すみれ" (3文字) → maxReadingLength = 3+2 = 5 + // "すみれいろ" (5文字) <= 5 → 返るべき + // "すみれいろのはな" (8文字) > 5 → 返らないべき + let results = source.searchCommonPrefix(inputReading: "すみれ", limit: 10) + #expect( + results.map(\.word).contains("菫色"), + "reading.count=5 <= maxReadingLength=5 なので返るべきです" + ) + #expect( + results.map(\.word).contains("菫色の花") == false, + "reading.count=8 > maxReadingLength=5 なので返らないべきです" + ) + } + + @Test func userDictionaryBuildStateRepositoryPersistsStatuses() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let locator = UserDictionaryArtifactLocator(rootDirectoryURL: directory.appendingPathComponent("Dictionary", isDirectory: true)) + let repository = FileUserDictionaryBuildStateRepository(locator: locator) + + let building = UserDictionaryBuildState(status: .building, updatedAt: Date()) + try await repository.save(building) + #expect(try await repository.load().status == .building) + + let ready = UserDictionaryBuildState(status: .ready, updatedAt: Date(), artifactVersion: "v1") + try await repository.save(ready) + let loadedReady = try await repository.load() + #expect(loadedReady.status == .ready) + #expect(loadedReady.artifactVersion == "v1") + + let failed = UserDictionaryBuildState(status: .failed("boom"), updatedAt: Date(), lastErrorMessage: "boom") + try await repository.save(failed) + #expect(try await repository.load().status == .failed("boom")) + } + + private func makeRepositories() throws -> DictionaryRepositories { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try makeRepositories(directory: directory) + } + + private func makeRepositories(directory: URL) throws -> DictionaryRepositories { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let databaseURL = directory.appendingPathComponent("dictionary.sqlite") + return try DictionaryRepositories(store: SQLiteDictionaryStore(databaseURL: databaseURL)) + } +} + +private struct StubCandidateSource: CandidateSource { + let kind: CandidateSourceKind + let candidates: [Candidate] + + func searchExact(reading: String, limit: Int) -> [Candidate] { + candidates.prefix(limit).map { $0 } + } + + func searchCommonPrefix(inputReading: String, limit: Int) -> [Candidate] { + [] + } + + func searchPredictive(prefix: String, limit: Int) -> [Candidate] { + [] + } +}