From ec95eec023dc41f7a5acd20894c3338de20de6f0 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 26 Sep 2026 04:01:00 -0700 Subject: [PATCH 1/4] Handle one-sided date ranges and date-only end dates consistently messages_fetch applied a date range only when both start and end were given, so start alone was ignored. Each bound now applies on its own, and an end before the start matches nothing instead of trapping. A date-only end already includes that whole day in messages_fetch, events_fetch, and reminders_fetch, but the descriptions said it uses local midnight. The descriptions now say so, and phone_calls_fetch normalizes date-only start and end values the same way. --- App/Services/Calendar.swift | 2 +- App/Services/Messages.swift | 37 ++++++++++++++++-------------------- App/Services/Phone.swift | 14 ++++++++++---- App/Services/Reminders.swift | 2 +- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index 0a486b7d..c4ff5ff5 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -70,7 +70,7 @@ final class CalendarService: Service { ), "end": .string( description: - "End date/time (defaults to one week from start; one day if start is date-only). If timezone is omitted, local time is assumed.", + "End date/time (defaults to one week from start; one day if start is date-only). If timezone is omitted, local time is assumed. A date-only value includes that whole day.", format: .dateTime ), "calendars": .array( diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index dc8074d3..b7ffd9a0 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -116,7 +116,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ), "end": .string( description: - "End of the date range (exclusive). If timezone is omitted, local time is assumed. Date-only uses local midnight.", + "End of the date range (exclusive). If timezone is omitted, local time is assumed. A date-only value includes that whole day.", format: .dateTime ), "query": .string( @@ -151,27 +151,22 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { $0.stringValue }) ?? [] + // Either bound may be given alone; the other stays open. + // chat.db stores dates as Int64 nanoseconds since 2001, so the open ends + // use dates that fit, rather than Date.distantPast and Date.distantFuture. + let calendar = Calendar.current + let start = arguments["start"]?.stringValue + .flatMap { ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: $0) } + .map { calendar.normalizedStartDate(from: $0.date, isDateOnly: $0.isDateOnly) } + let end = arguments["end"]?.stringValue + .flatMap { ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: $0) } + .map { calendar.normalizedEndDate(from: $0.date, isDateOnly: $0.isDateOnly) } var dateRange: Range? - if let startDateStr = arguments["start"]?.stringValue, - let endDateStr = arguments["end"]?.stringValue, - let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: startDateStr - ), - let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: endDateStr - ) - { - let calendar = Calendar.current - let normalizedStart = calendar.normalizedStartDate( - from: parsedStart.date, - isDateOnly: parsedStart.isDateOnly - ) - let normalizedEnd = calendar.normalizedEndDate( - from: parsedEnd.date, - isDateOnly: parsedEnd.isDateOnly - ) - - dateRange = normalizedStart ..< normalizedEnd + if start != nil || end != nil { + let lowerBound = start ?? Date(timeIntervalSinceReferenceDate: -9_000_000_000) + let upperBound = end ?? Date(timeIntervalSinceReferenceDate: 9_000_000_000) + // An end before the start matches nothing. + dateRange = lowerBound ..< max(lowerBound, upperBound) } let searchTerm = arguments["query"]?.stringValue diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index c906b6a8..69bc9c0b 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -26,12 +26,12 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { ), "start": .string( description: - "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed. Date-only uses local midnight.", format: .dateTime ), "end": .string( description: - "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed. A date-only value includes that whole day.", format: .dateTime ), "call_type": .string( @@ -76,7 +76,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { else { throw ArgumentError.invalid("start must be an ISO 8601 date") } - request.startDate = parsed.date + request.startDate = Calendar.current.normalizedStartDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) } if let end = try self.argument("end", in: arguments, as: \.stringValue) { guard @@ -86,7 +89,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { else { throw ArgumentError.invalid("end must be an ISO 8601 date") } - request.endDate = parsed.date + request.endDate = Calendar.current.normalizedEndDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) } try await self.requestDatabaseAccess() diff --git a/App/Services/Reminders.swift b/App/Services/Reminders.swift index 030dc575..d8950321 100644 --- a/App/Services/Reminders.swift +++ b/App/Services/Reminders.swift @@ -72,7 +72,7 @@ final class RemindersService: Service { ), "end": .string( description: - "End date/time range for fetching reminders. If timezone is omitted, local time is assumed. Date-only uses local midnight.", + "End date/time range for fetching reminders. If timezone is omitted, local time is assumed. A date-only value includes that whole day.", format: .dateTime ), "lists": .array( From a2d83497bf0c2d57738e08ef43ec9ffacf51613b Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 26 Sep 2026 04:30:06 -0700 Subject: [PATCH 2/4] Reject messages_fetch dates that are not ISO 8601 An unparseable start or end was dropped silently, which widened the range instead of reporting the mistake. --- App/Services/Messages.swift | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index b7ffd9a0..998b26cb 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -155,11 +155,17 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { // chat.db stores dates as Int64 nanoseconds since 2001, so the open ends // use dates that fit, rather than Date.distantPast and Date.distantFuture. let calendar = Calendar.current - let start = arguments["start"]?.stringValue - .flatMap { ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: $0) } + func parsedDate(_ name: String) throws -> (date: Date, isDateOnly: Bool)? { + guard let string = arguments[name]?.stringValue else { return nil } + guard let parsed = ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: string) + else { + throw ArgumentError.invalid("\(name) must be an ISO 8601 date") + } + return parsed + } + let start = try parsedDate("start") .map { calendar.normalizedStartDate(from: $0.date, isDateOnly: $0.isDateOnly) } - let end = arguments["end"]?.stringValue - .flatMap { ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: $0) } + let end = try parsedDate("end") .map { calendar.normalizedEndDate(from: $0.date, isDateOnly: $0.isDateOnly) } var dateRange: Range? if start != nil || end != nil { @@ -420,6 +426,17 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return FileManager.default.isReadableFile(atPath: messagesDatabasePath) } + private enum ArgumentError: LocalizedError { + case invalid(String) + + var errorDescription: String? { + switch self { + case .invalid(let message): + return "Invalid argument: \(message)" + } + } + } + private enum DatabaseAccessError: LocalizedError { case noBookmarkFound case securityScopeAccessFailed From ce0c554c8fcda23ddc48e6ce7c06504f399d8179 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 26 Sep 2026 04:33:13 -0700 Subject: [PATCH 3/4] Reject messages_fetch dates that are not strings --- App/Services/Messages.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 998b26cb..7e0a9281 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -156,7 +156,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { // use dates that fit, rather than Date.distantPast and Date.distantFuture. let calendar = Calendar.current func parsedDate(_ name: String) throws -> (date: Date, isDateOnly: Bool)? { - guard let string = arguments[name]?.stringValue else { return nil } + guard let value = arguments[name], !value.isNull else { return nil } + guard let string = value.stringValue else { + throw ArgumentError.invalid("\(name) has the wrong type") + } guard let parsed = ISO8601DateFormatter.parsedLenientISO8601Date(fromISO8601String: string) else { throw ArgumentError.invalid("\(name) must be an ISO 8601 date") From 5d11fb7a4af835b3df91bdd4264bede931f0c36a Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sat, 26 Sep 2026 04:36:40 -0700 Subject: [PATCH 4/4] Apply the date range to reminders_fetch without completed When completed was omitted, reminders_fetch fetched every reminder in the lists and ignored start and end. With a date range, it now fetches incomplete reminders by due date and completed ones by completion date, as the two explicit modes do. --- App/Services/Reminders.swift | 44 ++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/App/Services/Reminders.swift b/App/Services/Reminders.swift index d8950321..b0fee326 100644 --- a/App/Services/Reminders.swift +++ b/App/Services/Reminders.swift @@ -148,31 +148,35 @@ final class RemindersService: Service { endDate = calendar.normalizedEndDate(from: endDateValue, isDateOnly: endIsDateOnly) } - // Create predicate based on completion status - let predicate: NSPredicate + // Choose predicates based on completion status: completed reminders match by + // completion date and incomplete ones by due date. + let completedPredicate = self.eventStore.predicateForCompletedReminders( + withCompletionDateStarting: startDate, + ending: endDate, + calendars: reminderLists + ) + let incompletePredicate = self.eventStore.predicateForIncompleteReminders( + withDueDateStarting: startDate, + ending: endDate, + calendars: reminderLists + ) + let predicates: [NSPredicate] if case .bool(let completed) = arguments["completed"] { - if completed { - predicate = self.eventStore.predicateForCompletedReminders( - withCompletionDateStarting: startDate, - ending: endDate, - calendars: reminderLists - ) - } else { - predicate = self.eventStore.predicateForIncompleteReminders( - withDueDateStarting: startDate, - ending: endDate, - calendars: reminderLists - ) - } + predicates = [completed ? completedPredicate : incompletePredicate] + } else if startDate != nil || endDate != nil { + // Without a completion status, a date range applies to both kinds. + predicates = [incompletePredicate, completedPredicate] } else { - // If completion status not specified, use incomplete predicate as default - predicate = self.eventStore.predicateForReminders(in: reminderLists) + predicates = [self.eventStore.predicateForReminders(in: reminderLists)] } // Fetch reminders - let reminders = try await withCheckedThrowingContinuation { continuation in - self.eventStore.fetchReminders(matching: predicate) { fetchedReminders in - continuation.resume(returning: fetchedReminders ?? []) + var reminders: [EKReminder] = [] + for predicate in predicates { + reminders += try await withCheckedThrowingContinuation { continuation in + self.eventStore.fetchReminders(matching: predicate) { fetchedReminders in + continuation.resume(returning: fetchedReminders ?? []) + } } }