Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Core/Sources/Core/InputUtils/DateShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
36 changes: 25 additions & 11 deletions Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -574,6 +569,25 @@ public final class SegmentsManager {
requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled
)
)
// 曜日の日付候補を通常候補の後に補い、エンジンの上位候補の絞り込みによる欠落を防ぐ。
let weekdayShortcuts = DateShortcuts.weekdays(matching: composingText.convertTarget.toKatakana())
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 {
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
}

Expand Down
121 changes: 121 additions & 0 deletions Core/Tests/CoreTests/InputUtilsTests/DateShortcutsTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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 == "げつよう")
}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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のタイムゾーンを使います。


## 開発ガイド

Expand Down