remindctl/Sources/RemindCore/DateParsing.swift
2026-05-04 05:24:23 +01:00

121 lines
3.8 KiB
Swift

import Foundation
public struct ParsedUserDate: Equatable, Sendable {
public let date: Date
public let isDateOnly: Bool
public init(date: Date, isDateOnly: Bool) {
self.date = date
self.isDateOnly = isDateOnly
}
}
public enum DateParsing {
public static func parseUserDate(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> Date? {
parseUserDateWithMetadata(input, now: now, calendar: calendar)?.date
}
public static func parseUserDateWithMetadata(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> ParsedUserDate? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
if let relative = parseRelativeDate(lower, now: now, calendar: calendar) {
return relative
}
let iso =
isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso {
return ParsedUserDate(date: iso, isDateOnly: false)
}
let localISO =
localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm").date(from: trimmed)
if let localISO {
return ParsedUserDate(date: localISO, isDateOnly: false)
}
for (formatter, isDateOnly) in dateFormatters() {
if let date = formatter.date(from: trimmed) {
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
}
}
return nil
}
public static func formatDisplay(_ date: Date, isDateOnly: Bool = false, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeZone = calendar.timeZone
formatter.dateStyle = .medium
formatter.timeStyle = isDateOnly ? .none : .short
return formatter.string(from: date)
}
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> ParsedUserDate? {
switch input {
case "today":
return ParsedUserDate(date: calendar.startOfDay(for: now), isDateOnly: true)
case "tomorrow":
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "yesterday":
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "now":
return ParsedUserDate(date: now, isDateOnly: false)
default:
return nil
}
}
private static func isoFormatter(withFraction: Bool) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions =
withFraction
? [.withInternetDateTime, .withFractionalSeconds]
: [.withInternetDateTime]
return formatter
}
private static func localISOFormatter(format: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return formatter
}
private static func dateFormatters() -> [(DateFormatter, Bool)] {
let formats: [(String, Bool)] = [
("yyyy-MM-dd", true),
("yyyy-MM-dd HH:mm", false),
("yyyy-MM-dd HH:mm:ss", false),
("MM/dd/yyyy", true),
("MM/dd/yyyy HH:mm", false),
("dd-MM-yy", true),
("dd-MM-yyyy", true),
]
return formats.map { format, isDateOnly in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return (formatter, isDateOnly)
}
}
}