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..7e0a9281 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,31 @@ 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 + func parsedDate(_ name: String) throws -> (date: Date, isDateOnly: Bool)? { + 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") + } + return parsed + } + let start = try parsedDate("start") + .map { calendar.normalizedStartDate(from: $0.date, isDateOnly: $0.isDateOnly) } + let end = try parsedDate("end") + .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 @@ -425,6 +429,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 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..b0fee326 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( @@ -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 ?? []) + } } }