From 85a589366e3d2a359ef91e5d571eb8a398f644fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 05:58:14 +0100 Subject: [PATCH] feat: add location reminder triggers Co-authored-by: Octavio Froid --- CHANGELOG.md | 1 + Package.swift | 1 + README.md | 8 ++- Sources/RemindCore/EventKitStore.swift | 59 +++++++++++++++++++ Sources/RemindCore/Models.swift | 33 +++++++++++ Sources/remindctl/Commands/AddCommand.swift | 51 ++++++++++++++++ .../ReminderItemCodingTests.swift | 8 +++ Tests/remindctlTests/HelpPrinterTests.swift | 6 +- 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0d02a..33fff0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 0.2.0 - 2026-05-04 +- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius` - Add simple recurrence support via `--repeat` and `--no-repeat` - Add EventKit alarm support via `--alarm` and `--clear-alarm` - Add reminder `url` to JSON output when EventKit exposes one diff --git a/Package.swift b/Package.swift index 2683d24..b7f5e53 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( name: "RemindCore", dependencies: [], linkerSettings: [ + .linkedFramework("CoreLocation"), .linkedFramework("EventKit"), ] ), diff --git a/README.md b/README.md index fdb4d71..ff6122e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ remindctl list Projects --create remindctl add "Buy milk" remindctl add --title "Call mom" --list Personal --due tomorrow remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55" +remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA" remindctl add "Take vitamins" --due tomorrow --repeat daily remindctl edit 1 --title "New title" --due 2026-01-04 --clear-alarm remindctl list Work Errands # show reminders from multiple lists @@ -64,8 +65,8 @@ remindctl authorize # request permissions - `--json` emits JSON arrays/objects. - `--plain` emits tab-separated lines. - `--quiet` emits counts only. -- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, and - `recurrenceRule` when available. +- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, + `locationTrigger`, and `recurrenceRule` when available. File/image attachments are not exposed by EventKit. ## Date formats @@ -80,6 +81,9 @@ Date-only due inputs create all-day reminders; date-time inputs create timed rem Timed due reminders get a notification alarm at the due time unless `--alarm` sets a different alarm time. Use `edit --clear-alarm` to remove an alarm. +Use `--location
` on `add` to create a location trigger. Add `--leaving` to trigger when leaving +instead of arriving, and `--radius ` to customize the geofence radius. + ## Repeat Use `--repeat` with `add` or `edit` for simple recurrence: - `daily`, `weekly`, `biweekly`, `monthly`, `yearly` diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index 4e078b5..1d9f91d 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -1,3 +1,4 @@ +import CoreLocation import EventKit import Foundation @@ -116,6 +117,9 @@ public actor RemindersStore { if let recurrenceRule = draft.recurrenceRule { replaceRecurrence(on: reminder, with: recurrenceRule) } + if let locationTrigger = draft.locationTrigger { + reminder.addAlarm(try await locationAlarm(from: locationTrigger)) + } try eventStore.save(reminder, commit: true) return item(from: reminder) } @@ -211,6 +215,7 @@ extension RemindersStore { let dueDateIsAllDay: Bool let alarmDate: Date? let recurrenceRule: RecurrenceRule? + let locationTrigger: LocationTrigger? let listID: String let listName: String } @@ -234,6 +239,7 @@ extension RemindersStore { dueDateIsAllDay: isAllDay(components), alarmDate: Self.alarmDate(from: reminder), recurrenceRule: Self.recurrenceRule(from: reminder), + locationTrigger: Self.locationTrigger(from: reminder), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -257,6 +263,7 @@ extension RemindersStore { dueDateIsAllDay: data.dueDateIsAllDay, alarmDate: data.alarmDate, recurrenceRule: data.recurrenceRule, + locationTrigger: data.locationTrigger, listID: data.listID, listName: data.listName ) @@ -310,6 +317,7 @@ extension RemindersStore { dueDateIsAllDay: isAllDay(components), alarmDate: Self.alarmDate(from: reminder), recurrenceRule: Self.recurrenceRule(from: reminder), + locationTrigger: Self.locationTrigger(from: reminder), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -344,6 +352,44 @@ extension RemindersStore { guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil } return RecurrenceRule(frequency: frequency, interval: rule.interval) } + + private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm { + let structuredLocation = EKStructuredLocation(title: trigger.address) + let location: CLLocation + if let latitude = trigger.latitude, let longitude = trigger.longitude { + location = CLLocation(latitude: latitude, longitude: longitude) + } else { + let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address) + guard let geocodedLocation = placemarks.first?.location else { + throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)") + } + location = geocodedLocation + } + + structuredLocation.geoLocation = location + structuredLocation.radius = trigger.radius + + let alarm = EKAlarm() + alarm.structuredLocation = structuredLocation + alarm.proximity = trigger.proximity == .arriving ? .enter : .leave + return alarm + } + + private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? { + guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }), + let structuredLocation = alarm.structuredLocation, + let proximity = LocationProximity(eventKitProximity: alarm.proximity) + else { return nil } + + let coordinate = structuredLocation.geoLocation?.coordinate + return LocationTrigger( + address: structuredLocation.title ?? "", + latitude: coordinate?.latitude, + longitude: coordinate?.longitude, + radius: structuredLocation.radius, + proximity: proximity + ) + } } extension RecurrenceFrequency { @@ -381,3 +427,16 @@ extension RecurrenceRule { frequency.eventKitFrequency } } + +extension LocationProximity { + fileprivate init?(eventKitProximity: EKAlarmProximity) { + switch eventKitProximity { + case .enter: + self = .arriving + case .leave: + self = .leaving + default: + return nil + } + } +} diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 0c7a88f..757db00 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -74,6 +74,33 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable { } } +public enum LocationProximity: String, Codable, CaseIterable, Sendable { + case arriving + case leaving +} + +public struct LocationTrigger: Codable, Sendable, Equatable { + public let address: String + public let latitude: Double? + public let longitude: Double? + public let radius: Double + public let proximity: LocationProximity + + public init( + address: String, + latitude: Double? = nil, + longitude: Double? = nil, + radius: Double = 100, + proximity: LocationProximity = .arriving + ) { + self.address = address + self.latitude = latitude + self.longitude = longitude + self.radius = radius + self.proximity = proximity + } +} + public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let id: String public let title: String @@ -88,6 +115,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let dueDateIsAllDay: Bool public let alarmDate: Date? public let recurrenceRule: RecurrenceRule? + public let locationTrigger: LocationTrigger? public let listID: String public let listName: String @@ -105,6 +133,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { dueDateIsAllDay: Bool = false, alarmDate: Date? = nil, recurrenceRule: RecurrenceRule? = nil, + locationTrigger: LocationTrigger? = nil, listID: String, listName: String ) { @@ -121,6 +150,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.dueDateIsAllDay = dueDateIsAllDay self.alarmDate = alarmDate self.recurrenceRule = recurrenceRule + self.locationTrigger = locationTrigger self.listID = listID self.listName = listName } @@ -132,6 +162,7 @@ public struct ReminderDraft: Sendable { public let dueDate: ParsedUserDate? public let alarmDate: ParsedUserDate? public let recurrenceRule: RecurrenceRule? + public let locationTrigger: LocationTrigger? public let priority: ReminderPriority public init( @@ -140,6 +171,7 @@ public struct ReminderDraft: Sendable { dueDate: ParsedUserDate?, alarmDate: ParsedUserDate? = nil, recurrenceRule: RecurrenceRule? = nil, + locationTrigger: LocationTrigger? = nil, priority: ReminderPriority ) { self.title = title @@ -147,6 +179,7 @@ public struct ReminderDraft: Sendable { self.dueDate = dueDate self.alarmDate = alarmDate self.recurrenceRule = recurrenceRule + self.locationTrigger = locationTrigger self.priority = priority } } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 7c50c73..b33a935 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,6 +18,18 @@ enum AddCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue), + .make( + label: "location", + names: [.long("location")], + help: "Location address for geofence trigger", + parsing: .singleValue + ), + .make( + label: "radius", + names: [.long("radius")], + help: "Geofence radius in meters (default: 100)", + parsing: .singleValue + ), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), .make( label: "repeat", @@ -31,6 +43,9 @@ enum AddCommand { help: "none|low|medium|high", parsing: .singleValue ), + ], + flags: [ + .make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location") ] ) ), @@ -38,6 +53,7 @@ enum AddCommand { "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", "remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"", + "remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"", "remindctl add \"Take vitamins\" --due tomorrow --repeat daily", "remindctl add \"Review docs\" --priority high", ] @@ -65,11 +81,18 @@ enum AddCommand { let notes = values.option("notes") let dueValue = values.option("due") let alarmValue = values.option("alarm") + let locationValue = values.option("location") + let radiusValue = values.option("radius") let repeatValue = values.option("repeat") let priorityValue = values.option("priority") let dueDate = try dueValue.map(CommandHelpers.parseDueDate) let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate) + let locationTrigger = try makeLocationTrigger( + location: locationValue, + radius: radiusValue, + leaving: values.flag("leaving") + ) let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence) let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none @@ -92,10 +115,38 @@ enum AddCommand { dueDate: dueDate, alarmDate: alarmDate, recurrenceRule: recurrenceRule, + locationTrigger: locationTrigger, priority: priority ) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) } } + + private static func makeLocationTrigger( + location: String?, + radius: String?, + leaving: Bool + ) throws -> LocationTrigger? { + if location == nil { + if radius != nil || leaving { + throw RemindCoreError.operationFailed("Use --location with --radius or --leaving") + } + return nil + } + guard let location else { return nil } + let radius = try radius.map(parseRadius) ?? 100 + return LocationTrigger( + address: location, + radius: radius, + proximity: leaving ? .leaving : .arriving + ) + } + + private static func parseRadius(_ value: String) throws -> Double { + guard let radius = Double(value), radius > 0 else { + throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"") + } + return radius + } } diff --git a/Tests/RemindCoreTests/ReminderItemCodingTests.swift b/Tests/RemindCoreTests/ReminderItemCodingTests.swift index 212ed1b..4ecdefc 100644 --- a/Tests/RemindCoreTests/ReminderItemCodingTests.swift +++ b/Tests/RemindCoreTests/ReminderItemCodingTests.swift @@ -20,6 +20,13 @@ struct ReminderItemCodingTests { dueDate: nil, alarmDate: Date(timeIntervalSince1970: 1_700_000_300), recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2), + locationTrigger: LocationTrigger( + address: "1 Apple Park Way", + latitude: 37.3349, + longitude: -122.0090, + radius: 100, + proximity: .arriving + ), listID: "list", listName: "Inbox" ) @@ -33,5 +40,6 @@ struct ReminderItemCodingTests { #expect(json.contains(#""url":"https:\/\/example.com""#)) #expect(json.contains(#""alarmDate""#)) #expect(json.contains(#""recurrenceRule""#)) + #expect(json.contains(#""locationTrigger""#)) } } diff --git a/Tests/remindctlTests/HelpPrinterTests.swift b/Tests/remindctlTests/HelpPrinterTests.swift index efafaf3..7e134f7 100644 --- a/Tests/remindctlTests/HelpPrinterTests.swift +++ b/Tests/remindctlTests/HelpPrinterTests.swift @@ -22,12 +22,14 @@ struct HelpPrinterTests { #expect(joined.contains("authorize")) } - @Test("Add and edit help include alarm and repeat options") - func alarmAndRepeatHelp() { + @Test("Add and edit help include alarm, location, and repeat options") + func alarmLocationAndRepeatHelp() { let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n") let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n") #expect(addHelp.contains("--alarm")) + #expect(addHelp.contains("--location")) + #expect(addHelp.contains("--leaving")) #expect(addHelp.contains("--repeat")) #expect(editHelp.contains("--alarm")) #expect(editHelp.contains("--clear-alarm"))