diff --git a/CHANGELOG.md b/CHANGELOG.md index 12be271..0b6c30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 48c321f..ea0f84f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/RemindCore/DateParsing.swift b/Sources/RemindCore/DateParsing.swift index f90cba0..897618c 100644 --- a/Sources/RemindCore/DateParsing.swift +++ b/Sources/RemindCore/DateParsing.swift @@ -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 @@ -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) - } } } diff --git a/Tests/RemindCoreTests/DateParsingTests.swift b/Tests/RemindCoreTests/DateParsingTests.swift index c884d71..eeb2c56 100644 --- a/Tests/RemindCoreTests/DateParsingTests.swift +++ b/Tests/RemindCoreTests/DateParsingTests.swift @@ -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) diff --git a/docs/commands.md b/docs/commands.md index 9a0ecad..98dec10 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -58,6 +58,8 @@ Useful `add` options: - `--leaving` changes a location trigger to leaving. - `--radius ` 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