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
20 changes: 20 additions & 0 deletions Core/Sources/Core/InputUtils/DateShortcutFormats.swift
Original file line number Diff line number Diff line change
@@ -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)
]
}
71 changes: 71 additions & 0 deletions Core/Sources/Core/InputUtils/RelativeDateShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
35 changes: 24 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
[
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),
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,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
}

Expand Down
154 changes: 154 additions & 0 deletions Core/Tests/CoreTests/InputUtilsTests/RelativeDateShortcutsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ GitHub Sponsorsをご利用ください。

## 機能

* 相対日付変換:「らいしゅうのげつよう(び)」など全曜日、「げつまつ」、「3にちご」を日付候補に追加します。来週は月曜始まりの次週、月末は今月末、日数は今日基準の0〜3660日(半角・全角数字、数字部分は最大4桁)です。通常の変換候補も残ります。

* ニューラルかな漢字変換システム「Zenzai」による高精度な変換
* プロフィールプロンプト機能
* 履歴学習機能
Expand Down