Trying very hard not to miss calendar events
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve handling of recurring events

Co-authored-by: Claude <noreply@anthropic.com>

+269 -26
+22
crates/alarma-cli/src/output/mod.rs
··· 109 109 end: String, 110 110 #[tabled(rename = "Location")] 111 111 location: String, 112 + #[tabled(rename = "Recurs")] 113 + periodicity: String, 114 + #[tabled(rename = "First")] 115 + first_occurrence: String, 112 116 } 113 117 114 118 fn print_events_table(events: &[CalendarEvent]) -> io::Result<()> { ··· 121 125 .iter() 122 126 .map(|e| { 123 127 let (start_str, end_str) = format_event_times(&e.start, &e.end); 128 + let (periodicity, first_occurrence) = if let Some(ref pattern) = e.recurrence_pattern { 129 + let first_str = match &pattern.first_occurrence { 130 + EventTime::Date(d) => d.format("%Y-%m-%d").to_string(), 131 + EventTime::DateTime(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), 132 + }; 133 + (pattern.periodicity.clone(), first_str) 134 + } else { 135 + ("-".to_string(), "-".to_string()) 136 + }; 124 137 EventRow { 125 138 source: e.source_uid.clone(), 126 139 summary: e.summary.clone(), 127 140 start: start_str, 128 141 end: end_str, 129 142 location: e.location.clone().unwrap_or_else(|| "-".to_string()), 143 + periodicity, 144 + first_occurrence, 130 145 } 131 146 }) 132 147 .collect(); ··· 158 173 ); 159 174 if let Some(location) = &event.location { 160 175 println!(" Location: {}", location); 176 + } 177 + if let Some(ref pattern) = event.recurrence_pattern { 178 + let first_str = match &pattern.first_occurrence { 179 + EventTime::Date(d) => d.format("%Y-%m-%d").to_string(), 180 + EventTime::DateTime(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), 181 + }; 182 + println!(" Recurs: {} (first: {})", pattern.periodicity, first_str); 161 183 } 162 184 } 163 185 println!("\nTotal: {} event(s)", events.len());
+20
crates/alarma-core/src/domain/event.rs
··· 74 74 } 75 75 } 76 76 77 + /// Recurrence pattern information 78 + #[derive(Debug, Clone, PartialEq, Eq)] 79 + #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 80 + pub struct RecurrencePattern { 81 + /// Periodicity of recurrence (e.g., "daily", "weekly", "monthly") 82 + pub periodicity: String, 83 + /// First occurrence time of the recurring event 84 + pub first_occurrence: EventTime, 85 + } 86 + 77 87 /// Represents a calendar event 78 88 #[derive(Debug, Clone)] 79 89 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] ··· 92 102 pub end: EventTime, 93 103 /// Source UID (which calendar this event belongs to) 94 104 pub source_uid: String, 105 + /// Recurrence pattern for recurring events 106 + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] 107 + pub recurrence_pattern: Option<RecurrencePattern>, 95 108 } 96 109 97 110 impl CalendarEvent { ··· 111 124 start, 112 125 end, 113 126 source_uid, 127 + recurrence_pattern: None, 114 128 } 115 129 } 116 130 ··· 123 137 /// Sets the location 124 138 pub fn with_location(mut self, location: Option<String>) -> Self { 125 139 self.location = location; 140 + self 141 + } 142 + 143 + /// Sets the recurrence pattern 144 + pub fn with_recurrence_pattern(mut self, pattern: Option<RecurrencePattern>) -> Self { 145 + self.recurrence_pattern = pattern; 126 146 self 127 147 } 128 148
+1 -1
crates/alarma-core/src/domain/mod.rs
··· 3 3 pub mod event; 4 4 pub mod source; 5 5 6 - pub use event::{CalendarEvent, EventTime}; 6 + pub use event::{CalendarEvent, EventTime, RecurrencePattern}; 7 7 pub use source::CalendarSource;
+121
crates/alarma-eds/src/ffi/mod.rs
··· 263 263 Ok(components) 264 264 } 265 265 } 266 + 267 + /// Generate instances of recurring events with actual occurrence times 268 + pub fn generate_instances( 269 + &self, 270 + start_time: i64, 271 + end_time: i64, 272 + ) -> Result<Vec<(ICalComponent, ICalTime, ICalTime)>> { 273 + unsafe { 274 + // Collection structure to be passed to callback 275 + struct InstanceCollector { 276 + instances: Vec<(ICalComponent, ICalTime, ICalTime)>, 277 + } 278 + 279 + // Callback that collects each instance 280 + unsafe extern "C" fn collect_instance( 281 + icomp: *mut bindings::ICalComponent, 282 + instance_start: *mut bindings::ICalTime, 283 + instance_end: *mut bindings::ICalTime, 284 + user_data: bindings::gpointer, 285 + _cancellable: *mut bindings::GCancellable, 286 + _error: *mut *mut bindings::GError, 287 + ) -> bindings::gboolean { 288 + let collector = &mut *(user_data as *mut InstanceCollector); 289 + 290 + // Ref all objects before taking ownership 291 + glib::gobject_ffi::g_object_ref(icomp as *mut _); 292 + glib::gobject_ffi::g_object_ref(instance_start as *mut _); 293 + glib::gobject_ffi::g_object_ref(instance_end as *mut _); 294 + 295 + collector.instances.push(( 296 + ICalComponent { ptr: icomp }, 297 + ICalTime { 298 + ptr: instance_start, 299 + }, 300 + ICalTime { ptr: instance_end }, 301 + )); 302 + 303 + // Return 1 (TRUE) to continue generating instances 304 + 1 305 + } 306 + 307 + let mut collector = InstanceCollector { 308 + instances: Vec::new(), 309 + }; 310 + 311 + // Call the FFI function 312 + bindings::e_cal_client_generate_instances_sync( 313 + self.ptr as *mut bindings::ECalClient, 314 + start_time, 315 + end_time, 316 + ptr::null_mut(), // No cancellable 317 + Some(collect_instance), 318 + &mut collector as *mut _ as bindings::gpointer, 319 + ); 320 + 321 + Ok(collector.instances) 322 + } 323 + } 266 324 } 267 325 268 326 impl Drop for CalClient { ··· 349 407 unsafe { 350 408 let ptr = bindings::i_cal_component_get_dtend(self.ptr); 351 409 ICalTime { ptr } 410 + } 411 + } 412 + 413 + /// Check if this component has a RECURRENCE-ID property 414 + /// (indicates it's an instance of a recurring event) 415 + pub fn has_recurrence_id(&self) -> bool { 416 + unsafe { 417 + let ptr = bindings::i_cal_component_get_recurrenceid(self.ptr); 418 + let has_id = !ptr.is_null() && bindings::i_cal_time_is_null_time(ptr) == 0; 419 + if !ptr.is_null() { 420 + glib::gobject_ffi::g_object_unref(ptr as *mut _); 421 + } 422 + has_id 423 + } 424 + } 425 + 426 + /// Check if this component has an RRULE property 427 + /// (indicates it's a master recurring event) 428 + pub fn has_rrule(&self) -> bool { 429 + unsafe { 430 + let prop = bindings::i_cal_component_get_first_property( 431 + self.ptr, 432 + bindings::ICalPropertyKind_I_CAL_RRULE_PROPERTY, 433 + ); 434 + let has_rrule = !prop.is_null(); 435 + if !prop.is_null() { 436 + glib::gobject_ffi::g_object_unref(prop as *mut _); 437 + } 438 + has_rrule 439 + } 440 + } 441 + 442 + /// Get the recurrence frequency (periodicity) from RRULE 443 + pub fn recurrence_frequency(&self) -> Option<String> { 444 + unsafe { 445 + let prop = bindings::i_cal_component_get_first_property( 446 + self.ptr, 447 + bindings::ICalPropertyKind_I_CAL_RRULE_PROPERTY, 448 + ); 449 + if prop.is_null() { 450 + return None; 451 + } 452 + 453 + let rrule = bindings::i_cal_property_get_rrule(prop); 454 + let freq_str = if !rrule.is_null() { 455 + let freq = bindings::i_cal_recurrence_get_freq(rrule); 456 + let freq_cstr = bindings::i_cal_recurrence_frequency_to_string(freq); 457 + if !freq_cstr.is_null() { 458 + let result = CStr::from_ptr(freq_cstr).to_string_lossy().to_lowercase(); 459 + Some(result) 460 + } else { 461 + None 462 + } 463 + } else { 464 + None 465 + }; 466 + 467 + if !rrule.is_null() { 468 + glib::gobject_ffi::g_object_unref(rrule as *mut _); 469 + } 470 + glib::gobject_ffi::g_object_unref(prop as *mut _); 471 + 472 + freq_str 352 473 } 353 474 } 354 475 }
+105 -25
crates/alarma-eds/src/repository.rs
··· 3 3 //! This module implements the CalendarRepository trait using EDS as the backend. 4 4 5 5 use alarma_core::{ 6 - domain::{CalendarEvent, CalendarSource, EventTime}, 6 + domain::{CalendarEvent, CalendarSource, EventTime, RecurrencePattern}, 7 7 repository::{CalendarRepository, DateRange}, 8 8 AlarmaError, Result, 9 9 }; ··· 23 23 Ok(Self { registry }) 24 24 } 25 25 26 - /// Converts an ICalComponent to a CalendarEvent 27 - fn ical_to_event( 26 + /// Converts an ICalComponent instance to a CalendarEvent using provided times 27 + fn ical_to_event_instance( 28 28 component: &crate::ffi::ICalComponent, 29 + instance_start: &crate::ffi::ICalTime, 30 + instance_end: &crate::ffi::ICalTime, 29 31 source_uid: String, 30 32 ) -> Option<CalendarEvent> { 31 33 let summary = component ··· 35 37 let location = component.location(); 36 38 let uid = component.uid(); 37 39 38 - let start_time = component.dtstart(); 39 - let end_time = component.dtend(); 40 - 41 - let is_all_day = start_time.is_date(); 40 + let is_all_day = instance_start.is_date(); 42 41 43 42 let (start_event_time, end_event_time) = if is_all_day { 44 43 // All-day event - use date only 45 - let start_timestamp = start_time.as_timet(); 46 - let end_timestamp = end_time.as_timet(); 44 + let start_timestamp = instance_start.as_timet(); 45 + let end_timestamp = instance_end.as_timet(); 47 46 48 47 let start_dt = Local.timestamp_opt(start_timestamp, 0).single()?; 49 48 let end_dt = Local.timestamp_opt(end_timestamp, 0).single()?; ··· 54 53 ) 55 54 } else { 56 55 // Timed event - use full datetime 57 - let start_timestamp = start_time.as_timet(); 58 - let end_timestamp = end_time.as_timet(); 56 + let start_timestamp = instance_start.as_timet(); 57 + let end_timestamp = instance_end.as_timet(); 59 58 60 59 ( 61 60 EventTime::DateTime(Local.timestamp_opt(start_timestamp, 0).single()?), ··· 63 62 ) 64 63 }; 65 64 65 + // Extract recurrence pattern if this is a master 66 + let recurrence_pattern = if component.has_rrule() { 67 + component 68 + .recurrence_frequency() 69 + .map(|periodicity| { 70 + // For first_occurrence, use the master's original DTSTART, not the instance time 71 + // Note: This will be corrected later from pre-fetched master data 72 + let master_start = component.dtstart(); 73 + let master_is_all_day = master_start.is_date(); 74 + 75 + let first_occurrence = if master_is_all_day { 76 + let start_timestamp = master_start.as_timet(); 77 + let start_dt = Local.timestamp_opt(start_timestamp, 0).single()?; 78 + EventTime::Date(start_dt.date_naive()) 79 + } else { 80 + let start_timestamp = master_start.as_timet(); 81 + EventTime::DateTime(Local.timestamp_opt(start_timestamp, 0).single()?) 82 + }; 83 + 84 + Some(RecurrencePattern { 85 + periodicity, 86 + first_occurrence, 87 + }) 88 + }) 89 + .flatten() 90 + } else { 91 + None 92 + }; 93 + 66 94 Some( 67 95 CalendarEvent::new(uid, summary, start_event_time, end_event_time, source_uid) 68 96 .with_description(description) 69 - .with_location(location), 97 + .with_location(location) 98 + .with_recurrence_pattern(recurrence_pattern), 70 99 ) 71 100 } 72 101 } ··· 95 124 // Connect to the calendar 96 125 let client = CalClient::connect(source)?; 97 126 98 - // Query events 99 - let components = client.list_events(range.start_timestamp(), range.end_timestamp())?; 127 + // First, get master events to extract their original first occurrence dates 128 + use std::collections::HashMap; 129 + let master_components = 130 + client.list_events(range.start_timestamp(), range.end_timestamp())?; 131 + let mut master_first_occurrences: HashMap<String, EventTime> = HashMap::new(); 100 132 101 - // Convert to domain events and filter by actual start time 102 - // EDS may return recurring event masters that are outside the range, 103 - // so we need to filter by the actual event start time 104 - let range_start = range.start_timestamp(); 105 - let range_end = range.end_timestamp(); 133 + for comp in &master_components { 134 + if comp.has_rrule() { 135 + let uid = comp.uid(); 136 + let dtstart = comp.dtstart(); 137 + let is_all_day = dtstart.is_date(); 138 + 139 + let first_occurrence = if is_all_day { 140 + let timestamp = dtstart.as_timet(); 141 + let dt = Local.timestamp_opt(timestamp, 0).single(); 142 + dt.map(|d| EventTime::Date(d.date_naive())) 143 + } else { 144 + let timestamp = dtstart.as_timet(); 145 + Local 146 + .timestamp_opt(timestamp, 0) 147 + .single() 148 + .map(EventTime::DateTime) 149 + }; 106 150 107 - Ok(components 151 + if let Some(first_occ) = first_occurrence { 152 + // Extract base UID (the part before _R suffix for recurring instances) 153 + let base_uid = uid.split('_').next().unwrap_or(&uid).to_string(); 154 + master_first_occurrences.insert(base_uid, first_occ); 155 + } 156 + } 157 + } 158 + 159 + // Now use generate_instances to get all instances with correct occurrence times 160 + let instances = 161 + client.generate_instances(range.start_timestamp(), range.end_timestamp())?; 162 + 163 + // Convert instances to events 164 + let mut events: Vec<CalendarEvent> = instances 108 165 .iter() 109 - .filter_map(|comp| Self::ical_to_event(comp, source_uid.to_string())) 110 - .filter(|event| { 111 - let event_start = event.start.timestamp(); 112 - event_start >= range_start && event_start < range_end 166 + .filter_map(|(component, instance_start, instance_end)| { 167 + let mut event = Self::ical_to_event_instance( 168 + component, 169 + instance_start, 170 + instance_end, 171 + source_uid.to_string(), 172 + )?; 173 + 174 + // Fix the first_occurrence for recurring events using pre-fetched master data 175 + if let Some(ref mut pattern) = event.recurrence_pattern { 176 + let base_uid = event 177 + .uid 178 + .split('_') 179 + .next() 180 + .unwrap_or(&event.uid) 181 + .to_string(); 182 + if let Some(first_occ) = master_first_occurrences.get(&base_uid) { 183 + pattern.first_occurrence = first_occ.clone(); 184 + } 185 + } 186 + 187 + Some(event) 113 188 }) 114 - .collect()) 189 + .collect(); 190 + 191 + // Sort by start time 192 + events.sort_by_key(|e| e.start.timestamp()); 193 + 194 + Ok(events) 115 195 } 116 196 } 117 197