Skip to content
Open
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,14 @@ remindctl add "Lock up" --location "Home" --leaving
remindctl add "Get groceries" --location "123 Main St" --radius 200
```

Location triggers use EventKit and CoreLocation geocoding. They may depend on system location services and network availability.
Geofence radii must be finite, positive numbers of meters.
To use an exact point without geocoding, provide both `--latitude` and `--longitude`. With coordinates, `--location` is an optional display label:

```bash
remindctl add "Synthetic geofence" --latitude 12.5 --longitude=-34.25 --location "Synthetic label" --radius 200 --leaving
```

Latitude must be finite and between -90 and 90; longitude must be finite and between -180 and 180 (inclusive). Geofence radii must be finite, positive numbers of meters.
Address-only triggers use CoreLocation geocoding and may depend on network availability. Location triggers use EventKit and may depend on system location services.

## Output

Expand Down
49 changes: 28 additions & 21 deletions Sources/RemindCore/EventKitLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,16 @@ import CoreLocation
import EventKit

extension RemindersStore {
func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
static func locationAlarm(
from trigger: LocationTrigger,
geocode: (String) async throws -> CLLocation = geocodeLocation
) 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 request = GeocodeCancellation()
let placemarks: [CLPlacemark] = try await AsyncTimeout.withTimeout(
after: Self.externalOperationTimeout,
timeoutError: .operationFailed("Timed out geocoding location after 30 seconds")
) { completion in
let task = Task {
do {
let placemarks = try await request.geocoder.geocodeAddressString(trigger.address)
completion.resume(returning: placemarks)
} catch {
completion.resume(throwing: error)
}
}
request.install(task)
return { request.cancel() }
}
guard let geocodedLocation = placemarks.first?.location else {
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
}
location = geocodedLocation
location = try await geocode(trigger.address)
}

structuredLocation.geoLocation = location
Expand All @@ -39,6 +23,29 @@ extension RemindersStore {
return alarm
}

private static func geocodeLocation(_ address: String) async throws -> CLLocation {
let request = GeocodeCancellation()
let placemarks: [CLPlacemark] = try await AsyncTimeout.withTimeout(
after: Self.externalOperationTimeout,
timeoutError: .operationFailed("Timed out geocoding location after 30 seconds")
) { completion in
let task = Task {
do {
let placemarks = try await request.geocoder.geocodeAddressString(address)
completion.resume(returning: placemarks)
} catch {
completion.resume(throwing: error)
}
}
request.install(task)
return { request.cancel() }
}
guard let geocodedLocation = placemarks.first?.location else {
throw RemindCoreError.operationFailed("Could not geocode location: \(address)")
}
return geocodedLocation
}

static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
let structuredLocation = alarm.structuredLocation,
Expand Down
2 changes: 1 addition & 1 deletion Sources/RemindCore/EventKitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public actor RemindersStore {
replaceRecurrence(on: reminder, with: recurrenceRule)
}
if let locationTrigger = draft.locationTrigger {
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
reminder.addAlarm(try await Self.locationAlarm(from: locationTrigger))
}
try eventStore.save(reminder, commit: true)
return try item(from: reminder)
Expand Down
43 changes: 37 additions & 6 deletions Sources/remindctl/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@ enum AddCommand {
.make(
label: "location",
names: [.long("location")],
help: "Location address for geofence trigger",
help: "Geofence address, or display label with coordinates",
parsing: .singleValue
),
.make(
label: "latitude",
names: [.long("latitude")],
help: "Latitude (-90...90; requires --longitude)",
parsing: .singleValue
),
.make(
label: "longitude",
names: [.long("longitude")],
help: "Longitude (-180...180; requires --latitude)",
parsing: .singleValue
),
.make(
Expand Down Expand Up @@ -67,6 +79,7 @@ enum AddCommand {
"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 \"Synthetic geofence\" --latitude 12.5 --longitude=-34.25 --location \"Synthetic label\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high",
"remindctl add \"Buy headphones\" --url \"https://example.com/product\"",
Expand Down Expand Up @@ -107,7 +120,9 @@ enum AddCommand {
let locationTrigger = try makeLocationTrigger(
location: locationValue,
radius: radiusValue,
leaving: values.flag("leaving")
leaving: values.flag("leaving"),
latitude: values.option("latitude"),
longitude: values.option("longitude")
)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
Expand Down Expand Up @@ -143,22 +158,38 @@ enum AddCommand {
static func makeLocationTrigger(
location: String?,
radius: String?,
leaving: Bool
leaving: Bool,
latitude: String? = nil,
longitude: String? = nil
) throws -> LocationTrigger? {
guard let location else {
guard (latitude == nil) == (longitude == nil) else {
throw RemindCoreError.operationFailed("Provide --latitude and --longitude together")
}
let latitude = try latitude.map { try parseCoordinate($0, name: "latitude", range: -90...90) }
let longitude = try longitude.map { try parseCoordinate($0, name: "longitude", range: -180...180) }
guard location != nil || latitude != nil else {
if radius != nil || leaving {
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
throw RemindCoreError.operationFailed("Use --location or --latitude and --longitude with --radius or --leaving")
}
return nil
}
let radius = try radius.map(parseRadius) ?? 100
return LocationTrigger(
address: location,
address: location ?? "",
latitude: latitude,
longitude: longitude,
radius: radius,
proximity: leaving ? .leaving : .arriving
)
}

private static func parseCoordinate(_ value: String, name: String, range: ClosedRange<Double>) throws -> Double {
guard let coordinate = Double(value), coordinate.isFinite, range.contains(coordinate) else {
throw RemindCoreError.operationFailed("Invalid \(name): \"\(value)\" (expected \(range))")
}
return coordinate
}

private static func parseRadius(_ value: String) throws -> Double {
guard let radius = Double(value), radius.isFinite, radius > 0 else {
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
Expand Down
37 changes: 37 additions & 0 deletions Tests/RemindCoreTests/LocationAlarmTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import CoreLocation
import Testing

@testable import RemindCore

struct LocationAlarmTests {
enum UnexpectedGeocode: Error { case called }

@Test("Coordinate alarms bypass geocoding and preserve their label and geometry", arguments: [true, false])
func coordinateAlarm(leaving: Bool) async throws {
let trigger = LocationTrigger(
address: "Synthetic label",
latitude: -12.5,
longitude: 34.25,
radius: 200,
proximity: leaving ? .leaving : .arriving)
let alarm = try await RemindersStore.locationAlarm(from: trigger) { _ in
throw UnexpectedGeocode.called
}
let location = try #require(alarm.structuredLocation)
#expect(location.title == "Synthetic label")
#expect(location.geoLocation?.coordinate.latitude == -12.5)
#expect(location.geoLocation?.coordinate.longitude == 34.25)
#expect(location.radius == 200)
#expect(alarm.proximity == (leaving ? .leave : .enter))
}

@Test("Address-only alarms still use the geocoder")
func addressAlarm() async throws {
let alarm = try await RemindersStore.locationAlarm(from: LocationTrigger(address: "Synthetic address")) { address in
#expect(address == "Synthetic address")
return CLLocation(latitude: 12.5, longitude: -34.25)
}
#expect(alarm.structuredLocation?.geoLocation?.coordinate.latitude == 12.5)
#expect(alarm.structuredLocation?.geoLocation?.coordinate.longitude == -34.25)
}
}
99 changes: 99 additions & 0 deletions Tests/remindctlTests/AddCoordinateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import RemindCore
import Testing

@testable import remindctl

struct AddCoordinateTests {
@Test("The add parser accepts coordinate options", arguments: [true, false])
func coordinateOptions(labeled: Bool) throws {
var arguments = [
"remindctl", "add", "Synthetic coordinate reminder", "--latitude", "-12.5", "--longitude=34.25",
"--radius", "200", "--leaving",
]
if labeled { arguments += ["--location", "Synthetic label"] }
let values = try CommandRouter().program.resolve(arguments: arguments).parsedValues
let trigger = try #require(
try AddCommand.makeLocationTrigger(
location: values.option("location"),
radius: values.option("radius"),
leaving: values.flag("leaving"),
latitude: values.option("latitude"),
longitude: values.option("longitude")))
#expect(trigger.latitude == -12.5)
#expect(trigger.longitude == 34.25)
#expect(trigger.address == (labeled ? "Synthetic label" : ""))
#expect(trigger.radius == 200)
#expect(trigger.proximity == .leaving)
}

@Test(
"Coordinate pairs include zero and their range boundaries",
arguments: [
("0", "0"), ("-90", "-180"), ("90", "180"), ("1e1", "-2e1"),
])
func coordinateBoundaries(pair: (String, String)) throws {
let trigger = try #require(
try AddCommand.makeLocationTrigger(
location: nil, radius: nil, leaving: false, latitude: pair.0, longitude: pair.1))
#expect(trigger.latitude == Double(pair.0))
#expect(trigger.longitude == Double(pair.1))
#expect(trigger.radius == 100)
#expect(trigger.proximity == .arriving)
}

@Test("Coordinates must be supplied together", arguments: [true, false])
func missingCoordinate(latitudeOnly: Bool) {
#expect(throws: RemindCoreError.operationFailed("Provide --latitude and --longitude together")) {
try AddCommand.makeLocationTrigger(
location: "Synthetic label",
radius: nil,
leaving: false,
latitude: latitudeOnly ? "12.5" : nil,
longitude: latitudeOnly ? nil : "34.25")
}
}

@Test(
"Latitude must be finite and in range",
arguments: [
"nan", "inf", "+inf", "-inf", "1e309", "90.001", "-90.001", "text", "",
])
func invalidLatitude(_ value: String) {
#expect(throws: (any Error).self) {
try AddCommand.makeLocationTrigger(
location: nil, radius: nil, leaving: false, latitude: value, longitude: "34.25")
}
}

@Test(
"Longitude must be finite and in range",
arguments: [
"nan", "inf", "+inf", "-inf", "1e309", "180.001", "-180.001", "text", "",
])
func invalidLongitude(_ value: String) {
#expect(throws: (any Error).self) {
try AddCommand.makeLocationTrigger(
location: nil, radius: nil, leaving: false, latitude: "12.5", longitude: value)
}
}

@Test("Coordinate triggers still validate radius", arguments: ["nan", "inf", "0", "-1"])
func invalidRadius(_ value: String) {
#expect(throws: RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")) {
try AddCommand.makeLocationTrigger(
location: nil, radius: value, leaving: false, latitude: "12.5", longitude: "34.25")
}
}

@Test("Address-only and absent locations preserve their behavior")
func addressOnly() throws {
let trigger = try #require(
try AddCommand.makeLocationTrigger(location: "Synthetic address", radius: nil, leaving: false))
#expect(trigger.address == "Synthetic address")
#expect(trigger.latitude == nil)
#expect(trigger.longitude == nil)
#expect(trigger.radius == 100)
#expect(trigger.proximity == .arriving)
#expect(try AddCommand.makeLocationTrigger(location: nil, radius: nil, leaving: false) == nil)
}
}
4 changes: 3 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ remindctl add "Call Sam" --list Work --due tomorrow
remindctl add "Call Sam" --list-id 7A12 --due tomorrow
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
remindctl add "Synthetic geofence" --latitude 12.5 --longitude=-34.25 --location "Synthetic label"
```

Useful `add` options:
Expand All @@ -54,7 +55,8 @@ Useful `add` options:
- `--repeat <rule>` sets simple recurrence.
- `--priority <none|low|medium|high>` sets priority.
- `--url <url>` stores the EventKit URL and mirrors it into notes as `remindctl URL (managed): ...` so Reminders.app shows the link.
- `--location <address>` creates an arriving geofence trigger.
- `--location <address>` creates an arriving geofence trigger by geocoding the address.
- `--latitude <degrees>` and `--longitude <degrees>` together create a geofence at an exact point without geocoding. Latitude must be finite and in [-90, 90]; longitude must be finite and in [-180, 180]. With coordinates, `--location` is an optional display label.
- `--leaving` changes a location trigger to leaving.
- `--radius <meters>` adjusts the geofence radius.

Expand Down
Loading