Repository navigation
日付候補の優先書式を設定できるようにする #364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sinoda1114
wants to merge
2
commits into
azooKey:main
Choose a base branch
from
sinoda1114:codex/date-format-preference
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
日付候補の優先書式を設定できるようにする #364
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
124 changes: 124 additions & 0 deletions
124
Core/Sources/Core/InputUtils/DateCandidatePreference.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| 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, | ||
| now: Date = Date(), calendar: Calendar = .current | ||
| ) -> [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())) | ||
| } | ||
| } | ||
| if preference == .weekday { | ||
| for entry in dateEntries { | ||
| guard let text = numericWeekday(entry, now: now, calendar: calendar) else { continue } | ||
| entries.append(.init(word: text, 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<String>, 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]) | ||
| } | ||
|
|
||
| /// 年のない4桁入力だけを今年の月日として解釈する。相対日付の年は変更しない。 | ||
| static func numericWeekday(_ entry: DicdataElement, now: Date, calendar: Calendar) -> String? { | ||
| let digits = entry.ruby.unicodeScalars.compactMap { scalar -> Int? in | ||
| switch scalar.value { | ||
| case 0x30...0x39: Int(scalar.value - 0x30) | ||
| case 0xFF10...0xFF19: Int(scalar.value - 0xFF10) | ||
| default: nil | ||
| } | ||
| } | ||
| guard entry.ruby.unicodeScalars.count == 4, digits.count == 4, | ||
| paddedMonthDay(entry.word) == "\(digits[0])\(digits[1])/\(digits[2])\(digits[3])" else { | ||
| return nil | ||
| } | ||
| var gregorian = Calendar(identifier: .gregorian) | ||
| gregorian.timeZone = calendar.timeZone | ||
| let year = gregorian.component(.year, from: now) | ||
| let month = digits[0] * 10 + digits[1] | ||
| let day = digits[2] * 10 + digits[3] | ||
| guard let date = gregorian.date(from: DateComponents(year: year, month: month, day: day, hour: 12)), | ||
| gregorian.component(.year, from: date) == year, | ||
| gregorian.component(.month, from: date) == month, | ||
| gregorian.component(.day, from: date) == day else { | ||
| return nil | ||
| } | ||
| let formatter = DateFormatter() | ||
| formatter.locale = Locale(identifier: "ja_JP") | ||
| formatter.calendar = gregorian | ||
| formatter.timeZone = gregorian.timeZone | ||
| formatter.dateFormat = "M月d日(E)" | ||
| return formatter.string(from: date) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
Core/Tests/CoreTests/InputUtilsTests/DateCandidatePreferenceTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| @testable import Core | ||
| import Foundation | ||
| import KanaKanjiConverterModuleWithDefaultDictionary | ||
| import Testing | ||
|
|
||
| struct DateCandidatePreferenceTests { | ||
| private func entry(_ word: String) -> 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<String> = ["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 == "今日" }) | ||
| } | ||
| } | ||
|
|
||
| @Test func numericInputGetsThisYearsWeekdayAndKeepsOriginal() throws { | ||
| var calendar = Calendar(identifier: .gregorian) | ||
| calendar.timeZone = try #require(TimeZone(identifier: "Asia/Tokyo")) | ||
| let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 5))) | ||
| for ruby in ["1111", "1111"] { | ||
| let dateEntry = DicdataElement(word: "11/11", ruby: ruby, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18) | ||
| let original = [candidate("1111"), candidate("11/11")] | ||
| let result = DateCandidatePreference.applying(to: original, dateEntries: [dateEntry], readingCount: 4, | ||
| preference: .weekday, now: now, calendar: calendar) | ||
| #expect(result.map(\.text) == ["1111", "11月11日(水)", "11/11"]) | ||
| let added = try #require(result.first { $0.text == "11月11日(水)" }) | ||
| #expect(!added.isLearningTarget) | ||
| var composing = ComposingText() | ||
| composing.insertAtCursorPosition(ruby, inputStyle: .direct) | ||
| composing.prefixComplete(composingCount: added.composingCount) | ||
| #expect(composing.isEmpty) | ||
| } | ||
| } | ||
|
|
||
| @Test func numericLeapDayOnlyGetsWeekdayInLeapYear() throws { | ||
| var calendar = Calendar(identifier: .gregorian) | ||
| calendar.timeZone = try #require(TimeZone(identifier: "Asia/Tokyo")) | ||
| let entry = DicdataElement(word: "02/29", ruby: "0229", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18) | ||
| let commonYear = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))) | ||
| let leapYear = try #require(calendar.date(from: DateComponents(year: 2028, month: 1, day: 1))) | ||
| #expect(DateCandidatePreference.numericWeekday(entry, now: commonYear, calendar: calendar) == nil) | ||
| #expect(DateCandidatePreference.numericWeekday(entry, now: leapYear, calendar: calendar) == "2月29日(火)") | ||
| #expect(DateCandidatePreference.numericWeekday(self.entry("11/11"), now: commonYear, calendar: calendar) == nil) | ||
| } | ||
|
|
||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the user converts a four-digit month/day such as
1111,dateEntriesis always empty because it is built exclusively fromdynamicShortcuts, whose readings are fixed words such asキョウandアシタ; the converter's numeric11/11candidate is never inspected. ConsequentlynumericWeekdayis unreachable throughSegmentsManager, so the documented11月11日(水)candidate does not appear in actual input despite the direct helper test passing. Include numeric date entries from the conversion result or its provider before applying the preference.Useful? React with 👍 / 👎.