From 1687b49a6fef7cda8df3e4432a149efb2d266804 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 12:38:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(input):=20=E6=9B=9C=E6=97=A5=E3=81=AE?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E3=81=8B=E3=82=89=E7=9B=B4=E8=BF=91=E3=81=AE?= =?UTF-8?q?=E6=97=A5=E4=BB=98=E5=80=99=E8=A3=9C=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/DateShortcuts.swift | 52 ++++++++ .../Core/InputUtils/SegmentsManager.swift | 34 +++-- .../InputUtilsTests/DateShortcutsTests.swift | 121 ++++++++++++++++++ README.md | 7 + 4 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 Core/Sources/Core/InputUtils/DateShortcuts.swift create mode 100644 Core/Tests/CoreTests/InputUtilsTests/DateShortcutsTests.swift diff --git a/Core/Sources/Core/InputUtils/DateShortcuts.swift b/Core/Sources/Core/InputUtils/DateShortcuts.swift new file mode 100644 index 00000000..3ffa9cd9 --- /dev/null +++ b/Core/Sources/Core/InputUtils/DateShortcuts.swift @@ -0,0 +1,52 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +enum DateShortcuts { + struct Format { + let pattern: String + let value: PValue + let calendarType: DateTemplateLiteral.CalendarType + } + + static let formats: [Format] = [ + .init(pattern: "M/d", value: -18, calendarType: .western), + .init(pattern: "yyyy/MM/dd", value: -18.1, calendarType: .western), + .init(pattern: "yyyy-MM-dd", value: -18.2, calendarType: .western), + .init(pattern: "M月d日(E)", value: -18.3, calendarType: .western), + .init(pattern: "yyyy年M月d日", value: -18.4, calendarType: .western), + .init(pattern: "Gyyyy年M月d日", value: -18.5, calendarType: .japanese), + .init(pattern: "E曜日", value: -18.6, calendarType: .western) + ] + + /// Mozcと同じく、曜日だけの指定は今日を含む次の該当日(0〜6日後)として扱う。 + static func weekdays(matching ruby: String? = nil, now: Date = Date(), calendar: Calendar = .current) -> [DicdataElement] { + let readings = ["ニチヨウ", "ゲツヨウ", "カヨウ", "スイヨウ", "モクヨウ", "キンヨウ", "ドヨウ"] + if let ruby, !readings.contains(where: { ruby == $0 || ruby == $0 + "ビ" }) { + return [] + } + let today = calendar.startOfDay(for: now) + let currentWeekday = calendar.component(.weekday, from: today) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ja_JP") + formatter.timeZone = calendar.timeZone + + return readings.enumerated().flatMap { index, reading -> [DicdataElement] in + if let ruby, ruby != reading && ruby != reading + "ビ" { + return [] + } + let daysAhead = (index + 1 - currentWeekday + 7) % 7 + // 秒数の加算ではなく暦日で計算し、夏時間の切り替わりにも対応する。 + guard let date = calendar.date(byAdding: .day, value: daysAhead, to: today) else { + return [] + } + return formats.flatMap { format -> [DicdataElement] in + formatter.calendar = Calendar(identifier: format.calendarType.identifier) + formatter.dateFormat = format.pattern + let word = formatter.string(from: date) + return [reading, reading + "ビ"].filter { ruby == nil || $0 == ruby }.map { reading in + DicdataElement(word: word, ruby: reading, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: format.value) + } + } + } + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..5bd3c42e 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -532,16 +532,11 @@ public final class SegmentsManager { } /// 日付・時刻変換を事前に入れておく let dynamicShortcuts: [DicdataElement] = - [ - ("M/d", -18, DateTemplateLiteral.CalendarType.western), - ("yyyy/MM/dd", -18.1, .western), - ("yyyy-MM-dd", -18.2, .western), - ("M月d日(E)", -18.3, .western), - ("yyyy年M月d日", -18.4, .western), - ("Gyyyy年M月d日", -18.5, .japanese), - ("E曜日", -18.6, .western) - ].flatMap { (format, value: PValue, type) in - [ + DateShortcuts.formats.flatMap { dateFormat -> [DicdataElement] in + let format = dateFormat.pattern + let value = dateFormat.value + let type = dateFormat.calendarType + return [ .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-2", deltaUnit: 60 * 60 * 24).export(), ruby: "オトトイ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "-1", deltaUnit: 60 * 60 * 24).export(), ruby: "キノウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), .init(word: DateTemplateLiteral(format: format, type: type, language: .japanese, delta: "0", deltaUnit: 1).export(), ruby: "キョウ", cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: value), @@ -564,7 +559,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 +569,23 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + // 曜日の日付候補を通常候補の後に補い、エンジンの上位候補の絞り込みによる欠落を防ぐ。 + let weekdayShortcuts = DateShortcuts.weekdays(matching: composingText.convertTarget.toKatakana()) + var seen = Set(result.mainResults.map(\.text)) + let weekdayCandidates = weekdayShortcuts.compactMap { data -> Candidate? in + guard seen.insert(data.word).inserted else { + return nil + } + return Candidate( + text: data.word, + value: data.value(), + composingCount: .surfaceCount(composingText.convertTarget.count), + lastMid: data.mid, + data: [data], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: weekdayCandidates, at: min(5, result.mainResults.count)) self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/DateShortcutsTests.swift b/Core/Tests/CoreTests/InputUtilsTests/DateShortcutsTests.swift new file mode 100644 index 00000000..56c5e6f8 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/DateShortcutsTests.swift @@ -0,0 +1,121 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@Suite("Weekday date shortcuts") +struct DateShortcutsTests { + private func calendar(_ zone: String = "Asia/Tokyo") -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: zone)! + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12, calendar: Calendar) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))! + } + + @Test func allWeekdaysAndBothReadings() { + let calendar = calendar() + let shortcuts = DateShortcuts.weekdays(now: date(2026, 9, 5, calendar: calendar), calendar: calendar) + let expected = [ + ("ニチヨウ", "2026/09/06"), ("ゲツヨウ", "2026/09/07"), + ("カヨウ", "2026/09/08"), ("スイヨウ", "2026/09/09"), + ("モクヨウ", "2026/09/10"), ("キンヨウ", "2026/09/11"), + ("ドヨウ", "2026/09/05") + ] + for (reading, text) in expected { + for ruby in [reading, reading + "ビ"] { + #expect(shortcuts.contains { $0.ruby == ruby && $0.word == text }) + } + } + } + + @Test func existingDateFormats() { + let calendar = calendar() + let shortcuts = DateShortcuts.weekdays(now: date(2026, 9, 5, calendar: calendar), calendar: calendar) + let words = Set(shortcuts.filter { $0.ruby == "ゲツヨウ" }.map(\.word)) + #expect(words == Set(["9/7", "2026/09/07", "2026-09-07", "9月7日(月)", "2026年9月7日", "令和8年9月7日", "月曜日"])) + } + + @Test(arguments: [ + (2026, 9, 30, "ゲツヨウ", "2026/10/05"), + (2026, 12, 31, "ゲツヨウ", "2027/01/04"), + (2028, 2, 28, "カヨウ", "2028/02/29") + ]) + func calendarBoundaries(year: Int, month: Int, day: Int, reading: String, expected: String) { + let calendar = calendar() + let shortcuts = DateShortcuts.weekdays(now: date(year, month, day, calendar: calendar), calendar: calendar) + #expect(shortcuts.contains { $0.ruby == reading && $0.word == expected }) + } + + @Test func refreshesAfterMidnight() { + let calendar = calendar() + let monday = DateShortcuts.weekdays(now: date(2026, 9, 7, hour: 23, calendar: calendar), calendar: calendar) + let tuesday = DateShortcuts.weekdays(now: date(2026, 9, 8, hour: 0, calendar: calendar), calendar: calendar) + #expect(monday.contains { $0.ruby == "ゲツヨウ" && $0.word == "2026/09/07" }) + #expect(tuesday.contains { $0.ruby == "ゲツヨウ" && $0.word == "2026/09/14" }) + } + + @Test(arguments: [(2026, 3, 7, "2026/03/09"), (2026, 10, 31, "2026/11/02")]) + func daylightSaving(year: Int, month: Int, day: Int, expected: String) { + let calendar = calendar("America/Los_Angeles") + let shortcuts = DateShortcuts.weekdays(now: date(year, month, day, hour: 23, calendar: calendar), calendar: calendar) + #expect(shortcuts.contains { $0.ruby == "ゲツヨウ" && $0.word == expected }) + } + + @Test func usesLocalDay() { + let tokyo = calendar() + let losAngeles = calendar("America/Los_Angeles") + let now = date(2026, 9, 8, hour: 0, calendar: tokyo) + let shortcuts = DateShortcuts.weekdays(now: now, calendar: losAngeles) + #expect(shortcuts.contains { $0.ruby == "ゲツヨウ" && $0.word == "2026/09/07" }) + } + + @Test(arguments: ["", "ゲツ", "ゲツヨウニアイマショウ", "ライシュウノゲツヨウ", "キョウ"]) + func requiresWholeWeekdayReading(_ ruby: String) { + #expect(DateShortcuts.weekdays(matching: ruby).isEmpty) + } + + @MainActor + @Test(arguments: ["getsuyou", "getsuyoubi", "kayou", "kayoubi"]) + func romanInputCanCommitTheWholeDate(_ input: String) throws { + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil, + context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition(input, inputStyle: .roman2kana) + manager.update(requestRichCandidates: true) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected selecting state") + return + } + let candidate = try #require(candidates.first { $0.text.range(of: #"^\d{4}/\d{2}/\d{2}$"#, options: .regularExpression) != nil }) + #expect(!candidate.isLearningTarget) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.isEmpty) + } + + @MainActor + @Test(arguments: ["にちよう", "にちようび", "げつよう", "げつようび", "かよう", "かようび", "すいよう", "すいようび", "もくよう", "もくようび", "きんよう", "きんようび", "どよう", "どようび"]) + func appearsInConversionCandidates(_ reading: String) throws { + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil, + context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition(reading, inputStyle: .direct) + manager.update(requestRichCandidates: true) + manager.requestSetCandidateWindowState(visible: true) + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _): + #expect(candidates.contains { $0.text.range(of: #"^\d{4}/\d{2}/\d{2}$"#, options: .regularExpression) != nil }, "Candidates: \(candidates.map(\.text))") + case .hidden, .composing: + Issue.record("Expected date candidates in the selection window") + } + } +} diff --git a/README.md b/README.md index e710c7a7..8f6e7547 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,13 @@ GitHub Sponsorsをご利用ください。 * ライブ変換 * AZIKのネイティブサポート +### 曜日から日付を入力 + +「げつよう」「げつようび」など、各曜日の読みを変換すると、今日を含む次の該当日が候補に出ます。 +例えば2026年9月5日(土)に「げつよう」と入力すると、`2026/09/07`、`9/7`、`9月7日(月)`などを選べます。 +対象範囲は[Mozcの曜日変換](https://github.com/google/mozc/blob/master/src/rewriter/date_rewriter.cc)と同じ、今日から6日後までです。 +月曜日当日の「げつよう」は当日の日付になります。日付の計算にはMacのタイムゾーンを使います。 + ## 開発ガイド From 3e4e5e86879a6574fb0f6040ea7dc29d20c1810c Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 12:55:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?perf(input):=20=E6=9B=9C=E6=97=A5=E5=80=99?= =?UTF-8?q?=E8=A3=9C=E3=81=8C=E3=81=AA=E3=81=84=E5=A0=B4=E5=90=88=E3=81=AE?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E7=9C=81=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 5bd3c42e..6f4e4d40 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -571,21 +571,23 @@ public final class SegmentsManager { ) // 曜日の日付候補を通常候補の後に補い、エンジンの上位候補の絞り込みによる欠落を防ぐ。 let weekdayShortcuts = DateShortcuts.weekdays(matching: composingText.convertTarget.toKatakana()) - var seen = Set(result.mainResults.map(\.text)) - let weekdayCandidates = weekdayShortcuts.compactMap { data -> Candidate? in - guard seen.insert(data.word).inserted else { - return nil + if !weekdayShortcuts.isEmpty { + var seen = Set(result.mainResults.map(\.text)) + let weekdayCandidates = weekdayShortcuts.compactMap { data -> Candidate? in + guard seen.insert(data.word).inserted else { + return nil + } + return Candidate( + text: data.word, + value: data.value(), + composingCount: .surfaceCount(composingText.convertTarget.count), + lastMid: data.mid, + data: [data], + isLearningTarget: false + ) } - return Candidate( - text: data.word, - value: data.value(), - composingCount: .surfaceCount(composingText.convertTarget.count), - lastMid: data.mid, - data: [data], - isLearningTarget: false - ) + result.mainResults.insert(contentsOf: weekdayCandidates, at: min(5, result.mainResults.count)) } - result.mainResults.insert(contentsOf: weekdayCandidates, at: min(5, result.mainResults.count)) self.rawCandidates = result } From 155f7df1a2a4abef4bb17a622bf588c148cc8856 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 13:03:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(input):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=97?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=E7=AF=84=E5=9B=B2=E3=81=A8=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 2 +- .../EditedShortcutRangeTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 6f4e4d40..2457d935 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -571,7 +571,7 @@ public final class SegmentsManager { ) // 曜日の日付候補を通常候補の後に補い、エンジンの上位候補の絞り込みによる欠落を防ぐ。 let weekdayShortcuts = DateShortcuts.weekdays(matching: composingText.convertTarget.toKatakana()) - if !weekdayShortcuts.isEmpty { + if self.composingText.isAtEndIndex, !weekdayShortcuts.isEmpty { var seen = Set(result.mainResults.map(\.text)) let weekdayCandidates = weekdayShortcuts.compactMap { data -> Candidate? in guard seen.insert(data.word).inserted else { diff --git a/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift new file mode 100644 index 00000000..8d4767e4 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift @@ -0,0 +1,22 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Test func wholeInputShortcutIsSuppressedForEditedSegment() 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)) + manager.insertAtCursorPosition("げつよう", inputStyle: .direct) + manager.editSegment(count: -1) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected candidates for edited segment") + return + } + #expect(!choices.contains { $0.text.contains("/") || $0.text.contains("月") && $0.text.contains("日") }) + #expect(manager.convertTarget == "げつよう") +}