Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions Core/Sources/ConverterServer/ConverterServer+Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ extension ConverterServer {
kind: .toggle,
value: .bool(Config.TypeHalfSpace().value)
),
descriptor(
key: Config.DateFormatPreference.key,
title: "日付候補の優先書式",
section: "入力オプション",
kind: .selector(options: [
.init(title: "標準", value: .string(Config.DateFormatPreference.Value.standard.rawValue)),
.init(title: "MM/DDを優先", value: .string(Config.DateFormatPreference.Value.monthDay.rawValue)),
.init(title: "曜日付きを優先", value: .string(Config.DateFormatPreference.Value.weekday.rawValue))
]),
value: .string(Config.DateFormatPreference().value.rawValue)
),
descriptor(
key: Config.OptionDirectFullWidthInput.key,
title: "Optionキーで直接全角英数を入力",
Expand Down Expand Up @@ -233,6 +244,12 @@ extension ConverterServer {
Config.TypeBackSlash().value = try boolSettingValue(value, key: key)
case Config.TypeHalfSpace.key:
Config.TypeHalfSpace().value = try boolSettingValue(value, key: key)
case Config.DateFormatPreference.key:
guard case .string(let rawValue) = value,
let preference = Config.DateFormatPreference.Value(rawValue: rawValue) else {
throw ConverterServerError.invalidSettingValue(key)
}
Config.DateFormatPreference().value = preference
case Config.OptionDirectFullWidthInput.key:
Config.OptionDirectFullWidthInput().value = try boolSettingValue(value, key: key)
case Config.PunctuationStyle.key:
Expand Down
15 changes: 15 additions & 0 deletions Core/Sources/Core/Configs/DateFormatPreference.swift
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 Core/Sources/Core/InputUtils/DateCandidatePreference.swift
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
}
}
}
22 changes: 20 additions & 2 deletions Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ public final class SegmentsManager {
/// テストなどの設定注入のための型。外部には設定を露出させない。
public struct Context {
public init() {}
public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil) {
public init(useZenzai: Bool, resourcesDirectoryURL: URL? = nil,
dateFormatPreference: Config.DateFormatPreference.Value? = nil) {
self.useZenzai = useZenzai
self.resourcesDirectoryURL = resourcesDirectoryURL
self.dateFormatPreference = dateFormatPreference
}

var useZenzai: Bool = true
var resourcesDirectoryURL: URL?
var dateFormatPreference: Config.DateFormatPreference.Value?
}

public weak var delegate: (any SegmentManagerDelegate)?
Expand Down Expand Up @@ -564,7 +567,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 +577,21 @@ public final class SegmentsManager {
requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled
)
)
let dateEntries = dynamicShortcuts.compactMap { entry -> DicdataElement? in
guard entry.ruby == self.composingText.convertTarget.toKatakana(),
entry.word.hasPrefix("<date ") else {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Source numeric dates before applying the weekday preference

When the user converts a four-digit month/day such as 1111, dateEntries is always empty because it is built exclusively from dynamicShortcuts, whose readings are fixed words such as キョウ and アシタ; the converter's numeric 11/11 candidate is never inspected. Consequently numericWeekday is unreachable through SegmentsManager, so the documented 11月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 👍 / 👎.

}
let template = DateTemplateLiteral.import(from: entry.word)
guard template.format.contains("d") else {
return nil
}
return .init(word: template.previewString(), ruby: entry.ruby, cid: CIDData.固有名詞.cid,
mid: MIDData.一般.mid, value: entry.value())
}
DateCandidatePreference.apply(to: &result, dateEntries: dateEntries,
readingCount: self.composingText.convertTarget.count,
preference: self.context.dateFormatPreference ?? Config.DateFormatPreference().value)
self.rawCandidates = result
}

Expand Down
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)
}

}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,10 @@ Thanks to authors!!

## Acknowledgement
本プロジェクトは情報処理推進機構(IPA)による[2024年度未踏IT人材発掘・育成事業](https://www.ipa.go.jp/jinzai/mitou/it/2024/koubokekka.html)の支援を受けて開発を行いました。

### 日付候補の優先書式

カスタマイズの「日付候補の優先書式」で「標準」「MM/DDを優先」「曜日付きを優先」を選べます。
「きょう」などの日付候補について、希望する書式を日付候補内で優先します。例えば「MM/DDを優先」では `09/05`、「曜日付きを優先」では `9月5日(土)` を選びやすくします。元の書式と通常の変換候補も残り、標準では従来の順番を維持します。

4桁の月日変換と組み合わせた場合、「曜日付き」では今年の月日として曜日候補を補います(例:2026年の`1111` → `11月11日(水)`)。今年存在しない月日には曜日を付けません。年なしの`02/29`候補は維持します。
Loading