diff --git a/Core/Sources/ConverterServer/ConverterServer+Settings.swift b/Core/Sources/ConverterServer/ConverterServer+Settings.swift index e5229eff..bf1c2dfc 100644 --- a/Core/Sources/ConverterServer/ConverterServer+Settings.swift +++ b/Core/Sources/ConverterServer/ConverterServer+Settings.swift @@ -57,6 +57,17 @@ extension ConverterServer { kind: .toggle, value: .bool(Config.TypeHalfSpace().value) ), + descriptor( + key: Config.DateFormatPreference.key, + title: "日付候補の優先書式", + section: "入力オプション", + kind: .selector(options: [ + .init(title: "標準", value: .string(Config.DateFormatPreference.Value.standard.rawValue)), + .init(title: "MM/DDを優先", value: .string(Config.DateFormatPreference.Value.monthDay.rawValue)), + .init(title: "曜日付きを優先", value: .string(Config.DateFormatPreference.Value.weekday.rawValue)) + ]), + value: .string(Config.DateFormatPreference().value.rawValue) + ), descriptor( key: Config.OptionDirectFullWidthInput.key, title: "Optionキーで直接全角英数を入力", @@ -233,6 +244,12 @@ extension ConverterServer { Config.TypeBackSlash().value = try boolSettingValue(value, key: key) case Config.TypeHalfSpace.key: Config.TypeHalfSpace().value = try boolSettingValue(value, key: key) + case Config.DateFormatPreference.key: + guard case .string(let rawValue) = value, + let preference = Config.DateFormatPreference.Value(rawValue: rawValue) else { + throw ConverterServerError.invalidSettingValue(key) + } + Config.DateFormatPreference().value = preference case Config.OptionDirectFullWidthInput.key: Config.OptionDirectFullWidthInput().value = try boolSettingValue(value, key: key) case Config.PunctuationStyle.key: diff --git a/Core/Sources/Core/Configs/DateFormatPreference.swift b/Core/Sources/Core/Configs/DateFormatPreference.swift new file mode 100644 index 00000000..6b426d38 --- /dev/null +++ b/Core/Sources/Core/Configs/DateFormatPreference.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Config { + public struct DateFormatPreference: CustomCodableConfigItem { + public enum Value: String, Codable, Equatable, Hashable, Sendable { + case standard + case monthDay + case weekday + } + + public init() {} + public static let `default`: Value = .standard + public static let key = "dev.ensan.inputmethod.azooKeyMac.preference.dateFormatPreference" + } +} diff --git a/Core/Sources/Core/InputUtils/DateCandidatePreference.swift b/Core/Sources/Core/InputUtils/DateCandidatePreference.swift new file mode 100644 index 00000000..c4219edd --- /dev/null +++ b/Core/Sources/Core/InputUtils/DateCandidatePreference.swift @@ -0,0 +1,84 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +/// 日付の生成元が明示した候補だけを並べ替える。数字や分数の見た目だけでは判定しない。 +enum DateCandidatePreference { + static func apply( + to result: inout ConversionResult, + dateEntries: [DicdataElement], + readingCount: Int, + preference: Config.DateFormatPreference.Value + ) { + result.mainResults = applying(to: result.mainResults, dateEntries: dateEntries, + readingCount: readingCount, preference: preference) + result.firstClauseResults = reordered(result.firstClauseResults, dateWords: Set(dateEntries.map(\.word)), + preference: preference) + } + + static func applying( + to candidates: [Candidate], dateEntries: [DicdataElement], readingCount: Int, + preference: Config.DateFormatPreference.Value + ) -> [Candidate] { + guard preference != .standard, !dateEntries.isEmpty else { + return candidates + } + var entries = dateEntries + if preference == .monthDay { + for entry in dateEntries { + guard let padded = paddedMonthDay(entry.word), padded != entry.word else { continue } + entries.append(.init(word: padded, ruby: entry.ruby, cid: CIDData.固有名詞.cid, + mid: MIDData.一般.mid, value: entry.value())) + } + } + let words = Set(entries.map(\.word)) + // 希望した書式がエンジンの候補数制限で落ちても、元の候補を残して補完する。 + var seen = Set(candidates.map(\.text)) + let missing = entries.filter { isPreferred($0.word, preference: preference) && seen.insert($0.word).inserted } + let additions = missing.map { entry in + Candidate(text: entry.word, value: entry.value(), composingCount: .surfaceCount(readingCount), + lastMid: MIDData.一般.mid, data: [entry], isLearningTarget: false) + } + var result = candidates + result.insert(contentsOf: additions, at: min(5, result.count)) + return reordered(result, dateWords: words, preference: preference) + } + + static func reordered( + _ candidates: [Candidate], dateWords: Set, preference: Config.DateFormatPreference.Value + ) -> [Candidate] { + guard preference != .standard else { + return candidates + } + let indices = candidates.indices.filter { dateWords.contains(candidates[$0].text) } + let dates = indices.map { candidates[$0] } + let ordered = dates.filter { isPreferred($0.text, preference: preference) } + + dates.filter { !isPreferred($0.text, preference: preference) } + var result = candidates + for (index, candidate) in zip(indices, ordered) { result[index] = candidate } + return result + } + + static func paddedMonthDay(_ text: String) -> String? { + guard text.range(of: #"^[0-9]{1,2}/[0-9]{1,2}$"#, options: .regularExpression) != nil else { + return nil + } + let parts = text.split(separator: "/").compactMap { Int($0) } + guard parts.count == 2, (1...12).contains(parts[0]) else { + return nil + } + let monthLengths = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + guard (1...monthLengths[parts[0] - 1]).contains(parts[1]) else { + return nil + } + return String(format: "%02d/%02d", parts[0], parts[1]) + } + + private static func isPreferred(_ text: String, preference: Config.DateFormatPreference.Value) -> Bool { + switch preference { + case .standard: false + case .monthDay: paddedMonthDay(text) == text + case .weekday: + text.range(of: #"[((][日月火水木金土][))]"#, options: .regularExpression) != nil + } + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..26b3c66a 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -17,13 +17,16 @@ public final class SegmentsManager { /// テストなどの設定注入のための型。外部には設定を露出させない。 public struct Context { public init() {} - public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil) { + public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil, + dateFormatPreference: Config.DateFormatPreference.Value? = nil) { self.useZenzai = useZenzai self.resourcesDirectoryURL = resourcesDirectoryURL + self.dateFormatPreference = dateFormatPreference } var useZenzai: Bool = true var resourcesDirectoryURL: URL? + var dateFormatPreference: Config.DateFormatPreference.Value? } public weak var delegate: (any SegmentManagerDelegate)? @@ -564,7 +567,7 @@ public final class SegmentsManager { let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion) let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion) - let result = self.kanaKanjiConverter.requestCandidates( + var result = self.kanaKanjiConverter.requestCandidates( self.composingText, options: options( leftSideContext: leftSideContext, @@ -574,6 +577,24 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + // 文節を縮めたときは、入力全体に対する日付を補完しない。 + if self.composingText.isAtEndIndex { + let dateEntries = dynamicShortcuts.compactMap { entry -> DicdataElement? in + guard entry.ruby == self.composingText.convertTarget.toKatakana(), + entry.word.hasPrefix(" DicdataElement { + .init(word: word, ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18) + } + + private func candidate(_ word: String) -> Candidate { + .init(text: word, value: -18, composingCount: .surfaceCount(3), lastMid: MIDData.一般.mid, + data: [entry(word)], isLearningTarget: false) + } + + @Test func standardPreservesEveryCandidateAndAddsNothing() { + let original = ["今日", "2026/09/05", "9/5", "9月5日(土)"].map(candidate) + let result = DateCandidatePreference.applying(to: original, dateEntries: [entry("9/5")], + readingCount: 3, preference: .standard) + #expect(result.map(\.text) == original.map(\.text)) + #expect(Config.DateFormatPreference.default == .standard) + } + + @Test func preferenceOnlySwapsDateSlotsAndIsStable() { + let original = ["今日", "2026/09/05", "教", "9月5日(土)", "京", "09/05", "9/5"].map(candidate) + let words: Set = ["2026/09/05", "9月5日(土)", "09/05", "9/5"] + let result = DateCandidatePreference.reordered(original, dateWords: words, preference: .weekday) + #expect(result.map(\.text) == ["今日", "9月5日(土)", "教", "2026/09/05", "京", "09/05", "9/5"]) + let monthDay = DateCandidatePreference.reordered(original, dateWords: words, preference: .monthDay) + #expect(monthDay.map(\.text) == ["今日", "09/05", "教", "2026/09/05", "京", "9月5日(土)", "9/5"]) + } + + @Test func missingPaddedDateIsAddedWithoutReplacingOriginalOrDuplicates() { + let original = ["今日", "9/5", "教"].map(candidate) + let entries = [entry("9/5"), entry("9/5")] + let result = DateCandidatePreference.applying(to: original, dateEntries: entries, + readingCount: 3, preference: .monthDay) + #expect(result.map(\.text) == ["今日", "09/05", "教", "9/5"]) + #expect(result.filter { $0.text == "09/05" }.count == 1) + #expect(result.first { $0.text == "09/05" }?.isLearningTarget == false) + var text = ComposingText() + text.insertAtCursorPosition("きょう", inputStyle: .direct) + for candidate in result { + var remaining = text + remaining.prefixComplete(composingCount: candidate.composingCount) + #expect(remaining.convertTarget.isEmpty) + } + let repeated = DateCandidatePreference.applying(to: result, dateEntries: entries, + readingCount: 3, preference: .monthDay) + #expect(repeated.map(\.text) == result.map(\.text)) + } + + @Test func unregisteredFractionAndWeekdayWordsRemainUntouched() { + let original = ["1/2", "土曜日", "9月5日(土)", "今日", "9/5"].map(candidate) + let result = DateCandidatePreference.applying(to: original, dateEntries: [entry("9/5")], + readingCount: 3, preference: .monthDay) + #expect(result.map(\.text) == ["1/2", "土曜日", "9月5日(土)", "今日", "09/05", "9/5"]) + let noDates = DateCandidatePreference.applying(to: original, dateEntries: [], + readingCount: 3, preference: .weekday) + #expect(noDates.map(\.text) == original.map(\.text)) + } + + @Test func preferredDateCanBeRecoveredWhenEngineOmitsIt() { + let original = ["今日", "教", "京", "強", "きょう", "キョウ"].map(candidate) + let result = DateCandidatePreference.applying(to: original, dateEntries: [entry("9月5日(土)")], + readingCount: 3, preference: .weekday) + #expect(result.map(\.text) == ["今日", "教", "京", "強", "きょう", "9月5日(土)", "キョウ"]) + } + + @Test(arguments: [("9/5", "09/05"), ("02/29", "02/29"), ("12/31", "12/31")]) + func validMonthDay(input: String, expected: String) { + #expect(DateCandidatePreference.paddedMonthDay(input) == expected) + } + + @Test(arguments: ["0/1", "13/1", "2/30", "4/31", "9/0", "2026/9/5", "9/5", "9/5です", "1/2/3"]) + func rejectsOtherFormats(input: String) { + #expect(DateCandidatePreference.paddedMonthDay(input) == nil) + } + + @MainActor + @Test(arguments: [Config.DateFormatPreference.Value.monthDay, .weekday]) + func actualConversionOffersPreferredDateWithoutChangingSettings(preference: Config.DateFormatPreference.Value) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: directory, containerURL: nil, + context: .init(useZenzai: false, dateFormatPreference: preference)) + manager.insertAtCursorPosition("きょう", inputStyle: .direct) + for rich in [false, true] { + manager.update(requestRichCandidates: rich) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("候補欄が表示されません") + return + } + let pattern = preference == .monthDay ? #"^[0-9]{2}/[0-9]{2}$"# : #"^[0-9]+月[0-9]+日([日月火水木金土])$"# + #expect(candidates.contains { $0.text.range(of: pattern, options: .regularExpression) != nil }) + #expect(candidates.contains { $0.text == "今日" }) + } + } + + @MainActor + @Test(arguments: [Config.DateFormatPreference.Value.monthDay, .weekday]) + func shorteningDateReadingDoesNotOfferWholeReadingDates(preference: Config.DateFormatPreference.Value) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: directory, containerURL: nil, + context: .init(useZenzai: false, dateFormatPreference: preference)) + manager.insertAtCursorPosition("きょう", inputStyle: .direct) + manager.editSegment(count: -1) + for rich in [false, true] { + manager.update(requestRichCandidates: rich) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("候補欄が表示されません") + return + } + #expect(!candidates.contains { + $0.text.range(of: #"^[0-9]{2}/[0-9]{2}$|^[0-9]+月[0-9]+日([日月火水木金土])$"#, + options: .regularExpression) != nil + }) + } + } + + @MainActor + @Test func numericReadingDoesNotGenerateWeekdayDates() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: directory, containerURL: nil, + context: .init(useZenzai: false, dateFormatPreference: .weekday)) + manager.insertAtCursorPosition("1111", inputStyle: .direct) + manager.update(requestRichCandidates: true) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("候補欄が表示されません") + return + } + #expect(candidates.contains { $0.text == "1111" }) + #expect(!candidates.contains { + $0.text.range(of: #"^[0-9]+月[0-9]+日([日月火水木金土])$"#, options: .regularExpression) != nil + }) + } +} diff --git a/README.md b/README.md index e710c7a7..b6b7f820 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,8 @@ Thanks to authors!! ## Acknowledgement 本プロジェクトは情報処理推進機構(IPA)による[2024年度未踏IT人材発掘・育成事業](https://www.ipa.go.jp/jinzai/mitou/it/2024/koubokekka.html)の支援を受けて開発を行いました。 + +### 日付候補の優先書式 + +カスタマイズの「日付候補の優先書式」で「標準」「MM/DDを優先」「曜日付きを優先」を選べます。 +「きょう」などの日付候補について、希望する書式を日付候補内で優先します。例えば「MM/DDを優先」では `09/05`、「曜日付きを優先」では `9月5日(土)` を選びやすくします。元の書式と通常の変換候補も残り、標準では従来の順番を維持します。 diff --git a/azooKeyMac/Windows/ConfigWindow.swift b/azooKeyMac/Windows/ConfigWindow.swift index 099d4307..58cb23b5 100644 --- a/azooKeyMac/Windows/ConfigWindow.swift +++ b/azooKeyMac/Windows/ConfigWindow.swift @@ -752,6 +752,7 @@ struct ConfigWindow: View { keys: [ Config.TypeBackSlash.key, Config.TypeHalfSpace.key, + Config.DateFormatPreference.key, Config.OptionDirectFullWidthInput.key, Config.PunctuationStyle.key ]