diff --git a/Core/Sources/Core/InputUtils/DateShortcutFormats.swift b/Core/Sources/Core/InputUtils/DateShortcutFormats.swift new file mode 100644 index 00000000..c5e96c55 --- /dev/null +++ b/Core/Sources/Core/InputUtils/DateShortcutFormats.swift @@ -0,0 +1,20 @@ +import KanaKanjiConverterModuleWithDefaultDictionary + +/// 日付ショートカットで共通の表記と候補スコア。 +enum DateShortcutFormats { + struct Format { + let pattern: String + let value: PValue + let calendar: DateTemplateLiteral.CalendarType + } + + static let all: [Format] = [ + .init(pattern: "M/d", value: -18, calendar: .western), + .init(pattern: "yyyy/MM/dd", value: -18.1, calendar: .western), + .init(pattern: "yyyy-MM-dd", value: -18.2, calendar: .western), + .init(pattern: "M月d日(E)", value: -18.3, calendar: .western), + .init(pattern: "yyyy年M月d日", value: -18.4, calendar: .western), + .init(pattern: "Gyyyy年M月d日", value: -18.5, calendar: .japanese), + .init(pattern: "E曜日", value: -18.6, calendar: .western) + ] +} diff --git a/Core/Sources/Core/InputUtils/RelativeDateShortcuts.swift b/Core/Sources/Core/InputUtils/RelativeDateShortcuts.swift new file mode 100644 index 00000000..c3035b31 --- /dev/null +++ b/Core/Sources/Core/InputUtils/RelativeDateShortcuts.swift @@ -0,0 +1,71 @@ +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary + +enum RelativeDateShortcuts { + // 暦日の加算は最大10年程度に限定し、桁あふれや意図しない巨大な日付を避ける。 + static let maximumDaysAhead = 3660 + + static func date(matching ruby: String, now: Date = Date(), calendar: Calendar = .current) -> Date? { + let today = calendar.startOfDay(for: now) + if ruby == "ゲツマツ" { + guard let month = calendar.dateInterval(of: .month, for: today) else { + return nil + } + return calendar.date(byAdding: .day, value: -1, to: month.end) + } + let prefix = "ライシュウノ" + if ruby.hasPrefix(prefix) { + let weekdayReading = String(ruby.dropFirst(prefix.count)) + let weekdays = ["ゲツヨウ", "カヨウ", "スイヨウ", "モクヨウ", "キンヨウ", "ドヨウ", "ニチヨウ"] + guard let weekday = weekdays.firstIndex(where: { weekdayReading == $0 || weekdayReading == $0 + "ビ" }) else { + return nil + } + // Calendar.firstWeekday(地域設定)によらず、月曜を週の始まりとする。 + let daysSinceMonday = (calendar.component(.weekday, from: today) + 5) % 7 + return calendar.date(byAdding: .day, value: 7 - daysSinceMonday + weekday, to: today) + } + guard let days = daysAhead(matching: ruby) else { + return nil + } + return calendar.date(byAdding: .day, value: days, to: today) + } + + private static func daysAhead(matching ruby: String) -> Int? { + let suffix = "ニチゴ" + guard ruby.hasSuffix(suffix) else { + return nil + } + let digits = ruby.dropLast(suffix.count).unicodeScalars + guard !digits.isEmpty, digits.count <= 4 else { + return nil + } + var days = 0 + for scalar in digits { + let value: UInt32 + switch scalar.value { + case 0x30...0x39: value = scalar.value - 0x30 + case 0xFF10...0xFF19: value = scalar.value - 0xFF10 + default: return nil + } + days = days * 10 + Int(value) + } + guard days <= maximumDaysAhead else { + return nil + } + return days + } + + static func candidates(matching ruby: String, now: Date = Date(), calendar: Calendar = .current) -> [DicdataElement] { + guard let date = date(matching: ruby, now: now, calendar: calendar) else { + return [] + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ja_JP") + formatter.timeZone = calendar.timeZone + return DateShortcutFormats.all.map { format in + formatter.calendar = Calendar(identifier: format.calendar == .japanese ? .japanese : .gregorian) + formatter.dateFormat = format.pattern + return DicdataElement(word: formatter.string(from: date), ruby: ruby, 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..5645ea10 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 - [ + DateShortcutFormats.all.flatMap { dateFormat -> [DicdataElement] in + let format = dateFormat.pattern + let value = dateFormat.value + let type = dateFormat.calendar + 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,24 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + // 全体入力に対する候補は文節を短くしている間には出さない。 + let relativeDates = composingText.isAtEndIndex + ? RelativeDateShortcuts.candidates(matching: composingText.convertTarget.toKatakana()) + : [] + if !relativeDates.isEmpty { + let existingTexts = Set(result.mainResults.map(\.text)) + let candidates = relativeDates.filter { !existingTexts.contains($0.word) }.map { element in + Candidate( + text: element.word, + value: element.value(), + composingCount: .surfaceCount(composingText.convertTarget.count), + lastMid: element.mid, + data: [element], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: candidates, at: min(5, result.mainResults.count)) + } self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/RelativeDateShortcutsTests.swift b/Core/Tests/CoreTests/InputUtilsTests/RelativeDateShortcutsTests.swift new file mode 100644 index 00000000..4006a041 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/RelativeDateShortcutsTests.swift @@ -0,0 +1,154 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@Suite("Relative date shortcuts") +struct RelativeDateShortcutsTests { + 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 nextWeekStartsOnMondayForEveryCurrentWeekday() { + var calendar = calendar() + calendar.firstWeekday = 1 + let readings = ["ゲツヨウ", "カヨウ", "スイヨウ", "モクヨウ", "キンヨウ", "ドヨウ", "ニチヨウ"] + for currentDay in 7...13 { + let now = date(2026, 9, currentDay, calendar: calendar) + for (index, reading) in readings.enumerated() { + for suffix in ["", "ビ"] { + let actual = RelativeDateShortcuts.date(matching: "ライシュウノ" + reading + suffix, now: now, calendar: calendar) + #expect(actual == date(2026, 9, 14 + index, hour: 0, calendar: calendar)) + } + } + } + } + + @Test(arguments: [(2026, 2, 15, 28), (2028, 2, 15, 29), (2026, 4, 30, 30), (2026, 12, 31, 31)]) + func monthEnd(year: Int, month: Int, day: Int, lastDay: Int) { + let calendar = calendar() + let actual = RelativeDateShortcuts.date(matching: "ゲツマツ", now: date(year, month, day, calendar: calendar), calendar: calendar) + #expect(actual == date(year, month, lastDay, hour: 0, calendar: calendar)) + } + + @Test func dayOffsets() { + let cases = [ + (2026, 9, 30, "3ニチゴ", 2026, 10, 3), + (2026, 12, 31, "1ニチゴ", 2027, 1, 1), + (2028, 2, 28, "1ニチゴ", 2028, 2, 29), + (2026, 9, 5, "0ニチゴ", 2026, 9, 5), + (2026, 9, 5, "01ニチゴ", 2026, 9, 6), + (2026, 9, 5, "0003ニチゴ", 2026, 9, 8) + ] + let calendar = calendar() + for (year, month, day, reading, targetYear, targetMonth, targetDay) in cases { + let actual = RelativeDateShortcuts.date(matching: reading, now: date(year, month, day, calendar: calendar), calendar: calendar) + #expect(actual == date(targetYear, targetMonth, targetDay, hour: 0, calendar: calendar)) + } + } + + @Test func maximumOffsetAndYearBoundary() { + let calendar = calendar() + let now = date(2026, 12, 31, calendar: calendar) + #expect(RelativeDateShortcuts.date(matching: "3660ニチゴ", now: now, calendar: calendar) == calendar.date(byAdding: .day, value: 3660, to: calendar.startOfDay(for: now))) + #expect(RelativeDateShortcuts.date(matching: "ライシュウノゲツヨウ", now: now, calendar: calendar) == date(2027, 1, 4, hour: 0, calendar: calendar)) + } + + @Test(arguments: [(2026, 3, 7, 9), (2026, 10, 31, 2)]) + func daylightSaving(year: Int, month: Int, day: Int, targetDay: Int) { + let calendar = calendar("America/Los_Angeles") + let now = date(year, month, day, hour: 23, calendar: calendar) + let targetMonth = month == 10 ? 11 : month + let target = date(year, targetMonth, targetDay, hour: 0, calendar: calendar) + #expect(RelativeDateShortcuts.date(matching: "2ニチゴ", now: now, calendar: calendar) == target) + #expect(RelativeDateShortcuts.date(matching: "ライシュウノゲツヨウ", now: now, calendar: calendar) == target) + } + + @Test func sevenExistingFormats() { + let calendar = calendar() + let elements = RelativeDateShortcuts.candidates(matching: "3ニチゴ", now: date(2026, 9, 5, calendar: calendar), calendar: calendar) + #expect(elements.map(\.word) == ["9/8", "2026/09/08", "2026-09-08", "9月8日(火)", "2026年9月8日", "令和8年9月8日", "火曜日"]) + #expect(elements.allSatisfy { $0.ruby == "3ニチゴ" }) + } + + @Test(arguments: ["", "ゲツヨウ", "ゲツヨウビ", "キョウ", "ライシュウ", "ライシュウノゲツ", "ライシュウノゲツヨウニ", "ゲツマツニ", "ニチゴ", "-1ニチゴ", "+1ニチゴ", "1.5ニチゴ", "٣ニチゴ", "③ニチゴ", "三ニチゴ", "3661ニチゴ", "999999999999ニチゴ", "00001ニチゴ", "3ニチゴニ", "アト3ニチゴ"]) + func requiresWholeSupportedReading(_ reading: String) { + #expect(RelativeDateShortcuts.candidates(matching: reading).isEmpty) + } + + @MainActor + @Test(arguments: ["らいしゅうのげつよう", "らいしゅうのにちようび", "げつまつ", "3にちご", "3にちご"]) + func dateCanCommitWholeInput(_ reading: String) throws { + try verifyWholeInput(reading, inputStyle: .direct) + } + + @MainActor + @Test(arguments: ["raishuunogetsuyou", "getsumatsu", "3nichigo"]) + func romanDateCanCommitWholeInput(_ input: String) throws { + try verifyWholeInput(input, inputStyle: .roman2kana) + } + + @MainActor + @Test(arguments: ["げつまつ", "らいしゅうのげつよう", "3にちご"]) + func shortenedSegmentDoesNotOfferWholeInputDate(_ reading: String) throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition(reading, 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("Expected selecting state") + return + } + #expect(!candidates.contains { $0.text.range(of: #"^\d{4}/\d{2}/\d{2}$"#, options: .regularExpression) != nil }) + } + manager.editSegment(count: reading.count) + manager.update(requestRichCandidates: 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 }) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.isEmpty) + } + + @MainActor + private func verifyWholeInput(_ input: String, inputStyle: InputStyle) throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition(input, inputStyle: inputStyle) + 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("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) + #expect(candidates.contains { $0.text.range(of: "[年月日/曜日-]", options: .regularExpression) == nil }) + #expect(candidates.filter { $0.text == candidate.text }.count == 1) + if rich { + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.isEmpty) + } + } + } +} diff --git a/README.md b/README.md index e710c7a7..2cea0083 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ GitHub Sponsorsをご利用ください。 ## 機能 +* 相対日付変換:「らいしゅうのげつよう(び)」など全曜日、「げつまつ」、「3にちご」を日付候補に追加します。来週は月曜始まりの次週、月末は今月末、日数は今日基準の0〜3660日(半角・全角数字、数字部分は最大4桁)です。通常の変換候補も残ります。 + * ニューラルかな漢字変換システム「Zenzai」による高精度な変換 * プロフィールプロンプト機能 * 履歴学習機能