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 9c8c32b..aacb561 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, complete, and delete reminders -- **Calendar aliases** - Use friendly names instead of long IDs -- JSON output for easy parsing and scripting +- List, create, update, and delete calendar events +- 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 -- Support for all calendar and reminder list types (iCloud, Exchange, local, etc.) +- Support for iCloud, Exchange, and local calendars ## Requirements @@ -19,47 +19,43 @@ 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: +### Permissions -**System Settings → Privacy & Security → Calendars / Reminders** +On first run, macOS will prompt for Calendar and Reminders access. Manage permissions in **System Settings → Privacy & Security → Calendars / Reminders**. -## Usage +## Calendars ### List Calendars -List all calendars (event calendars and reminder lists): +**Command:** ```bash ekctl list calendars ``` -Output: +**Output:** + ```json { "calendars": [ @@ -70,38 +66,55 @@ 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 Calendar + +**Command:** + +```bash +ekctl calendar create --title "Project X" --color "#FF5500" +``` + +### Update Calendar + +**Command:** + +```bash +ekctl calendar update CALENDAR_ID --title "New Name" --color "#00FF00" +``` + +### Delete Calendar -Instead of using long calendar IDs, you can create friendly aliases: +**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 -ekctl alias list +**List aliases:** -# Remove an alias -ekctl alias remove work +```bash +ekctl alias list ``` -Output for `ekctl alias list`: +**Output:** + ```json { "aliases": [ @@ -115,40 +128,34 @@ Output for `ekctl alias list`: } ``` -Once set, use aliases anywhere you would use a calendar ID: +**Remove alias:** ```bash -# These are equivalent: -ekctl list events --calendar "CA513B39-1659-4359-8FE9-0C2A3DCEF153" --from ... -ekctl list events --calendar work --from ... +ekctl alias remove work +``` + +**Usage:** -# 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" +```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" +ekctl list events --calendar work --from "2026-01-01T00:00:00Z" --to "2026-01-31T23:59:59Z" ``` Aliases are stored in `~/.ekctl/config.json`. +## Events + ### List Events -List events in a calendar within a date range: +**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 +180,69 @@ Output: } ``` -### Show Event Details +### Show Event + +**Command:** ```bash -ekctl show event "ABC123:DEF456" +ekctl show event EVENT_ID ``` ### Add Event -Create a new calendar event: +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" +``` + +With location, notes, and alarms: -# Event with location and notes +```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" +``` -# All-day event (using full ID also works) +Recurring event (weekly): + +```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 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 ``` -Output: +With travel time: + +```bash +ekctl add event \ + --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:** + ```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 +256,63 @@ Output: } ``` +### 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:** + ```bash -ekctl delete event "ABC123:DEF456" +ekctl delete event EVENT_ID ``` -Output: +**Output:** + ```json { "status": "success", @@ -245,22 +321,30 @@ Output: } ``` +## Reminders + ### List Reminders -List reminders in a reminder list: +All reminders: ```bash -# List all reminders (using alias) ekctl list reminders --list personal +``` + +Only incomplete: -# List only incomplete reminders +```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 +366,41 @@ Output: } ``` -### Show Reminder Details +### Show Reminder + +**Command:** ```bash -ekctl show reminder "REM123-456-789" +ekctl show reminder REMINDER_ID ``` ### Add Reminder -Create a new reminder: +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 +420,58 @@ Output: } ``` +### Update Reminder + +**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 Reminder -Mark a reminder as completed: +**Command:** ```bash -ekctl complete reminder "REM123-456-789" +ekctl complete reminder REMINDER_ID ``` -Output: +**Output:** + ```json { "status": "success", @@ -358,8 +487,10 @@ Output: ### Delete Reminder +**Command:** + ```bash -ekctl delete reminder "REM123-456-789" +ekctl delete reminder REMINDER_ID ``` ## Date Format @@ -367,7 +498,7 @@ ekctl delete reminder "REM123-456-789" 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 | @@ -378,7 +509,6 @@ All dates use **ISO 8601** format with timezone. 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 ``` @@ -427,7 +557,7 @@ ekctl list events \ ## Error Handling -When an error occurs, the output includes an error message: +All errors return JSON with `status: "error"`: ```json { @@ -437,19 +567,17 @@ 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) -## Help +- `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) -Get help for any command: +## Help ```bash ekctl --help ekctl list --help ekctl add event --help -ekctl list reminders --help ``` ## License @@ -458,4 +586,4 @@ MIT License ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Pull requests welcome. diff --git a/Sources/Ekctl.swift b/Sources/Ekctl.swift deleted file mode 100644 index 91db334..0000000 --- a/Sources/Ekctl.swift +++ /dev/null @@ -1,404 +0,0 @@ -import ArgumentParser -import EventKit -import Foundation - -// MARK: - Main Command - -@main -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], - defaultSubcommand: List.self - ) -} - -// MARK: - List Commands - -struct List: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "List calendars, events, or reminders.", - subcommands: [ListCalendars.self, ListEvents.self, ListReminders.self] - ) -} - -struct ListCalendars: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "calendars", - abstract: "List all calendars and reminder lists." - ) - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.listCalendars() - print(result.toJSON()) - } -} - -struct ListEvents: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "events", - abstract: "List events in a calendar within a date range." - ) - - @Option(name: .long, help: "The calendar ID or alias.") - var calendar: String - - @Option(name: .long, help: "Start date in ISO8601 format (e.g., 2026-02-01T00:00:00Z).") - var from: String - - @Option(name: .long, help: "End date in ISO8601 format (e.g., 2026-02-07T23:59:59Z).") - var to: String - - func run() throws { - let manager = EventKitManager() - 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()) - 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()) - throw ExitCode.failure - } - - let calendarID = ConfigManager.resolveAlias(calendar) - let result = manager.listEvents(calendarID: calendarID, from: startDate, to: endDate) - print(result.toJSON()) - } -} - -struct ListReminders: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "reminders", - abstract: "List reminders in a reminder list." - ) - - @Option(name: .long, help: "The reminder list ID or alias.") - var list: String - - @Option(name: .long, help: "Filter by completion status (true/false).") - var completed: Bool? - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let listID = ConfigManager.resolveAlias(list) - let result = manager.listReminders(listID: listID, completed: completed) - print(result.toJSON()) - } -} - -// MARK: - Show Commands - -struct Show: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Show details of a specific item.", - subcommands: [ShowEvent.self, ShowReminder.self] - ) -} - -struct ShowEvent: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "event", - abstract: "Show details of a specific event." - ) - - @Argument(help: "The event ID to show.") - var eventID: String - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.showEvent(eventID: eventID) - print(result.toJSON()) - } -} - -struct ShowReminder: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "reminder", - abstract: "Show details of a specific reminder." - ) - - @Argument(help: "The reminder ID to show.") - var reminderID: String - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.showReminder(reminderID: reminderID) - print(result.toJSON()) - } -} - -// MARK: - Add Commands - -struct Add: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Add a new event or reminder.", - subcommands: [AddEvent.self, AddReminder.self] - ) -} - -struct AddEvent: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "event", - abstract: "Create a new calendar event." - ) - - @Option(name: .long, help: "The calendar ID or alias.") - var calendar: String - - @Option(name: .long, help: "The event title.") - var title: String - - @Option(name: .long, help: "Start date in ISO8601 format.") - var start: String - - @Option(name: .long, help: "End date in ISO8601 format.") - var end: String - - @Option(name: .long, help: "Optional location.") - var location: String? - - @Option(name: .long, help: "Optional notes.") - var notes: String? - - @Flag(name: .long, help: "Mark as all-day event.") - var allDay: Bool = false - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - - guard let startDate = ISO8601DateFormatter().date(from: start) else { - print(JSONOutput.error("Invalid --start date format. Use ISO8601.").toJSON()) - throw ExitCode.failure - } - guard let endDate = ISO8601DateFormatter().date(from: end) else { - print(JSONOutput.error("Invalid --end date format. Use ISO8601.").toJSON()) - throw ExitCode.failure - } - - let calendarID = ConfigManager.resolveAlias(calendar) - let result = manager.addEvent( - calendarID: calendarID, - title: title, - startDate: startDate, - endDate: endDate, - location: location, - notes: notes, - allDay: allDay - ) - print(result.toJSON()) - } -} - -struct AddReminder: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "reminder", - abstract: "Create a new reminder." - ) - - @Option(name: .long, help: "The reminder list ID or alias.") - var list: String - - @Option(name: .long, help: "The reminder title.") - var title: String - - @Option(name: .long, help: "Optional due date in ISO8601 format.") - var due: String? - - @Option(name: .long, help: "Priority (0=none, 1=high, 5=medium, 9=low).") - var priority: Int? - - @Option(name: .long, help: "Optional notes.") - var notes: String? - - 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 - } - - let listID = ConfigManager.resolveAlias(list) - let result = manager.addReminder( - listID: listID, - title: title, - dueDate: dueDate, - priority: priority ?? 0, - notes: notes - ) - print(result.toJSON()) - } -} - -// MARK: - Delete Commands - -struct Delete: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Delete an event or reminder.", - subcommands: [DeleteEvent.self, DeleteReminder.self] - ) -} - -struct DeleteEvent: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "event", - abstract: "Delete a calendar event." - ) - - @Argument(help: "The event ID to delete.") - var eventID: String - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.deleteEvent(eventID: eventID) - print(result.toJSON()) - } -} - -struct DeleteReminder: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "reminder", - abstract: "Delete a reminder." - ) - - @Argument(help: "The reminder ID to delete.") - var reminderID: String - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.deleteReminder(reminderID: reminderID) - print(result.toJSON()) - } -} - -// MARK: - Complete Command - -struct Complete: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Mark items as completed.", - subcommands: [CompleteReminder.self] - ) -} - -struct CompleteReminder: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "reminder", - abstract: "Mark a reminder as completed." - ) - - @Argument(help: "The reminder ID to complete.") - var reminderID: String - - func run() throws { - let manager = EventKitManager() - try manager.requestAccess() - let result = manager.completeReminder(reminderID: reminderID) - print(result.toJSON()) - } -} - -// MARK: - Alias Commands - -struct Alias: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Manage calendar and reminder list aliases.", - subcommands: [AliasSet.self, AliasRemove.self, AliasList.self] - ) -} - -struct AliasSet: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "set", - abstract: "Create or update an alias for a calendar or reminder list." - ) - - @Argument(help: "The alias name (e.g., 'work', 'personal', 'groceries').") - var name: String - - @Argument(help: "The calendar or reminder list ID.") - var id: String - - 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()) - } catch { - print(JSONOutput.error("Failed to save alias: \(error.localizedDescription)").toJSON()) - throw ExitCode.failure - } - } -} - -struct AliasRemove: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "remove", - abstract: "Remove an alias." - ) - - @Argument(help: "The alias name to remove.") - var name: String - - func run() throws { - do { - let removed = try ConfigManager.removeAlias(name: name) - if removed { - 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()) - throw ExitCode.failure - } - } -} - -struct AliasList: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List all configured aliases." - ) - - func run() throws { - let aliases = ConfigManager.getAliases() - var aliasList: [[String: String]] = [] - - for (name, id) in aliases.sorted(by: { $0.key < $1.key }) { - aliasList.append(["name": name, "id": id]) - } - - print(JSONOutput.success([ - "aliases": aliasList, - "count": aliasList.count, - "configPath": ConfigManager.configPath() - ]).toJSON()) - } -} diff --git a/Sources/EventKitManager.swift b/Sources/EventKitManager.swift deleted file mode 100644 index 4ecc757..0000000 --- a/Sources/EventKitManager.swift +++ /dev/null @@ -1,437 +0,0 @@ -import ArgumentParser -import EventKit -import Foundation - -/// EventKitManager handles all interactions with the EventKit framework. -/// -/// IMPORTANT: macOS Permission Requirements -/// ---------------------------------------- -/// On macOS, command-line tools require special setup to access Calendar and Reminders: -/// -/// 1. The tool must be code-signed with appropriate entitlements -/// 2. An Info.plist must include privacy usage descriptions: -/// - NSCalendarsUsageDescription: Explains why calendar access is needed -/// - NSRemindersUsageDescription: Explains why reminders access is needed -/// -/// 3. For development, you can embed the Info.plist: -/// - Add to Package.swift target: linkerSettings: [.unsafeFlags(["-sectcreate", "__TEXT", "__info_plist", "Info.plist"])] -/// - Or sign the binary: codesign --entitlements entitlements.plist -s - ekctl -/// -/// 4. The first time the tool runs, macOS will prompt the user to grant access. -/// If denied, all operations will fail with a permission error. -/// -/// 5. Users can manage permissions in: System Settings > Privacy & Security > Calendars/Reminders -class EventKitManager { - 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 { - let semaphore = DispatchSemaphore(value: 0) - var calendarError: Error? - var reminderError: Error? - - // Request calendar access - if #available(macOS 14.0, *) { - eventStore.requestFullAccessToEvents { granted, error in - self.calendarAccessGranted = granted - calendarError = error - semaphore.signal() - } - } else { - eventStore.requestAccess(to: .event) { granted, error in - self.calendarAccessGranted = granted - calendarError = error - semaphore.signal() - } - } - semaphore.wait() - - // Request reminders access - if #available(macOS 14.0, *) { - eventStore.requestFullAccessToReminders { granted, error in - self.reminderAccessGranted = granted - reminderError = error - semaphore.signal() - } - } else { - eventStore.requestAccess(to: .reminder) { granted, error in - self.reminderAccessGranted = granted - reminderError = error - semaphore.signal() - } - } - semaphore.wait() - - // Check for errors - if let error = calendarError { - print(JSONOutput.error("Calendar access error: \(error.localizedDescription)").toJSON()) - throw ExitCode.failure - } - if let error = reminderError { - 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()) - throw ExitCode.failure - } - } - - // MARK: - Calendar Operations - - /// Lists all calendars (event calendars and reminder lists) - func listCalendars() -> JSONOutput { - var calendars: [[String: Any]] = [] - - // Event calendars - for calendar in eventStore.calendars(for: .event) { - calendars.append([ - "id": calendar.calendarIdentifier, - "title": calendar.title, - "type": "event", - "source": calendar.source?.title ?? "Unknown", - "color": calendar.cgColor?.hexString ?? "#000000", - "allowsModifications": calendar.allowsContentModifications - ]) - } - - // Reminder lists - for calendar in eventStore.calendars(for: .reminder) { - calendars.append([ - "id": calendar.calendarIdentifier, - "title": calendar.title, - "type": "reminder", - "source": calendar.source?.title ?? "Unknown", - "color": calendar.cgColor?.hexString ?? "#000000", - "allowsModifications": calendar.allowsContentModifications - ]) - } - - return JSONOutput.success(["calendars": calendars]) - } - - // MARK: - Event Operations - - /// Lists events in a calendar within a date range - 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)") - } - - let predicate = eventStore.predicateForEvents( - withStart: startDate, - end: endDate, - calendars: [calendar] - ) - - let events = eventStore.events(matching: predicate) - let eventDicts = events.map { eventToDict($0) } - - return JSONOutput.success(["events": eventDicts, "count": eventDicts.count]) - } - - /// Shows details of a specific event - func showEvent(eventID: String) -> JSONOutput { - guard let event = eventStore.event(withIdentifier: eventID) else { - return JSONOutput.error("Event not found with ID: \(eventID)") - } - - return JSONOutput.success(["event": eventToDict(event)]) - } - - /// Creates a new calendar event - func addEvent( - calendarID: String, - title: String, - startDate: Date, - endDate: Date, - location: String?, - notes: String?, - allDay: Bool - ) -> JSONOutput { - guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { - return JSONOutput.error("Calendar not found with ID: \(calendarID)") - } - - guard calendar.allowsContentModifications else { - return JSONOutput.error("Calendar '\(calendar.title)' does not allow modifications.") - } - - let event = EKEvent(eventStore: eventStore) - event.calendar = calendar - event.title = title - event.startDate = startDate - event.endDate = endDate - event.location = location - event.notes = notes - event.isAllDay = allDay - - do { - try eventStore.save(event, span: .thisEvent) - return JSONOutput.success([ - "status": "success", - "message": "Event created successfully", - "event": eventToDict(event) - ]) - } catch { - return JSONOutput.error("Failed to create event: \(error.localizedDescription)") - } - } - - /// Deletes a calendar event - func deleteEvent(eventID: String) -> JSONOutput { - guard let event = eventStore.event(withIdentifier: eventID) else { - return JSONOutput.error("Event not found with ID: \(eventID)") - } - - let title = event.title ?? "Untitled" - - do { - try eventStore.remove(event, span: .thisEvent) - return JSONOutput.success([ - "status": "success", - "message": "Event '\(title)' deleted successfully", - "deletedEventID": eventID - ]) - } catch { - return JSONOutput.error("Failed to delete event: \(error.localizedDescription)") - } - } - - // MARK: - Reminder Operations - - /// Lists reminders in a reminder list - 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)") - } - - let predicate = eventStore.predicateForReminders(in: [calendar]) - - var reminders: [EKReminder] = [] - let semaphore = DispatchSemaphore(value: 0) - - eventStore.fetchReminders(matching: predicate) { fetchedReminders in - if let fetchedReminders = fetchedReminders { - reminders = fetchedReminders - } - semaphore.signal() - } - semaphore.wait() - - // Filter by completion status if specified - if let completed = completed { - reminders = reminders.filter { $0.isCompleted == completed } - } - - let reminderDicts = reminders.map { reminderToDict($0) } - - return JSONOutput.success(["reminders": reminderDicts, "count": reminderDicts.count]) - } - - /// Shows details of a specific reminder - func showReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { - return JSONOutput.error("Reminder not found with ID: \(reminderID)") - } - - return JSONOutput.success(["reminder": reminderToDict(reminder)]) - } - - /// Creates a new reminder - func addReminder( - listID: String, - title: String, - dueDate: Date?, - priority: Int, - notes: String? - ) -> JSONOutput { - guard let calendar = eventStore.calendar(withIdentifier: listID) else { - return JSONOutput.error("Reminder list not found with ID: \(listID)") - } - - guard calendar.allowsContentModifications else { - return JSONOutput.error("Reminder list '\(calendar.title)' does not allow modifications.") - } - - let reminder = EKReminder(eventStore: eventStore) - reminder.calendar = calendar - reminder.title = title - reminder.priority = priority - reminder.notes = notes - - 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 created successfully", - "reminder": reminderToDict(reminder) - ]) - } catch { - return JSONOutput.error("Failed to create reminder: \(error.localizedDescription)") - } - } - - /// Marks a reminder as completed - func completeReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { - return JSONOutput.error("Reminder not found with ID: \(reminderID)") - } - - reminder.isCompleted = true - reminder.completionDate = Date() - - do { - try eventStore.save(reminder, commit: true) - return JSONOutput.success([ - "status": "success", - "message": "Reminder '\(reminder.title ?? "Untitled")' marked as completed", - "reminder": reminderToDict(reminder) - ]) - } catch { - return JSONOutput.error("Failed to complete reminder: \(error.localizedDescription)") - } - } - - /// Deletes a reminder - func deleteReminder(reminderID: String) -> JSONOutput { - guard let reminder = eventStore.calendarItem(withIdentifier: reminderID) as? EKReminder else { - return JSONOutput.error("Reminder not found with ID: \(reminderID)") - } - - let title = reminder.title ?? "Untitled" - - do { - try eventStore.remove(reminder, commit: true) - return JSONOutput.success([ - "status": "success", - "message": "Reminder '\(title)' deleted successfully", - "deletedReminderID": reminderID - ]) - } catch { - return JSONOutput.error("Failed to delete reminder: \(error.localizedDescription)") - } - } - - // MARK: - Helper Methods - - /// Creates a date formatter that outputs ISO 8601 format in the user's local timezone - private func localDateFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" // ISO 8601 with timezone offset - formatter.timeZone = TimeZone.current - return formatter - } - - /// Converts an EKEvent to a dictionary for JSON output - private func eventToDict(_ event: EKEvent) -> [String: Any] { - let formatter = localDateFormatter() - - var dict: [String: Any] = [ - "id": event.eventIdentifier ?? "", - "title": event.title ?? "", - "calendar": [ - "id": event.calendar?.calendarIdentifier ?? "", - "title": event.calendar?.title ?? "" - ], - "allDay": event.isAllDay - ] - - if let startDate = event.startDate { - dict["startDate"] = formatter.string(from: startDate) - } - if let endDate = event.endDate { - dict["endDate"] = formatter.string(from: endDate) - } - if let location = event.location, !location.isEmpty { - dict["location"] = location - } else { - dict["location"] = NSNull() - } - if let notes = event.notes, !notes.isEmpty { - dict["notes"] = notes - } else { - dict["notes"] = NSNull() - } - if let url = event.url { - dict["url"] = url.absoluteString - } - - dict["hasAlarms"] = event.hasAlarms - dict["hasRecurrenceRules"] = event.hasRecurrenceRules - - return dict - } - - /// Converts an EKReminder to a dictionary for JSON output - private func reminderToDict(_ reminder: EKReminder) -> [String: Any] { - let formatter = localDateFormatter() - - var dict: [String: Any] = [ - "id": reminder.calendarItemIdentifier, - "title": reminder.title ?? "", - "list": [ - "id": reminder.calendar?.calendarIdentifier ?? "", - "title": reminder.calendar?.title ?? "" - ], - "completed": reminder.isCompleted, - "priority": reminder.priority - ] - - if let dueDateComponents = reminder.dueDateComponents, - let dueDate = Calendar.current.date(from: dueDateComponents) { - dict["dueDate"] = formatter.string(from: dueDate) - } else { - dict["dueDate"] = NSNull() - } - - if let completionDate = reminder.completionDate { - dict["completionDate"] = formatter.string(from: completionDate) - } - - if let notes = reminder.notes, !notes.isEmpty { - dict["notes"] = notes - } else { - dict["notes"] = NSNull() - } - - if let url = reminder.url { - dict["url"] = url.absoluteString - } - - return dict - } -} - -// MARK: - CGColor Extension for Hex String - -import CoreGraphics - -extension CGColor { - var hexString: String { - guard let components = components, components.count >= 3 else { - return "#000000" - } - - let r = Int(components[0] * 255) - let g = Int(components[1] * 255) - let b = Int(components[2] * 255) - - return String(format: "#%02X%02X%02X", r, g, b) - } -} diff --git a/Sources/ekctl/Ekctl.swift b/Sources/ekctl/Ekctl.swift new file mode 100644 index 0000000..c30f593 --- /dev/null +++ b/Sources/ekctl/Ekctl.swift @@ -0,0 +1,809 @@ +import ArgumentParser +import EventKit +import Foundation +import ekctlCore + +// MARK: - Main Command + +@main +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.3.0", + subcommands: [ + List.self, Show.self, Add.self, Update.self, Delete.self, Complete.self, Alias.self, + CalendarCmd.self, + ], + defaultSubcommand: List.self + ) +} + +// MARK: - List Commands + +struct List: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List calendars, events, or reminders.", + subcommands: [ListCalendars.self, ListEvents.self, ListReminders.self] + ) +} + +struct ListCalendars: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "calendars", + abstract: "List all calendars and reminder lists." + ) + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.listCalendars() + print(result.toJSON()) + } +} + +struct ListEvents: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "events", + abstract: "List events in a calendar within a date range." + ) + + @Option(name: .long, help: "The calendar ID or alias.") + var calendar: String + + @Option(name: .long, help: "Start date in ISO8601 format (e.g., 2026-02-01T00:00:00Z).") + var from: String + + @Option(name: .long, help: "End date in ISO8601 format (e.g., 2026-02-07T23:59:59Z).") + var to: String + + func run() throws { + let manager = EventKitManager() + 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()) + 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()) + throw ExitCode.failure + } + + let calendarID = ConfigManager.resolveAlias(calendar) + let result = manager.listEvents(calendarID: calendarID, from: startDate, to: endDate) + print(result.toJSON()) + } +} + +struct ListReminders: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminders", + abstract: "List reminders in a reminder list." + ) + + @Option(name: .long, help: "The reminder list ID or alias.") + var list: String + + @Option(name: .long, help: "Filter by completion status (true/false).") + var completed: Bool? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let listID = ConfigManager.resolveAlias(list) + let result = manager.listReminders(listID: listID, completed: completed) + print(result.toJSON()) + } +} + +// MARK: - Show Commands + +struct Show: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Show details of a specific item.", + subcommands: [ShowEvent.self, ShowReminder.self] + ) +} + +struct ShowEvent: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "event", + abstract: "Show details of a specific event." + ) + + @Argument(help: "The event ID to show.") + var eventID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.showEvent(eventID: eventID) + print(result.toJSON()) + } +} + +struct ShowReminder: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminder", + abstract: "Show details of a specific reminder." + ) + + @Argument(help: "The reminder ID to show.") + var reminderID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.showReminder(reminderID: reminderID) + print(result.toJSON()) + } +} + +// MARK: - Add Commands + +struct Add: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Add a new event or reminder.", + subcommands: [AddEvent.self, AddReminder.self] + ) +} + +struct AddEvent: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "event", + abstract: "Create a new calendar event." + ) + + @Option(name: .long, help: "The calendar ID or alias.") + var calendar: String + + @Option(name: .long, help: "The event title.") + var title: String + + @Option(name: .long, help: "Start date in ISO8601 format.") + var start: String + + @Option(name: .long, help: "End date in ISO8601 format.") + var end: String + + @Option(name: .long, help: "Optional location.") + var location: String? + + @Option(name: .long, help: "Optional notes.") + var notes: String? + + @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 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.") + var url: String? + + @Option(name: .long, help: "Availability (busy, free, tentative, unavailable).") + var availability: String? + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + + guard let startDate = ISO8601DateFormatter().date(from: start) else { + print(JSONOutput.error("Invalid --start date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + guard let endDate = ISO8601DateFormatter().date(from: end) else { + print(JSONOutput.error("Invalid --end date format. Use ISO8601.").toJSON()) + 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 + } + } + + 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, + title: title, + startDate: startDate, + endDate: endDate, + location: location, + notes: notes, + 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()) + } +} + +struct AddReminder: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminder", + abstract: "Create a new reminder." + ) + + @Option(name: .long, help: "The reminder list ID or alias.") + var list: String + + @Option(name: .long, help: "The reminder title.") + var title: String + + @Option(name: .long, help: "Optional due date in ISO8601 format.") + var due: String? + + @Option(name: .long, help: "Priority (0=none, 1=high, 5=medium, 9=low).") + var priority: String? + + @Option(name: .long, help: "Optional notes.") + var notes: String? + + 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 + } + + // 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( + listID: listID, + title: title, + dueDate: dueDate, + priority: priorityInt, + notes: notes + ) + print(result.toJSON()) + } +} + +// MARK: - Update Command + +struct Update: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Update an existing event or reminder.", + subcommands: [UpdateEvent.self, UpdateReminder.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 { + 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 { + print(JSONOutput.error("Invalid --end date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + endDate = da + } + + 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) + } + + 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()) + } +} + +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( + 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( + abstract: "Delete an event or reminder.", + subcommands: [DeleteEvent.self, DeleteReminder.self] + ) +} + +struct DeleteEvent: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "event", + abstract: "Delete a calendar event." + ) + + @Argument(help: "The event ID to delete.") + var eventID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.deleteEvent(eventID: eventID) + print(result.toJSON()) + } +} + +struct DeleteReminder: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminder", + abstract: "Delete a reminder." + ) + + @Argument(help: "The reminder ID to delete.") + var reminderID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.deleteReminder(reminderID: reminderID) + print(result.toJSON()) + } +} + +// MARK: - Complete Command + +struct Complete: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Mark items as completed.", + subcommands: [CompleteReminder.self] + ) +} + +struct CompleteReminder: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reminder", + abstract: "Mark a reminder as completed." + ) + + @Argument(help: "The reminder ID to complete.") + var reminderID: String + + func run() throws { + let manager = EventKitManager() + try manager.requestAccess() + let result = manager.completeReminder(reminderID: reminderID) + print(result.toJSON()) + } +} + +// MARK: - Alias Commands + +struct Alias: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Manage calendar and reminder list aliases.", + subcommands: [AliasSet.self, AliasRemove.self, AliasList.self] + ) +} + +struct AliasSet: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Create or update an alias for a calendar or reminder list." + ) + + @Argument(help: "The alias name (e.g., 'work', 'personal', 'groceries').") + var name: String + + @Argument(help: "The calendar or reminder list ID.") + var id: String + + 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()) + } catch { + print(JSONOutput.error("Failed to save alias: \(error.localizedDescription)").toJSON()) + throw ExitCode.failure + } + } +} + +struct AliasRemove: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "remove", + abstract: "Remove an alias." + ) + + @Argument(help: "The alias name to remove.") + var name: String + + func run() throws { + do { + let removed = try ConfigManager.removeAlias(name: name) + if removed { + 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()) + throw ExitCode.failure + } + } +} + +struct AliasList: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List all configured aliases." + ) + + func run() throws { + let aliases = ConfigManager.getAliases() + var aliasList: [[String: String]] = [] + + for (name, id) in aliases.sorted(by: { $0.key < $1.key }) { + aliasList.append(["name": name, "id": id]) + } + + 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/ekctlCore/EventKitManager.swift b/Sources/ekctlCore/EventKitManager.swift new file mode 100644 index 0000000..1c4e375 --- /dev/null +++ b/Sources/ekctlCore/EventKitManager.swift @@ -0,0 +1,796 @@ +import ArgumentParser +import CoreGraphics +import CoreLocation +import EventKit +import Foundation + +/// EventKitManager handles all interactions with the EventKit framework. +/// +/// IMPORTANT: macOS Permission Requirements +/// ---------------------------------------- +/// On macOS, command-line tools require special setup to access Calendar and Reminders: +/// +/// 1. The tool must be code-signed with appropriate entitlements +/// 2. An Info.plist must include privacy usage descriptions: +/// - NSCalendarsUsageDescription: Explains why calendar access is needed +/// - NSRemindersUsageDescription: Explains why reminders access is needed +/// +/// 3. For development, you can embed the Info.plist: +/// - Add to Package.swift target: linkerSettings: [.unsafeFlags(["-sectcreate", "__TEXT", "__info_plist", "Info.plist"])] +/// - Or sign the binary: codesign --entitlements entitlements.plist -s - ekctl +/// +/// 4. The first time the tool runs, macOS will prompt the user to grant access. +/// If denied, all operations will fail with a permission error. +/// +/// 5. Users can manage permissions in: System Settings > Privacy & Security > Calendars/Reminders + +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. + public func requestAccess() throws { + let semaphore = DispatchSemaphore(value: 0) + var calendarError: Error? + var reminderError: Error? + + // Request calendar access + if #available(macOS 14.0, *) { + eventStore.requestFullAccessToEvents { granted, error in + self.calendarAccessGranted = granted + calendarError = error + semaphore.signal() + } + } else { + eventStore.requestAccess(to: .event) { granted, error in + self.calendarAccessGranted = granted + calendarError = error + semaphore.signal() + } + } + semaphore.wait() + + // Request reminders access + if #available(macOS 14.0, *) { + eventStore.requestFullAccessToReminders { granted, error in + self.reminderAccessGranted = granted + reminderError = error + semaphore.signal() + } + } else { + eventStore.requestAccess(to: .reminder) { granted, error in + self.reminderAccessGranted = granted + reminderError = error + semaphore.signal() + } + } + semaphore.wait() + + // Check for errors + if let error = calendarError { + print(JSONOutput.error("Calendar access error: \(error.localizedDescription)").toJSON()) + throw ExitCode.failure + } + if let error = reminderError { + 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()) + throw ExitCode.failure + } + } + + // MARK: - Calendar Operations + + /// Create a new calendar + 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") + } + + 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 + 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)") + } + + 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 + public 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) + public func listCalendars() -> JSONOutput { + var calendars: [[String: Any]] = [] + + // Event calendars + for calendar in eventStore.calendars(for: .event) { + calendars.append([ + "id": calendar.calendarIdentifier, + "title": calendar.title, + "type": "event", + "source": calendar.source?.title ?? "Unknown", + "color": calendar.cgColor?.hexString ?? "#000000", + "allowsModifications": calendar.allowsContentModifications, + ]) + } + + // Reminder lists + for calendar in eventStore.calendars(for: .reminder) { + calendars.append([ + "id": calendar.calendarIdentifier, + "title": calendar.title, + "type": "reminder", + "source": calendar.source?.title ?? "Unknown", + "color": calendar.cgColor?.hexString ?? "#000000", + "allowsModifications": calendar.allowsContentModifications, + ]) + } + + return JSONOutput.success(["calendars": calendars]) + } + + // MARK: - Event Operations + + /// Lists events in a calendar within a date range + 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)") + } + + let predicate = eventStore.predicateForEvents( + withStart: startDate, + end: endDate, + calendars: [calendar] + ) + + let events = eventStore.events(matching: predicate) + let eventDicts = events.map { eventToDict($0) } + + return JSONOutput.success(["events": eventDicts, "count": eventDicts.count]) + } + + /// Shows details of a specific event + public func showEvent(eventID: String) -> JSONOutput { + guard let event = eventStore.event(withIdentifier: eventID) else { + return JSONOutput.error("Event not found with ID: \(eventID)") + } + + return JSONOutput.success(["event": eventToDict(event)]) + } + + /// Updates an existing calendar event + public func updateEvent( + eventID: String, + title: String?, + startDate: Date?, + endDate: Date?, + location: String?, + notes: String?, + allDay: Bool?, + url: String?, + availability: String?, + travelTime: TimeInterval?, + 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 + // 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 { + // 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) } + } + 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 + public func addEvent( + calendarID: String, + title: String, + startDate: Date, + endDate: Date, + location: String?, + notes: String?, + 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, + 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)") + } + + guard calendar.allowsContentModifications else { + return JSONOutput.error("Calendar '\(calendar.title)' does not allow modifications.") + } + + let event = EKEvent(eventStore: eventStore) + event.calendar = calendar + event.title = title + event.startDate = startDate + event.endDate = endDate + // 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) + return JSONOutput.success([ + "status": "success", + "message": "Event created successfully", + "event": eventToDict(event), + ]) + } catch { + return JSONOutput.error("Failed to create event: \(error.localizedDescription)") + } + } + + /// Deletes a calendar event + public func deleteEvent(eventID: String) -> JSONOutput { + guard let event = eventStore.event(withIdentifier: eventID) else { + return JSONOutput.error("Event not found with ID: \(eventID)") + } + + let title = event.title ?? "Untitled" + + do { + try eventStore.remove(event, span: .thisEvent) + return JSONOutput.success([ + "status": "success", + "message": "Event '\(title)' deleted successfully", + "deletedEventID": eventID, + ]) + } catch { + return JSONOutput.error("Failed to delete event: \(error.localizedDescription)") + } + } + + // MARK: - Reminder Operations + + /// Lists reminders in a reminder list + 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)") + } + + let predicate = eventStore.predicateForReminders(in: [calendar]) + + var reminders: [EKReminder] = [] + let semaphore = DispatchSemaphore(value: 0) + + eventStore.fetchReminders(matching: predicate) { fetchedReminders in + if let fetchedReminders = fetchedReminders { + reminders = fetchedReminders + } + semaphore.signal() + } + semaphore.wait() + + // Filter by completion status if specified + if let completed = completed { + reminders = reminders.filter { $0.isCompleted == completed } + } + + let reminderDicts = reminders.map { reminderToDict($0) } + + return JSONOutput.success(["reminders": reminderDicts, "count": reminderDicts.count]) + } + + /// Shows details of a specific reminder + 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)") + } + + return JSONOutput.success(["reminder": reminderToDict(reminder)]) + } + + /// Creates a new reminder + public func addReminder( + listID: String, + title: String, + dueDate: Date?, + priority: Int, + notes: String? + ) -> JSONOutput { + guard let calendar = eventStore.calendar(withIdentifier: listID) else { + return JSONOutput.error("Reminder list not found with ID: \(listID)") + } + + guard calendar.allowsContentModifications else { + return JSONOutput.error( + "Reminder list '\(calendar.title)' does not allow modifications.") + } + + let reminder = EKReminder(eventStore: eventStore) + reminder.calendar = calendar + reminder.title = title + reminder.priority = priority + reminder.notes = notes + + 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 created successfully", + "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 + 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)") + } + + reminder.isCompleted = true + reminder.completionDate = Date() + + do { + try eventStore.save(reminder, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Reminder '\(reminder.title ?? "Untitled")' marked as completed", + "reminder": reminderToDict(reminder), + ]) + } catch { + return JSONOutput.error("Failed to complete reminder: \(error.localizedDescription)") + } + } + + /// Deletes a reminder + 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)") + } + + let title = reminder.title ?? "Untitled" + + do { + try eventStore.remove(reminder, commit: true) + return JSONOutput.success([ + "status": "success", + "message": "Reminder '\(title)' deleted successfully", + "deletedReminderID": reminderID, + ]) + } catch { + return JSONOutput.error("Failed to delete reminder: \(error.localizedDescription)") + } + } + + // 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() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" // ISO 8601 with timezone offset + formatter.timeZone = TimeZone.current + return formatter + } + + /// Converts an EKEvent to a dictionary for JSON output + private func eventToDict(_ event: EKEvent) -> [String: Any] { + let formatter = localDateFormatter() + + var dict: [String: Any] = [ + "id": event.eventIdentifier ?? "", + "title": event.title ?? "", + "calendar": [ + "id": event.calendar?.calendarIdentifier ?? "", + "title": event.calendar?.title ?? "", + ], + "allDay": event.isAllDay, + ] + + if let startDate = event.startDate { + dict["startDate"] = formatter.string(from: startDate) + } + if let endDate = event.endDate { + dict["endDate"] = formatter.string(from: endDate) + } + if let location = event.location, !location.isEmpty { + dict["location"] = location + } else { + dict["location"] = NSNull() + } + if let notes = event.notes, !notes.isEmpty { + dict["notes"] = notes + } else { + dict["notes"] = NSNull() + } + if let url = event.url { + dict["url"] = url.absoluteString + } + + dict["hasAlarms"] = event.hasAlarms + dict["hasRecurrenceRules"] = event.hasRecurrenceRules + + return dict + } + + /// Converts an EKReminder to a dictionary for JSON output + private func reminderToDict(_ reminder: EKReminder) -> [String: Any] { + let formatter = localDateFormatter() + + var dict: [String: Any] = [ + "id": reminder.calendarItemIdentifier, + "title": reminder.title ?? "", + "list": [ + "id": reminder.calendar?.calendarIdentifier ?? "", + "title": reminder.calendar?.title ?? "", + ], + "completed": reminder.isCompleted, + "priority": reminder.priority, + ] + + if let dueDateComponents = reminder.dueDateComponents, + let dueDate = Calendar.current.date(from: dueDateComponents) + { + dict["dueDate"] = formatter.string(from: dueDate) + } else { + dict["dueDate"] = NSNull() + } + + if let completionDate = reminder.completionDate { + dict["completionDate"] = formatter.string(from: completionDate) + } + + if let notes = reminder.notes, !notes.isEmpty { + dict["notes"] = notes + } else { + dict["notes"] = NSNull() + } + + if let url = reminder.url { + dict["url"] = url.absoluteString + } + + return dict + } +} + +// MARK: - CGColor Extension for Hex String + +extension CGColor { + public var hexString: String { + guard let components = components, components.count >= 3 else { + return "#000000" + } + + let r = Int(components[0] * 255) + let g = Int(components[1] * 255) + let b = Int(components[2] * 255) + + return String(format: "#%02X%02X%02X", r, g, b) + } + + public 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) + } +} 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