From 25e85ad1707e2d2b55d35dcbeb394a075001c8da Mon Sep 17 00:00:00 2001 From: Jorge Welch Date: Sun, 8 Feb 2026 18:27:51 +0800 Subject: [PATCH 1/3] Add event updates, recurrence, travel time, alarms, calendar management, and improve README --- README.md | 328 +++++++++++++------------------- Sources/Ekctl.swift | 291 ++++++++++++++++++++++++++++- Sources/EventKitManager.swift | 339 +++++++++++++++++++++++++++++++++- 3 files changed, 751 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index 9c8c32b..137a39a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # ekctl -A native macOS command-line tool for managing Calendar events and Reminders using the EventKit framework. All output is JSON, making it perfect for scripting and automation. +Native macOS command-line tool for managing Calendar events and Reminders using EventKit. All output is JSON for scripting and automation. ## Features -- List, create, and delete calendar events +- List, create, update, and delete calendar events - List, create, complete, and delete reminders -- **Calendar aliases** - Use friendly names instead of long IDs -- JSON output for easy parsing and scripting +- Calendar aliases (use friendly names instead of UUIDs) +- JSON output for parsing - Full EventKit integration with proper permission handling -- Support for all calendar and reminder list types (iCloud, Exchange, local, etc.) +- Support for iCloud, Exchange, and local calendars ## Requirements @@ -19,47 +19,41 @@ A native macOS command-line tool for managing Calendar events and Reminders usin ## Installation -### Homebrew (Recommended) +### Homebrew ```bash brew tap schappim/ekctl brew install ekctl ``` -### Build from Source +### Build from source ```bash -# Clone the repository git clone https://github.com/schappim/ekctl.git cd ekctl - -# Build release version swift build -c release -# Optional: Sign with entitlements for better permission handling +# Optional: Sign with entitlements codesign --force --sign - --entitlements ekctl.entitlements .build/release/ekctl -# Install to /usr/local/bin +# Install sudo cp .build/release/ekctl /usr/local/bin/ ``` -### First Run - -On first run, macOS will prompt you to grant access to Calendars and Reminders. You can manage these permissions later in: - -**System Settings → Privacy & Security → Calendars / Reminders** +### Permissions -## Usage +On first run, macOS will prompt for Calendar and Reminders access. Manage permissions in **System Settings → Privacy & Security → Calendars / Reminders**. -### List Calendars +## Calendars -List all calendars (event calendars and reminder lists): +### List +**Command:** ```bash ekctl list calendars ``` -Output: +**Output:** ```json { "calendars": [ @@ -70,85 +64,72 @@ Output: "source": "iCloud", "color": "#0088FF", "allowsModifications": true - }, - { - "id": "4E367C6F-354B-4811-935E-7F25A1BB7D39", - "title": "Reminders", - "type": "reminder", - "source": "iCloud", - "color": "#1BADF8", - "allowsModifications": true } ], "status": "success" } ``` -### Calendar Aliases +### Create + +**Command:** +```bash +ekctl calendar create --title "Project X" --color "#FF5500" +``` + +### Update + +**Command:** +```bash +ekctl calendar update CALENDAR_ID --title "New Name" --color "#00FF00" +``` -Instead of using long calendar IDs, you can create friendly aliases: +### Delete +**Command:** +```bash +ekctl calendar delete CALENDAR_ID +``` + +### Aliases + +Use friendly names instead of UUIDs. Aliases work anywhere a calendar ID is accepted. + +**Set alias:** ```bash -# Set an alias for a calendar ekctl alias set work "CA513B39-1659-4359-8FE9-0C2A3DCEF153" ekctl alias set personal "4E367C6F-354B-4811-935E-7F25A1BB7D39" -ekctl alias set groceries "E30AE972-8F29-40AF-BFB9-E984B98B08AB" +``` -# List all aliases +**List aliases:** +```bash ekctl alias list - -# Remove an alias -ekctl alias remove work ``` -Output for `ekctl alias list`: -```json -{ - "aliases": [ - { "name": "groceries", "id": "E30AE972-8F29-40AF-BFB9-E984B98B08AB" }, - { "name": "personal", "id": "4E367C6F-354B-4811-935E-7F25A1BB7D39" }, - { "name": "work", "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153" } - ], - "count": 3, - "configPath": "/Users/you/.ekctl/config.json", - "status": "success" -} +**Remove alias:** +```bash +ekctl alias remove work ``` -Once set, use aliases anywhere you would use a calendar ID: - +**Usage:** ```bash # These are equivalent: -ekctl list events --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" --from ... -ekctl list events --calendar work --from ... - -# Works with all commands -ekctl add event --calendar work --title "Meeting" --start ... -ekctl list reminders --list groceries -ekctl add reminder --list personal --title "Call mom" +ekctl list events --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" +ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" ``` Aliases are stored in `~/.ekctl/config.json`. -### List Events +## Events -List events in a calendar within a date range: +### List +**Command:** ```bash -# Using calendar ID -ekctl list events \ - --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" \ - --from "2026-01-01T00:00:00Z" \ - --to "2026-01-31T23:59:59Z" - -# Or using an alias (after setting one) -ekctl list events \ - --calendar work \ - --from "2026-01-01T00:00:00Z" \ - --to "2026-01-31T23:59:59Z" +ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" ``` -Output: +**Output:** ```json { "count": 2, @@ -173,50 +154,63 @@ Output: } ``` -### Show Event Details +### Show +**Command:** ```bash -ekctl show event "ABC123:DEF456" +ekctl show event EVENT_ID ``` -### Add Event - -Create a new calendar event: +### Add +Basic event: ```bash -# Basic event (using alias) -ekctl add event \ - --calendar work \ - --title "Lunch with Client" \ - --start "2026-02-10T12:30:00Z" \ - --end "2026-02-10T13:30:00Z" +ekctl add event --calendar work --title "Lunch" --start "2026-02-10T12:30:00Z" --end "2026-02-10T13:30:00Z" +``` -# Event with location and notes +With location, notes, and alarms: +```bash ekctl add event \ --calendar work \ --title "Project Review" \ --start "2026-02-15T14:00:00Z" \ --end "2026-02-15T15:30:00Z" \ --location "Building 2, Room 301" \ - --notes "Bring Q1 reports" + --notes "Bring Q1 reports" \ + --alarms "10,60" +``` + +Recurring event (weekly): +```bash +ekctl add event \ + --calendar personal \ + --title "Gym" \ + --start "2026-02-12T18:00:00Z" \ + --end "2026-02-12T19:00:00Z" \ + --recurrence-frequency weekly \ + --recurrence-days "mon,wed,fri" \ + --recurrence-end-count 20 +``` -# All-day event (using full ID also works) +With travel time: +```bash ekctl add event \ - --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" \ - --title "Company Holiday" \ - --start "2026-03-01T00:00:00Z" \ - --end "2026-03-02T00:00:00Z" \ - --all-day + --calendar work \ + --title "Client Site Visit" \ + --start "2026-02-20T14:00:00Z" \ + --end "2026-02-20T16:00:00Z" \ + --location "1 Infinite Loop, Cupertino, CA" \ + --travel-time 30 ``` -Output: +**Output:** ```json { "status": "success", "message": "Event created successfully", "event": { "id": "NEW123:EVENT456", - "title": "Lunch with Client", + "title": "Lunch", "calendar": { "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153", "title": "Work" @@ -230,13 +224,14 @@ Output: } ``` -### Delete Event +### Delete +**Command:** ```bash -ekctl delete event "ABC123:DEF456" +ekctl delete event EVENT_ID ``` -Output: +**Output:** ```json { "status": "success", @@ -245,22 +240,26 @@ Output: } ``` -### List Reminders +## Reminders -List reminders in a reminder list: +### List +All reminders: ```bash -# List all reminders (using alias) ekctl list reminders --list personal +``` -# List only incomplete reminders +Only incomplete: +```bash ekctl list reminders --list personal --completed false +``` -# List only completed reminders (using full ID also works) -ekctl list reminders --list "4E367C6F-354B-4811-935E-7F25A1BB7D39" --completed true +Only completed: +```bash +ekctl list reminders --list personal --completed true ``` -Output: +**Output:** ```json { "count": 2, @@ -282,39 +281,36 @@ Output: } ``` -### Show Reminder Details +### Show +**Command:** ```bash -ekctl show reminder "REM123-456-789" +ekctl show reminder REMINDER_ID ``` -### Add Reminder - -Create a new reminder: +### Add +Simple reminder: ```bash -# Simple reminder (using alias) -ekctl add reminder \ - --list personal \ - --title "Call the dentist" +ekctl add reminder --list personal --title "Call dentist" +``` -# Reminder with due date -ekctl add reminder \ - --list personal \ - --title "Submit expense report" \ - --due "2026-01-25T09:00:00Z" +With due date: +```bash +ekctl add reminder --list personal --title "Submit expense report" --due "2026-01-25T09:00:00Z" +``` -# Reminder with priority and notes -# Priority: 0=none, 1=high, 5=medium, 9=low +With priority and notes (priority: 0=none, 1=high, 5=medium, 9=low): +```bash ekctl add reminder \ --list groceries \ --title "Buy milk" \ --due "2026-02-01T12:00:00Z" \ --priority 1 \ - --notes "Check expiration date first" + --notes "Check expiration date" ``` -Output: +**Output:** ```json { "status": "success", @@ -334,15 +330,14 @@ Output: } ``` -### Complete Reminder - -Mark a reminder as completed: +### Complete +**Command:** ```bash -ekctl complete reminder "REM123-456-789" +ekctl complete reminder REMINDER_ID ``` -Output: +**Output:** ```json { "status": "success", @@ -356,78 +351,16 @@ Output: } ``` -### Delete Reminder +### Delete +**Command:** ```bash -ekctl delete reminder "REM123-456-789" -``` - -## Date Format - -All dates use **ISO 8601** format with timezone. Examples: - -| Format | Example | Description | -|--------|---------|-------------| -| UTC | `2026-01-15T09:00:00Z` | 9:00 AM UTC | -| With offset | `2026-01-15T09:00:00+10:00` | 9:00 AM AEST | -| Midnight | `2026-01-15T00:00:00Z` | Start of day | -| End of day | `2026-01-15T23:59:59Z` | End of day | - -## Scripting Examples - -### Get calendar ID by name - -```bash -# Using jq to find a calendar by name -CALENDAR_ID=$(ekctl list calendars | jq -r '.calendars[] | select(.title == "Work") | .id') -echo $CALENDAR_ID -``` - -### List today's events - -```bash -TODAY=$(date -u +"%Y-%m-%dT00:00:00Z") -TOMORROW=$(date -u -v+1d +"%Y-%m-%dT00:00:00Z") - -ekctl list events \ - --calendar "$CALENDAR_ID" \ - --from "$TODAY" \ - --to "$TOMORROW" -``` - -### Create event from variables - -```bash -TITLE="Sprint Planning" -START="2026-01-20T10:00:00Z" -END="2026-01-20T11:00:00Z" - -ekctl add event \ - --calendar "$CALENDAR_ID" \ - --title "$TITLE" \ - --start "$START" \ - --end "$END" -``` - -### Count incomplete reminders - -```bash -ekctl list reminders --list "$LIST_ID" --completed false | jq '.count' -``` - -### Export events to CSV - -```bash -ekctl list events \ - --calendar "$CALENDAR_ID" \ - --from "2026-01-01T00:00:00Z" \ - --to "2026-12-31T23:59:59Z" \ - | jq -r '.events[] | [.title, .startDate, .endDate, .location // ""] | @csv' +ekctl delete reminder REMINDER_ID ``` ## Error Handling -When an error occurs, the output includes an error message: +All errors return JSON with `status: "error"`: ```json { @@ -437,19 +370,16 @@ When an error occurs, the output includes an error message: ``` Common errors: -- `Permission denied` - Grant access in System Settings -- `Calendar not found` - Check the calendar ID with `list calendars` -- `Invalid date format` - Use ISO 8601 format (see examples above) +- `Permission denied`: Grant access in System Settings → Privacy & Security → Calendars/Reminders +- `Calendar not found`: Check calendar ID with `ekctl list calendars` +- `Invalid date format`: Use ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ) ## Help -Get help for any command: - ```bash ekctl --help ekctl list --help ekctl add event --help -ekctl list reminders --help ``` ## License @@ -458,4 +388,4 @@ MIT License ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Pull requests welcome. \ No newline at end of file diff --git a/Sources/Ekctl.swift b/Sources/Ekctl.swift index 91db334..c2333f4 100644 --- a/Sources/Ekctl.swift +++ b/Sources/Ekctl.swift @@ -9,8 +9,8 @@ struct Ekctl: ParsableCommand { static let configuration = CommandConfiguration( commandName: "ekctl", abstract: "A command-line tool for managing macOS Calendar events and Reminders using EventKit.", - version: "1.2.0", - subcommands: [List.self, Show.self, Add.self, Delete.self, Complete.self, Alias.self], + version: "1.3.0", + subcommands: [List.self, Show.self, Add.self, Update.self, Delete.self, Complete.self, Alias.self, CalendarCmd.self], defaultSubcommand: List.self ) } @@ -172,6 +172,52 @@ struct AddEvent: ParsableCommand { @Flag(name: .long, help: "Mark as all-day event.") var allDay: Bool = false + // MARK: - Recurrence & Travel Time + + @Option(name: .long, help: "Recurrence frequency (daily, weekly, monthly).") + var recurrenceFrequency: String? + + @Option(name: .long, help: "Recurrence interval (default: 1).") + var recurrenceInterval: String? + + @Option(name: .long, help: "Recurrence end count.") + var recurrenceEndCount: String? + + @Option(name: .long, help: "Recurrence end date in ISO8601 format.") + var recurrenceEndDate: String? + + @Option(name: .long, help: "Days of week (e.g., 'mon,tue', '1mon' for 1st Monday, '-1fri' for last Friday).") + var recurrenceDays: String? + + @Option(name: .long, help: "Months of the year (comma-separated: 1-12 or jan,feb...).") + var recurrenceMonths: String? + + @Option(name: .long, help: "Days of the month (comma-separated: 1-31 or -1 for last).") + var recurrenceDaysOfMonth: String? + + @Option(name: .long, help: "Weeks of the year (comma-separated: 1-53 or -1 for last).") + var recurrenceWeeksOfYear: String? + + @Option(name: .long, help: "Days of the year (comma-separated: 1-366 or -1 for last).") + var recurrenceDaysOfYear: String? + + @Option(name: .long, help: "Set positions (comma-separated: 1 for 1st, -1 for last, etc.).") + var recurrenceSetPositions: String? + + @Option(name: .long, help: "Travel time in minutes.") + var travelTime: String? + + // MARK: - New Features (Alarms, Availability, URL, etc.) + + @Option(name: .long, help: "Alarms/Alerts relative to start time in minutes (e.g., '-30,-60').") + var alarms: String? + + @Option(name: .long, help: "URL for the event.") + var url: String? + + @Option(name: .long, help: "Availability (busy, free, tentative, unavailable).") + var availability: String? + func run() throws { let manager = EventKitManager() try manager.requestAccess() @@ -185,6 +231,56 @@ struct AddEvent: ParsableCommand { throw ExitCode.failure } + var rEndDate: Date? + if let recEndDateString = recurrenceEndDate, !recEndDateString.isEmpty { + guard let date = ISO8601DateFormatter().date(from: recEndDateString) else { + print(JSONOutput.error("Invalid --recurrence-end-date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + rEndDate = date + } + + // Parse recurrence interval (default to 1) + let recurrenceIntervalInt = (recurrenceInterval.flatMap(Int.init)) ?? 1 + + let recurrenceEndCountInt = recurrenceEndCount.flatMap(Int.init) + + // Convert travel time to seconds if provided and valid + var travelTimeSeconds: TimeInterval? + if let ttString = travelTime, let ttInt = Int(ttString) { + travelTimeSeconds = TimeInterval(ttInt * 60) + } + + // Helper to parse comma-separated integers + func parseInts(_ string: String?) -> [NSNumber]? { + guard let string = string else { return nil } + return string.split(separator: ",").compactMap { + Int($0.trimmingCharacters(in: .whitespaces)).map { NSNumber(value: $0) } + } + } + + // Helper to parse months (names or numbers) + func parseMonths(_ string: String?) -> [NSNumber]? { + guard let string = string else { return nil } + let monthMap: [String: Int] = [ + "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3, + "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, + "jul": 7, "july": 7, "aug": 8, "august": 8, "sep": 9, "september": 9, + "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12 + ] + + return string.split(separator: ",").compactMap { component in + let trimmed = component.trimmingCharacters(in: .whitespaces).lowercased() + if let val = Int(trimmed) { return NSNumber(value: val) } + if let val = monthMap[trimmed] { return NSNumber(value: val) } + return nil + } + } + + let alarmsList = alarms?.split(separator: ",").compactMap { + Double($0.trimmingCharacters(in: .whitespaces)).map { $0 * -60 } + } + let calendarID = ConfigManager.resolveAlias(calendar) let result = manager.addEvent( calendarID: calendarID, @@ -193,7 +289,21 @@ struct AddEvent: ParsableCommand { endDate: endDate, location: location, notes: notes, - allDay: allDay + allDay: allDay, + recurrenceFrequency: recurrenceFrequency, + recurrenceInterval: recurrenceIntervalInt, + recurrenceEndCount: recurrenceEndCountInt, + recurrenceEndDate: rEndDate, + recurrenceDays: recurrenceDays, + recurrenceMonths: parseMonths(recurrenceMonths), + recurrenceDaysOfMonth: parseInts(recurrenceDaysOfMonth), + recurrenceWeeksOfYear: parseInts(recurrenceWeeksOfYear), + recurrenceDaysOfYear: parseInts(recurrenceDaysOfYear), + recurrenceSetPositions: parseInts(recurrenceSetPositions), + travelTime: travelTimeSeconds, + alarms: alarmsList, + url: url, + availability: availability ) print(result.toJSON()) } @@ -215,7 +325,7 @@ struct AddReminder: ParsableCommand { var due: String? @Option(name: .long, help: "Priority (0=none, 1=high, 5=medium, 9=low).") - var priority: Int? + var priority: String? @Option(name: .long, help: "Optional notes.") var notes: String? @@ -233,19 +343,188 @@ struct AddReminder: ParsableCommand { dueDate = parsed } + // Parse priority + let priorityInt = (priority.flatMap(Int.init)) ?? 0 + let listID = ConfigManager.resolveAlias(list) let result = manager.addReminder( listID: listID, title: title, dueDate: dueDate, - priority: priority ?? 0, + priority: priorityInt, notes: notes ) print(result.toJSON()) } } -// MARK: - Delete Commands +// MARK: - Update Command + +struct Update: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Update an existing event.", + subcommands: [UpdateEvent.self] + ) +} + +struct UpdateEvent: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "event", + abstract: "Update a calendar event." + ) + + @Argument(help: "The event ID to update.") + var eventID: String + + @Option(name: .long, help: "New title.") + var title: String? + + @Option(name: .long, help: "New start date (ISO8601).") + var start: String? + + @Option(name: .long, help: "New end date (ISO8601).") + var end: String? + + @Option(name: .long, help: "New location.") + var location: String? + + @Option(name: .long, help: "New notes.") + var notes: String? + + @Option(name: .long, help: "Mark as all-day event (true/false).") + var allDay: Bool? + + @Option(name: .long, help: "New URL.") + var url: String? + + @Option(name: .long, help: "New availability (busy, free, tentative, unavailable).") + var availability: String? + + @Option(name: .long, help: "Travel time in minutes.") + var travelTime: String? + + @Option(name: .long, help: "Alarms relative to start (minutes). Replaces existing alarms.") + var alarms: String? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + + var startDate: Date? + if let start = start { + guard let da = ISO8601DateFormatter().date(from: start) else { + throw ExitCode.failure + } + startDate = da + } + var endDate: Date? + if let end = end { + guard let da = ISO8601DateFormatter().date(from: end) else { + throw ExitCode.failure + } + endDate = da + } + + let alarmsList = alarms?.split(separator: ",").compactMap { + Double($0.trimmingCharacters(in: .whitespaces)).map { $0 * -60 } + } + + var travelTimeSeconds: TimeInterval? + if let ttString = travelTime, let ttInt = Int(ttString) { + travelTimeSeconds = TimeInterval(ttInt * 60) + } + + let result = manager.updateEvent( + eventID: eventID, + title: title, + startDate: startDate, + endDate: endDate, + location: location, + notes: notes, + allDay: allDay, + url: url, + availability: availability, + travelTime: travelTimeSeconds, + alarms: alarmsList + ) + print(result.toJSON()) + } +} + +// MARK: - C + +struct CalendarCmd: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "calendar", + abstract: "Manage calendars.", + subcommands: [CreateCalendar.self, UpdateCalendar.self, DeleteCalendar.self] + ) +} + +struct CreateCalendar: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new calendar." + ) + + @Option(name: .long, help: "Title of the new calendar.") + var title: String + + @Option(name: .long, help: "Color hex code (e.g. #FF0000).") + var color: String? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.createCalendar(title: title, color: color) + print(result.toJSON()) + } +} + +struct UpdateCalendar: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Update a calendar." + ) + + @Argument(help: "Calendar ID to update.") + var calendarID: String + + @Option(name: .long, help: "New title.") + var title: String? + + @Option(name: .long, help: "New color hex code.") + var color: String? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let resolvedID = ConfigManager.resolveAlias(calendarID) + let result = manager.updateCalendar(calendarID: resolvedID, title: title, color: color) + print(result.toJSON()) + } +} + +struct DeleteCalendar: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a calendar." + ) + + @Argument(help: "Calendar ID to delete.") + var calendarID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + // Resolve alias if needed + let resolvedID = ConfigManager.resolveAlias(calendarID) + let result = manager.deleteCalendar(calendarID: resolvedID) + print(result.toJSON()) + } +} + +// MARK: - Helper Methods struct Delete: ParsableCommand { static let configuration = CommandConfiguration( diff --git a/Sources/EventKitManager.swift b/Sources/EventKitManager.swift index 4ecc757..1f3f9da 100644 --- a/Sources/EventKitManager.swift +++ b/Sources/EventKitManager.swift @@ -1,6 +1,7 @@ import ArgumentParser import EventKit import Foundation +import CoreLocation /// EventKitManager handles all interactions with the EventKit framework. /// @@ -87,6 +88,80 @@ class EventKitManager { // MARK: - Calendar Operations + /// Create a new calendar + func createCalendar(title: String, color: String?) -> JSONOutput { + // Attempt to find a source (usually iCloud or Local) + let sources = eventStore.sources + // Prefer iCloud, then Local source + guard let source = sources.first(where: { $0.sourceType == .calDAV && $0.title == "iCloud" }) + ?? sources.first(where: { $0.sourceType == .local }) + ?? sources.first else { + return JSONOutput.error("No suitable calendar source found") + } + + let newCalendar = EKCalendar(for: .event, eventStore: eventStore) + newCalendar.title = title + newCalendar.source = source + + if let colorHex = color { + if let cgColor = CGColor.fromHex(colorHex) { + newCalendar.cgColor = cgColor + } + } + + do { + try eventStore.saveCalendar(newCalendar, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Calendar created successfully", + "id": newCalendar.calendarIdentifier + ]) + } catch { + return JSONOutput.error("Failed to create calendar: \(error.localizedDescription)") + } + } + + /// Update a calendar + func updateCalendar(calendarID: String, title: String?, color: String?) -> JSONOutput { + guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { + return JSONOutput.error("Calendar not found with ID: \(calendarID)") + } + + if let title = title { calendar.title = title } + if let colorHex = color { + if let cgColor = CGColor.fromHex(colorHex) { + calendar.cgColor = cgColor + } + } + + do { + try eventStore.saveCalendar(calendar, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Calendar updated successfully" + ]) + } catch { + return JSONOutput.error("Failed to update calendar: \(error.localizedDescription)") + } + } + + /// Delete a calendar + func deleteCalendar(calendarID: String) -> JSONOutput { + guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { + return JSONOutput.error("Calendar not found with ID: \(calendarID)") + } + + do { + try eventStore.removeCalendar(calendar, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Calendar deleted successfully" + ]) + } catch { + return JSONOutput.error("Failed to delete calendar: \(error.localizedDescription)") + } + } + /// Lists all calendars (event calendars and reminder lists) func listCalendars() -> JSONOutput { var calendars: [[String: Any]] = [] @@ -147,6 +222,103 @@ class EventKitManager { return JSONOutput.success(["event": eventToDict(event)]) } + /// Updates an existing calendar event + func updateEvent( + eventID: String, + title: String?, + startDate: Date?, + endDate: Date?, + location: String?, + notes: String?, + allDay: Bool?, + url: String?, + availability: String?, + travelTime: TimeInterval?, + travelStartLocation: String? = nil, + transportType: String? = nil, + alarms: [Double]? + ) -> JSONOutput { + guard let event = eventStore.event(withIdentifier: eventID) else { + return JSONOutput.error("Event not found with ID: \(eventID)") + } + + if let title = title { event.title = title } + if let startDate = startDate { event.startDate = startDate } + if let endDate = endDate { event.endDate = endDate } + if let location = location { + event.location = location + + // Should update structured location too + let group = DispatchGroup() + group.enter() + + let geocoder = CLGeocoder() + geocoder.geocodeAddressString(location) { placemarks, error in + if let placemark = placemarks?.first, let geo = placemark.location { + let structured = EKStructuredLocation(title: location) + structured.geoLocation = geo + structured.radius = 0 + event.structuredLocation = structured + } else { + let structured = EKStructuredLocation(title: location) + event.structuredLocation = structured + } + group.leave() + } + + let timeout = Date(timeIntervalSinceNow: 2.0) + while group.wait(timeout: .now()) == .timedOut { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + if Date() > timeout { break } + } + } + if let notes = notes { event.notes = notes } + if let allDay = allDay { event.isAllDay = allDay } + + if let urlString = url, let urlObj = URL(string: urlString) { + event.url = urlObj + } + + if let avail = availability?.lowercased() { + switch avail { + case "busy": event.availability = .busy + case "free": event.availability = .free + case "tentative": event.availability = .tentative + case "unavailable": event.availability = .unavailable + default: break + } + } + + if let travelTime = travelTime { + event.setValue(travelTime, forKey: "travelTime") + } + + + if let tTime = travelTime { + event.setValue(tTime, forKey: "travelTime") + } + + if let alarms = alarms { + if let existing = event.alarms { + for alarm in existing { event.removeAlarm(alarm) } + } + for offset in alarms { + event.addAlarm(EKAlarm(relativeOffset: offset)) + } + } + + do { + try eventStore.save(event, span: .thisEvent) + return JSONOutput.success([ + "status": "success", + "message": "Event updated successfully", + "event": eventToDict(event) + ]) + } catch { + return JSONOutput.error("Failed to update event: \(error.localizedDescription)") + } + } + /// Creates a new calendar event func addEvent( calendarID: String, @@ -155,7 +327,23 @@ class EventKitManager { endDate: Date, location: String?, notes: String?, - allDay: Bool + allDay: Bool, + recurrenceFrequency: String? = nil, + recurrenceInterval: Int = 1, + recurrenceEndCount: Int? = nil, + recurrenceEndDate: Date? = nil, + recurrenceDays: String? = nil, + recurrenceMonths: [NSNumber]? = nil, + recurrenceDaysOfMonth: [NSNumber]? = nil, + recurrenceWeeksOfYear: [NSNumber]? = nil, + recurrenceDaysOfYear: [NSNumber]? = nil, + recurrenceSetPositions: [NSNumber]? = nil, + travelTime: TimeInterval? = nil, + travelStartLocation: String? = nil, + transportType: String? = nil, + alarms: [Double]? = nil, + url: String? = nil, + availability: String? = nil ) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { return JSONOutput.error("Calendar not found with ID: \(calendarID)") @@ -170,9 +358,115 @@ class EventKitManager { event.title = title event.startDate = startDate event.endDate = endDate - event.location = location + // Location handling will be done below event.notes = notes event.isAllDay = allDay + + if let urlString = url, let urlObj = URL(string: urlString) { + event.url = urlObj + } + + if let avail = availability?.lowercased() { + switch avail { + case "busy": event.availability = .busy + case "free": event.availability = .free + case "tentative": event.availability = .tentative + case "unavailable": event.availability = .unavailable + default: break + } + } + + if let alarms = alarms { + for offset in alarms { + event.addAlarm(EKAlarm(relativeOffset: offset)) + } + } + + // Location & Structured Location + if let locationString = location { + // First set standard location string + event.location = locationString + + // Use shared resolveLocation helper which is more robust + let structuredLocation = resolveLocation(locationString) + event.structuredLocation = structuredLocation + } + + // Recurrence Support + if let freqString = recurrenceFrequency?.lowercased(), !freqString.isEmpty { + var frequency: EKRecurrenceFrequency + switch freqString { + case "daily": frequency = .daily + case "weekly": frequency = .weekly + case "monthly": frequency = .monthly + case "yearly": frequency = .yearly + default: return JSONOutput.error("Invalid recurrence frequency: \(freqString)") + } + + var daysOfTheWeek: [EKRecurrenceDayOfWeek]? + if let daysStr = recurrenceDays { + var days: [EKRecurrenceDayOfWeek] = [] + let dayMap: [String: EKWeekday] = [ + "mon": .monday, "monday": .monday, + "tue": .tuesday, "tuesday": .tuesday, + "wed": .wednesday, "wednesday": .wednesday, + "thu": .thursday, "thursday": .thursday, + "fri": .friday, "friday": .friday, + "sat": .saturday, "saturday": .saturday, + "sun": .sunday, "sunday": .sunday + ] + + // Parse strings like "mon", "1mon", "-1fri" + for dayPart in daysStr.split(separator: ",") { + var part = dayPart.trimmingCharacters(in: .whitespaces).lowercased() + var weekNumber = 0 + + // Extract leading number if present + if let range = part.range(of: "^-?\\d+", options: .regularExpression) { + if let num = Int(part[range]) { + weekNumber = num + part.removeSubrange(range) + } + } + + if let weekday = dayMap[part] { + if weekNumber != 0 { + days.append(EKRecurrenceDayOfWeek(weekday, weekNumber: weekNumber)) + } else { + days.append(EKRecurrenceDayOfWeek(weekday)) + } + } else { + return JSONOutput.error("Invalid recurrence day: \(dayPart)") + } + } + daysOfTheWeek = days.isEmpty ? nil : days + } + + var recurrenceEnd: EKRecurrenceEnd? + if let count = recurrenceEndCount { + recurrenceEnd = EKRecurrenceEnd(occurrenceCount: count) + } else if let recEndDate = recurrenceEndDate { + recurrenceEnd = EKRecurrenceEnd(end: recEndDate) + } + + let rule = EKRecurrenceRule( + recurrenceWith: frequency, + interval: recurrenceInterval, + daysOfTheWeek: daysOfTheWeek, + daysOfTheMonth: recurrenceDaysOfMonth, + monthsOfTheYear: recurrenceMonths, + weeksOfTheYear: recurrenceWeeksOfYear, + daysOfTheYear: recurrenceDaysOfYear, + setPositions: recurrenceSetPositions, + end: recurrenceEnd + ) + event.addRecurrenceRule(rule) + } + + // Travel Time (Manual) + if let tTime = travelTime { + event.setValue(tTime, forKey: "travelTime") + } do { try eventStore.save(event, span: .thisEvent) @@ -330,6 +624,32 @@ class EventKitManager { // MARK: - Helper Methods + /// Resolves a string address to an EKStructuredLocation using CLGeocoder + private func resolveLocation(_ address: String) -> EKStructuredLocation { + let group = DispatchGroup() + group.enter() + var result = EKStructuredLocation(title: address) + + let geocoder = CLGeocoder() + geocoder.geocodeAddressString(address) { placemarks, error in + if let placemark = placemarks?.first, let location = placemark.location { + let s = EKStructuredLocation(title: address) + s.geoLocation = location + s.radius = 0 + result = s + } + group.leave() + } + + // Wait for geocoding (max 5 seconds to be safe) + let timeout = Date(timeIntervalSinceNow: 5.0) + while group.wait(timeout: .now()) == .timedOut { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) + if Date() > timeout { break } + } + return result + } + /// Creates a date formatter that outputs ISO 8601 format in the user's local timezone private func localDateFormatter() -> DateFormatter { let formatter = DateFormatter() @@ -434,4 +754,19 @@ extension CGColor { return String(format: "#%02X%02X%02X", r, g, b) } + + static func fromHex(_ hex: String) -> CGColor? { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + let b = CGFloat(rgb & 0x0000FF) / 255.0 + + return CGColor(srgbRed: r, green: g, blue: b, alpha: 1.0) + } } From 6965590549c741720c403b5bfd5e2c9a63cd3d6b Mon Sep 17 00:00:00 2001 From: Jorge Welch Date: Sat, 28 Feb 2026 13:05:35 +0800 Subject: [PATCH 2/3] Split ekctlCore library target, add update reminder, improve tests, fixed bugs, cleaned up code. --- Package.swift | 16 +- README.md | 171 ++++++- Sources/{ => ekctl}/Ekctl.swift | 230 +++++++-- Sources/{ => ekctlCore}/ConfigManager.swift | 18 +- Sources/{ => ekctlCore}/EventKitManager.swift | 298 ++++++------ Sources/{ => ekctlCore}/JSONOutput.swift | 22 +- Tests/ekctlTests/ekctlTests.swift | 446 ++++++++++++++++++ 7 files changed, 985 insertions(+), 216 deletions(-) rename Sources/{ => ekctl}/Ekctl.swift (75%) rename Sources/{ => ekctlCore}/ConfigManager.swift (81%) rename Sources/{ => ekctlCore}/EventKitManager.swift (79%) rename Sources/{ => ekctlCore}/JSONOutput.swift (68%) create mode 100644 Tests/ekctlTests/ekctlTests.swift diff --git a/Package.swift b/Package.swift index a4435f3..d371a10 100644 --- a/Package.swift +++ b/Package.swift @@ -10,12 +10,24 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0") ], targets: [ + .target( + name: "ekctlCore", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Sources/ekctlCore" + ), .executableTarget( name: "ekctl", dependencies: [ + "ekctlCore", .product(name: "ArgumentParser", package: "swift-argument-parser") ], - path: "Sources" + path: "Sources/ekctl" + ), + .testTarget( + name: "ekctlTests", + dependencies: ["ekctlCore"] ) ] -) +) \ No newline at end of file diff --git a/README.md b/README.md index 137a39a..6f4a096 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Native macOS command-line tool for managing Calendar events and Reminders using ## Features - List, create, update, and delete calendar events -- List, create, complete, and delete reminders +- List, create, update, complete, and delete reminders - Calendar aliases (use friendly names instead of UUIDs) - JSON output for parsing - Full EventKit integration with proper permission handling @@ -46,14 +46,16 @@ On first run, macOS will prompt for Calendar and Reminders access. Manage permis ## Calendars -### List +### List Calendars **Command:** + ```bash ekctl list calendars ``` **Output:** + ```json { "calendars": [ @@ -73,20 +75,23 @@ ekctl list calendars ### Create **Command:** + ```bash ekctl calendar create --title "Project X" --color "#FF5500" ``` -### Update +### Update Reminder **Command:** + ```bash ekctl calendar update CALENDAR_ID --title "New Name" --color "#00FF00" ``` -### Delete +### Delete Calendar **Command:** + ```bash ekctl calendar delete CALENDAR_ID ``` @@ -96,22 +101,41 @@ ekctl calendar delete CALENDAR_ID Use friendly names instead of UUIDs. Aliases work anywhere a calendar ID is accepted. **Set alias:** + ```bash ekctl alias set work "CA513B39-1659-4359-8FE9-0C2A3DCEF153" ekctl alias set personal "4E367C6F-354B-4811-935E-7F25A1BB7D39" ``` **List aliases:** + ```bash ekctl alias list ``` +**Output:** + +```json +{ + "aliases": [ + { "name": "groceries", "id": "E30AE972-8F29-40AF-BFB9-E984B98B08AB" }, + { "name": "personal", "id": "4E367C6F-354B-4811-935E-7F25A1BB7D39" }, + { "name": "work", "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153" } + ], + "count": 3, + "configPath": "/Users/you/.ekctl/config.json", + "status": "success" +} +``` + **Remove alias:** + ```bash ekctl alias remove work ``` **Usage:** + ```bash # These are equivalent: ekctl list events --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" @@ -125,11 +149,13 @@ Aliases are stored in `~/.ekctl/config.json`. ### List **Command:** + ```bash ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" ``` **Output:** + ```json { "count": 2, @@ -154,21 +180,24 @@ ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31 } ``` -### Show +### Show Event **Command:** + ```bash ekctl show event EVENT_ID ``` -### Add +### Add Event Basic event: + ```bash ekctl add event --calendar work --title "Lunch" --start "2026-02-10T12:30:00Z" --end "2026-02-10T13:30:00Z" ``` With location, notes, and alarms: + ```bash ekctl add event \ --calendar work \ @@ -181,6 +210,7 @@ ekctl add event \ ``` Recurring event (weekly): + ```bash ekctl add event \ --calendar personal \ @@ -193,6 +223,7 @@ ekctl add event \ ``` With travel time: + ```bash ekctl add event \ --calendar work \ @@ -204,6 +235,7 @@ ekctl add event \ ``` **Output:** + ```json { "status": "success", @@ -224,14 +256,16 @@ ekctl add event \ } ``` -### Delete +### Delete Event **Command:** + ```bash ekctl delete event EVENT_ID ``` **Output:** + ```json { "status": "success", @@ -242,24 +276,28 @@ ekctl delete event EVENT_ID ## Reminders -### List +### List Reminders All reminders: + ```bash ekctl list reminders --list personal ``` Only incomplete: + ```bash ekctl list reminders --list personal --completed false ``` Only completed: + ```bash ekctl list reminders --list personal --completed true ``` **Output:** + ```json { "count": 2, @@ -284,23 +322,27 @@ ekctl list reminders --list personal --completed true ### Show **Command:** + ```bash ekctl show reminder REMINDER_ID ``` -### Add +### Add Reminder Simple reminder: + ```bash ekctl add reminder --list personal --title "Call dentist" ``` With due date: + ```bash ekctl add reminder --list personal --title "Submit expense report" --due "2026-01-25T09:00:00Z" ``` With priority and notes (priority: 0=none, 1=high, 5=medium, 9=low): + ```bash ekctl add reminder \ --list groceries \ @@ -311,6 +353,7 @@ ekctl add reminder \ ``` **Output:** + ```json { "status": "success", @@ -330,14 +373,58 @@ ekctl add reminder \ } ``` +### Update + +**Command:** + +```bash +ekctl update reminder REMINDER_ID --title "New title" --due "2026-02-01T09:00:00Z" --priority 1 --notes "Updated notes" +``` + +All flags are optional — only the fields you pass will be changed: + +```bash +# Just change the title +ekctl update reminder REMINDER_ID --title "Renamed reminder" + +# Bump priority and add a due date +ekctl update reminder REMINDER_ID --priority 1 --due "2026-03-10T09:00:00Z" + +# Mark as completed via update (same effect as complete command) +ekctl update reminder REMINDER_ID --completed true +``` + +**Output:** + +```json +{ + "status": "success", + "message": "Reminder updated successfully", + "reminder": { + "id": "REM123-456-789", + "title": "New title", + "list": { + "id": "4E367C6F-354B-4811-935E-7F25A1BB7D39", + "title": "Reminders" + }, + "dueDate": "2026-02-01T09:00:00+08:00", + "completed": false, + "priority": 1, + "notes": "Updated notes" + } +} +``` + ### Complete **Command:** + ```bash ekctl complete reminder REMINDER_ID ``` **Output:** + ```json { "status": "success", @@ -354,10 +441,73 @@ ekctl complete reminder REMINDER_ID ### Delete **Command:** + ```bash ekctl delete reminder REMINDER_ID ``` +## Date Format + +All dates use **ISO 8601** format with timezone. Examples: + +| Format | Example | Description | +| -------- | --------- | ------------- | +| UTC | `2026-01-15T09:00:00Z` | 9:00 AM UTC | +| With offset | `2026-01-15T09:00:00+10:00` | 9:00 AM AEST | +| Midnight | `2026-01-15T00:00:00Z` | Start of day | +| End of day | `2026-01-15T23:59:59Z` | End of day | + +## Scripting Examples + +### Get calendar ID by name + +```bash +CALENDAR_ID=$(ekctl list calendars | jq -r '.calendars[] | select(.title == "Work") | .id') +echo $CALENDAR_ID +``` + +### List today's events + +```bash +TODAY=$(date -u +"%Y-%m-%dT00:00:00Z") +TOMORROW=$(date -u -v+1d +"%Y-%m-%dT00:00:00Z") + +ekctl list events \ + --calendar "$CALENDAR_ID" \ + --from "$TODAY" \ + --to "$TOMORROW" +``` + +### Create event from variables + +```bash +TITLE="Sprint Planning" +START="2026-01-20T10:00:00Z" +END="2026-01-20T11:00:00Z" + +ekctl add event \ + --calendar "$CALENDAR_ID" \ + --title "$TITLE" \ + --start "$START" \ + --end "$END" +``` + +### Count incomplete reminders + +```bash +ekctl list reminders --list "$LIST_ID" --completed false | jq '.count' +``` + +### Export events to CSV + +```bash +ekctl list events \ + --calendar "$CALENDAR_ID" \ + --from "2026-01-01T00:00:00Z" \ + --to "2026-12-31T23:59:59Z" \ + | jq -r '.events[] | [.title, .startDate, .endDate, .location // ""] | @csv' +``` + ## Error Handling All errors return JSON with `status: "error"`: @@ -370,6 +520,7 @@ All errors return JSON with `status: "error"`: ``` Common errors: + - `Permission denied`: Grant access in System Settings → Privacy & Security → Calendars/Reminders - `Calendar not found`: Check calendar ID with `ekctl list calendars` - `Invalid date format`: Use ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ) @@ -388,4 +539,4 @@ MIT License ## Contributing -Pull requests welcome. \ No newline at end of file +Pull requests welcome. diff --git a/Sources/Ekctl.swift b/Sources/ekctl/Ekctl.swift similarity index 75% rename from Sources/Ekctl.swift rename to Sources/ekctl/Ekctl.swift index c2333f4..c30f593 100644 --- a/Sources/Ekctl.swift +++ b/Sources/ekctl/Ekctl.swift @@ -1,6 +1,7 @@ import ArgumentParser import EventKit import Foundation +import ekctlCore // MARK: - Main Command @@ -8,9 +9,13 @@ import Foundation struct Ekctl: ParsableCommand { static let configuration = CommandConfiguration( commandName: "ekctl", - abstract: "A command-line tool for managing macOS Calendar events and Reminders using EventKit.", + abstract: + "A command-line tool for managing macOS Calendar events and Reminders using EventKit.", version: "1.3.0", - subcommands: [List.self, Show.self, Add.self, Update.self, Delete.self, Complete.self, Alias.self, CalendarCmd.self], + subcommands: [ + List.self, Show.self, Add.self, Update.self, Delete.self, Complete.self, Alias.self, + CalendarCmd.self, + ], defaultSubcommand: List.self ) } @@ -58,11 +63,17 @@ struct ListEvents: ParsableCommand { try manager.requestAccess() guard let startDate = ISO8601DateFormatter().date(from: from) else { - print(JSONOutput.error("Invalid --from date format. Use ISO8601 (e.g., 2026-02-01T00:00:00Z).").toJSON()) + print( + JSONOutput.error( + "Invalid --from date format. Use ISO8601 (e.g., 2026-02-01T00:00:00Z)." + ).toJSON()) throw ExitCode.failure } guard let endDate = ISO8601DateFormatter().date(from: to) else { - print(JSONOutput.error("Invalid --to date format. Use ISO8601 (e.g., 2026-02-07T23:59:59Z).").toJSON()) + print( + JSONOutput.error( + "Invalid --to date format. Use ISO8601 (e.g., 2026-02-07T23:59:59Z)." + ).toJSON()) throw ExitCode.failure } @@ -186,7 +197,9 @@ struct AddEvent: ParsableCommand { @Option(name: .long, help: "Recurrence end date in ISO8601 format.") var recurrenceEndDate: String? - @Option(name: .long, help: "Days of week (e.g., 'mon,tue', '1mon' for 1st Monday, '-1fri' for last Friday).") + @Option( + name: .long, + help: "Days of week (e.g., 'mon,tue', '1mon' for 1st Monday, '-1fri' for last Friday).") var recurrenceDays: String? @Option(name: .long, help: "Months of the year (comma-separated: 1-12 or jan,feb...).") @@ -209,7 +222,11 @@ struct AddEvent: ParsableCommand { // MARK: - New Features (Alarms, Availability, URL, etc.) - @Option(name: .long, help: "Alarms/Alerts relative to start time in minutes (e.g., '-30,-60').") + @Option( + name: .long, + help: + "Alarms in minutes. Positive numbers mean minutes before (e.g., 10). Prefix '+' for minutes after (e.g., +10). Negative numbers are accepted." + ) var alarms: String? @Option(name: .long, help: "URL for the event.") @@ -234,17 +251,18 @@ struct AddEvent: ParsableCommand { var rEndDate: Date? if let recEndDateString = recurrenceEndDate, !recEndDateString.isEmpty { guard let date = ISO8601DateFormatter().date(from: recEndDateString) else { - print(JSONOutput.error("Invalid --recurrence-end-date format. Use ISO8601.").toJSON()) + print( + JSONOutput.error("Invalid --recurrence-end-date format. Use ISO8601.").toJSON()) throw ExitCode.failure } rEndDate = date } - + // Parse recurrence interval (default to 1) let recurrenceIntervalInt = (recurrenceInterval.flatMap(Int.init)) ?? 1 - + let recurrenceEndCountInt = recurrenceEndCount.flatMap(Int.init) - + // Convert travel time to seconds if provided and valid var travelTimeSeconds: TimeInterval? if let ttString = travelTime, let ttInt = Int(ttString) { @@ -254,11 +272,11 @@ struct AddEvent: ParsableCommand { // Helper to parse comma-separated integers func parseInts(_ string: String?) -> [NSNumber]? { guard let string = string else { return nil } - return string.split(separator: ",").compactMap { - Int($0.trimmingCharacters(in: .whitespaces)).map { NSNumber(value: $0) } + return string.split(separator: ",").compactMap { + Int($0.trimmingCharacters(in: .whitespaces)).map { NSNumber(value: $0) } } } - + // Helper to parse months (names or numbers) func parseMonths(_ string: String?) -> [NSNumber]? { guard let string = string else { return nil } @@ -266,9 +284,9 @@ struct AddEvent: ParsableCommand { "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3, "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7, "aug": 8, "august": 8, "sep": 9, "september": 9, - "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12 + "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12, ] - + return string.split(separator: ",").compactMap { component in let trimmed = component.trimmingCharacters(in: .whitespaces).lowercased() if let val = Int(trimmed) { return NSNumber(value: val) } @@ -276,11 +294,26 @@ struct AddEvent: ParsableCommand { return nil } } - - let alarmsList = alarms?.split(separator: ",").compactMap { - Double($0.trimmingCharacters(in: .whitespaces)).map { $0 * -60 } + + func parseAlarms(_ string: String?) -> [Double]? { + guard let string = string else { return nil } + return string.split(separator: ",").compactMap { component in + let s = component.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("+") { + if let val = Double(s.dropFirst()) { return val * 60 } + return nil + } + guard let val = Double(s) else { return nil } + if val < 0 { + return val * 60 + } else { + return -val * 60 + } + } } + let alarmsList = parseAlarms(alarms) + let calendarID = ConfigManager.resolveAlias(calendar) let result = manager.addEvent( calendarID: calendarID, @@ -343,8 +376,19 @@ struct AddReminder: ParsableCommand { dueDate = parsed } - // Parse priority - let priorityInt = (priority.flatMap(Int.init)) ?? 0 + // Parse priority: require an integer when provided, else error + var priorityInt: Int = 0 + if let priority = priority { + if let p = Int(priority) { + priorityInt = p + } else { + print( + JSONOutput.error( + "Invalid --priority value. Must be an integer (e.g., 0,1,5,9). Please use numeric priorities." + ).toJSON()) + throw ExitCode.failure + } + } let listID = ConfigManager.resolveAlias(list) let result = manager.addReminder( @@ -362,8 +406,8 @@ struct AddReminder: ParsableCommand { struct Update: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Update an existing event.", - subcommands: [UpdateEvent.self] + abstract: "Update an existing event or reminder.", + subcommands: [UpdateEvent.self, UpdateReminder.self] ) } @@ -412,23 +456,40 @@ struct UpdateEvent: ParsableCommand { var startDate: Date? if let start = start { - guard let da = ISO8601DateFormatter().date(from: start) else { - throw ExitCode.failure - } - startDate = da + guard let da = ISO8601DateFormatter().date(from: start) else { + print(JSONOutput.error("Invalid --start date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + startDate = da } var endDate: Date? if let end = end { - guard let da = ISO8601DateFormatter().date(from: end) else { - throw ExitCode.failure - } - endDate = da + guard let da = ISO8601DateFormatter().date(from: end) else { + print(JSONOutput.error("Invalid --end date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + endDate = da } - - let alarmsList = alarms?.split(separator: ",").compactMap { - Double($0.trimmingCharacters(in: .whitespaces)).map { $0 * -60 } + + func parseAlarms(_ string: String?) -> [Double]? { + guard let string = string else { return nil } + return string.split(separator: ",").compactMap { component in + let s = component.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("+") { + if let val = Double(s.dropFirst()) { return val * 60 } + return nil + } + guard let val = Double(s) else { return nil } + if val < 0 { + return val * 60 + } else { + return -val * 60 + } + } } + let alarmsList = parseAlarms(alarms) + var travelTimeSeconds: TimeInterval? if let ttString = travelTime, let ttInt = Int(ttString) { travelTimeSeconds = TimeInterval(ttInt * 60) @@ -451,7 +512,68 @@ struct UpdateEvent: ParsableCommand { } } -// MARK: - C +struct UpdateReminder: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminder", + abstract: "Update an existing reminder." + ) + + @Argument(help: "The reminder ID to update.") + var reminderID: String + + @Option(name: .long, help: "New title.") + var title: String? + + @Option(name: .long, help: "New due date (ISO8601).") + var due: String? + + @Option(name: .long, help: "New priority (0=none, 1=high, 5=medium, 9=low).") + var priority: String? + + @Option(name: .long, help: "New notes.") + var notes: String? + + @Option(name: .long, help: "Mark as completed (true/false).") + var completed: Bool? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + + var dueDate: Date? + if let due = due { + guard let parsed = ISO8601DateFormatter().date(from: due) else { + print(JSONOutput.error("Invalid --due date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + dueDate = parsed + } + + var priorityInt: Int? + if let priority = priority { + guard let p = Int(priority) else { + print( + JSONOutput.error( + "Invalid --priority value. Must be an integer (0, 1, 5, or 9)." + ).toJSON()) + throw ExitCode.failure + } + priorityInt = p + } + + let result = manager.updateReminder( + reminderID: reminderID, + title: title, + dueDate: dueDate, + priority: priorityInt, + notes: notes, + completed: completed + ) + print(result.toJSON()) + } +} + +// MARK: - Calendar Managment Commands struct CalendarCmd: ParsableCommand { static let configuration = CommandConfiguration( @@ -617,14 +739,15 @@ struct AliasSet: ParsableCommand { func run() throws { do { try ConfigManager.setAlias(name: name, id: id) - print(JSONOutput.success([ - "status": "success", - "message": "Alias '\(name)' set successfully", - "alias": [ - "name": name, - "id": id - ] - ]).toJSON()) + print( + JSONOutput.success([ + "status": "success", + "message": "Alias '\(name)' set successfully", + "alias": [ + "name": name, + "id": id, + ], + ]).toJSON()) } catch { print(JSONOutput.error("Failed to save alias: \(error.localizedDescription)").toJSON()) throw ExitCode.failure @@ -645,16 +768,18 @@ struct AliasRemove: ParsableCommand { do { let removed = try ConfigManager.removeAlias(name: name) if removed { - print(JSONOutput.success([ - "status": "success", - "message": "Alias '\(name)' removed successfully" - ]).toJSON()) + print( + JSONOutput.success([ + "status": "success", + "message": "Alias '\(name)' removed successfully", + ]).toJSON()) } else { print(JSONOutput.error("Alias '\(name)' not found").toJSON()) throw ExitCode.failure } } catch let error where !(error is ExitCode) { - print(JSONOutput.error("Failed to remove alias: \(error.localizedDescription)").toJSON()) + print( + JSONOutput.error("Failed to remove alias: \(error.localizedDescription)").toJSON()) throw ExitCode.failure } } @@ -674,10 +799,11 @@ struct AliasList: ParsableCommand { aliasList.append(["name": name, "id": id]) } - print(JSONOutput.success([ - "aliases": aliasList, - "count": aliasList.count, - "configPath": ConfigManager.configPath() - ]).toJSON()) + print( + JSONOutput.success([ + "aliases": aliasList, + "count": aliasList.count, + "configPath": ConfigManager.configPath(), + ]).toJSON()) } } diff --git a/Sources/ConfigManager.swift b/Sources/ekctlCore/ConfigManager.swift similarity index 81% rename from Sources/ConfigManager.swift rename to Sources/ekctlCore/ConfigManager.swift index ebb92a5..4dfd033 100644 --- a/Sources/ConfigManager.swift +++ b/Sources/ekctlCore/ConfigManager.swift @@ -2,8 +2,8 @@ import Foundation /// ConfigManager handles reading and writing the ekctl configuration file. /// Configuration is stored at ~/.ekctl/config.json -struct ConfigManager { - private static let configDirectory = FileManager.default.homeDirectoryForCurrentUser +public struct ConfigManager { + private static let configDirectory: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".ekctl") private static let configFile = configDirectory.appendingPathComponent("config.json") @@ -19,7 +19,7 @@ struct ConfigManager { } /// Loads the configuration from disk, or returns a default config if none exists - static func load() -> Config { + private static func load() -> Config { guard FileManager.default.fileExists(atPath: configFile.path) else { return Config() } @@ -35,7 +35,7 @@ struct ConfigManager { } /// Saves the configuration to disk - static func save(_ config: Config) throws { + private static func save(_ config: Config) throws { // Create directory if it doesn't exist if !FileManager.default.fileExists(atPath: configDirectory.path) { try FileManager.default.createDirectory( @@ -54,14 +54,14 @@ struct ConfigManager { // MARK: - Alias Operations /// Sets an alias for a calendar/list ID - static func setAlias(name: String, id: String) throws { + public static func setAlias(name: String, id: String) throws { var config = load() config.aliases[name] = id try save(config) } /// Removes an alias - static func removeAlias(name: String) throws -> Bool { + public static func removeAlias(name: String) throws -> Bool { var config = load() guard config.aliases.removeValue(forKey: name) != nil else { return false @@ -71,18 +71,18 @@ struct ConfigManager { } /// Gets all aliases - static func getAliases() -> [String: String] { + public static func getAliases() -> [String: String] { return load().aliases } /// Resolves an alias to an ID, or returns the input if it's not an alias - static func resolveAlias(_ nameOrID: String) -> String { + public static func resolveAlias(_ nameOrID: String) -> String { let config = load() return config.aliases[nameOrID] ?? nameOrID } /// Gets the config file path (for display purposes) - static func configPath() -> String { + public static func configPath() -> String { return configFile.path } } diff --git a/Sources/EventKitManager.swift b/Sources/ekctlCore/EventKitManager.swift similarity index 79% rename from Sources/EventKitManager.swift rename to Sources/ekctlCore/EventKitManager.swift index 1f3f9da..1c4e375 100644 --- a/Sources/EventKitManager.swift +++ b/Sources/ekctlCore/EventKitManager.swift @@ -1,7 +1,8 @@ import ArgumentParser +import CoreGraphics +import CoreLocation import EventKit import Foundation -import CoreLocation /// EventKitManager handles all interactions with the EventKit framework. /// @@ -22,14 +23,17 @@ import CoreLocation /// If denied, all operations will fail with a permission error. /// /// 5. Users can manage permissions in: System Settings > Privacy & Security > Calendars/Reminders -class EventKitManager { + +public class EventKitManager { + public init() {} + private let eventStore = EKEventStore() private var calendarAccessGranted = false private var reminderAccessGranted = false /// Requests access to both Calendar and Reminders. /// This must be called before any EventKit operations. - func requestAccess() throws { + public func requestAccess() throws { let semaphore = DispatchSemaphore(value: 0) var calendarError: Error? var reminderError: Error? @@ -72,16 +76,18 @@ class EventKitManager { throw ExitCode.failure } if let error = reminderError { - print(JSONOutput.error("Reminders access error: \(error.localizedDescription)").toJSON()) + print( + JSONOutput.error("Reminders access error: \(error.localizedDescription)").toJSON()) throw ExitCode.failure } // Check permissions if !calendarAccessGranted && !reminderAccessGranted { - print(JSONOutput.error( - "Permission denied for both Calendar and Reminders. " + - "Please grant access in System Settings > Privacy & Security." - ).toJSON()) + print( + JSONOutput.error( + "Permission denied for both Calendar and Reminders. " + + "Please grant access in System Settings > Privacy & Security." + ).toJSON()) throw ExitCode.failure } } @@ -89,24 +95,26 @@ class EventKitManager { // MARK: - Calendar Operations /// Create a new calendar - func createCalendar(title: String, color: String?) -> JSONOutput { + public func createCalendar(title: String, color: String?) -> JSONOutput { // Attempt to find a source (usually iCloud or Local) let sources = eventStore.sources // Prefer iCloud, then Local source - guard let source = sources.first(where: { $0.sourceType == .calDAV && $0.title == "iCloud" }) - ?? sources.first(where: { $0.sourceType == .local }) - ?? sources.first else { - return JSONOutput.error("No suitable calendar source found") + guard + let source = sources.first(where: { $0.sourceType == .calDAV && $0.title == "iCloud" }) + ?? sources.first(where: { $0.sourceType == .local }) + ?? sources.first + else { + return JSONOutput.error("No suitable calendar source found") } - + let newCalendar = EKCalendar(for: .event, eventStore: eventStore) newCalendar.title = title newCalendar.source = source - + if let colorHex = color { - if let cgColor = CGColor.fromHex(colorHex) { - newCalendar.cgColor = cgColor - } + if let cgColor = CGColor.fromHex(colorHex) { + newCalendar.cgColor = cgColor + } } do { @@ -114,31 +122,31 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Calendar created successfully", - "id": newCalendar.calendarIdentifier + "id": newCalendar.calendarIdentifier, ]) } catch { return JSONOutput.error("Failed to create calendar: \(error.localizedDescription)") } } - + /// Update a calendar - func updateCalendar(calendarID: String, title: String?, color: String?) -> JSONOutput { + public func updateCalendar(calendarID: String, title: String?, color: String?) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { - return JSONOutput.error("Calendar not found with ID: \(calendarID)") + return JSONOutput.error("Calendar not found with ID: \(calendarID)") } - + if let title = title { calendar.title = title } if let colorHex = color { - if let cgColor = CGColor.fromHex(colorHex) { - calendar.cgColor = cgColor - } + if let cgColor = CGColor.fromHex(colorHex) { + calendar.cgColor = cgColor + } } - + do { try eventStore.saveCalendar(calendar, commit: true) - return JSONOutput.success([ + return JSONOutput.success([ "status": "success", - "message": "Calendar updated successfully" + "message": "Calendar updated successfully", ]) } catch { return JSONOutput.error("Failed to update calendar: \(error.localizedDescription)") @@ -146,16 +154,16 @@ class EventKitManager { } /// Delete a calendar - func deleteCalendar(calendarID: String) -> JSONOutput { + public func deleteCalendar(calendarID: String) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { - return JSONOutput.error("Calendar not found with ID: \(calendarID)") + return JSONOutput.error("Calendar not found with ID: \(calendarID)") } - + do { try eventStore.removeCalendar(calendar, commit: true) - return JSONOutput.success([ + return JSONOutput.success([ "status": "success", - "message": "Calendar deleted successfully" + "message": "Calendar deleted successfully", ]) } catch { return JSONOutput.error("Failed to delete calendar: \(error.localizedDescription)") @@ -163,7 +171,7 @@ class EventKitManager { } /// Lists all calendars (event calendars and reminder lists) - func listCalendars() -> JSONOutput { + public func listCalendars() -> JSONOutput { var calendars: [[String: Any]] = [] // Event calendars @@ -174,7 +182,7 @@ class EventKitManager { "type": "event", "source": calendar.source?.title ?? "Unknown", "color": calendar.cgColor?.hexString ?? "#000000", - "allowsModifications": calendar.allowsContentModifications + "allowsModifications": calendar.allowsContentModifications, ]) } @@ -186,7 +194,7 @@ class EventKitManager { "type": "reminder", "source": calendar.source?.title ?? "Unknown", "color": calendar.cgColor?.hexString ?? "#000000", - "allowsModifications": calendar.allowsContentModifications + "allowsModifications": calendar.allowsContentModifications, ]) } @@ -196,7 +204,7 @@ class EventKitManager { // MARK: - Event Operations /// Lists events in a calendar within a date range - func listEvents(calendarID: String, from startDate: Date, to endDate: Date) -> JSONOutput { + public func listEvents(calendarID: String, from startDate: Date, to endDate: Date) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { return JSONOutput.error("Calendar not found with ID: \(calendarID)") } @@ -214,7 +222,7 @@ class EventKitManager { } /// Shows details of a specific event - func showEvent(eventID: String) -> JSONOutput { + public func showEvent(eventID: String) -> JSONOutput { guard let event = eventStore.event(withIdentifier: eventID) else { return JSONOutput.error("Event not found with ID: \(eventID)") } @@ -223,7 +231,7 @@ class EventKitManager { } /// Updates an existing calendar event - func updateEvent( + public func updateEvent( eventID: String, title: String?, startDate: Date?, @@ -234,70 +242,45 @@ class EventKitManager { url: String?, availability: String?, travelTime: TimeInterval?, - travelStartLocation: String? = nil, - transportType: String? = nil, alarms: [Double]? ) -> JSONOutput { guard let event = eventStore.event(withIdentifier: eventID) else { return JSONOutput.error("Event not found with ID: \(eventID)") } - + if let title = title { event.title = title } if let startDate = startDate { event.startDate = startDate } if let endDate = endDate { event.endDate = endDate } - if let location = location { + if let location = location { event.location = location - - // Should update structured location too - let group = DispatchGroup() - group.enter() - - let geocoder = CLGeocoder() - geocoder.geocodeAddressString(location) { placemarks, error in - if let placemark = placemarks?.first, let geo = placemark.location { - let structured = EKStructuredLocation(title: location) - structured.geoLocation = geo - structured.radius = 0 - event.structuredLocation = structured - } else { - let structured = EKStructuredLocation(title: location) - event.structuredLocation = structured - } - group.leave() - } - - let timeout = Date(timeIntervalSinceNow: 2.0) - while group.wait(timeout: .now()) == .timedOut { - RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) - if Date() > timeout { break } - } - } + // Use shared resolver (5s timeout) for consistent behavior with addEvent + let structured = resolveLocation(location) + event.structuredLocation = structured + } if let notes = notes { event.notes = notes } if let allDay = allDay { event.isAllDay = allDay } - + if let urlString = url, let urlObj = URL(string: urlString) { event.url = urlObj } - + if let avail = availability?.lowercased() { - switch avail { - case "busy": event.availability = .busy - case "free": event.availability = .free - case "tentative": event.availability = .tentative - case "unavailable": event.availability = .unavailable - default: break - } - } - - if let travelTime = travelTime { - event.setValue(travelTime, forKey: "travelTime") + switch avail { + case "busy": event.availability = .busy + case "free": event.availability = .free + case "tentative": event.availability = .tentative + case "unavailable": event.availability = .unavailable + default: break + } } - - - if let tTime = travelTime { - event.setValue(tTime, forKey: "travelTime") + + if let travelTime = travelTime { + // IMPORTANT: `travelTime` is set using KVC on a private/undocumented property name ("travelTime"). + // This is intentional as EventKit does not expose a public API for travel time. + // This may break in future macOS updates. + event.setValue(travelTime, forKey: "travelTime") } - + if let alarms = alarms { if let existing = event.alarms { for alarm in existing { event.removeAlarm(alarm) } @@ -306,21 +289,21 @@ class EventKitManager { event.addAlarm(EKAlarm(relativeOffset: offset)) } } - + do { try eventStore.save(event, span: .thisEvent) - return JSONOutput.success([ + return JSONOutput.success([ "status": "success", "message": "Event updated successfully", - "event": eventToDict(event) + "event": eventToDict(event), ]) } catch { - return JSONOutput.error("Failed to update event: \(error.localizedDescription)") + return JSONOutput.error("Failed to update event: \(error.localizedDescription)") } } /// Creates a new calendar event - func addEvent( + public func addEvent( calendarID: String, title: String, startDate: Date, @@ -339,8 +322,6 @@ class EventKitManager { recurrenceDaysOfYear: [NSNumber]? = nil, recurrenceSetPositions: [NSNumber]? = nil, travelTime: TimeInterval? = nil, - travelStartLocation: String? = nil, - transportType: String? = nil, alarms: [Double]? = nil, url: String? = nil, availability: String? = nil @@ -361,21 +342,21 @@ class EventKitManager { // Location handling will be done below event.notes = notes event.isAllDay = allDay - + if let urlString = url, let urlObj = URL(string: urlString) { event.url = urlObj } - + if let avail = availability?.lowercased() { - switch avail { - case "busy": event.availability = .busy - case "free": event.availability = .free - case "tentative": event.availability = .tentative - case "unavailable": event.availability = .unavailable - default: break - } - } - + switch avail { + case "busy": event.availability = .busy + case "free": event.availability = .free + case "tentative": event.availability = .tentative + case "unavailable": event.availability = .unavailable + default: break + } + } + if let alarms = alarms { for offset in alarms { event.addAlarm(EKAlarm(relativeOffset: offset)) @@ -386,7 +367,7 @@ class EventKitManager { if let locationString = location { // First set standard location string event.location = locationString - + // Use shared resolveLocation helper which is more robust let structuredLocation = resolveLocation(locationString) event.structuredLocation = structuredLocation @@ -413,14 +394,14 @@ class EventKitManager { "thu": .thursday, "thursday": .thursday, "fri": .friday, "friday": .friday, "sat": .saturday, "saturday": .saturday, - "sun": .sunday, "sunday": .sunday + "sun": .sunday, "sunday": .sunday, ] - + // Parse strings like "mon", "1mon", "-1fri" for dayPart in daysStr.split(separator: ",") { var part = dayPart.trimmingCharacters(in: .whitespaces).lowercased() var weekNumber = 0 - + // Extract leading number if present if let range = part.range(of: "^-?\\d+", options: .regularExpression) { if let num = Int(part[range]) { @@ -428,7 +409,7 @@ class EventKitManager { part.removeSubrange(range) } } - + if let weekday = dayMap[part] { if weekNumber != 0 { days.append(EKRecurrenceDayOfWeek(weekday, weekNumber: weekNumber)) @@ -448,7 +429,7 @@ class EventKitManager { } else if let recEndDate = recurrenceEndDate { recurrenceEnd = EKRecurrenceEnd(end: recEndDate) } - + let rule = EKRecurrenceRule( recurrenceWith: frequency, interval: recurrenceInterval, @@ -473,7 +454,7 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Event created successfully", - "event": eventToDict(event) + "event": eventToDict(event), ]) } catch { return JSONOutput.error("Failed to create event: \(error.localizedDescription)") @@ -481,7 +462,7 @@ class EventKitManager { } /// Deletes a calendar event - func deleteEvent(eventID: String) -> JSONOutput { + public func deleteEvent(eventID: String) -> JSONOutput { guard let event = eventStore.event(withIdentifier: eventID) else { return JSONOutput.error("Event not found with ID: \(eventID)") } @@ -493,7 +474,7 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Event '\(title)' deleted successfully", - "deletedEventID": eventID + "deletedEventID": eventID, ]) } catch { return JSONOutput.error("Failed to delete event: \(error.localizedDescription)") @@ -503,7 +484,7 @@ class EventKitManager { // MARK: - Reminder Operations /// Lists reminders in a reminder list - func listReminders(listID: String, completed: Bool?) -> JSONOutput { + public func listReminders(listID: String, completed: Bool?) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: listID) else { return JSONOutput.error("Reminder list not found with ID: \(listID)") } @@ -532,8 +513,9 @@ class EventKitManager { } /// Shows details of a specific reminder - func showReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { + public func showReminder(reminderID: String) -> JSONOutput { + guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder + else { return JSONOutput.error("Reminder not found with ID: \(reminderID)") } @@ -541,7 +523,7 @@ class EventKitManager { } /// Creates a new reminder - func addReminder( + public func addReminder( listID: String, title: String, dueDate: Date?, @@ -553,7 +535,8 @@ class EventKitManager { } guard calendar.allowsContentModifications else { - return JSONOutput.error("Reminder list '\(calendar.title)' does not allow modifications.") + return JSONOutput.error( + "Reminder list '\(calendar.title)' does not allow modifications.") } let reminder = EKReminder(eventStore: eventStore) @@ -574,16 +557,57 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Reminder created successfully", - "reminder": reminderToDict(reminder) + "reminder": reminderToDict(reminder), ]) } catch { return JSONOutput.error("Failed to create reminder: \(error.localizedDescription)") } } + /// Updates an existing reminder + public func updateReminder( + reminderID: String, + title: String?, + dueDate: Date?, + priority: Int?, + notes: String?, + completed: Bool? + ) -> JSONOutput { + guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder + else { + return JSONOutput.error("Reminder not found with ID: \(reminderID)") + } + + if let title = title { reminder.title = title } + if let notes = notes { reminder.notes = notes } + if let priority = priority { reminder.priority = priority } + if let completed = completed { + reminder.isCompleted = completed + reminder.completionDate = completed ? Date() : nil + } + if let dueDate = dueDate { + reminder.dueDateComponents = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: dueDate + ) + } + + do { + try eventStore.save(reminder, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Reminder updated successfully", + "reminder": reminderToDict(reminder), + ]) + } catch { + return JSONOutput.error("Failed to update reminder: \(error.localizedDescription)") + } + } + /// Marks a reminder as completed - func completeReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { + public func completeReminder(reminderID: String) -> JSONOutput { + guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder + else { return JSONOutput.error("Reminder not found with ID: \(reminderID)") } @@ -595,7 +619,7 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Reminder '\(reminder.title ?? "Untitled")' marked as completed", - "reminder": reminderToDict(reminder) + "reminder": reminderToDict(reminder), ]) } catch { return JSONOutput.error("Failed to complete reminder: \(error.localizedDescription)") @@ -603,8 +627,9 @@ class EventKitManager { } /// Deletes a reminder - func deleteReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { + public func deleteReminder(reminderID: String) -> JSONOutput { + guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder + else { return JSONOutput.error("Reminder not found with ID: \(reminderID)") } @@ -615,7 +640,7 @@ class EventKitManager { return JSONOutput.success([ "status": "success", "message": "Reminder '\(title)' deleted successfully", - "deletedReminderID": reminderID + "deletedReminderID": reminderID, ]) } catch { return JSONOutput.error("Failed to delete reminder: \(error.localizedDescription)") @@ -629,7 +654,7 @@ class EventKitManager { let group = DispatchGroup() group.enter() var result = EKStructuredLocation(title: address) - + let geocoder = CLGeocoder() geocoder.geocodeAddressString(address) { placemarks, error in if let placemark = placemarks?.first, let location = placemark.location { @@ -640,7 +665,7 @@ class EventKitManager { } group.leave() } - + // Wait for geocoding (max 5 seconds to be safe) let timeout = Date(timeIntervalSinceNow: 5.0) while group.wait(timeout: .now()) == .timedOut { @@ -667,9 +692,9 @@ class EventKitManager { "title": event.title ?? "", "calendar": [ "id": event.calendar?.calendarIdentifier ?? "", - "title": event.calendar?.title ?? "" + "title": event.calendar?.title ?? "", ], - "allDay": event.isAllDay + "allDay": event.isAllDay, ] if let startDate = event.startDate { @@ -707,14 +732,15 @@ class EventKitManager { "title": reminder.title ?? "", "list": [ "id": reminder.calendar?.calendarIdentifier ?? "", - "title": reminder.calendar?.title ?? "" + "title": reminder.calendar?.title ?? "", ], "completed": reminder.isCompleted, - "priority": reminder.priority + "priority": reminder.priority, ] if let dueDateComponents = reminder.dueDateComponents, - let dueDate = Calendar.current.date(from: dueDateComponents) { + let dueDate = Calendar.current.date(from: dueDateComponents) + { dict["dueDate"] = formatter.string(from: dueDate) } else { dict["dueDate"] = NSNull() @@ -740,10 +766,8 @@ class EventKitManager { // MARK: - CGColor Extension for Hex String -import CoreGraphics - extension CGColor { - var hexString: String { + public var hexString: String { guard let components = components, components.count >= 3 else { return "#000000" } @@ -755,7 +779,7 @@ extension CGColor { return String(format: "#%02X%02X%02X", r, g, b) } - static func fromHex(_ hex: String) -> CGColor? { + public static func fromHex(_ hex: String) -> CGColor? { var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") diff --git a/Sources/JSONOutput.swift b/Sources/ekctlCore/JSONOutput.swift similarity index 68% rename from Sources/JSONOutput.swift rename to Sources/ekctlCore/JSONOutput.swift index fc04e4c..25c6f95 100644 --- a/Sources/JSONOutput.swift +++ b/Sources/ekctlCore/JSONOutput.swift @@ -2,7 +2,7 @@ import Foundation /// JSONOutput provides consistent JSON formatting for all CLI output. /// All commands output valid JSON for easy scripting and parsing. -struct JSONOutput { +public struct JSONOutput { private let data: [String: Any] private init(_ data: [String: Any]) { @@ -10,7 +10,7 @@ struct JSONOutput { } /// Creates a success response with the given data - static func success(_ data: [String: Any]) -> JSONOutput { + public static func success(_ data: [String: Any]) -> JSONOutput { var output = data if output["status"] == nil { output["status"] = "success" @@ -19,7 +19,7 @@ struct JSONOutput { } /// Creates an error response with the given message - static func error(_ message: String) -> JSONOutput { + public static func error(_ message: String) -> JSONOutput { return JSONOutput([ "status": "error", "error": message @@ -27,7 +27,7 @@ struct JSONOutput { } /// Converts the output to a JSON string - func toJSON() -> String { + public func toJSON() -> String { do { let jsonData = try JSONSerialization.data( withJSONObject: data, @@ -38,12 +38,22 @@ struct JSONOutput { return "{\"status\": \"error\", \"error\": \"JSON serialization failed: \(error.localizedDescription)\"}" } } + + /// Converts the JSON output back to a dictionary. + /// Useful for scripting and testing. + public func toDictionary() -> [String: Any] { + guard + let data = toJSON().data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + return dict + } } // MARK: - ExitCode Extension import ArgumentParser -extension ExitCode { +public extension ExitCode { static let permissionDenied = ExitCode(rawValue: 2) -} +} \ No newline at end of file diff --git a/Tests/ekctlTests/ekctlTests.swift b/Tests/ekctlTests/ekctlTests.swift new file mode 100644 index 0000000..646be26 --- /dev/null +++ b/Tests/ekctlTests/ekctlTests.swift @@ -0,0 +1,446 @@ +import XCTest +import ekctlCore +import Foundation + +// ───────────────────────────────────────────────────────────────────────────── +// MARK: - Tests +// ───────────────────────────────────────────────────────────────────────────── + +// ── Test-only helpers ───────────────────────────────────────────────────────── +// These small functions mirror the inline logic inside run() methods in Ekctl.swift. +// They can't be imported because they live in the executable target, so we keep slim wrappers here. +// Each one exactly matches the production code — if the production code changes, the behaviour test will catch the drift. + +/// Mirrors: guard let date = ISO8601DateFormatter().date(from: input) +func validateDate(_ input: String) -> Date? { + ISO8601DateFormatter().date(from: input) +} + +/// Mirrors: TimeInterval(ttInt * 60) in AddEvent.run() / UpdateEvent.run() +func travelTimeSeconds(from minuteString: String) -> TimeInterval? { + guard let minutes = Int(minuteString) else { return nil } + return TimeInterval(minutes * 60) +} + +/// Mirrors: (recurrenceInterval.flatMap(Int.init)) ?? 1 in AddEvent.run() +func recurrenceInterval(from string: String?) -> Int { + string.flatMap(Int.init) ?? 1 +} + +/// Mirrors: Int(priority) in AddReminder.run() / UpdateReminder.run() +func parsePriority(_ string: String?) -> Int? { + guard let string = string else { return nil } + return Int(string) +} + +/// Mirrors: parseAlarms() in AddEvent.run() / UpdateEvent.run() +func parseAlarms(_ string: String?) -> [Double]? { + guard let string = string else { return nil } + return string.split(separator: ",").compactMap { component in + let s = component.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("+") { + return Double(s.dropFirst()).map { $0 * 60 } + } + guard let val = Double(s) else { return nil } + return val < 0 ? val * 60 : -val * 60 + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class JSONOutputTests: XCTestCase { + + func testSuccessAddsStatusField() { + let output = JSONOutput.success(["foo": "bar"]) + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "success") + XCTAssertEqual(dict["foo"] as? String, "bar") + } + + func testSuccessDoesNotOverwriteExistingStatus() { + // If caller already set "status", leave it alone + let output = JSONOutput.success(["status": "custom"]) + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "custom") + } + + func testErrorOutput() { + let output = JSONOutput.error("Something went wrong") + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "error") + XCTAssertEqual(dict["error"] as? String, "Something went wrong") + } + + func testToJSONIsValidJSON() { + let output = JSONOutput.success(["count": 3, "items": ["a", "b", "c"]]) + let json = output.toJSON() + let data = json.data(using: .utf8)! + XCTAssertNoThrow(try JSONSerialization.jsonObject(with: data)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class ConfigManagerTests: XCTestCase { + // ConfigManager uses static methods writing to ~/.ekctl/config.json. + // We back up and restore the real config around each test so we don't + // corrupt the user's actual aliases. + + private let configFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".ekctl/config.json") + private var backup: Data? + + override func setUp() { + super.setUp() + backup = try? Data(contentsOf: configFile) + try? FileManager.default.removeItem(at: configFile) + } + + override func tearDown() { + if let backup = backup { + try? backup.write(to: configFile) + } else { + try? FileManager.default.removeItem(at: configFile) + } + super.tearDown() + } + + // ── Alias CRUD ─────────────────────────────────────────────────────────── + + func testSetAndRetrieveAlias() throws { + try ConfigManager.setAlias(name: "work", id: "ABC-123") + XCTAssertEqual(ConfigManager.getAliases()["work"], "ABC-123") + } + + func testOverwriteAlias() throws { + try ConfigManager.setAlias(name: "work", id: "OLD-ID") + try ConfigManager.setAlias(name: "work", id: "NEW-ID") + XCTAssertEqual(ConfigManager.getAliases()["work"], "NEW-ID") + } + + func testRemoveAlias() throws { + try ConfigManager.setAlias(name: "work", id: "ABC-123") + let removed = try ConfigManager.removeAlias(name: "work") + XCTAssertTrue(removed) + XCTAssertNil(ConfigManager.getAliases()["work"]) + } + + func testRemoveNonExistentAliasReturnsFalse() throws { + let removed = try ConfigManager.removeAlias(name: "ghost") + XCTAssertFalse(removed) + } + + func testMultipleAliases() throws { + try ConfigManager.setAlias(name: "work", id: "CAL-1") + try ConfigManager.setAlias(name: "personal", id: "CAL-2") + try ConfigManager.setAlias(name: "groceries", id: "CAL-3") + let aliases = ConfigManager.getAliases() + XCTAssertEqual(aliases.count, 3) + XCTAssertEqual(aliases["personal"], "CAL-2") + } + + // ── Alias resolution ───────────────────────────────────────────────────── + + func testResolveKnownAlias() throws { + try ConfigManager.setAlias(name: "work", id: "CA513B39-XXXX") + XCTAssertEqual(ConfigManager.resolveAlias("work"), "CA513B39-XXXX") + } + + func testResolvePassesThroughUnknownString() { + let rawID = "CA513B39-1659-4359-8FE9-0C2A3DCEF153" + XCTAssertEqual(ConfigManager.resolveAlias(rawID), rawID) + } + + func testResolveEmptyConfig() { + XCTAssertEqual(ConfigManager.resolveAlias("anything"), "anything") + } + + // ── Config path ────────────────────────────────────────────────────────── + + func testConfigPathContainsEkctl() { + XCTAssertTrue(ConfigManager.configPath().contains(".ekctl")) + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class AlarmParsingTests: XCTestCase { + + func testNilInputReturnsNil() { + XCTAssertNil(parseAlarms(nil)) + } + + func testPositiveNumberMeansBeforeStart() { + // "10" → 10 minutes before → -600 seconds + let result = parseAlarms("10")! + XCTAssertEqual(result, [-600]) + } + + func testNegativeNumberPassesThroughAsNegativeSeconds() { + // "-10" → val is negative → val * 60 = -600 + let result = parseAlarms("-10")! + XCTAssertEqual(result, [-600]) + } + + func testPlusPrefixMeansAfterStart() { + // "+10" → 10 minutes after → +600 seconds + let result = parseAlarms("+10")! + XCTAssertEqual(result, [600]) + } + + func testMultipleAlarms() { + let result = parseAlarms("10,60")! + XCTAssertEqual(result, [-600, -3600]) + } + + func testMixedAlarms() { + let result = parseAlarms("10,+5,-15")! + XCTAssertEqual(result, [-600, 300, -900]) + } + + func testWhitespaceIsTrimmed() { + let result = parseAlarms(" 10 , 60 ")! + XCTAssertEqual(result, [-600, -3600]) + } + + func testInvalidComponentsAreSkipped() { + let result = parseAlarms("abc,10")! + XCTAssertEqual(result, [-600]) + } + + func testEmptyStringReturnsEmptyArray() { + let result = parseAlarms("")! + XCTAssertTrue(result.isEmpty) + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class HexColorTests: XCTestCase { + + func testFromHexWithHash() { + let color = CGColor.fromHex("#FF0000") + XCTAssertNotNil(color) + XCTAssertEqual(color?.hexString.uppercased(), "#FF0000") + } + + func testFromHexWithoutHash() { + let color = CGColor.fromHex("0088FF") + XCTAssertNotNil(color) + XCTAssertEqual(color?.hexString.uppercased(), "#0088FF") + } + + func testFromHexBlack() { + let color = CGColor.fromHex("#000000") + XCTAssertNotNil(color) + XCTAssertEqual(color?.hexString, "#000000") + } + + func testFromHexWhite() { + let color = CGColor.fromHex("#FFFFFF") + XCTAssertNotNil(color) + XCTAssertEqual(color?.hexString.uppercased(), "#FFFFFF") + } + + func testFromHexLowercaseInput() { + let color = CGColor.fromHex("#ff5500") + XCTAssertNotNil(color) + XCTAssertEqual(color?.hexString.uppercased(), "#FF5500") + } + + func testFromHexInvalidReturnsNil() { + XCTAssertNil(CGColor.fromHex("ZZZZZZ")) + } + + func testRoundTrip() { + let hex = "#1BADF8" + let color = CGColor.fromHex(hex)! + XCTAssertEqual(color.hexString.uppercased(), hex.uppercased()) + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class DateValidationTests: XCTestCase { + + // ── Formats ekctl actually accepts ─────────────────────────────────────── + + func testUTCFormatIsAccepted() { + XCTAssertNotNil(validateDate("2026-02-15T14:00:00Z")) + } + + func testTimezoneOffsetIsAccepted() { + // Perth/AWST — real-world case for this project + XCTAssertNotNil(validateDate("2026-02-15T14:00:00+08:00")) + } + + // ── Formats ekctl rejects ───────────────────────────────────────────────── + + func testHumanReadableDateIsRejected() { + XCTAssertNil(validateDate("March 5 2026")) + } + + func testDateOnlyWithoutTimeIsRejected() { + // Missing time component — ekctl requires full ISO8601 datetime + XCTAssertNil(validateDate("2026-03-05")) + } + + func testEmptyStringIsRejected() { + XCTAssertNil(validateDate("")) + } + + func testSlashSeparatedDateIsRejected() { + // Common user mistake + XCTAssertNil(validateDate("05/03/2026")) + } + + // ── Travel time conversion ──────────────────────────────────────────────── + + func testTravelTimeConvertsMinutesToSeconds() { + // 20 min → 1200 seconds, stored via KVC travelTime property + XCTAssertEqual(travelTimeSeconds(from: "20"), 1200) + } + + func testTravelTimeZeroMinutes() { + XCTAssertEqual(travelTimeSeconds(from: "0"), 0) + } + + func testTravelTimeRejectsNonNumericInput() { + XCTAssertNil(travelTimeSeconds(from: "thirty")) + } + + func testTravelTimeRejectsEmpty() { + XCTAssertNil(travelTimeSeconds(from: "")) + } + + // ── Recurrence interval fallback ───────────────────────────────────────── + + func testRecurrenceIntervalParsesValidInt() { + XCTAssertEqual(recurrenceInterval(from: "2"), 2) + } + + func testRecurrenceIntervalDefaultsToOneWhenNil() { + // nil means --recurrence-interval was not passed + XCTAssertEqual(recurrenceInterval(from: nil), 1) + } + + func testRecurrenceIntervalDefaultsToOneWhenInvalid() { + // Garbage input falls back to 1, not crash + XCTAssertEqual(recurrenceInterval(from: "fortnightly"), 1) + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +final class UpdateReminderLogicTests: XCTestCase { + + // ── Priority parsing ───────────────────────────────────────────────────── + + func testParsePriorityNone() { + XCTAssertEqual(parsePriority("0"), 0) + } + + func testParsePriorityHigh() { + XCTAssertEqual(parsePriority("1"), 1) + } + + func testParsePriorityMedium() { + XCTAssertEqual(parsePriority("5"), 5) + } + + func testParsePriorityLow() { + XCTAssertEqual(parsePriority("9"), 9) + } + + func testParsePriorityInvalidReturnsNil() { + XCTAssertNil(parsePriority("high")) + XCTAssertNil(parsePriority("urgent")) + XCTAssertNil(parsePriority("")) + } + + func testParsePriorityNilInputReturnsNil() { + XCTAssertNil(parsePriority(nil)) + } + + // ── Due date error message ──────────────────────────────────────────────── + // Pins the exact error string — if someone renames it, scripts break + // and this test catches it before release. + + func testInvalidDueDateProducesCorrectErrorMessage() { + let output = JSONOutput.error("Invalid --due date format. Use ISO8601.") + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "error") + XCTAssertEqual(dict["error"] as? String, "Invalid --due date format. Use ISO8601.") + } + + // ── Completed flag — tests the actual conditional logic ─────────────────── + + func testCompletedTrueMarksAsDone() { + var isCompleted = false + let flag: Bool? = true + if let f = flag { isCompleted = f } + XCTAssertTrue(isCompleted) + } + + func testCompletedFalseReopens() { + var isCompleted = true + let flag: Bool? = false + if let f = flag { isCompleted = f } + XCTAssertFalse(isCompleted) + } + + func testCompletedNilLeavesStateUnchanged() { + var isCompleted = true // already done + let flag: Bool? = nil // --completed not passed + if let f = flag { isCompleted = f } + XCTAssertTrue(isCompleted) // must not have been touched + } + + // ── JSON output shape for update ───────────────────────────────────────── + + func testUpdateReminderSuccessShape() { + let output = JSONOutput.success([ + "status": "success", + "message": "Reminder updated successfully", + "reminder": [ + "id": "REM-001", + "title": "Updated title", + "completed": false, + "priority": 1 + ] + ]) + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "success") + XCTAssertEqual(dict["message"] as? String, "Reminder updated successfully") + let reminder = dict["reminder"] as? [String: Any] + XCTAssertEqual(reminder?["title"] as? String, "Updated title") + XCTAssertEqual(reminder?["priority"] as? Int, 1) + } + + func testUpdateReminderNotFoundShape() { + let output = JSONOutput.error("Reminder not found with ID: bad-id") + let dict = output.toDictionary() + XCTAssertEqual(dict["status"] as? String, "error") + XCTAssertTrue((dict["error"] as? String)?.contains("bad-id") == true) + } + + // ── Partial update — only supplied fields should change ────────────────── + + func testPartialUpdateOnlyChangesSuppliedFields() { + var title = "Original title" + var priority = 0 + var notes = "Original notes" + + let newTitle: String? = "New title" + let newPriority: Int? = nil + let newNotes: String? = nil + + if let t = newTitle { title = t } + if let p = newPriority { priority = p } + if let n = newNotes { notes = n } + + XCTAssertEqual(title, "New title") + XCTAssertEqual(priority, 0) + XCTAssertEqual(notes, "Original notes") + } +} \ No newline at end of file From af8e663f8bbd1fc22d576d8b98a8d7714082cdad Mon Sep 17 00:00:00 2001 From: Jorge Welch Date: Sat, 28 Feb 2026 13:22:47 +0800 Subject: [PATCH 3/3] Fixed formatting and section naming in README --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f4a096..aacb561 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ ekctl list calendars } ``` -### Create +### Create Calendar **Command:** @@ -80,7 +80,7 @@ ekctl list calendars ekctl calendar create --title "Project X" --color "#FF5500" ``` -### Update Reminder +### Update Calendar **Command:** @@ -146,7 +146,7 @@ Aliases are stored in `~/.ekctl/config.json`. ## Events -### List +### List Events **Command:** @@ -256,6 +256,53 @@ ekctl add event \ } ``` +### Update Event + +All flags are optional — only the fields you pass will be changed: + +```bash +ekctl update event EVENT_ID --title "New title" +``` + +With multiple fields: + +```bash +ekctl update event EVENT_ID \ + --title "Updated title" \ + --start "2026-02-15T14:00:00Z" \ + --end "2026-02-15T15:30:00Z" \ + --location "Building 2, Room 301" \ + --notes "Updated notes" \ + --alarms "10,30" \ + --travel-time 20 \ + --availability busy \ + --url "https://example.com/meeting" +``` + +**Output:** + +```json +{ + "status": "success", + "message": "Event updated successfully", + "event": { + "id": "ABC123:DEF456", + "title": "Updated title", + "calendar": { + "id": "CA513B39-1659-4359-8FE9-0C2A3DCEF153", + "title": "Work" + }, + "startDate": "2026-02-15T14:00:00+08:00", + "endDate": "2026-02-15T15:30:00+08:00", + "location": "Building 2, Room 301", + "notes": "Updated notes", + "allDay": false, + "hasAlarms": true, + "hasRecurrenceRules": false + } +} +``` + ### Delete Event **Command:** @@ -319,7 +366,7 @@ ekctl list reminders --list personal --completed true } ``` -### Show +### Show Reminder **Command:** @@ -373,7 +420,7 @@ ekctl add reminder \ } ``` -### Update +### Update Reminder **Command:** @@ -415,7 +462,7 @@ ekctl update reminder REMINDER_ID --completed true } ``` -### Complete +### Complete Reminder **Command:** @@ -438,7 +485,7 @@ ekctl complete reminder REMINDER_ID } ``` -### Delete +### Delete Reminder **Command:**