use block2::RcBlock;
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
use objc2::Message;
use objc2::rc::Retained;
use objc2::runtime::Bool;
use objc2_event_kit::{
EKAlarm, EKAlarmProximity, EKAlarmType, EKAuthorizationStatus, EKCalendar,
EKCalendarEventAvailabilityMask, EKCalendarItem, EKEntityType, EKEvent, EKEventAvailability,
EKEventStatus, EKEventStore, EKRecurrenceDayOfWeek, EKRecurrenceEnd, EKRecurrenceFrequency,
EKRecurrenceRule, EKReminder, EKSource, EKSpan, EKStructuredLocation, EKWeekday,
};
use objc2_foundation::{
NSArray, NSCalendar, NSDate, NSDateComponents, NSError, NSNumber, NSString,
};
use std::sync::{Arc, Condvar, Mutex};
use thiserror::Error;
#[cfg(feature = "location")]
#[path = "location.rs"]
pub mod location;
#[cfg(feature = "mcp")]
#[path = "mcp.rs"]
pub mod mcp;
#[derive(Error, Debug)]
pub enum EventKitError {
#[error("Authorization denied")]
AuthorizationDenied,
#[error("Authorization restricted by system policy")]
AuthorizationRestricted,
#[error("Authorization not determined")]
AuthorizationNotDetermined,
#[error("Failed to request authorization: {0}")]
AuthorizationRequestFailed(String),
#[error("No default calendar")]
NoDefaultCalendar,
#[error("Calendar not found: {0}")]
CalendarNotFound(String),
#[error("Item not found: {0}")]
ItemNotFound(String),
#[error("Failed to save: {0}")]
SaveFailed(String),
#[error("Failed to delete: {0}")]
DeleteFailed(String),
#[error("Failed to fetch: {0}")]
FetchFailed(String),
#[error("EventKit error: {0}")]
EventKitError(String),
#[error("Invalid date range")]
InvalidDateRange,
#[error("Invalid URL: {0}")]
InvalidURL(String),
}
pub type RemindersError = EventKitError;
pub type Result<T> = std::result::Result<T, EventKitError>;
#[derive(Debug, Clone)]
#[allow(non_snake_case)]
pub struct ReminderItem {
pub identifier: String,
pub title: String,
pub notes: Option<String>,
pub completed: bool,
pub priority: usize,
pub calendar_title: Option<String>,
pub calendar_id: Option<String>,
pub due_date: Option<DateTime<Local>>,
pub start_date: Option<DateTime<Local>>,
pub completion_date: Option<DateTime<Local>>,
pub external_identifier: Option<String>,
pub location: Option<String>,
#[allow(non_snake_case)]
pub URL: Option<String>,
pub creation_date: Option<DateTime<Local>>,
pub last_modified_date: Option<DateTime<Local>>,
pub timezone: Option<String>,
pub due_date_timezone: Option<String>,
pub structured_location: Option<StructuredLocation>,
pub parent_id: Option<String>,
pub attachments_count: usize,
pub has_alarms: bool,
pub has_recurrence_rules: bool,
pub has_attendees: bool,
pub has_notes: bool,
pub attendees: Vec<ParticipantInfo>,
}
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
pub struct ReminderDraft<'a> {
pub title: &'a str,
pub notes: Option<&'a str>,
pub calendar_title: Option<&'a str>,
pub priority: Option<usize>,
pub due_date: Option<DateTime<Local>>,
pub start_date: Option<DateTime<Local>>,
#[allow(non_snake_case)]
pub URL: Option<&'a str>,
pub location: Option<&'a str>,
pub structured_location: Option<&'a StructuredLocation>,
pub due_date_timezone: Option<&'a str>,
}
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
pub struct ReminderPatch<'a> {
pub title: Option<&'a str>,
pub notes: Option<&'a str>,
pub completed: Option<bool>,
pub priority: Option<usize>,
pub due_date: Option<Option<DateTime<Local>>>,
pub start_date: Option<Option<DateTime<Local>>>,
pub calendar_title: Option<&'a str>,
#[allow(non_snake_case)]
pub URL: Option<Option<&'a str>>,
pub location: Option<Option<&'a str>>,
pub structured_location: Option<Option<&'a StructuredLocation>>,
pub due_date_timezone: Option<Option<&'a str>>,
pub completion_date: Option<Option<DateTime<Local>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalendarType {
Local,
CalDAV,
Exchange,
Subscription,
Birthday,
Unknown,
}
#[derive(Debug, Clone)]
pub struct SourceInfo {
pub identifier: String,
pub title: String,
pub source_type: String,
}
#[derive(Debug, Clone)]
pub struct CalendarInfo {
pub identifier: String,
pub title: String,
pub source: Option<String>,
pub source_id: Option<String>,
pub calendar_type: CalendarType,
pub allows_modifications: bool,
pub is_immutable: bool,
pub is_subscribed: bool,
pub color: Option<(f64, f64, f64, f64)>,
pub allowed_entity_types: Vec<String>,
pub supported_event_availabilities: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlarmProximity {
#[default]
None,
Enter,
Leave,
}
#[derive(Debug, Clone)]
pub struct StructuredLocation {
pub title: String,
pub latitude: f64,
pub longitude: f64,
pub radius: f64,
}
#[derive(Debug, Clone, Default)]
pub struct AlarmInfo {
pub relative_offset: Option<f64>,
pub absolute_date: Option<DateTime<Local>>,
pub proximity: AlarmProximity,
pub location: Option<StructuredLocation>,
pub email_address: Option<String>,
pub sound_name: Option<String>,
pub url: Option<String>,
pub alarm_type: AlarmType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlarmType {
#[default]
Display,
Audio,
Procedure,
Email,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecurrenceFrequency {
#[default]
Daily,
Weekly,
Monthly,
Yearly,
}
#[derive(Debug, Clone, Default)]
pub enum RecurrenceEndCondition {
#[default]
Never,
AfterCount(usize),
OnDate(DateTime<Local>),
}
#[derive(Debug, Clone, Default)]
pub struct RecurrenceRule {
pub frequency: RecurrenceFrequency,
pub interval: usize,
pub end: RecurrenceEndCondition,
pub days_of_week: Option<Vec<u8>>,
pub days_of_month: Option<Vec<i32>>,
pub months_of_year: Option<Vec<i32>>,
pub weeks_of_year: Option<Vec<i32>>,
pub days_of_year: Option<Vec<i32>>,
pub set_positions: Option<Vec<i32>>,
}
pub struct RemindersManager {
store: Retained<EKEventStore>,
}
impl RemindersManager {
pub fn new() -> Self {
let store = unsafe { EKEventStore::new() };
Self { store }
}
pub fn authorization_status() -> AuthorizationStatus {
let status =
unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Reminder) };
status.into()
}
pub fn request_access(&self) -> Result<bool> {
let result = Arc::new((Mutex::new(None::<(bool, Option<String>)>), Condvar::new()));
let result_clone = Arc::clone(&result);
let completion = RcBlock::new(move |granted: Bool, error: *mut NSError| {
let error_msg = if !error.is_null() {
let error_ref = unsafe { &*error };
Some(format!("{:?}", error_ref))
} else {
None
};
let (lock, cvar) = &*result_clone;
let mut res = lock.lock().unwrap();
*res = Some((granted.as_bool(), error_msg));
cvar.notify_one();
});
unsafe {
let block_ptr = &*completion as *const _ as *mut _;
self.store
.requestFullAccessToRemindersWithCompletion(block_ptr);
}
let (lock, cvar) = &*result;
let mut res = lock.lock().unwrap();
while res.is_none() {
res = cvar.wait(res).unwrap();
}
match res.take() {
Some((granted, None)) => Ok(granted),
Some((_, Some(error))) => Err(RemindersError::AuthorizationRequestFailed(error)),
None => Err(RemindersError::AuthorizationRequestFailed(
"Unknown error".to_string(),
)),
}
}
pub fn ensure_authorized(&self) -> Result<()> {
match Self::authorization_status() {
AuthorizationStatus::FullAccess => Ok(()),
AuthorizationStatus::NotDetermined => {
if self.request_access()? {
Ok(())
} else {
Err(RemindersError::AuthorizationDenied)
}
}
AuthorizationStatus::Denied => Err(RemindersError::AuthorizationDenied),
AuthorizationStatus::Restricted => Err(RemindersError::AuthorizationRestricted),
AuthorizationStatus::WriteOnly => Ok(()), }
}
pub fn list_calendars(&self) -> Result<Vec<CalendarInfo>> {
self.ensure_authorized()?;
let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
let mut result = Vec::new();
for calendar in calendars.iter() {
result.push(calendar_to_info(&calendar));
}
Ok(result)
}
pub fn list_sources(&self) -> Result<Vec<SourceInfo>> {
self.ensure_authorized()?;
let sources = unsafe { self.store.sources() };
let mut result = Vec::new();
for source in sources.iter() {
result.push(source_to_info(&source));
}
Ok(result)
}
pub fn default_calendar(&self) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = unsafe { self.store.defaultCalendarForNewReminders() };
match calendar {
Some(cal) => Ok(calendar_to_info(&cal)),
None => Err(RemindersError::NoDefaultCalendar),
}
}
pub fn fetch_all_reminders(&self) -> Result<Vec<ReminderItem>> {
self.fetch_reminders(None)
}
pub fn fetch_reminders(&self, calendar_titles: Option<&[&str]>) -> Result<Vec<ReminderItem>> {
self.ensure_authorized()?;
let calendars: Option<Retained<NSArray<EKCalendar>>> = match calendar_titles {
Some(titles) => {
let all_calendars =
unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
for cal in all_calendars.iter() {
let title = unsafe { cal.title() };
let title_str = title.to_string();
if titles.iter().any(|t| *t == title_str) {
matching.push(cal.retain());
}
}
if matching.is_empty() {
return Err(RemindersError::CalendarNotFound(titles.join(", ")));
}
Some(NSArray::from_retained_slice(&matching))
}
None => None,
};
let predicate = unsafe {
self.store
.predicateForRemindersInCalendars(calendars.as_deref())
};
let result = Arc::new((Mutex::new(None::<Vec<ReminderItem>>), Condvar::new()));
let result_clone = Arc::clone(&result);
let completion = RcBlock::new(move |reminders: *mut NSArray<EKReminder>| {
let items = if reminders.is_null() {
Vec::new()
} else {
let reminders = unsafe { Retained::retain(reminders).unwrap() };
reminders.iter().map(|r| reminder_to_item(&r)).collect()
};
let (lock, cvar) = &*result_clone;
let mut guard = lock.lock().unwrap();
*guard = Some(items);
cvar.notify_one();
});
unsafe {
self.store
.fetchRemindersMatchingPredicate_completion(&predicate, &completion);
}
let (lock, cvar) = &*result;
let mut guard = lock.lock().unwrap();
while guard.is_none() {
guard = cvar.wait(guard).unwrap();
}
guard
.take()
.ok_or_else(|| RemindersError::FetchFailed("Unknown error".to_string()))
}
pub fn fetch_incomplete_reminders(&self) -> Result<Vec<ReminderItem>> {
self.fetch_incomplete_reminders_in_due_range(None, None, None)
}
pub fn fetch_incomplete_reminders_in_due_range(
&self,
starting: Option<DateTime<Local>>,
ending: Option<DateTime<Local>>,
calendar_titles: Option<&[&str]>,
) -> Result<Vec<ReminderItem>> {
self.ensure_authorized()?;
let calendars = self.resolve_reminder_calendars(calendar_titles)?;
let start_ns = starting.map(datetime_to_nsdate);
let end_ns = ending.map(datetime_to_nsdate);
let predicate = unsafe {
self.store
.predicateForIncompleteRemindersWithDueDateStarting_ending_calendars(
start_ns.as_deref(),
end_ns.as_deref(),
calendars.as_deref(),
)
};
self.fetch_by_predicate(&predicate)
}
pub fn fetch_completed_reminders_in_range(
&self,
starting: Option<DateTime<Local>>,
ending: Option<DateTime<Local>>,
calendar_titles: Option<&[&str]>,
) -> Result<Vec<ReminderItem>> {
self.ensure_authorized()?;
let calendars = self.resolve_reminder_calendars(calendar_titles)?;
let start_ns = starting.map(datetime_to_nsdate);
let end_ns = ending.map(datetime_to_nsdate);
let predicate = unsafe {
self.store
.predicateForCompletedRemindersWithCompletionDateStarting_ending_calendars(
start_ns.as_deref(),
end_ns.as_deref(),
calendars.as_deref(),
)
};
self.fetch_by_predicate(&predicate)
}
fn resolve_reminder_calendars(
&self,
calendar_titles: Option<&[&str]>,
) -> Result<Option<Retained<NSArray<EKCalendar>>>> {
let Some(titles) = calendar_titles else {
return Ok(None);
};
let all_calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
for cal in all_calendars.iter() {
let title_str = unsafe { cal.title() }.to_string();
if titles.iter().any(|t| *t == title_str) {
matching.push(cal.retain());
}
}
if matching.is_empty() {
return Err(RemindersError::CalendarNotFound(titles.join(", ")));
}
Ok(Some(NSArray::from_retained_slice(&matching)))
}
fn fetch_by_predicate(
&self,
predicate: &objc2_foundation::NSPredicate,
) -> Result<Vec<ReminderItem>> {
let result = Arc::new((Mutex::new(None::<Vec<ReminderItem>>), Condvar::new()));
let result_clone = Arc::clone(&result);
let completion = RcBlock::new(move |reminders: *mut NSArray<EKReminder>| {
let items = if reminders.is_null() {
Vec::new()
} else {
let reminders = unsafe { Retained::retain(reminders).unwrap() };
reminders.iter().map(|r| reminder_to_item(&r)).collect()
};
let (lock, cvar) = &*result_clone;
let mut guard = lock.lock().unwrap();
*guard = Some(items);
cvar.notify_one();
});
unsafe {
self.store
.fetchRemindersMatchingPredicate_completion(predicate, &completion);
}
let (lock, cvar) = &*result;
let mut guard = lock.lock().unwrap();
while guard.is_none() {
guard = cvar.wait(guard).unwrap();
}
guard
.take()
.ok_or_else(|| RemindersError::FetchFailed("Unknown error".to_string()))
}
pub fn create_reminder(&self, draft: &ReminderDraft<'_>) -> Result<ReminderItem> {
self.ensure_authorized()?;
let reminder = unsafe { EKReminder::reminderWithEventStore(&self.store) };
let ns_title = NSString::from_str(draft.title);
unsafe { reminder.setTitle(Some(&ns_title)) };
if let Some(notes_text) = draft.notes {
let ns_notes = NSString::from_str(notes_text);
unsafe { reminder.setNotes(Some(&ns_notes)) };
}
if let Some(p) = draft.priority {
unsafe { reminder.setPriority(p) };
}
if let Some(due) = draft.due_date {
let components = datetime_to_date_components(due);
unsafe { reminder.setDueDateComponents(Some(&components)) };
}
if let Some(start) = draft.start_date {
let components = datetime_to_date_components(start);
unsafe { reminder.setStartDateComponents(Some(&components)) };
}
if draft.URL.is_some() {
set_item_URL(&reminder, draft.URL)?;
}
if draft.location.is_some() {
set_item_location(&reminder, draft.location);
}
if let Some(sl) = draft.structured_location {
set_reminder_structured_location(&reminder, Some(sl));
}
if draft.due_date_timezone.is_some() {
set_reminder_due_date_timezone(&reminder, draft.due_date_timezone);
}
let calendar = if let Some(cal_title) = draft.calendar_title {
self.find_calendar_by_title(cal_title)?
} else {
unsafe { self.store.defaultCalendarForNewReminders() }
.ok_or(RemindersError::NoDefaultCalendar)?
};
unsafe { reminder.setCalendar(Some(&calendar)) };
self.save_reminder_and_refresh(&reminder)?;
Ok(reminder_to_item(&reminder))
}
pub fn update_reminder(
&self,
identifier: &str,
patch: &ReminderPatch<'_>,
) -> Result<ReminderItem> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
if let Some(t) = patch.title {
let ns_title = NSString::from_str(t);
unsafe { reminder.setTitle(Some(&ns_title)) };
}
if let Some(n) = patch.notes {
let ns_notes = NSString::from_str(n);
unsafe { reminder.setNotes(Some(&ns_notes)) };
}
if let Some(c) = patch.completed {
unsafe { reminder.setCompleted(c) };
}
if let Some(p) = patch.priority {
unsafe { reminder.setPriority(p) };
}
if let Some(due_opt) = patch.due_date {
match due_opt {
Some(due) => {
let components = datetime_to_date_components(due);
unsafe { reminder.setDueDateComponents(Some(&components)) };
}
None => unsafe { reminder.setDueDateComponents(None) },
}
}
if let Some(start_opt) = patch.start_date {
match start_opt {
Some(start) => {
let components = datetime_to_date_components(start);
unsafe { reminder.setStartDateComponents(Some(&components)) };
}
None => unsafe { reminder.setStartDateComponents(None) },
}
}
if let Some(url_opt) = patch.URL {
set_item_URL(&reminder, url_opt)?;
}
if let Some(loc_opt) = patch.location {
set_item_location(&reminder, loc_opt);
}
if let Some(sl_opt) = patch.structured_location {
set_reminder_structured_location(&reminder, sl_opt);
}
if let Some(tz_opt) = patch.due_date_timezone {
set_reminder_due_date_timezone(&reminder, tz_opt);
}
if let Some(cd_opt) = patch.completion_date {
match cd_opt {
Some(d) => {
let nsdate = datetime_to_nsdate(d);
unsafe { reminder.setCompletionDate(Some(&nsdate)) };
}
None => unsafe { reminder.setCompletionDate(None) },
}
}
if let Some(cal_title) = patch.calendar_title {
let calendar = self.find_calendar_by_title(cal_title)?;
unsafe { reminder.setCalendar(Some(&calendar)) };
}
self.save_reminder_and_refresh(&reminder)?;
Ok(reminder_to_item(&reminder))
}
pub fn complete_reminder(&self, identifier: &str) -> Result<ReminderItem> {
self.update_reminder(
identifier,
&ReminderPatch {
completed: Some(true),
..Default::default()
},
)
}
pub fn uncomplete_reminder(&self, identifier: &str) -> Result<ReminderItem> {
self.update_reminder(
identifier,
&ReminderPatch {
completed: Some(false),
..Default::default()
},
)
}
pub fn delete_reminder(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
unsafe {
self.store
.removeReminder_commit_error(&reminder, true)
.map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
}
Ok(())
}
pub fn get_reminder(&self, identifier: &str) -> Result<ReminderItem> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
Ok(reminder_to_item(&reminder))
}
pub fn dump_reminder_raw(&self, identifier: &str, read_values: bool) -> Result<String> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
let mut out = String::new();
out.push_str(&reflect_object_full(
"EKReminder (instance)",
&*reminder,
read_values,
));
if let Some(calendar) = unsafe { reminder.calendar() } {
out.push('\n');
out.push_str(&reflect_object_full(
"EKCalendar (reminder.calendar)",
&*calendar,
read_values,
));
if let Some(source) = unsafe { calendar.source() } {
out.push('\n');
out.push_str(&reflect_object_full(
"EKSource (calendar.source)",
&*source,
read_values,
));
}
}
Ok(out)
}
pub fn dump_reminder_private(&self, identifier: &str) -> Result<String> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
Ok(probe_private_selectors(&reminder))
}
pub fn get_alarms(&self, identifier: &str) -> Result<Vec<AlarmInfo>> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
Ok(get_item_alarms(&reminder))
}
pub fn add_alarm(&self, identifier: &str, alarm: &AlarmInfo) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
add_item_alarm(&reminder, alarm)?;
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn remove_all_alarms(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
clear_item_alarms(&reminder);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn remove_alarm(&self, identifier: &str, index: usize) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
remove_item_alarm(&reminder, index)?;
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
#[allow(non_snake_case)]
pub fn set_URL(&self, identifier: &str, url: Option<&str>) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
set_item_URL(&reminder, url)?;
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn set_location(&self, identifier: &str, location: Option<&str>) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
set_item_location(&reminder, location);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn set_due_date_timezone(&self, identifier: &str, tz_name: Option<&str>) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
set_reminder_due_date_timezone(&reminder, tz_name);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn set_structured_location(
&self,
identifier: &str,
loc: Option<&StructuredLocation>,
) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
set_reminder_structured_location(&reminder, loc);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn set_geofence(
&self,
identifier: &str,
geofence: Option<(&StructuredLocation, AlarmProximity)>,
) -> Result<()> {
self.ensure_authorized()?;
#[cfg(feature = "location")]
if geofence.is_some() {
use crate::location::{LocationAuthorizationStatus as L, LocationManager};
let loc_mgr = LocationManager::new();
if loc_mgr.authorization_status() == L::NotDetermined {
loc_mgr.request_when_in_use_authorization();
std::thread::sleep(std::time::Duration::from_millis(500));
}
match loc_mgr.authorization_status() {
L::Authorized => {}
L::Denied => return Err(EventKitError::AuthorizationDenied),
L::Restricted => return Err(EventKitError::AuthorizationRestricted),
L::NotDetermined => return Err(EventKitError::AuthorizationNotDetermined),
}
}
let reminder = self.find_reminder_by_id(identifier)?;
set_reminder_geofence(&reminder, geofence);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn get_recurrence_rules(&self, identifier: &str) -> Result<Vec<RecurrenceRule>> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
Ok(get_item_recurrence_rules(&reminder))
}
pub fn set_recurrence_rule(&self, identifier: &str, rule: &RecurrenceRule) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
set_item_recurrence_rule(&reminder, rule);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn remove_recurrence_rules(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let reminder = self.find_reminder_by_id(identifier)?;
clear_item_recurrence_rules(&reminder);
self.save_reminder_and_refresh(&reminder)?;
Ok(())
}
pub fn create_calendar(&self, title: &str) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = unsafe {
EKCalendar::calendarForEntityType_eventStore(EKEntityType::Reminder, &self.store)
};
let ns_title = NSString::from_str(title);
unsafe { calendar.setTitle(&ns_title) };
let source = self.find_best_source_for_reminders()?;
unsafe { calendar.setSource(Some(&source)) };
unsafe {
self.store
.saveCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
}
Ok(calendar_to_info(&calendar))
}
pub fn rename_calendar(&self, identifier: &str, new_title: &str) -> Result<CalendarInfo> {
self.update_calendar(identifier, Some(new_title), None)
}
pub fn update_calendar(
&self,
identifier: &str,
new_title: Option<&str>,
color_rgba: Option<(f64, f64, f64, f64)>,
) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = self.find_calendar_by_id(identifier)?;
if !unsafe { calendar.allowsContentModifications() } {
return Err(EventKitError::SaveFailed(
"Calendar does not allow modifications".to_string(),
));
}
if let Some(title) = new_title {
let ns_title = NSString::from_str(title);
unsafe { calendar.setTitle(&ns_title) };
}
if let Some((r, g, b, a)) = color_rgba {
let cg = objc2_core_graphics::CGColor::new_srgb(r, g, b, a);
unsafe { calendar.setCGColor(Some(&cg)) };
}
unsafe {
self.store
.saveCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
}
Ok(calendar_to_info(&calendar))
}
pub fn delete_calendar(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let calendar = self.find_calendar_by_id(identifier)?;
if !unsafe { calendar.allowsContentModifications() } {
return Err(EventKitError::DeleteFailed(
"Calendar does not allow modifications".to_string(),
));
}
unsafe {
self.store
.removeCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
}
Ok(())
}
pub fn get_calendar(&self, identifier: &str) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = self.find_calendar_by_id(identifier)?;
Ok(calendar_to_info(&calendar))
}
fn find_best_source_for_reminders(&self) -> Result<Retained<objc2_event_kit::EKSource>> {
if let Some(default_cal) = unsafe { self.store.defaultCalendarForNewReminders() }
&& let Some(source) = unsafe { default_cal.source() }
{
return Ok(source);
}
let sources = unsafe { self.store.sources() };
for source in sources.iter() {
let calendars = unsafe { source.calendarsForEntityType(EKEntityType::Reminder) };
if !calendars.is_empty() {
return Ok(source.retain());
}
}
Err(EventKitError::SaveFailed(
"No suitable source found for creating reminder calendar".to_string(),
))
}
fn find_calendar_by_id(&self, identifier: &str) -> Result<Retained<EKCalendar>> {
let ns_id = NSString::from_str(identifier);
let calendar = unsafe { self.store.calendarWithIdentifier(&ns_id) };
match calendar {
Some(cal) => Ok(cal),
None => Err(EventKitError::CalendarNotFound(identifier.to_string())),
}
}
fn find_calendar_by_title(&self, title: &str) -> Result<Retained<EKCalendar>> {
let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
for cal in calendars.iter() {
let cal_title = unsafe { cal.title() };
if cal_title.to_string() == title {
return Ok(cal.retain());
}
}
Err(RemindersError::CalendarNotFound(title.to_string()))
}
fn save_reminder_and_refresh(&self, reminder: &EKReminder) -> Result<()> {
unsafe {
self.store
.saveReminder_commit_error(reminder, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
self.store.refreshSourcesIfNecessary();
}
Ok(())
}
fn find_reminder_by_id(&self, identifier: &str) -> Result<Retained<EKReminder>> {
unsafe { self.store.refreshSourcesIfNecessary() };
let ns_id = NSString::from_str(identifier);
let item = unsafe { self.store.calendarItemWithIdentifier(&ns_id) };
match item {
Some(item) => {
if let Some(reminder) = item.downcast_ref::<EKReminder>() {
Ok(reminder.retain())
} else {
Err(EventKitError::ItemNotFound(identifier.to_string()))
}
}
None => Err(EventKitError::ItemNotFound(identifier.to_string())),
}
}
}
impl Default for RemindersManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationStatus {
NotDetermined,
Restricted,
Denied,
FullAccess,
WriteOnly,
}
impl From<EKAuthorizationStatus> for AuthorizationStatus {
fn from(status: EKAuthorizationStatus) -> Self {
if status == EKAuthorizationStatus::NotDetermined {
AuthorizationStatus::NotDetermined
} else if status == EKAuthorizationStatus::Restricted {
AuthorizationStatus::Restricted
} else if status == EKAuthorizationStatus::Denied {
AuthorizationStatus::Denied
} else if status == EKAuthorizationStatus::FullAccess {
AuthorizationStatus::FullAccess
} else if status == EKAuthorizationStatus::WriteOnly {
AuthorizationStatus::WriteOnly
} else {
AuthorizationStatus::NotDetermined
}
}
}
impl std::fmt::Display for AuthorizationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthorizationStatus::NotDetermined => write!(f, "Not Determined"),
AuthorizationStatus::Restricted => write!(f, "Restricted"),
AuthorizationStatus::Denied => write!(f, "Denied"),
AuthorizationStatus::FullAccess => write!(f, "Full Access"),
AuthorizationStatus::WriteOnly => write!(f, "Write Only"),
}
}
}
const REFLECT_VALUE_DENYLIST: &[&str] =
&["objectID", "objectIDURIRepresentation", "URIRepresentation"];
fn reflect_object_full<T: Message>(header: &str, obj: &T, read_values: bool) -> String {
use objc2::ffi::{class_copyPropertyList, free, property_getAttributes, property_getName};
use objc2::msg_send;
use objc2::rc::Retained;
use objc2::runtime::{AnyClass, AnyObject};
use std::ffi::CStr;
let obj: &AnyObject = unsafe { &*(obj as *const T as *const AnyObject) };
let mut out = String::new();
out.push_str(&format!("=== {header} ===\n"));
let mut chain: Vec<&'static AnyClass> = Vec::new();
let mut current = Some(obj.class());
while let Some(cls) = current {
chain.push(cls);
current = cls.superclass();
}
out.push_str("Class chain: ");
out.push_str(
&chain
.iter()
.map(|c| c.name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" -> "),
);
out.push_str("\n\n");
for cls in &chain {
let cls_name = cls.name().to_string_lossy().into_owned();
if cls_name == "NSObject" {
continue;
}
let mut count: u32 = 0;
let props_ptr =
unsafe { class_copyPropertyList(*cls as *const AnyClass, &mut count as *mut u32) };
if props_ptr.is_null() || count == 0 {
if !props_ptr.is_null() {
unsafe { free(props_ptr as *mut _) };
}
continue;
}
let mut entries: Vec<(String, String, String)> = Vec::new();
for i in 0..count as isize {
let prop = unsafe { *props_ptr.offset(i) };
if prop.is_null() {
continue;
}
let name_ptr = unsafe { property_getName(prop) };
let attrs_ptr = unsafe { property_getAttributes(prop) };
let name = if name_ptr.is_null() {
String::from("<?>")
} else {
unsafe { CStr::from_ptr(name_ptr) }
.to_string_lossy()
.into_owned()
};
let attrs = if attrs_ptr.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(attrs_ptr) }
.to_string_lossy()
.into_owned()
};
let type_is_objectid = attrs.contains("ObjectID");
let value_str = if !read_values {
String::from("<value read skipped (pass --values to attempt)>")
} else if REFLECT_VALUE_DENYLIST.contains(&name.as_str()) || type_is_objectid {
String::from("<skipped: type/name denylisted (would abort process)>")
} else {
let name_for_closure = name.clone();
let obj_ptr = obj as *const AnyObject;
match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
let key = NSString::from_str(&name_for_closure);
let obj_ref: &AnyObject = unsafe { &*obj_ptr };
let value_obj: Option<Retained<AnyObject>> =
unsafe { msg_send![obj_ref, valueForKey: &*key] };
match value_obj {
None => String::from("(null)"),
Some(v) => describe_object(&v),
}
})) {
Ok(s) => s,
Err(Some(exc)) => {
format!("<NSException: {}>", describe_object(&**exc as &AnyObject))
}
Err(None) => String::from("<unknown ObjC exception>"),
}
};
entries.push((name, attrs, value_str));
}
unsafe { free(props_ptr as *mut _) };
if entries.is_empty() {
continue;
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
out.push_str(&format!("[{cls_name}]\n"));
for (name, attrs, value) in entries {
out.push_str(&format!(" {name}\n"));
out.push_str(&format!(" attrs: {attrs}\n"));
let indented = value.replace('\n', "\n ");
out.push_str(&format!(" value: {indented}\n"));
}
out.push('\n');
}
out
}
fn probe_private_selectors(reminder: &EKReminder) -> String {
use objc2::msg_send;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Sel};
use std::ffi::CString;
const PROBES: &[&str] = &[
"appLink", "URLString", "externalData", "externalModificationTag",
"richLink",
"richLinkData",
"URLWithPreview",
"urlPreview",
"linkPreview",
"siriSuggestedURL",
"displayURL",
"flaggedURL",
"_richLink",
"_url",
"primaryLink",
"structuredLocation",
"geoLocation",
"place",
"placemark",
"tags",
"tagDictionaries",
"tagNames",
"userTags",
"_tags",
"_tagDictionaries",
"hashtags",
"structuredData",
"localStructuredData",
"_structuredData",
"meltedObject",
"unfrozen",
"unfrozenObject",
"writableInstance",
"flags",
"flags2",
"isFlagged",
"image",
"imageData",
"thumbnail",
"color",
"creatorBundleID",
"parentReminder",
"subreminders",
"subtasks",
"childReminders",
];
let obj: &AnyObject = unsafe { &*(reminder as *const EKReminder as *const AnyObject) };
let mut out = String::from("=== Private-selector probe on EKReminder ===\n");
out.push_str("(each call wrapped in @try/@catch — caught exceptions mean the selector doesn't exist on this class)\n\n");
for name in PROBES {
let c_name = CString::new(*name).expect("selector name has no NUL");
let sel = Sel::register(&c_name);
let name_owned = name.to_string();
let obj_ptr = obj as *const AnyObject;
let line = match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
let obj_ref: &AnyObject = unsafe { &*obj_ptr };
let responds: bool = unsafe { msg_send![obj_ref, respondsToSelector: sel] };
if !responds {
return format!("{name_owned}: <does not respond>");
}
let result: Option<Retained<AnyObject>> =
unsafe { msg_send![obj_ref, performSelector: sel] };
match result {
None => format!("{name_owned}: (null)"),
Some(v) => {
let cls: &objc2::runtime::AnyClass = (*v).class();
let cls_name = cls.name().to_string_lossy().into_owned();
let desc = describe_object(&v);
let one_line = desc.replace('\n', "\\n");
format!("{name_owned}: [{cls_name}] {one_line}")
}
}
})) {
Ok(s) => s,
Err(Some(exc)) => format!(
"{name}: <NSException: {}>",
describe_object(&**exc as &AnyObject)
),
Err(None) => format!("{name}: <unknown ObjC exception>"),
};
out.push_str(&line);
out.push('\n');
}
let backing: Option<Retained<AnyObject>> = unsafe { msg_send![obj, backingObject] };
if let Some(backing) = backing {
let bcls = (*backing).class().name().to_string_lossy().into_owned();
out.push_str(&format!("\n--- backingObject ({bcls}) probe ---\n"));
for name in PROBES {
let c_name = std::ffi::CString::new(*name).expect("no NUL");
let sel = objc2::runtime::Sel::register(&c_name);
let name_owned = name.to_string();
let backing_ptr = &*backing as *const AnyObject;
let line = match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
let bref: &AnyObject = unsafe { &*backing_ptr };
let responds: bool = unsafe { msg_send![bref, respondsToSelector: sel] };
if !responds {
return format!("{name_owned}: <does not respond>");
}
let result: Option<Retained<AnyObject>> =
unsafe { msg_send![bref, performSelector: sel] };
match result {
None => format!("{name_owned}: (null)"),
Some(v) => {
let cls = (*v).class().name().to_string_lossy().into_owned();
format!(
"{name_owned}: [{cls}] {}",
describe_object(&v).replace('\n', "\\n")
)
}
}
})) {
Ok(s) => s,
Err(Some(exc)) => format!(
"{name}: <NSException: {}>",
describe_object(&**exc as &AnyObject)
),
Err(None) => format!("{name}: <unknown ObjC exception>"),
};
out.push_str(&line);
out.push('\n');
}
}
out.push_str(
"\n--- All zero-arg instance methods on EKReminder containing url/tag/link/data ---\n",
);
out.push_str(&list_interesting_methods(obj.class()));
out
}
fn list_interesting_methods(start: &objc2::runtime::AnyClass) -> String {
use objc2::ffi::{class_copyMethodList, free, method_getName};
use objc2::runtime::AnyClass;
use std::ffi::CStr;
let needles = [
"url",
"tag",
"link",
"data",
"flag",
"image",
"preview",
"attachment",
"rich",
"location",
"structured",
"place",
"address",
"geo",
];
let mut hits: Vec<(String, String)> = Vec::new();
let mut current: Option<&AnyClass> = Some(start);
while let Some(cls) = current {
let cls_name = cls.name().to_string_lossy().into_owned();
if cls_name == "NSObject" {
break;
}
let mut count: u32 = 0;
let ptr = unsafe { class_copyMethodList(cls as *const AnyClass, &mut count as *mut u32) };
if !ptr.is_null() {
for i in 0..count as isize {
let m = unsafe { *ptr.offset(i) };
if m.is_null() {
continue;
}
let Some(sel) = (unsafe { method_getName(m) }) else {
continue;
};
let name_cstr_ptr = unsafe { objc2::ffi::sel_getName(sel) };
if name_cstr_ptr.is_null() {
continue;
}
let name = unsafe { CStr::from_ptr(name_cstr_ptr) }
.to_string_lossy()
.into_owned();
let lower = name.to_lowercase();
if needles.iter().any(|n| lower.contains(n)) {
hits.push((cls_name.clone(), name));
}
}
unsafe { free(ptr as *mut _) };
}
current = cls.superclass();
}
hits.sort();
hits.dedup();
if hits.is_empty() {
return String::from("(none)\n");
}
let mut out = String::new();
for (cls, sel) in hits {
out.push_str(&format!(" [{cls}] {sel}\n"));
}
out
}
fn describe_object(obj: &objc2::runtime::AnyObject) -> String {
use objc2::msg_send;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
let desc: Option<Retained<AnyObject>> = unsafe { msg_send![obj, description] };
match desc {
None => String::from("<no description>"),
Some(d) => {
let any: &AnyObject = &d;
let ns: &NSString = unsafe { &*(any as *const AnyObject as *const NSString) };
ns.to_string()
}
}
}
fn reminder_to_item(reminder: &EKReminder) -> ReminderItem {
let identifier = unsafe { reminder.calendarItemIdentifier() }.to_string();
let title = unsafe { reminder.title() }.to_string();
let notes = unsafe { reminder.notes() }.map(|n| n.to_string());
let completed = unsafe { reminder.isCompleted() };
let priority = unsafe { reminder.priority() };
let cal = unsafe { reminder.calendar() };
let calendar_title = cal.as_ref().map(|c| unsafe { c.title() }.to_string());
let calendar_id = cal
.as_ref()
.map(|c| unsafe { c.calendarIdentifier() }.to_string());
let due_date = unsafe { reminder.dueDateComponents() }
.and_then(|components| date_components_to_datetime(&components));
let start_date = unsafe { reminder.startDateComponents() }
.and_then(|components| date_components_to_datetime(&components));
let completion_date =
unsafe { reminder.completionDate() }.map(|date| nsdate_to_datetime(&date));
let external_identifier =
unsafe { reminder.calendarItemExternalIdentifier() }.map(|id| id.to_string());
let location = unsafe { reminder.location() }.map(|loc| loc.to_string());
#[allow(non_snake_case)]
let URL = unsafe { reminder.URL() }
.as_ref()
.and_then(|url_ref| url_ref.absoluteString())
.map(|abs_str| abs_str.to_string());
let creation_date = unsafe { reminder.creationDate() }.map(|date| nsdate_to_datetime(&date));
let last_modified_date =
unsafe { reminder.lastModifiedDate() }.map(|date| nsdate_to_datetime(&date));
let timezone = unsafe { reminder.timeZone() }.map(|tz| tz.name().to_string());
let due_date_timezone = get_reminder_due_date_timezone(reminder);
let structured_location = get_reminder_structured_location(reminder);
let parent_id = get_reminder_parent_id(reminder);
let attachments_count = get_item_attachments_count(reminder);
let has_alarms = unsafe { reminder.hasAlarms() };
let has_recurrence_rules = unsafe { reminder.hasRecurrenceRules() };
let has_attendees = unsafe { reminder.hasAttendees() };
let has_notes = unsafe { reminder.hasNotes() };
ReminderItem {
identifier,
title,
notes,
completed,
priority,
calendar_title,
calendar_id,
due_date,
start_date,
completion_date,
external_identifier,
location,
URL,
creation_date,
last_modified_date,
timezone,
due_date_timezone,
structured_location,
parent_id,
attachments_count,
has_alarms,
has_recurrence_rules,
has_attendees,
has_notes,
attendees: get_item_attendees(reminder),
}
}
fn get_reminder_due_date_timezone(reminder: &EKReminder) -> Option<String> {
use objc2::msg_send;
use objc2::rc::Retained;
let tz: Option<Retained<objc2_foundation::NSTimeZone>> =
unsafe { msg_send![reminder, dueDateTimeZone] };
tz.map(|tz| tz.name().to_string())
}
fn get_reminder_structured_location(reminder: &EKReminder) -> Option<StructuredLocation> {
if let Some(sl) = direct_structured_location(reminder) {
return Some(sl);
}
let alarms = unsafe { reminder.alarms() }?;
for i in 0..alarms.len() {
let alarm = alarms.objectAtIndex(i);
let prox = unsafe { alarm.proximity() };
if prox == EKAlarmProximity::None {
continue;
}
let Some(structured) = (unsafe { alarm.structuredLocation() }) else {
continue;
};
return Some(structured_to_plain(&structured));
}
None
}
fn direct_structured_location(reminder: &EKReminder) -> Option<StructuredLocation> {
use objc2::msg_send;
use objc2::rc::Retained;
let sl: Option<Retained<EKStructuredLocation>> =
unsafe { msg_send![reminder, structuredLocation] };
sl.map(|s| structured_to_plain(&s))
}
fn structured_to_plain(structured: &EKStructuredLocation) -> StructuredLocation {
let title = unsafe { structured.title() }
.map(|t| t.to_string())
.unwrap_or_default();
let radius = unsafe { structured.radius() };
let (latitude, longitude) = read_geo_location_coords(structured);
StructuredLocation {
title,
latitude,
longitude,
radius,
}
}
#[cfg(feature = "location")]
fn read_geo_location_coords(structured: &EKStructuredLocation) -> (f64, f64) {
let cl = unsafe { structured.geoLocation() };
let Some(cl) = cl else { return (0.0, 0.0) };
let coord = unsafe { cl.coordinate() };
(coord.latitude, coord.longitude)
}
#[cfg(not(feature = "location"))]
fn read_geo_location_coords(_structured: &EKStructuredLocation) -> (f64, f64) {
(0.0, 0.0)
}
fn get_reminder_parent_id(reminder: &EKReminder) -> Option<String> {
use objc2::msg_send;
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
let reminder_obj: &AnyObject = unsafe { &*(reminder as *const EKReminder as *const AnyObject) };
let reminder_ptr = reminder_obj as *const AnyObject;
let value = objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
let key = NSString::from_str("parentID");
let obj_ref: &AnyObject = unsafe { &*reminder_ptr };
let v: Option<Retained<AnyObject>> = unsafe { msg_send![obj_ref, valueForKey: &*key] };
v
}))
.ok()
.flatten()?;
let desc: Retained<AnyObject> = unsafe { msg_send![&*value, description] };
let ns: &NSString = unsafe { &*(&*desc as *const AnyObject as *const NSString) };
Some(ns.to_string())
}
fn get_item_attachments_count(item: &EKCalendarItem) -> usize {
use objc2::msg_send;
use objc2::rc::Retained;
use objc2_foundation::NSArray;
let attachments: Option<Retained<NSArray>> = unsafe { msg_send![item, attachments] };
attachments.map(|a| a.len()).unwrap_or(0)
}
fn source_to_info(source: &EKSource) -> SourceInfo {
let identifier = unsafe { source.sourceIdentifier() }.to_string();
let title = unsafe { source.title() }.to_string();
let source_type = unsafe { source.sourceType() };
let source_type = match source_type.0 {
0 => "local",
1 => "exchange",
2 => "caldav",
3 => "mobileme",
4 => "subscribed",
5 => "birthdays",
_ => "unknown",
}
.to_string();
SourceInfo {
identifier,
title,
source_type,
}
}
fn calendar_to_info(calendar: &EKCalendar) -> CalendarInfo {
let identifier = unsafe { calendar.calendarIdentifier() }.to_string();
let title = unsafe { calendar.title() }.to_string();
let source = unsafe { calendar.source() }.map(|s| unsafe { s.title() }.to_string());
let source_id =
unsafe { calendar.source() }.map(|s| unsafe { s.sourceIdentifier() }.to_string());
let allows_modifications = unsafe { calendar.allowsContentModifications() };
let is_immutable = unsafe { calendar.isImmutable() };
let is_subscribed = unsafe { calendar.isSubscribed() };
let cal_type = unsafe { calendar.r#type() };
let calendar_type = match cal_type.0 {
0 => CalendarType::Local,
1 => CalendarType::CalDAV,
2 => CalendarType::Exchange,
3 => CalendarType::Subscription,
4 => CalendarType::Birthday,
_ => CalendarType::Unknown,
};
let color: Option<(f64, f64, f64, f64)> = unsafe {
calendar.CGColor().and_then(|cg| {
use objc2_core_graphics::CGColor as CG;
let n = CG::number_of_components(Some(&cg));
if n >= 3 {
let ptr = CG::components(Some(&cg));
let r = *ptr;
let g = *ptr.add(1);
let b = *ptr.add(2);
let a = if n >= 4 { *ptr.add(3) } else { 1.0 };
Some((r, g, b, a))
} else {
None
}
})
};
let entity_mask = unsafe { calendar.allowedEntityTypes() };
let mut allowed_entity_types = Vec::new();
if entity_mask.0 & 1 != 0 {
allowed_entity_types.push("event".to_string());
}
if entity_mask.0 & 2 != 0 {
allowed_entity_types.push("reminder".to_string());
}
let avail_mask = unsafe { calendar.supportedEventAvailabilities() };
let mut supported_event_availabilities = Vec::new();
if avail_mask.contains(EKCalendarEventAvailabilityMask::Busy) {
supported_event_availabilities.push("busy".to_string());
}
if avail_mask.contains(EKCalendarEventAvailabilityMask::Free) {
supported_event_availabilities.push("free".to_string());
}
if avail_mask.contains(EKCalendarEventAvailabilityMask::Tentative) {
supported_event_availabilities.push("tentative".to_string());
}
if avail_mask.contains(EKCalendarEventAvailabilityMask::Unavailable) {
supported_event_availabilities.push("unavailable".to_string());
}
CalendarInfo {
identifier,
title,
source,
source_id,
calendar_type,
allows_modifications,
is_immutable,
is_subscribed,
color,
allowed_entity_types,
supported_event_availabilities,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EventAvailability {
NotSupported,
#[default]
Busy,
Free,
Tentative,
Unavailable,
}
impl EventAvailability {
pub fn to_ek(self) -> EKEventAvailability {
match self {
Self::NotSupported => EKEventAvailability::NotSupported,
Self::Busy => EKEventAvailability::Busy,
Self::Free => EKEventAvailability::Free,
Self::Tentative => EKEventAvailability::Tentative,
Self::Unavailable => EKEventAvailability::Unavailable,
}
}
pub fn from_ek(raw: EKEventAvailability) -> Self {
match raw {
EKEventAvailability::NotSupported => Self::NotSupported,
EKEventAvailability::Busy => Self::Busy,
EKEventAvailability::Free => Self::Free,
EKEventAvailability::Tentative => Self::Tentative,
EKEventAvailability::Unavailable => Self::Unavailable,
_ => Self::NotSupported,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EventStatus {
#[default]
None,
Confirmed,
Tentative,
Canceled,
}
impl EventStatus {
pub fn from_ek(raw: EKEventStatus) -> Self {
match raw {
EKEventStatus::None => Self::None,
EKEventStatus::Confirmed => Self::Confirmed,
EKEventStatus::Tentative => Self::Tentative,
EKEventStatus::Canceled => Self::Canceled,
_ => Self::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EventSpan {
#[default]
This,
Future,
}
impl EventSpan {
pub(crate) fn to_ek(self) -> EKSpan {
match self {
Self::This => EKSpan::ThisEvent,
Self::Future => EKSpan::FutureEvents,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParticipantRole {
Unknown,
Required,
Optional,
Chair,
NonParticipant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParticipantStatus {
Unknown,
Pending,
Accepted,
Declined,
Tentative,
Delegated,
Completed,
InProcess,
}
#[derive(Debug, Clone)]
#[allow(non_snake_case)]
pub struct ParticipantInfo {
pub name: Option<String>,
pub URL: Option<String>,
pub role: ParticipantRole,
pub status: ParticipantStatus,
pub is_current_user: bool,
}
#[derive(Debug, Clone)]
#[allow(non_snake_case)]
pub struct EventItem {
pub identifier: String,
pub title: String,
pub notes: Option<String>,
pub location: Option<String>,
pub start_date: DateTime<Local>,
pub end_date: DateTime<Local>,
pub all_day: bool,
pub calendar_title: Option<String>,
pub calendar_id: Option<String>,
#[allow(non_snake_case)]
pub URL: Option<String>,
pub availability: EventAvailability,
pub status: EventStatus,
pub is_detached: bool,
pub occurrence_date: Option<DateTime<Local>>,
pub structured_location: Option<StructuredLocation>,
pub creation_date: Option<DateTime<Local>>,
pub last_modified_date: Option<DateTime<Local>>,
pub external_identifier: Option<String>,
pub timezone: Option<String>,
pub attachments_count: usize,
pub attendees: Vec<ParticipantInfo>,
pub organizer: Option<ParticipantInfo>,
}
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
pub struct EventDraft<'a> {
pub title: &'a str,
pub start: Option<DateTime<Local>>,
pub end: Option<DateTime<Local>>,
pub notes: Option<&'a str>,
pub location: Option<&'a str>,
pub calendar_title: Option<&'a str>,
pub all_day: bool,
pub URL: Option<&'a str>,
pub availability: Option<EventAvailability>,
pub structured_location: Option<&'a StructuredLocation>,
}
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
pub struct EventPatch<'a> {
pub title: Option<&'a str>,
pub notes: Option<Option<&'a str>>,
pub location: Option<Option<&'a str>>,
pub start: Option<DateTime<Local>>,
pub end: Option<DateTime<Local>>,
pub all_day: Option<bool>,
pub calendar_title: Option<&'a str>,
pub URL: Option<Option<&'a str>>,
pub availability: Option<EventAvailability>,
pub structured_location: Option<Option<&'a StructuredLocation>>,
pub span: EventSpan,
}
pub struct EventsManager {
store: Retained<EKEventStore>,
}
impl EventsManager {
pub fn new() -> Self {
let store = unsafe { EKEventStore::new() };
Self { store }
}
pub fn authorization_status() -> AuthorizationStatus {
let status = unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) };
status.into()
}
pub fn request_access(&self) -> Result<bool> {
let result = Arc::new((Mutex::new(None::<(bool, Option<String>)>), Condvar::new()));
let result_clone = Arc::clone(&result);
let completion = RcBlock::new(move |granted: Bool, error: *mut NSError| {
let error_msg = if !error.is_null() {
let error_ref = unsafe { &*error };
Some(format!("{:?}", error_ref))
} else {
None
};
let (lock, cvar) = &*result_clone;
let mut res = lock.lock().unwrap();
*res = Some((granted.as_bool(), error_msg));
cvar.notify_one();
});
unsafe {
let block_ptr = &*completion as *const _ as *mut _;
self.store
.requestFullAccessToEventsWithCompletion(block_ptr);
}
let (lock, cvar) = &*result;
let mut res = lock.lock().unwrap();
while res.is_none() {
res = cvar.wait(res).unwrap();
}
match res.take() {
Some((granted, None)) => Ok(granted),
Some((_, Some(error))) => Err(EventKitError::AuthorizationRequestFailed(error)),
None => Err(EventKitError::AuthorizationRequestFailed(
"Unknown error".to_string(),
)),
}
}
pub fn ensure_authorized(&self) -> Result<()> {
match Self::authorization_status() {
AuthorizationStatus::FullAccess => Ok(()),
AuthorizationStatus::NotDetermined => {
if self.request_access()? {
Ok(())
} else {
Err(EventKitError::AuthorizationDenied)
}
}
AuthorizationStatus::Denied => Err(EventKitError::AuthorizationDenied),
AuthorizationStatus::Restricted => Err(EventKitError::AuthorizationRestricted),
AuthorizationStatus::WriteOnly => Ok(()),
}
}
pub fn list_calendars(&self) -> Result<Vec<CalendarInfo>> {
self.ensure_authorized()?;
let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
let mut result = Vec::new();
for calendar in calendars.iter() {
result.push(calendar_to_info(&calendar));
}
Ok(result)
}
pub fn default_calendar(&self) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = unsafe { self.store.defaultCalendarForNewEvents() };
match calendar {
Some(cal) => Ok(calendar_to_info(&cal)),
None => Err(EventKitError::NoDefaultCalendar),
}
}
pub fn fetch_today_events(&self) -> Result<Vec<EventItem>> {
let now = Local::now();
let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap();
let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap();
self.fetch_events(
Local.from_local_datetime(&start).unwrap(),
Local.from_local_datetime(&end).unwrap(),
None,
)
}
pub fn fetch_upcoming_events(&self, days: i64) -> Result<Vec<EventItem>> {
let now = Local::now();
let end = now + Duration::days(days);
self.fetch_events(now, end, None)
}
pub fn fetch_events(
&self,
start: DateTime<Local>,
end: DateTime<Local>,
calendar_titles: Option<&[&str]>,
) -> Result<Vec<EventItem>> {
self.ensure_authorized()?;
if start >= end {
return Err(EventKitError::InvalidDateRange);
}
let calendars: Option<Retained<NSArray<EKCalendar>>> = match calendar_titles {
Some(titles) => {
let all_calendars =
unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
for cal in all_calendars.iter() {
let title = unsafe { cal.title() };
let title_str = title.to_string();
if titles.iter().any(|t| *t == title_str) {
matching.push(cal.retain());
}
}
if matching.is_empty() {
return Err(EventKitError::CalendarNotFound(titles.join(", ")));
}
Some(NSArray::from_retained_slice(&matching))
}
None => None,
};
let start_date = datetime_to_nsdate(start);
let end_date = datetime_to_nsdate(end);
let predicate = unsafe {
self.store
.predicateForEventsWithStartDate_endDate_calendars(
&start_date,
&end_date,
calendars.as_deref(),
)
};
let events = unsafe { self.store.eventsMatchingPredicate(&predicate) };
let mut items = Vec::new();
for event in events.iter() {
items.push(event_to_item(&event));
}
items.sort_by_key(|a| a.start_date);
Ok(items)
}
pub fn create_event(&self, draft: &EventDraft<'_>) -> Result<EventItem> {
self.ensure_authorized()?;
let start = draft.start.ok_or(EventKitError::InvalidDateRange)?;
let end = draft.end.ok_or(EventKitError::InvalidDateRange)?;
let event = unsafe { EKEvent::eventWithEventStore(&self.store) };
let ns_title = NSString::from_str(draft.title);
unsafe { event.setTitle(Some(&ns_title)) };
let start_date = datetime_to_nsdate(start);
let end_date = datetime_to_nsdate(end);
unsafe {
event.setStartDate(Some(&start_date));
event.setEndDate(Some(&end_date));
event.setAllDay(draft.all_day);
}
if let Some(notes_text) = draft.notes {
let ns_notes = NSString::from_str(notes_text);
unsafe { event.setNotes(Some(&ns_notes)) };
}
if let Some(loc) = draft.location {
let ns_location = NSString::from_str(loc);
unsafe { event.setLocation(Some(&ns_location)) };
}
if draft.URL.is_some() {
set_item_URL(&event, draft.URL)?;
}
if let Some(av) = draft.availability {
unsafe { event.setAvailability(av.to_ek()) };
}
if let Some(sl) = draft.structured_location {
let built = build_ek_structured_location(sl);
unsafe { event.setStructuredLocation(Some(&built)) };
}
let calendar = if let Some(cal_title) = draft.calendar_title {
self.find_calendar_by_title(cal_title)?
} else {
unsafe { self.store.defaultCalendarForNewEvents() }
.ok_or(EventKitError::NoDefaultCalendar)?
};
unsafe { event.setCalendar(Some(&calendar)) };
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(event_to_item(&event))
}
pub fn update_event(&self, identifier: &str, patch: &EventPatch<'_>) -> Result<EventItem> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
if let Some(t) = patch.title {
let ns_title = NSString::from_str(t);
unsafe { event.setTitle(Some(&ns_title)) };
}
if let Some(notes_opt) = patch.notes {
let ns = notes_opt.map(NSString::from_str);
unsafe { event.setNotes(ns.as_deref()) };
}
if let Some(loc_opt) = patch.location {
let ns = loc_opt.map(NSString::from_str);
unsafe { event.setLocation(ns.as_deref()) };
}
if let Some(s) = patch.start {
let start_date = datetime_to_nsdate(s);
unsafe { event.setStartDate(Some(&start_date)) };
}
if let Some(e) = patch.end {
let end_date = datetime_to_nsdate(e);
unsafe { event.setEndDate(Some(&end_date)) };
}
if let Some(ad) = patch.all_day {
unsafe { event.setAllDay(ad) };
}
if let Some(url_opt) = patch.URL {
set_item_URL(&event, url_opt)?;
}
if let Some(av) = patch.availability {
unsafe { event.setAvailability(av.to_ek()) };
}
if let Some(sl_opt) = patch.structured_location {
let built = sl_opt.map(build_ek_structured_location);
unsafe { event.setStructuredLocation(built.as_deref()) };
}
if let Some(cal_title) = patch.calendar_title {
let calendar = self.find_calendar_by_title(cal_title)?;
unsafe { event.setCalendar(Some(&calendar)) };
}
self.save_event_and_refresh(&event, patch.span.to_ek())?;
Ok(event_to_item(&event))
}
pub fn delete_event(&self, identifier: &str, affect_future: bool) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
let span = if affect_future {
EKSpan::FutureEvents
} else {
EKSpan::ThisEvent
};
unsafe {
self.store
.removeEvent_span_commit_error(&event, span, true)
.map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
}
Ok(())
}
pub fn get_event(&self, identifier: &str) -> Result<EventItem> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
Ok(event_to_item(&event))
}
pub fn create_event_calendar(&self, title: &str) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = unsafe {
EKCalendar::calendarForEntityType_eventStore(EKEntityType::Event, &self.store)
};
let ns_title = NSString::from_str(title);
unsafe { calendar.setTitle(&ns_title) };
if let Some(default_cal) = unsafe { self.store.defaultCalendarForNewEvents() }
&& let Some(source) = unsafe { default_cal.source() }
{
unsafe { calendar.setSource(Some(&source)) };
}
unsafe {
self.store
.saveCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
}
Ok(calendar_to_info(&calendar))
}
pub fn rename_event_calendar(&self, identifier: &str, new_title: &str) -> Result<CalendarInfo> {
self.update_event_calendar(identifier, Some(new_title), None)
}
pub fn update_event_calendar(
&self,
identifier: &str,
new_title: Option<&str>,
color_rgba: Option<(f64, f64, f64, f64)>,
) -> Result<CalendarInfo> {
self.ensure_authorized()?;
let calendar = unsafe {
self.store
.calendarWithIdentifier(&NSString::from_str(identifier))
}
.ok_or_else(|| EventKitError::CalendarNotFound(identifier.to_string()))?;
if let Some(title) = new_title {
let ns_title = NSString::from_str(title);
unsafe { calendar.setTitle(&ns_title) };
}
if let Some((r, g, b, a)) = color_rgba {
let cg = objc2_core_graphics::CGColor::new_srgb(r, g, b, a);
unsafe { calendar.setCGColor(Some(&cg)) };
}
unsafe {
self.store
.saveCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
}
Ok(calendar_to_info(&calendar))
}
pub fn delete_event_calendar(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let calendar = unsafe {
self.store
.calendarWithIdentifier(&NSString::from_str(identifier))
}
.ok_or_else(|| EventKitError::CalendarNotFound(identifier.to_string()))?;
unsafe {
self.store
.removeCalendar_commit_error(&calendar, true)
.map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
}
Ok(())
}
pub fn get_event_alarms(&self, identifier: &str) -> Result<Vec<AlarmInfo>> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
Ok(get_item_alarms(&event))
}
pub fn add_event_alarm(&self, identifier: &str, alarm: &AlarmInfo) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
add_item_alarm(&event, alarm)?;
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(())
}
pub fn get_event_recurrence_rules(&self, identifier: &str) -> Result<Vec<RecurrenceRule>> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
Ok(get_item_recurrence_rules(&event))
}
pub fn set_event_recurrence_rule(&self, identifier: &str, rule: &RecurrenceRule) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
set_item_recurrence_rule(&event, rule);
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(())
}
pub fn remove_event_recurrence_rules(&self, identifier: &str) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
clear_item_recurrence_rules(&event);
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(())
}
pub fn remove_event_alarm(&self, identifier: &str, index: usize) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
remove_item_alarm(&event, index)?;
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(())
}
#[allow(non_snake_case)]
pub fn set_event_URL(&self, identifier: &str, url: Option<&str>) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
set_item_URL(&event, url)?;
self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
Ok(())
}
pub fn set_event_availability(
&self,
identifier: &str,
availability: EventAvailability,
) -> Result<()> {
self.ensure_authorized()?;
let event = self.find_event_by_id(identifier)?;
unsafe { event.setAvailability(availability.to_ek()) };
self.save_event_and_refresh(&event, EKSpan::ThisEvent)
}
fn find_calendar_by_title(&self, title: &str) -> Result<Retained<EKCalendar>> {
let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
for cal in calendars.iter() {
let cal_title = unsafe { cal.title() };
if cal_title.to_string() == title {
return Ok(cal.retain());
}
}
Err(EventKitError::CalendarNotFound(title.to_string()))
}
fn find_event_by_id(&self, identifier: &str) -> Result<Retained<EKEvent>> {
unsafe { self.store.refreshSourcesIfNecessary() };
let ns_id = NSString::from_str(identifier);
let event = unsafe { self.store.eventWithIdentifier(&ns_id) };
match event {
Some(e) => Ok(e),
None => Err(EventKitError::ItemNotFound(identifier.to_string())),
}
}
fn save_event_and_refresh(&self, event: &EKEvent, span: EKSpan) -> Result<()> {
unsafe {
self.store
.saveEvent_span_commit_error(event, span, true)
.map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
self.store.refreshSourcesIfNecessary();
}
Ok(())
}
}
impl Default for EventsManager {
fn default() -> Self {
Self::new()
}
}
fn event_to_item(event: &EKEvent) -> EventItem {
let identifier = unsafe { event.eventIdentifier() }
.map(|s| s.to_string())
.unwrap_or_default();
let title = unsafe { event.title() }.to_string();
let notes = unsafe { event.notes() }.map(|n| n.to_string());
let location = unsafe { event.location() }.map(|l| l.to_string());
let all_day = unsafe { event.isAllDay() };
let cal = unsafe { event.calendar() };
let calendar_title = cal.as_ref().map(|c| unsafe { c.title() }.to_string());
let calendar_id = cal
.as_ref()
.map(|c| unsafe { c.calendarIdentifier() }.to_string());
let start_ns: Retained<NSDate> = unsafe { event.startDate() };
let end_ns: Retained<NSDate> = unsafe { event.endDate() };
let start_date = nsdate_to_datetime(&start_ns);
let end_date = nsdate_to_datetime(&end_ns);
#[allow(non_snake_case)]
let URL = get_item_URL(event);
let availability = EventAvailability::from_ek(unsafe { event.availability() });
let status = EventStatus::from_ek(unsafe { event.status() });
let is_detached = unsafe { event.isDetached() };
let occurrence_date = unsafe { event.occurrenceDate() }.map(|d| nsdate_to_datetime(&d));
let structured_location = unsafe { event.structuredLocation() }.map(|loc| {
let title = unsafe { loc.title() }
.map(|t| t.to_string())
.unwrap_or_default();
let radius = unsafe { loc.radius() };
let (latitude, longitude) = unsafe { loc.geoLocation() }
.map(|geo| {
let coord = unsafe { geo.coordinate() };
(coord.latitude, coord.longitude)
})
.unwrap_or((0.0, 0.0));
StructuredLocation {
title,
latitude,
longitude,
radius,
}
});
let attendees = get_item_attendees(event);
let organizer = unsafe { event.organizer() }.map(|p| participant_to_info(&p));
let creation_date = unsafe { event.creationDate() }.map(|d| nsdate_to_datetime(&d));
let last_modified_date = unsafe { event.lastModifiedDate() }.map(|d| nsdate_to_datetime(&d));
let external_identifier =
unsafe { event.calendarItemExternalIdentifier() }.map(|s| s.to_string());
let timezone = unsafe { event.timeZone() }.map(|tz| tz.name().to_string());
let attachments_count = get_item_attachments_count(event);
EventItem {
identifier,
title,
notes,
location,
start_date,
end_date,
all_day,
calendar_title,
calendar_id,
URL,
availability,
status,
is_detached,
occurrence_date,
structured_location,
creation_date,
last_modified_date,
external_identifier,
timezone,
attachments_count,
attendees,
organizer,
}
}
fn get_item_attendees(item: &EKCalendarItem) -> Vec<ParticipantInfo> {
let attendees = unsafe { item.attendees() };
let Some(attendees) = attendees else {
return Vec::new();
};
let mut result = Vec::new();
for i in 0..attendees.len() {
let p = attendees.objectAtIndex(i);
result.push(participant_to_info(&p));
}
result
}
fn participant_to_info(p: &objc2_event_kit::EKParticipant) -> ParticipantInfo {
let name = unsafe { p.name() }.map(|n| n.to_string());
#[allow(non_snake_case)]
let URL = unsafe { p.URL() }.absoluteString().map(|s| s.to_string());
let role = unsafe { p.participantRole() };
let role = match role.0 {
1 => ParticipantRole::Required,
2 => ParticipantRole::Optional,
3 => ParticipantRole::Chair,
4 => ParticipantRole::NonParticipant,
_ => ParticipantRole::Unknown,
};
let status = unsafe { p.participantStatus() };
let status = match status.0 {
1 => ParticipantStatus::Pending,
2 => ParticipantStatus::Accepted,
3 => ParticipantStatus::Declined,
4 => ParticipantStatus::Tentative,
5 => ParticipantStatus::Delegated,
6 => ParticipantStatus::Completed,
7 => ParticipantStatus::InProcess,
_ => ParticipantStatus::Unknown,
};
let is_current_user = unsafe { p.isCurrentUser() };
ParticipantInfo {
name,
URL,
role,
status,
is_current_user,
}
}
fn datetime_to_nsdate(dt: DateTime<Local>) -> Retained<NSDate> {
let timestamp = dt.timestamp() as f64;
NSDate::dateWithTimeIntervalSince1970(timestamp)
}
fn nsdate_to_datetime(date: &NSDate) -> DateTime<Local> {
let timestamp = date.timeIntervalSince1970();
Local.timestamp_opt(timestamp as i64, 0).unwrap()
}
fn date_components_to_datetime(components: &NSDateComponents) -> Option<DateTime<Local>> {
let calendar = NSCalendar::currentCalendar();
let date = calendar.dateFromComponents(components)?;
Some(nsdate_to_datetime(&date))
}
fn datetime_to_date_components(dt: DateTime<Local>) -> Retained<NSDateComponents> {
let components = NSDateComponents::new();
components.setYear(dt.year() as isize);
components.setMonth(dt.month() as isize);
components.setDay(dt.day() as isize);
components.setHour(dt.hour() as isize);
components.setMinute(dt.minute() as isize);
components.setSecond(dt.second() as isize);
components
}
fn get_item_alarms(item: &EKCalendarItem) -> Vec<AlarmInfo> {
let alarms = unsafe { item.alarms() };
let Some(alarms) = alarms else {
return Vec::new();
};
let mut result = Vec::new();
for i in 0..alarms.len() {
let alarm = alarms.objectAtIndex(i);
result.push(alarm_to_info(&alarm));
}
result
}
fn add_item_alarm(item: &EKCalendarItem, alarm: &AlarmInfo) -> Result<()> {
let ek_alarm = create_ek_alarm(alarm)?;
unsafe { item.addAlarm(&ek_alarm) };
Ok(())
}
fn remove_item_alarm(item: &EKCalendarItem, index: usize) -> Result<()> {
let alarms = unsafe { item.alarms() };
let Some(alarms) = alarms else {
return Err(EventKitError::ItemNotFound("No alarms on this item".into()));
};
if index >= alarms.len() {
return Err(EventKitError::ItemNotFound(format!(
"Alarm index {} out of range ({})",
index,
alarms.len()
)));
}
let alarm = alarms.objectAtIndex(index);
unsafe { item.removeAlarm(&alarm) };
Ok(())
}
fn clear_item_alarms(item: &EKCalendarItem) {
unsafe { item.setAlarms(None) };
}
fn get_item_recurrence_rules(item: &EKCalendarItem) -> Vec<RecurrenceRule> {
let rules = unsafe { item.recurrenceRules() };
let Some(rules) = rules else {
return Vec::new();
};
let mut result = Vec::new();
for i in 0..rules.len() {
let rule = rules.objectAtIndex(i);
result.push(recurrence_rule_to_info(&rule));
}
result
}
fn set_item_recurrence_rule(item: &EKCalendarItem, rule: &RecurrenceRule) {
let ek_rule = create_ek_recurrence_rule(rule);
unsafe {
let rules = NSArray::from_retained_slice(&[ek_rule]);
item.setRecurrenceRules(Some(&rules));
}
}
fn clear_item_recurrence_rules(item: &EKCalendarItem) {
unsafe { item.setRecurrenceRules(None) };
}
#[allow(non_snake_case)]
fn set_item_URL(item: &EKCalendarItem, url: Option<&str>) -> Result<()> {
let ns_url = match url {
None => None,
Some(s) => {
let ns_str = NSString::from_str(s);
let parsed =
objc2_foundation::NSURL::URLWithString_encodingInvalidCharacters(&ns_str, false);
match parsed {
Some(u) => Some(u),
None => return Err(EventKitError::InvalidURL(s.to_string())),
}
}
};
unsafe { item.setURL(ns_url.as_deref()) };
Ok(())
}
#[allow(non_snake_case)]
fn get_item_URL(item: &EKCalendarItem) -> Option<String> {
unsafe { item.URL() }.map(|u| u.absoluteString().unwrap().to_string())
}
fn set_item_location(item: &EKCalendarItem, location: Option<&str>) {
match location {
Some(text) => {
let ns_loc = NSString::from_str(text);
unsafe { item.setLocation(Some(&ns_loc)) };
}
None => unsafe { item.setLocation(None) },
}
}
fn set_reminder_due_date_timezone(reminder: &EKReminder, tz_name: Option<&str>) {
use objc2_foundation::NSTimeZone;
let Some(components) = (unsafe { reminder.dueDateComponents() }) else {
return;
};
let tz = tz_name.and_then(|n| {
let ns_n = NSString::from_str(n);
NSTimeZone::timeZoneWithName(&ns_n)
});
components.setTimeZone(tz.as_deref());
unsafe { reminder.setDueDateComponents(Some(&components)) };
}
fn set_reminder_structured_location(reminder: &EKReminder, loc: Option<&StructuredLocation>) {
use objc2::msg_send;
let ek = loc.map(build_ek_structured_location);
let _: () =
unsafe { msg_send![reminder, setStructuredLocationWithoutPrediction: ek.as_deref()] };
let _: () = unsafe { msg_send![reminder, setStructuredLocation: ek.as_deref()] };
}
fn build_ek_structured_location(loc: &StructuredLocation) -> Retained<EKStructuredLocation> {
let title = NSString::from_str(&loc.title);
let structured = unsafe { EKStructuredLocation::locationWithTitle(&title) };
unsafe { structured.setRadius(loc.radius) };
#[cfg(feature = "location")]
{
use objc2::AnyThread;
use objc2_core_location::CLLocation;
let cl = unsafe {
CLLocation::initWithLatitude_longitude(CLLocation::alloc(), loc.latitude, loc.longitude)
};
unsafe { structured.setGeoLocation(Some(&cl)) };
}
structured
}
fn set_reminder_geofence(
reminder: &EKReminder,
geofence: Option<(&StructuredLocation, AlarmProximity)>,
) {
if let Some(alarms) = unsafe { reminder.alarms() } {
for i in (0..alarms.len()).rev() {
let alarm = alarms.objectAtIndex(i);
if unsafe { alarm.proximity() } != EKAlarmProximity::None {
unsafe { reminder.removeAlarm(&alarm) };
}
}
}
let Some((loc, proximity)) = geofence else {
return;
};
let info = AlarmInfo {
relative_offset: Some(0.0),
proximity,
location: Some(loc.clone()),
..Default::default()
};
let alarm = create_ek_alarm(&info).expect("geofence alarm has no URL to parse");
unsafe { reminder.addAlarm(&alarm) };
}
fn recurrence_rule_to_info(rule: &EKRecurrenceRule) -> RecurrenceRule {
let frequency = unsafe { rule.frequency() };
let frequency = match frequency {
EKRecurrenceFrequency::Daily => RecurrenceFrequency::Daily,
EKRecurrenceFrequency::Weekly => RecurrenceFrequency::Weekly,
EKRecurrenceFrequency::Monthly => RecurrenceFrequency::Monthly,
EKRecurrenceFrequency::Yearly => RecurrenceFrequency::Yearly,
_ => RecurrenceFrequency::Daily,
};
let interval = unsafe { rule.interval() } as usize;
let end = unsafe { rule.recurrenceEnd() }
.map(|end| {
let count = unsafe { end.occurrenceCount() };
if count > 0 {
RecurrenceEndCondition::AfterCount(count)
} else if let Some(date) = unsafe { end.endDate() } {
RecurrenceEndCondition::OnDate(nsdate_to_datetime(&date))
} else {
RecurrenceEndCondition::Never
}
})
.unwrap_or(RecurrenceEndCondition::Never);
let days_of_week = unsafe { rule.daysOfTheWeek() }.map(|days| {
let mut result = Vec::new();
for i in 0..days.len() {
let day = days.objectAtIndex(i);
let weekday = unsafe { day.dayOfTheWeek() };
result.push(weekday.0 as u8);
}
result
});
fn nsnumber_array_to_vec(arr: Option<Retained<NSArray<NSNumber>>>) -> Option<Vec<i32>> {
arr.map(|days| {
let mut result = Vec::with_capacity(days.len());
for i in 0..days.len() {
result.push(days.objectAtIndex(i).intValue());
}
result
})
}
let days_of_month = nsnumber_array_to_vec(unsafe { rule.daysOfTheMonth() });
let months_of_year = nsnumber_array_to_vec(unsafe { rule.monthsOfTheYear() });
let weeks_of_year = nsnumber_array_to_vec(unsafe { rule.weeksOfTheYear() });
let days_of_year = nsnumber_array_to_vec(unsafe { rule.daysOfTheYear() });
let set_positions = nsnumber_array_to_vec(unsafe { rule.setPositions() });
RecurrenceRule {
frequency,
interval,
end,
days_of_week,
days_of_month,
months_of_year,
weeks_of_year,
days_of_year,
set_positions,
}
}
fn create_ek_recurrence_rule(rule: &RecurrenceRule) -> Retained<EKRecurrenceRule> {
let frequency = match rule.frequency {
RecurrenceFrequency::Daily => EKRecurrenceFrequency::Daily,
RecurrenceFrequency::Weekly => EKRecurrenceFrequency::Weekly,
RecurrenceFrequency::Monthly => EKRecurrenceFrequency::Monthly,
RecurrenceFrequency::Yearly => EKRecurrenceFrequency::Yearly,
};
let end = match &rule.end {
RecurrenceEndCondition::Never => None,
RecurrenceEndCondition::AfterCount(count) => {
Some(unsafe { EKRecurrenceEnd::recurrenceEndWithOccurrenceCount(*count) })
}
RecurrenceEndCondition::OnDate(date) => {
let nsdate = datetime_to_nsdate(*date);
Some(unsafe { EKRecurrenceEnd::recurrenceEndWithEndDate(&nsdate) })
}
};
let days_of_week: Option<Vec<Retained<EKRecurrenceDayOfWeek>>> =
rule.days_of_week.as_ref().map(|days| {
days.iter()
.map(|&d| {
let weekday = EKWeekday(d as isize);
unsafe { EKRecurrenceDayOfWeek::dayOfWeek(weekday) }
})
.collect()
});
fn vec_to_nsnumber_array(v: Option<&Vec<i32>>) -> Option<Vec<Retained<NSNumber>>> {
v.map(|nums| nums.iter().map(|&n| NSNumber::new_i32(n)).collect())
}
let days_of_month = vec_to_nsnumber_array(rule.days_of_month.as_ref());
let months_of_year = vec_to_nsnumber_array(rule.months_of_year.as_ref());
let weeks_of_year = vec_to_nsnumber_array(rule.weeks_of_year.as_ref());
let days_of_year = vec_to_nsnumber_array(rule.days_of_year.as_ref());
let set_positions = vec_to_nsnumber_array(rule.set_positions.as_ref());
let days_of_week_arr = days_of_week
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
let days_of_month_arr = days_of_month
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
let months_of_year_arr = months_of_year
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
let weeks_of_year_arr = weeks_of_year
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
let days_of_year_arr = days_of_year
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
let set_positions_arr = set_positions
.as_ref()
.map(|v| NSArray::from_retained_slice(v));
unsafe {
use objc2::AnyThread;
EKRecurrenceRule::initRecurrenceWithFrequency_interval_daysOfTheWeek_daysOfTheMonth_monthsOfTheYear_weeksOfTheYear_daysOfTheYear_setPositions_end(
EKRecurrenceRule::alloc(),
frequency,
rule.interval as isize,
days_of_week_arr.as_deref(),
days_of_month_arr.as_deref(),
months_of_year_arr.as_deref(),
weeks_of_year_arr.as_deref(),
days_of_year_arr.as_deref(),
set_positions_arr.as_deref(),
end.as_deref(),
)
}
}
fn alarm_to_info(alarm: &EKAlarm) -> AlarmInfo {
let relative_offset = unsafe { alarm.relativeOffset() };
let absolute_date = unsafe { alarm.absoluteDate() }.map(|d| nsdate_to_datetime(&d));
let proximity = unsafe { alarm.proximity() };
let proximity = match proximity {
EKAlarmProximity::Enter => AlarmProximity::Enter,
EKAlarmProximity::Leave => AlarmProximity::Leave,
_ => AlarmProximity::None,
};
let location = unsafe { alarm.structuredLocation() }.map(|loc| {
let title = unsafe { loc.title() }
.map(|t| t.to_string())
.unwrap_or_default();
let radius = unsafe { loc.radius() };
let (latitude, longitude) = unsafe { loc.geoLocation() }
.map(|geo| {
let coord = unsafe { geo.coordinate() };
(coord.latitude, coord.longitude)
})
.unwrap_or((0.0, 0.0));
StructuredLocation {
title,
latitude,
longitude,
radius,
}
});
let email_address = unsafe { alarm.emailAddress() }.map(|s| s.to_string());
let sound_name = unsafe { alarm.soundName() }.map(|s| s.to_string());
#[allow(deprecated)]
let url = unsafe { alarm.url() }
.as_ref()
.and_then(|u| u.absoluteString())
.map(|s| s.to_string());
let alarm_type = match unsafe { alarm.r#type() } {
EKAlarmType::Display => AlarmType::Display,
EKAlarmType::Audio => AlarmType::Audio,
EKAlarmType::Procedure => AlarmType::Procedure,
EKAlarmType::Email => AlarmType::Email,
_ => AlarmType::Unknown,
};
AlarmInfo {
relative_offset: Some(relative_offset),
absolute_date,
proximity,
location,
email_address,
sound_name,
url,
alarm_type,
}
}
fn create_ek_alarm(info: &AlarmInfo) -> Result<Retained<EKAlarm>> {
let alarm = if let Some(date) = &info.absolute_date {
let nsdate = datetime_to_nsdate(*date);
unsafe { EKAlarm::alarmWithAbsoluteDate(&nsdate) }
} else {
let offset = info.relative_offset.unwrap_or(0.0);
unsafe { EKAlarm::alarmWithRelativeOffset(offset) }
};
let prox = match info.proximity {
AlarmProximity::Enter => EKAlarmProximity::Enter,
AlarmProximity::Leave => EKAlarmProximity::Leave,
AlarmProximity::None => EKAlarmProximity::None,
};
unsafe { alarm.setProximity(prox) };
if let Some(loc) = &info.location {
let structured = build_ek_structured_location(loc);
unsafe { alarm.setStructuredLocation(Some(&structured)) };
}
if let Some(email) = &info.email_address {
let ns = NSString::from_str(email);
unsafe { alarm.setEmailAddress(Some(&ns)) };
}
if let Some(sound) = &info.sound_name {
let ns = NSString::from_str(sound);
unsafe { alarm.setSoundName(Some(&ns)) };
}
if let Some(u) = &info.url {
let ns_str = NSString::from_str(u);
let parsed =
objc2_foundation::NSURL::URLWithString_encodingInvalidCharacters(&ns_str, false)
.ok_or_else(|| EventKitError::InvalidURL(u.clone()))?;
#[allow(deprecated)]
unsafe {
alarm.setUrl(Some(&parsed))
};
}
Ok(alarm)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_span_default_is_this_and_maps_to_ek() {
assert_eq!(EventSpan::default(), EventSpan::This);
assert_eq!(EventSpan::This.to_ek(), EKSpan::ThisEvent);
assert_eq!(EventSpan::Future.to_ek(), EKSpan::FutureEvents);
}
#[test]
fn event_availability_round_trips_every_variant() {
for v in [
EventAvailability::NotSupported,
EventAvailability::Busy,
EventAvailability::Free,
EventAvailability::Tentative,
EventAvailability::Unavailable,
] {
assert_eq!(EventAvailability::from_ek(v.to_ek()), v);
}
}
#[test]
fn event_status_from_ek_covers_each_variant() {
assert_eq!(EventStatus::from_ek(EKEventStatus::None), EventStatus::None);
assert_eq!(
EventStatus::from_ek(EKEventStatus::Confirmed),
EventStatus::Confirmed
);
assert_eq!(
EventStatus::from_ek(EKEventStatus::Tentative),
EventStatus::Tentative
);
assert_eq!(
EventStatus::from_ek(EKEventStatus::Canceled),
EventStatus::Canceled
);
}
#[test]
fn event_draft_default_has_empty_title_and_zero_dates() {
let d: EventDraft<'_> = EventDraft::default();
assert_eq!(d.title, "");
assert!(d.start.is_none());
assert!(d.end.is_none());
assert!(!d.all_day);
assert!(d.URL.is_none());
assert!(d.availability.is_none());
}
#[test]
fn event_patch_default_touches_nothing() {
let p: EventPatch<'_> = EventPatch::default();
assert!(p.title.is_none());
assert!(p.notes.is_none());
assert!(p.URL.is_none());
assert!(p.availability.is_none());
assert!(p.all_day.is_none());
assert_eq!(p.span, EventSpan::This);
}
#[test]
fn test_authorization_status_display() {
assert_eq!(
format!("{}", AuthorizationStatus::NotDetermined),
"Not Determined"
);
assert_eq!(
format!("{}", AuthorizationStatus::FullAccess),
"Full Access"
);
}
#[test]
fn test_event_item_debug() {
let event = EventItem {
identifier: "test".to_string(),
title: "Test Event".to_string(),
notes: None,
location: None,
start_date: Local::now(),
end_date: Local::now(),
all_day: false,
calendar_title: None,
calendar_id: None,
URL: None,
availability: EventAvailability::Busy,
status: EventStatus::None,
is_detached: false,
occurrence_date: None,
structured_location: None,
creation_date: None,
last_modified_date: None,
external_identifier: None,
timezone: None,
attachments_count: 0,
attendees: Vec::new(),
organizer: None,
};
assert!(format!("{:?}", event).contains("Test Event"));
}
}