diff --git a/README.md b/README.md index 9c8c32b..3c38e26 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A native macOS command-line tool for managing Calendar events and Reminders usin ## Features - List, create, and delete calendar events +- **Recurring events** - Create events with daily, weekly, monthly, or yearly recurrence rules - List, create, complete, and delete reminders - **Calendar aliases** - Use friendly names instead of long IDs - JSON output for easy parsing and scripting @@ -209,6 +210,82 @@ ekctl add event \ --all-day ``` +#### Recurring Events + +Create events that repeat on a schedule using `--frequency` and related options: + +```bash +# Daily standup +ekctl add event \ + --calendar work \ + --title "Daily Standup" \ + --start "2026-02-01T09:00:00Z" \ + --end "2026-02-01T09:15:00Z" \ + --frequency daily + +# Weekly team meeting every Monday and Wednesday +ekctl add event \ + --calendar work \ + --title "Team Sync" \ + --start "2026-02-03T14:00:00Z" \ + --end "2026-02-03T15:00:00Z" \ + --frequency weekly \ + --days-of-the-week mon,wed + +# Biweekly 1:1 on Fridays +ekctl add event \ + --calendar work \ + --title "1:1 with Manager" \ + --start "2026-02-07T11:00:00Z" \ + --end "2026-02-07T11:30:00Z" \ + --frequency weekly \ + --interval 2 \ + --days-of-the-week fri + +# Monthly on the 15th, ending after 6 occurrences +ekctl add event \ + --calendar work \ + --title "Monthly Report Due" \ + --start "2026-02-15T09:00:00Z" \ + --end "2026-02-15T10:00:00Z" \ + --frequency monthly \ + --days-of-the-month 15 \ + --recurrence-count 6 + +# Last Friday of every month +ekctl add event \ + --calendar work \ + --title "Month-End Review" \ + --start "2026-01-30T16:00:00Z" \ + --end "2026-01-30T17:00:00Z" \ + --frequency monthly \ + --days-of-the-week -1fri + +# Yearly event ending on a specific date +ekctl add event \ + --calendar personal \ + --title "Annual Checkup" \ + --start "2026-03-10T10:00:00Z" \ + --end "2026-03-10T11:00:00Z" \ + --frequency yearly \ + --recurrence-end-date "2030-12-31T23:59:59Z" +``` + +**Recurrence options:** + +| Option | Description | +|--------|-------------| +| `--frequency` | **Required for recurrence.** `daily`, `weekly`, `monthly`, or `yearly` | +| `--interval` | Repeat every N periods (default: 1). E.g., `2` with `weekly` = every 2 weeks | +| `--days-of-the-week` | Comma-separated days: `mon,tue,wed,thu,fri,sat,sun`. Prefix with week number for monthly rules: `1mon` (first Monday), `-1fri` (last Friday) | +| `--days-of-the-month` | Comma-separated days (1–31). Negative values count from end: `-1` = last day | +| `--months-of-the-year` | Comma-separated months (1–12) | +| `--weeks-of-the-year` | Comma-separated weeks (1–53 or negative from end) | +| `--days-of-the-year` | Comma-separated days (1–366 or negative from end) | +| `--set-positions` | Filter expanded recurrence rules (e.g., `1,-1` for first and last) | +| `--recurrence-end-date` | Stop recurring after this date (ISO 8601) | +| `--recurrence-count` | Stop recurring after this many occurrences | + Output: ```json { diff --git a/Sources/Ekctl.swift b/Sources/Ekctl.swift index 91db334..35a9f51 100644 --- a/Sources/Ekctl.swift +++ b/Sources/Ekctl.swift @@ -9,7 +9,7 @@ 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", + version: "1.3.0", subcommands: [List.self, Show.self, Add.self, Delete.self, Complete.self, Alias.self], defaultSubcommand: List.self ) @@ -172,6 +172,36 @@ struct AddEvent: ParsableCommand { @Flag(name: .long, help: "Mark as all-day event.") var allDay: Bool = false + @Option(name: .long, help: "Recurrence frequency: daily, weekly, monthly, or yearly.") + var frequency: String? + + @Option(name: .long, help: "Recurrence interval (default 1). E.g., 2 with weekly = every 2 weeks.") + var interval: Int? + + @Option(name: .long, parsing: .unconditional, help: "Days of the week (comma-separated). Plain: mon,tue,fri. With week number: 1mon (first Monday), -1fri (last Friday).") + var daysOfTheWeek: String? + + @Option(name: .long, parsing: .unconditional, help: "Days of the month (comma-separated, 1-31 or negative from end: -1 = last day).") + var daysOfTheMonth: String? + + @Option(name: .long, help: "Months of the year (comma-separated, 1-12).") + var monthsOfTheYear: String? + + @Option(name: .long, parsing: .unconditional, help: "Weeks of the year (comma-separated, 1-53 or negative from end).") + var weeksOfTheYear: String? + + @Option(name: .long, parsing: .unconditional, help: "Days of the year (comma-separated, 1-366 or negative from end).") + var daysOfTheYear: String? + + @Option(name: .long, parsing: .unconditional, help: "Set positions to filter expanded rules (comma-separated, e.g., 1,-1).") + var setPositions: String? + + @Option(name: .long, help: "End recurrence after this date (ISO8601 format).") + var recurrenceEndDate: String? + + @Option(name: .long, help: "End recurrence after this many occurrences.") + var recurrenceCount: Int? + func run() throws { let manager = EventKitManager() try manager.requestAccess() @@ -185,6 +215,15 @@ struct AddEvent: ParsableCommand { throw ExitCode.failure } + var parsedRecurrenceEndDate: Date? + if let recurrenceEndDate = recurrenceEndDate { + guard let parsed = ISO8601DateFormatter().date(from: recurrenceEndDate) else { + print(JSONOutput.error("Invalid --recurrence-end-date format. Use ISO8601.").toJSON()) + throw ExitCode.failure + } + parsedRecurrenceEndDate = parsed + } + let calendarID = ConfigManager.resolveAlias(calendar) let result = manager.addEvent( calendarID: calendarID, @@ -193,7 +232,17 @@ struct AddEvent: ParsableCommand { endDate: endDate, location: location, notes: notes, - allDay: allDay + allDay: allDay, + frequency: frequency, + interval: interval ?? 1, + daysOfTheWeek: daysOfTheWeek, + daysOfTheMonth: daysOfTheMonth, + monthsOfTheYear: monthsOfTheYear, + weeksOfTheYear: weeksOfTheYear, + daysOfTheYear: daysOfTheYear, + setPositions: setPositions, + recurrenceEndDate: parsedRecurrenceEndDate, + recurrenceCount: recurrenceCount ) print(result.toJSON()) } diff --git a/Sources/EventKitManager.swift b/Sources/EventKitManager.swift index 4ecc757..122fb6b 100644 --- a/Sources/EventKitManager.swift +++ b/Sources/EventKitManager.swift @@ -155,7 +155,17 @@ class EventKitManager { endDate: Date, location: String?, notes: String?, - allDay: Bool + allDay: Bool, + frequency: String? = nil, + interval: Int = 1, + daysOfTheWeek: String? = nil, + daysOfTheMonth: String? = nil, + monthsOfTheYear: String? = nil, + weeksOfTheYear: String? = nil, + daysOfTheYear: String? = nil, + setPositions: String? = nil, + recurrenceEndDate: Date? = nil, + recurrenceCount: Int? = nil ) -> JSONOutput { guard let calendar = eventStore.calendar(withIdentifier: calendarID) else { return JSONOutput.error("Calendar not found with ID: \(calendarID)") @@ -174,6 +184,56 @@ class EventKitManager { event.notes = notes event.isAllDay = allDay + if let frequency = frequency { + guard let parsedFrequency = parseFrequency(frequency) else { + return JSONOutput.error("Invalid frequency '\(frequency)'. Use: daily, weekly, monthly, or yearly.") + } + + var parsedDaysOfTheWeek: [EKRecurrenceDayOfWeek]? + if let raw = daysOfTheWeek { + let parsed = raw.split(separator: ",").compactMap { parseDayOfWeek(String($0)) } + if parsed.isEmpty { + return JSONOutput.error("Invalid --days-of-the-week. Use: mon,tue,wed,thu,fri,sat,sun or with week number: 1mon, -1fri.") + } + parsedDaysOfTheWeek = parsed + } + + let (adjustedStartDate, adjustedEndDate) = adjustStartDateForRecurrence( + startDate: startDate, + endDate: endDate, + frequency: parsedFrequency, + daysOfTheWeek: parsedDaysOfTheWeek + ) + event.startDate = adjustedStartDate + event.endDate = adjustedEndDate + + let parsedDaysOfTheMonth = parseIntList(daysOfTheMonth) + let parsedMonthsOfTheYear = parseIntList(monthsOfTheYear) + let parsedWeeksOfTheYear = parseIntList(weeksOfTheYear) + let parsedDaysOfTheYear = parseIntList(daysOfTheYear) + let parsedSetPositions = parseIntList(setPositions) + + var end: EKRecurrenceEnd? + if let endDate = recurrenceEndDate { + end = EKRecurrenceEnd(end: endDate) + } else if let count = recurrenceCount { + end = EKRecurrenceEnd(occurrenceCount: count) + } + + let rule = EKRecurrenceRule( + recurrenceWith: parsedFrequency, + interval: interval, + daysOfTheWeek: parsedDaysOfTheWeek, + daysOfTheMonth: parsedDaysOfTheMonth, + monthsOfTheYear: parsedMonthsOfTheYear, + weeksOfTheYear: parsedWeeksOfTheYear, + daysOfTheYear: parsedDaysOfTheYear, + setPositions: parsedSetPositions, + end: end + ) + event.addRecurrenceRule(rule) + } + do { try eventStore.save(event, span: .thisEvent) return JSONOutput.success([ @@ -375,9 +435,223 @@ class EventKitManager { dict["hasAlarms"] = event.hasAlarms dict["hasRecurrenceRules"] = event.hasRecurrenceRules + if event.hasRecurrenceRules, let rules = event.recurrenceRules { + dict["recurrenceRules"] = rules.map { recurrenceRuleToDict($0) } + } + return dict } + private func recurrenceRuleToDict(_ rule: EKRecurrenceRule) -> [String: Any] { + var dict: [String: Any] = [ + "frequency": frequencyString(rule.frequency), + "interval": rule.interval + ] + + if let end = rule.recurrenceEnd { + if let endDate = end.endDate { + let formatter = localDateFormatter() + dict["end"] = ["type": "date", "date": formatter.string(from: endDate)] + } else if end.occurrenceCount > 0 { + dict["end"] = ["type": "count", "count": end.occurrenceCount] + } + } + + if let days = rule.daysOfTheWeek { + dict["daysOfTheWeek"] = days.map { day -> [String: Any] in + var d: [String: Any] = ["dayOfTheWeek": weekdayString(day.dayOfTheWeek)] + if day.weekNumber != 0 { + d["weekNumber"] = day.weekNumber + } + return d + } + } + if let days = rule.daysOfTheMonth { + dict["daysOfTheMonth"] = days.map { $0.intValue } + } + if let months = rule.monthsOfTheYear { + dict["monthsOfTheYear"] = months.map { $0.intValue } + } + if let weeks = rule.weeksOfTheYear { + dict["weeksOfTheYear"] = weeks.map { $0.intValue } + } + if let days = rule.daysOfTheYear { + dict["daysOfTheYear"] = days.map { $0.intValue } + } + if let positions = rule.setPositions { + dict["setPositions"] = positions.map { $0.intValue } + } + + return dict + } + + private func frequencyString(_ frequency: EKRecurrenceFrequency) -> String { + switch frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return "unknown" + } + } + + private func adjustStartDateForRecurrence( + startDate: Date, + endDate: Date, + frequency: EKRecurrenceFrequency, + daysOfTheWeek: [EKRecurrenceDayOfWeek]? + ) -> (start: Date, end: Date) { + guard frequency == .monthly, + let days = daysOfTheWeek, + !days.isEmpty, + days.allSatisfy({ $0.weekNumber != 0 }) else { + return (startDate, endDate) + } + + let cal = Calendar.current + let duration = endDate.timeIntervalSince(startDate) + + for monthOffset in 0..<13 { + guard let refDate = cal.date(byAdding: .month, value: monthOffset, to: startDate) else { continue } + let year = cal.component(.year, from: refDate) + let month = cal.component(.month, from: refDate) + + var earliest: Date? + for day in days { + guard let candidate = nthWeekdayInMonth( + weekday: day.dayOfTheWeek, + weekNumber: day.weekNumber, + year: year, + month: month + ) else { continue } + + var comps = cal.dateComponents([.year, .month, .day], from: candidate) + let timeComps = cal.dateComponents([.hour, .minute, .second], from: startDate) + comps.hour = timeComps.hour + comps.minute = timeComps.minute + comps.second = timeComps.second + guard let withTime = cal.date(from: comps) else { continue } + + if withTime >= startDate { + if earliest == nil || withTime < earliest! { + earliest = withTime + } + } + } + + if let adjusted = earliest { + return (adjusted, adjusted.addingTimeInterval(duration)) + } + } + + return (startDate, endDate) + } + + private func nthWeekdayInMonth(weekday: EKWeekday, weekNumber: Int, year: Int, month: Int) -> Date? { + let cal = Calendar.current + let calWeekday = ekWeekdayToCalendarWeekday(weekday) + + if weekNumber > 0 { + var comps = DateComponents() + comps.year = year + comps.month = month + comps.weekday = calWeekday + comps.weekdayOrdinal = weekNumber + guard let date = cal.date(from: comps) else { return nil } + guard cal.component(.month, from: date) == month else { return nil } + return date + } + + var firstOfMonth = DateComponents() + firstOfMonth.year = year + firstOfMonth.month = month + firstOfMonth.day = 1 + guard let first = cal.date(from: firstOfMonth), + let range = cal.range(of: .day, in: .month, for: first) else { return nil } + + var lastDayComps = DateComponents() + lastDayComps.year = year + lastDayComps.month = month + lastDayComps.day = range.count + guard let lastDay = cal.date(from: lastDayComps) else { return nil } + + let lastWeekday = cal.component(.weekday, from: lastDay) + var diff = lastWeekday - calWeekday + if diff < 0 { diff += 7 } + + let daysBack = diff + (-(weekNumber + 1)) * 7 + return cal.date(byAdding: .day, value: -daysBack, to: lastDay) + } + + private func ekWeekdayToCalendarWeekday(_ weekday: EKWeekday) -> Int { + switch weekday { + case .sunday: return 1 + case .monday: return 2 + case .tuesday: return 3 + case .wednesday: return 4 + case .thursday: return 5 + case .friday: return 6 + case .saturday: return 7 + @unknown default: return 1 + } + } + + private func parseFrequency(_ string: String) -> EKRecurrenceFrequency? { + switch string.lowercased() { + case "daily": return .daily + case "weekly": return .weekly + case "monthly": return .monthly + case "yearly": return .yearly + default: return nil + } + } + + private func weekdayString(_ weekday: EKWeekday) -> String { + switch weekday { + case .sunday: return "sun" + case .monday: return "mon" + case .tuesday: return "tue" + case .wednesday: return "wed" + case .thursday: return "thu" + case .friday: return "fri" + case .saturday: return "sat" + @unknown default: return "unknown" + } + } + + private func parseWeekday(_ string: String) -> EKWeekday? { + switch string.lowercased() { + case "sun": return .sunday + case "mon": return .monday + case "tue": return .tuesday + case "wed": return .wednesday + case "thu": return .thursday + case "fri": return .friday + case "sat": return .saturday + default: return nil + } + } + + private func parseDayOfWeek(_ string: String) -> EKRecurrenceDayOfWeek? { + let s = string.trimmingCharacters(in: .whitespaces).lowercased() + if s.count == 3, let weekday = parseWeekday(s) { + return EKRecurrenceDayOfWeek(weekday) + } + let letters = s.suffix(3) + let digits = s.dropLast(3) + guard let weekday = parseWeekday(String(letters)), + let weekNumber = Int(digits) else { + return nil + } + return EKRecurrenceDayOfWeek(weekday, weekNumber: weekNumber) + } + + private func parseIntList(_ string: String?) -> [NSNumber]? { + guard let string = string else { return nil } + let numbers = string.split(separator: ",").compactMap { Int(String($0).trimmingCharacters(in: .whitespaces)) } + return numbers.isEmpty ? nil : numbers.map { NSNumber(value: $0) } + } + /// Converts an EKReminder to a dictionary for JSON output private func reminderToDict(_ reminder: EKReminder) -> [String: Any] { let formatter = localDateFormatter()