cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use chrono::{DateTime, Local, TimeZone};
use objc2::rc::Retained;
use objc2_event_kit::{EKAuthorizationStatus, EKCalendar, EKEntityType, EKEvent, EKEventStore};
use objc2_foundation::NSArray;

use crate::MacosError;

use super::CalendarEvent;

pub(super) fn list_events(
    from: DateTime<Local>,
    to: DateTime<Local>,
    calendar_filter: Option<&str>,
) -> Result<Option<Vec<CalendarEvent>>, MacosError> {
    if !authorization_allows_event_reads() {
        return Ok(None);
    }

    let store = unsafe { EKEventStore::new() };
    let calendars = filtered_calendars(&store, calendar_filter)?;
    let from_date =
        objc2_foundation::NSDate::dateWithTimeIntervalSince1970(from.timestamp() as f64);
    let to_date = objc2_foundation::NSDate::dateWithTimeIntervalSince1970(to.timestamp() as f64);
    let predicate = unsafe {
        store.predicateForEventsWithStartDate_endDate_calendars(
            &from_date,
            &to_date,
            calendars.as_deref(),
        )
    };
    let events = unsafe { store.eventsMatchingPredicate(&predicate) };
    let items = events
        .to_vec()
        .into_iter()
        .map(|event| event_to_item(&event))
        .collect::<Result<Vec<_>, _>>()?;
    Ok(Some(items))
}

fn authorization_allows_event_reads() -> bool {
    let status = unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) };
    status == EKAuthorizationStatus::FullAccess
}

fn filtered_calendars(
    store: &EKEventStore,
    calendar_filter: Option<&str>,
) -> Result<Option<Retained<NSArray<EKCalendar>>>, MacosError> {
    let Some(filter) = calendar_filter else {
        return Ok(None);
    };

    let calendars = unsafe { store.calendarsForEntityType(EKEntityType::Event) };
    let matching = calendars
        .to_vec()
        .into_iter()
        .filter(|calendar| unsafe { calendar.title() }.to_string() == filter)
        .collect::<Vec<_>>();

    Ok(Some(NSArray::from_retained_slice(&matching)))
}

fn event_to_item(event: &EKEvent) -> Result<CalendarEvent, MacosError> {
    let title = unsafe { event.title() }.to_string();
    let start = nsdate_to_calendar_string(unsafe { event.startDate() }.as_ref())?;
    let end = nsdate_to_calendar_string(unsafe { event.endDate() }.as_ref())?;
    let calendar = unsafe { event.calendar() }
        .map(|calendar| unsafe { calendar.title() }.to_string())
        .unwrap_or_default();
    let location = unsafe { event.location() }
        .map(|value| value.to_string())
        .unwrap_or_default();
    let notes = unsafe { event.notes() }
        .map(|value| value.to_string())
        .unwrap_or_default();
    let all_day = unsafe { event.isAllDay() };

    Ok(CalendarEvent {
        title,
        start,
        end,
        calendar,
        location,
        notes,
        all_day,
    })
}

fn nsdate_to_calendar_string(date: &objc2_foundation::NSDate) -> Result<String, MacosError> {
    let timestamp = date.timeIntervalSince1970();
    let local = Local
        .timestamp_opt(timestamp as i64, 0)
        .single()
        .ok_or_else(|| MacosError::Other(format!("invalid EventKit date: {timestamp}")))?;
    Ok(local.format("%Y-%m-%dT%H:%M:%S").to_string())
}

#[cfg(test)]
mod tests {
    use chrono::{Local, TimeZone};

    use super::nsdate_to_calendar_string;

    #[test]
    fn eventkit_dates_format_like_calendar_output() {
        let local = Local
            .with_ymd_and_hms(2026, 4, 21, 16, 30, 45)
            .single()
            .expect("local datetime");
        let date =
            objc2_foundation::NSDate::dateWithTimeIntervalSince1970(local.timestamp() as f64);

        let formatted = nsdate_to_calendar_string(&date).expect("formatted date");

        assert_eq!(formatted, "2026-04-21T16:30:45");
    }
}