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 @@ -3,6 +3,8 @@
## Unreleased

- Run developer checks once with the existing 90% coverage gate, pin CI to Swift 6.2 and current Node/pnpm tooling, and execute universal CLI smoke checks in CI.
- Reject out-of-range numeric reminder indexes before converting them to array offsets, preventing an arithmetic-overflow crash for the minimum integer.
- Honor the supplied calendar's time zone when RemindCore parses local dates, while retaining explicit ISO 8601 offsets.

## 0.3.6 - 2026-09-07

Expand Down
23 changes: 10 additions & 13 deletions Sources/RemindCore/DateParsing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum DateParsing {
parseUserDateWithMetadata(input, now: now, calendar: calendar)?.date
}

/// Interprets local dates in the supplied calendar's time zone; explicit ISO offsets take precedence.
public static func parseUserDateWithMetadata(
_ input: String,
now: Date = Date(),
Expand All @@ -39,15 +40,15 @@ public enum DateParsing {
}

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)
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() {
for (formatter, isDateOnly) in dateFormatters(calendar: calendar) {
if let date = formatter.date(from: trimmed) {
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
}
Expand Down Expand Up @@ -91,15 +92,15 @@ public enum DateParsing {
return formatter
}

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

private static func dateFormatters() -> [(DateFormatter, Bool)] {
private static func dateFormatters(calendar: Calendar) -> [(DateFormatter, Bool)] {
let formats: [(String, Bool)] = [
("yyyy-MM-dd", true),
("yyyy-MM-dd HH:mm", false),
Expand All @@ -110,11 +111,7 @@ public enum DateParsing {
("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)
(localFormatter(format: format, calendar: calendar), isDateOnly)
}
}
}
5 changes: 2 additions & 3 deletions Sources/RemindCore/IDResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ public enum IDResolver {
for input in inputs {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if let index = Int(trimmed) {
let idx = index - 1
guard idx >= 0 && idx < numericSorted.count else {
guard index > 0 && index <= numericSorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(numericSorted[idx])
resolved.append(numericSorted[index - 1])
continue
}

Expand Down
20 changes: 20 additions & 0 deletions Tests/RemindCoreTests/DateParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ struct DateParsingTests {
#expect(parsed?.isDateOnly == false)
}

@Test("Local dates honor the supplied time zone", arguments: [14 * 3600, -10 * 3600])
func suppliedTimeZone(_ offset: Int) throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try #require(TimeZone(secondsFromGMT: offset))
let expected = try #require(
calendar.date(from: DateComponents(year: 2026, month: 1, day: 3, hour: 12, minute: 34)))

for input in ["2026-01-03T12:34", "2026-01-03 12:34", "01/03/2026 12:34"] {
let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar))
#expect(parsed.date == expected)
#expect(!parsed.isDateOnly)
}
let allDay = try #require(DateParsing.parseUserDateWithMetadata("2026-01-03", calendar: calendar))
#expect(allDay.date == calendar.startOfDay(for: expected))
#expect(allDay.isDateOnly)

let absolute = "2026-01-03T12:34:00Z"
#expect(DateParsing.parseUserDate(absolute, calendar: calendar) == ISO8601DateFormatter().date(from: absolute))
}

@Test("Formatted date parsing")
func formattedParsing() {
let input = "2026-01-03 10:30"
Expand Down
8 changes: 8 additions & 0 deletions Tests/RemindCoreTests/IDResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ struct IDResolverTests {
#expect(resolved.first?.title == "Second")
}

@Test("Reject out-of-range indexes without overflow", arguments: [Int.min, -1, 0, 3, Int.max])
func rejectInvalidIndex(_ index: Int) {
let input = String(index)
#expect(throws: RemindCoreError.invalidIdentifier(input)) {
_ = try IDResolver.resolve([input], from: sampleReminders())
}
}

@Test("Resolve by prefix")
func resolvePrefix() throws {
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ remindctl edit 4A83 --no-repeat
```

`edit`, `complete`, and `delete` accept indexes from the current default listing or ID prefixes.
Numeric indexes must be positive and within the current view; out-of-range values return an error.

If `add`, `edit`, or `complete` reports `Reminder is missing a calendar`, EventKit saved the reminder but returned it without its list. Check Reminders.app before retrying; this error does not roll back the saved change.

Expand Down