#!/usr/bin/env swift import EventKit import Foundation let eventStore = EKEventStore() private func requestAccess() -> Bool { let semaphore = DispatchSemaphore(value: 0) var grantedAccess = false eventStore.requestAccess(to: .reminder) { granted, _ in grantedAccess = granted semaphore.signal() } semaphore.wait() return grantedAccess } struct CodableSummary : Codable { let currentTime: Date let source: String let reminders: [CodableReminder] } struct CodableReminder : Codable { let uid: String let title: String? let notes: String? let completed: Bool let priority: Int let completionDate: Date? let creationDate: Date? let dueDate: Date? let url: URL? } private func toCodable(_ ekReminder: EKReminder) -> CodableReminder { let dueDate: Date? if let dueDateComponents = ekReminder.dueDateComponents { if let actualDueDate = dueDateComponents.date { dueDate = actualDueDate } else { let calendar = dueDateComponents.calendar ?? Calendar.current dueDate = calendar.date(from: dueDateComponents) } } else { dueDate = nil } return CodableReminder( uid: ekReminder.calendarItemIdentifier, title: ekReminder.title, notes: ekReminder.notes, completed: ekReminder.isCompleted, priority: ekReminder.priority, completionDate: ekReminder.completionDate, creationDate: ekReminder.creationDate, dueDate: dueDate, url: ekReminder.url?.absoluteURL ) } private func intToPriority(_ value: Int) -> String { if let priority = EKReminderPriority(rawValue: UInt(value)) { switch priority { case EKReminderPriority.high: return "high" case EKReminderPriority.medium: return "medium" case EKReminderPriority.low: return "low" case EKReminderPriority.none: return "none" @unknown default: return "unknown" } } return "unknown" } private func componentsToIsoDate(_ value: DateComponents) -> String { if let year = value.year { if let month = value.month { if let day = value.day { return String(format: "%04d-%02d-%02-d", year, month, day) } } } return "0000-00-00" } private func nicerDebugEkReminder(_ ekReminder: EKReminder) -> String { if let title = ekReminder.title { if title.count > 0 { return String(format: "\"%@\" [%@]", title.replacingOccurrences(of: "\n", with: " "), ekReminder.calendarItemIdentifier) } } if ekReminder.description.count > 0 { return String(format: "\"%@\" [%@]", ekReminder.description.replacingOccurrences(of: "\n", with: " "), ekReminder.calendarItemIdentifier) } return String(format: "\"\" [%@]", ekReminder.calendarItemIdentifier) } private func dateCloseEnough(_ one: Date?, _ two: Date?) -> Bool { if let one_nn = one { if let two_nn = two { if abs(one_nn.distance(to: two_nn)) >= 1.0 { return false; } } else { return false; } } else if two != nil { return false; } return true; } private func updateEkReminder(ekReminder: EKReminder, reference: CodableReminder, timeZone: TimeZone) { if (ekReminder.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) != (reference.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) { print("Updating title on \(nicerDebugEkReminder(ekReminder))") print(" old: [\(ekReminder.title ?? "")]") print(" new: [\(reference.title ?? "")]") ekReminder.title = reference.title } if (ekReminder.notes ?? "").trimmingCharacters(in: .whitespacesAndNewlines) != (reference.notes ?? "").trimmingCharacters(in: .whitespacesAndNewlines) { print("Updating notes on \(nicerDebugEkReminder(ekReminder))") print(" old: [\(ekReminder.notes ?? "")]") print(" new: [\(reference.notes ?? "")]") ekReminder.notes = reference.notes } if ekReminder.isCompleted != reference.completed { print("Updating isCompleted on \(nicerDebugEkReminder(ekReminder))") print(" old: \(ekReminder.isCompleted)") print(" new: \(reference.completed)") ekReminder.isCompleted = reference.completed } if ekReminder.priority != reference.priority { print("Updating priority on \(nicerDebugEkReminder(ekReminder))") print(" old: \(intToPriority(ekReminder.priority)) [\(ekReminder.priority)]") print(" new: \(intToPriority(reference.priority)) [\(reference.priority)]") ekReminder.priority = reference.priority } if !dateCloseEnough(ekReminder.completionDate, reference.completionDate) { print("Updating completionDate on \(nicerDebugEkReminder(ekReminder))") if let date = ekReminder.completionDate { print(" old: \(date) [\(date.timeIntervalSince1970)]") } else { print(" old: ") } if let date = reference.completionDate { print(" new: \(date) [\(date.timeIntervalSince1970)]") } else { print(" new: ") } ekReminder.completionDate = reference.completionDate } if !dateCloseEnough(ekReminder.creationDate, reference.creationDate) { print("Different creationDate on \(nicerDebugEkReminder(ekReminder))") if let date = ekReminder.completionDate { print(" old: \(date) [\(date.timeIntervalSince1970)]") } else { print(" old: ") } if let date = reference.completionDate { print(" new: \(date) [\(date.timeIntervalSince1970)]") } else { print(" new: ") } print(" cannot change creationDate!") } if let dueDate = reference.dueDate { let calendar = ekReminder.dueDateComponents?.calendar ?? Calendar(identifier: .gregorian) var dueDateComponents = calendar.dateComponents(in: timeZone, from: dueDate) if ((dueDateComponents.hour == 0 || dueDateComponents.hour == nil) && (dueDateComponents.minute == 0 || dueDateComponents.minute == nil) && (dueDateComponents.second == 0 || dueDateComponents.second == nil) && (dueDateComponents.nanosecond == 0 || dueDateComponents.nanosecond == nil)) { dueDateComponents.hour = nil dueDateComponents.minute = nil dueDateComponents.second = nil dueDateComponents.nanosecond = nil dueDateComponents.timeZone = nil dueDateComponents.weekday = nil dueDateComponents.weekdayOrdinal = nil dueDateComponents.quarter = nil dueDateComponents.weekOfMonth = nil dueDateComponents.weekOfYear = nil dueDateComponents.yearForWeekOfYear = nil dueDateComponents.isLeapMonth = nil } if let oldDueDateComponents = ekReminder.dueDateComponents { if oldDueDateComponents != dueDateComponents { print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))") print(" old: \(componentsToIsoDate(oldDueDateComponents)) [\(oldDueDateComponents)]") print(" new: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]") ekReminder.dueDateComponents = dueDateComponents } } else { print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))") print(" old: ") print(" new: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]") ekReminder.dueDateComponents = dueDateComponents } } else if let oldDueDateComponents = ekReminder.dueDateComponents { print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))") print(" old: \(componentsToIsoDate(oldDueDateComponents)) [\(oldDueDateComponents)]") print(" new: ") } } private func newReminder(store: EKEventStore, reference: CodableReminder, timeZone: TimeZone) -> EKReminder { let reminder = EKReminder(eventStore: store) reminder.title = reference.title reminder.notes = reference.notes reminder.isCompleted = reference.completed reminder.priority = reference.priority reminder.completionDate = reference.completionDate reminder.url = reference.url reminder.calendar = eventStore.defaultCalendarForNewReminders() print("New reminder \(reminder.calendarItemIdentifier)") print(" title: [\(reminder.title ?? "")]") print(" notes: [\(reminder.notes ?? "")]") print(" isCompleted: \(reminder.isCompleted)") print(" priority: \(reminder.priority)") if let date = reminder.completionDate { print(" completionDate: \(date) [\(date.timeIntervalSince1970)]") } else { print(" completionDate: ") } if let date = reminder.creationDate { print(" creationDate: \(date) [\(date.timeIntervalSince1970)]") } else { print(" creationDate: ") } if let dueDate = reference.dueDate { var dueDateComponents = Calendar(identifier: .gregorian).dateComponents(in: timeZone, from: dueDate) if ((dueDateComponents.hour == 0 || dueDateComponents.hour == nil) && (dueDateComponents.minute == 0 || dueDateComponents.minute == nil) && (dueDateComponents.second == 0 || dueDateComponents.second == nil) && (dueDateComponents.nanosecond == 0 || dueDateComponents.nanosecond == nil)) { dueDateComponents.hour = nil dueDateComponents.minute = nil dueDateComponents.second = nil dueDateComponents.nanosecond = nil dueDateComponents.timeZone = nil dueDateComponents.weekday = nil dueDateComponents.weekdayOrdinal = nil dueDateComponents.quarter = nil dueDateComponents.weekOfMonth = nil dueDateComponents.weekOfYear = nil dueDateComponents.yearForWeekOfYear = nil dueDateComponents.isLeapMonth = nil } reminder.dueDateComponents = dueDateComponents print(" dueDateComponents: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]") } else { print(" dueDateComponents: ") } print(" url: \(reminder.url?.absoluteString ?? "")") return reminder } private func dump(timeZone: TimeZone) { let calendars = eventStore.calendars(for: EKEntityType.reminder) let predicate = eventStore.predicateForReminders(in: calendars) let semaphore = DispatchSemaphore(value: 0) var remindersList: [CodableReminder] = [] eventStore.fetchReminders(matching: predicate) { (ekReminders) in for reminder in ekReminders ?? [EKReminder]() { remindersList.append(toCodable(reminder)) } semaphore.signal() } semaphore.wait() let currentHost = Host.current().name ?? "unknown-host" let currentUser = NSUserName() let source = "\(currentUser)@\(currentHost)" let encoder = JSONEncoder() let customTzDateFormatter = DateFormatter() customTzDateFormatter.timeZone = timeZone customTzDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" encoder.dateEncodingStrategy = .formatted(customTzDateFormatter) encoder.outputFormatting = .prettyPrinted if let json = try? encoder.encode(CodableSummary(currentTime: Date(), source: source, reminders: remindersList)) { print(String(data: json, encoding: .utf8)!) exit(0) } else { print("Could not encode: " + remindersList.debugDescription) exit(2) } } private func load(timeZone: TimeZone, commit: Bool) { let standardInput = FileHandle.standardInput let decoder = JSONDecoder() let customTzDateFormatter = DateFormatter() customTzDateFormatter.timeZone = timeZone customTzDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" decoder.dateDecodingStrategy = .formatted(customTzDateFormatter) let decoded: CodableSummary var input: Data = Data() var input_: Data repeat { input_ = standardInput.availableData input.append(input_) } while (input_.count > 0) do { decoded = try decoder.decode(CodableSummary.self, from: input) } catch { print("Could not decode: \(error)") exit(4) } print("Updating with data from \(decoded.source) at \(decoded.currentTime)") var success = true for reminder in decoded.reminders { if let ekReminder = eventStore.calendarItem(withIdentifier: reminder.uid) { updateEkReminder(ekReminder: ekReminder as! EKReminder, reference: reminder, timeZone: timeZone) try! eventStore.save(ekReminder as! EKReminder, commit: false) } else { if reminder.uid == "undefined" { let newReminder = newReminder(store: eventStore, reference: reminder, timeZone: timeZone) try! eventStore.save(newReminder, commit: false) } else { print("Could not find matching reminder with identifier \(reminder.uid)") success = false } } } if success && commit { print("Comitting...") try! eventStore.commit() } } if requestAccess() { let timeZone: TimeZone if let tzIndex = CommandLine.arguments.firstIndex(of: "--timezone") { if let userTimeZone = TimeZone.init(identifier: CommandLine.arguments[tzIndex + 1]) { timeZone = userTimeZone } else { print("Invalid time zone \(CommandLine.arguments[tzIndex + 1])") exit(5) } } else { timeZone = TimeZone.current } if (CommandLine.arguments.contains("--load")) { load(timeZone: timeZone, commit: CommandLine.arguments.contains("--commit")) } else if (CommandLine.arguments.contains("--dump")) { dump(timeZone: timeZone) } else if (isatty(fileno(stdin)) == 0) { load(timeZone: timeZone, commit: CommandLine.arguments.contains("--commit")) } else { dump(timeZone: timeZone) } } else { print("You need to grant reminders access") exit(1) }