Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Reject impossible ISO dates, malformed offsets, and trailing date text; select explicit field order so day-first inputs cannot silently become another date while retaining unambiguous legacy spellings.

## 0.3.7 - 2026-09-13

**Highlights:** `--delete --rename` can no longer silently delete a list, and list mutations are validated before Reminders is touched.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ Accepted by `--due` and date filters:
- Local ISO 8601 without timezone, such as `2026-01-03T12:34:56`

Date-only due values create all-day reminders. Date-time values create timed reminders.
Absolute dates must contain a valid calendar date, time, and offset; invalid dates and trailing text are rejected. Unpadded month/day/time fields and year-first `/` or `.` separators remain accepted. Legacy `MM/dd/yyyy` and `dd-MM-yy`/`dd-MM-yyyy` inputs retain their explicit month/day order.

## Alarms

Expand Down
78 changes: 29 additions & 49 deletions Sources/RemindCore/DateParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,17 @@ public enum DateParsing {
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 =
localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", calendar: calendar).date(from: trimmed)
?? localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS", calendar: calendar).date(from: trimmed)
?? localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss", calendar: calendar).date(from: trimmed)
?? localFormatter(format: "yyyy-MM-dd'T'HH:mm", calendar: calendar).date(from: trimmed)
if let localISO {
return ParsedUserDate(date: localISO, isDateOnly: false)
}

for (formatter, isDateOnly) in dateFormatters(calendar: calendar) {
if let date = formatter.date(from: trimmed) {
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
}
let absolute = trimmed.uppercased()
for (pattern, format, isDateOnly) in absoluteFormats {
// Select field order before parsing: DateFormatter can otherwise reinterpret day-first dates.
guard absolute.range(of: "\\A\(pattern)\\z", options: .regularExpression) != nil else { continue }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = calendar.timeZone
formatter.dateFormat = format
formatter.isLenient = false
return formatter.date(from: absolute).map { ParsedUserDate(date: $0, isDateOnly: isDateOnly) }
}

return nil
Expand Down Expand Up @@ -83,35 +74,24 @@ public enum DateParsing {
}
}

private static func isoFormatter(withFraction: Bool) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions =
withFraction
? [.withInternetDateTime, .withFractionalSeconds]
: [.withInternetDateTime]
return formatter
}

private static func localFormatter(format: String, calendar: Calendar) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = format
return formatter
}

private static func dateFormatters(calendar: Calendar) -> [(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),
private static var absoluteFormats: [(pattern: String, format: String, isDateOnly: Bool)] {
let date = "[0-9]{4}[-/.][0-9]{1,2}[-/.][0-9]{1,2}"
let time = "(?:[01]?[0-9]|2[0-3]):[0-5]?[0-9]"
let seconds = "\(time):[0-5]?[0-9]"
let zone = "(?:Z|[+-](?:[01][0-9]|2[0-3]):?[0-5][0-9])"
return [
("\(date)T\(seconds)\\.[0-9]+\(zone)", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", false),
("\(date)T\(seconds)\(zone)", "yyyy-MM-dd'T'HH:mm:ssXXXXX", false),
("\(date)T\(seconds)\\.[0-9]+", "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", false),
("\(date)T\(seconds)", "yyyy-MM-dd'T'HH:mm:ss", false),
("\(date)T\(time)", "yyyy-MM-dd'T'HH:mm", false),
(date, "yyyy-MM-dd", true),
("\(date) +\(time)", "yyyy-MM-dd HH:mm", false),
("\(date) +\(seconds)", "yyyy-MM-dd HH:mm:ss", false),
("[0-9]{1,2}/[0-9]{1,2}/[0-9]{4}", "MM/dd/yyyy", true),
("[0-9]{1,2}/[0-9]{1,2}/[0-9]{4} +\(time)", "MM/dd/yyyy HH:mm", false),
("[0-9]{1,2}-[0-9]{1,2}-[0-9]{2}", "dd-MM-yy", true),
("[0-9]{1,2}-[0-9]{1,2}-[0-9]{4}", "dd-MM-yyyy", true),
]
return formats.map { format, isDateOnly in
(localFormatter(format: format, calendar: calendar), isDateOnly)
}
}
}
63 changes: 63 additions & 0 deletions Tests/RemindCoreTests/DateParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,69 @@ struct DateParsingTests {
#expect(parsed != nil)
}

@Test(
"Reject malformed absolute dates",
arguments: [
"2026-02-30T12:00:00Z", "2025-02-29T12:00:00.123Z",
"2026-04-31T12:00:00+02:00", "2026-01-03T12:00:00Zjunk",
"2026-01-03T12:00:00+99:00", "2026-01-03T12:00:00+01:99",
"2026-01-03T25:00:00Z", "2026-01-03T12:60:00Z",
"2026-02-30", "2026-13-01", "2026-01-03junk",
])
func rejectMalformedAbsoluteDates(_ input: String) {
#expect(DateParsing.parseUserDate(input, calendar: calendar) == nil)
#expect(ReminderFiltering.parse(input, calendar: calendar) == nil)
}

@Test(
"Date formats do not steal each other's inputs",
arguments: [
"2026-01-03", "01/03/2026", "03-01-26", "03-01-2026",
])
func disambiguateDateFormats(_ input: String) throws {
let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar))
#expect(parsed.date == calendar.date(from: DateComponents(year: 2026, month: 1, day: 3)))
#expect(parsed.isDateOnly)
}

@Test(
"Preserve unambiguous legacy date spellings",
arguments: [
"2026-1-3", "2026/1/3", "2026.1.3", "1/3/2026",
])
func legacyDateSpellings(_ input: String) throws {
let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar))
#expect(parsed.date == calendar.date(from: DateComponents(year: 2026, month: 1, day: 3)))
#expect(parsed.isDateOnly)
}

@Test(
"Preserve unpadded times and whitespace",
arguments: [
"2026-01-03 9:05", "2026-01-03 9:5", "2026-1-3T9:5", "2026-01-03 09:05",
])
func legacyTimeSpellings(_ input: String) throws {
let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar))
#expect(parsed.date == calendar.date(from: DateComponents(year: 2026, month: 1, day: 3, hour: 9, minute: 5)))
#expect(!parsed.isDateOnly)
}

@Test(
"Valid ISO offsets and fractions survive strict validation",
arguments: [
"2024-02-29T12:34:56Z", "2024-02-29T12:34:56.123456Z",
"2024-02-29T14:34:56+02:00", "2024-02-29T02:34:56-1000",
"2024-02-29t12:34:56z",
"2024-2-29T2:34:56-1000",
])
func validAbsoluteDates(_ input: String) throws {
let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar))
let expected = try #require(
calendar.date(from: DateComponents(year: 2024, month: 2, day: 29, hour: 12, minute: 34, second: 56)))
#expect(abs(parsed.date.timeIntervalSince(expected)) < 1)
#expect(!parsed.isDateOnly)
}

@Test("Format display output")
func displayFormatting() {
let date = Date(timeIntervalSince1970: 1_700_000_000)
Expand Down
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Useful `add` options:
- `--leaving` changes a location trigger to leaving.
- `--radius <meters>` adjusts the geofence radius.

Due dates and alarms accept relative dates, `YYYY-MM-DD`, local date-times, and ISO 8601 timestamps with explicit offsets. Impossible dates, invalid offsets, and trailing text fail instead of being normalized to another date. Unpadded month/day/time fields, extra spaces before a time, and year-first `/` or `.` separators remain accepted. Legacy slash dates use `MM/dd/yyyy`; day-first hyphen dates use `dd-MM-yy` or `dd-MM-yyyy`.

`--alarm` schedules a notification; it does not enable the native **Urgent** toggle in Reminders.app. EventKit does not expose Urgent, so `remindctl` cannot set it. Use `--alarm` for a notification, or enable Urgent directly in Reminders.app.

## Edit reminders
Expand Down