Skip to content
Open
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
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"
}
}
84 changes: 84 additions & 0 deletions Core/Sources/Core/InputUtils/DateCandidatePreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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
) -> [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()))
}
}
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])
}

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
}
}
}
25 changes: 23 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,24 @@ public final class SegmentsManager {
requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled
)
)
// 文節を縮めたときは、入力全体に対する日付を補完しない。
if self.composingText.isAtEndIndex {
let dateEntries = dynamicShortcuts.compactMap { entry -> DicdataElement? in
guard entry.ruby == self.composingText.convertTarget.toKatakana(),
entry.word.hasPrefix("<date ") else {
return nil
}
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,148 @@
@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 == "今日" })
}
}

@MainActor
@Test(arguments: [Config.DateFormatPreference.Value.monthDay, .weekday])
func shorteningDateReadingDoesNotOfferWholeReadingDates(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)
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("候補欄が表示されません")
return
}
#expect(!candidates.contains {
$0.text.range(of: #"^[0-9]{2}/[0-9]{2}$|^[0-9]+月[0-9]+日([日月火水木金土])$"#,
options: .regularExpression) != nil
})
}
}

@MainActor
@Test func numericReadingDoesNotGenerateWeekdayDates() 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: .weekday))
manager.insertAtCursorPosition("1111", inputStyle: .direct)
manager.update(requestRichCandidates: true)
manager.requestSetCandidateWindowState(visible: true)
guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else {
Issue.record("候補欄が表示されません")
return
}
#expect(candidates.contains { $0.text == "1111" })
#expect(!candidates.contains {
$0.text.range(of: #"^[0-9]+月[0-9]+日([日月火水木金土])$"#, options: .regularExpression) != nil
})
}
}
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,8 @@ 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日(土)` を選びやすくします。元の書式と通常の変換候補も残り、標準では従来の順番を維持します。
1 change: 1 addition & 0 deletions azooKeyMac/Windows/ConfigWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ struct ConfigWindow: View {
keys: [
Config.TypeBackSlash.key,
Config.TypeHalfSpace.key,
Config.DateFormatPreference.key,
Config.OptionDirectFullWidthInput.key,
Config.PunctuationStyle.key
]
Expand Down