mod cache;
pub mod config;
mod event;
mod state;
use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::caldir::Caldir;
use crate::calendar::config::CalendarConfig;
use crate::calendar::event::CalendarEvent;
use crate::calendar::state::CalendarState;
use crate::error::{CalDirError, CalDirResult};
use crate::event::{Event, EventTime, Recurrence};
use crate::recurrence::{expand_recurring_event, truncate_recurrence_before};
use crate::remote::Remote;
use crate::utils::slugify;
#[derive(Clone, Serialize, Deserialize)]
pub struct Calendar {
pub slug: String,
pub config: CalendarConfig,
}
impl Calendar {
pub fn new(slug: &str) -> Self {
Calendar {
slug: slug.to_string(),
config: CalendarConfig::default(),
}
}
fn base_slug_for(name: Option<&str>) -> String {
name.map(slugify).unwrap_or_else(|| "calendar".to_string())
}
pub fn unique_slug_for(name: Option<&str>) -> CalDirResult<String> {
let base = Self::base_slug_for(name);
let caldir = Caldir::load()?;
let data_path = caldir.data_path();
if !data_path.join(&base).exists() {
return Ok(base);
}
for n in 2..=100 {
let suffixed = format!("{}-{}", base, n);
if !data_path.join(&suffixed).exists() {
return Ok(suffixed);
}
}
Err(CalDirError::Config(format!(
"Too many calendar name collisions for '{}'",
base
)))
}
pub fn load(slug: &str) -> CalDirResult<Self> {
let calendar_dir = Self::path_for(slug)?;
let config = CalendarConfig::load(&calendar_dir)?;
Ok(Calendar {
slug: slug.to_string(),
config,
})
}
pub fn path_for(slug: &str) -> CalDirResult<PathBuf> {
let caldir = Caldir::load()?;
Ok(caldir.data_path().join(slug))
}
pub fn path(&self) -> CalDirResult<PathBuf> {
Self::path_for(&self.slug)
}
pub fn state(&self) -> CalendarState {
CalendarState::load(self.clone())
}
pub fn save_config(&self) -> CalDirResult<()> {
self.config.save(&self.path()?)
}
pub fn account_email(&self) -> Option<&str> {
self.config.remote.as_ref()?.account_identifier()
}
pub fn remote(&self) -> Option<&Remote> {
self.config.remote.as_ref()
}
pub fn events(&self) -> CalDirResult<Vec<CalendarEvent>> {
let data_path = self.path()?;
cache::cached_events_for_dir(&data_path)
}
pub fn events_in_range(
&self,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> CalDirResult<Vec<Event>> {
let all_events = self.events()?.into_iter().map(|ce| ce.event);
events_in_range_from_events(all_events, from, to)
}
pub fn search_events(&self, query: &str) -> CalDirResult<Vec<CalendarEvent>> {
let query_lower = query.to_lowercase();
let all = self.events()?;
Ok(all
.into_iter()
.filter(|ce| ce.event.summary.to_lowercase().contains(&query_lower))
.collect())
}
pub fn create_event(&self, event: &Event) -> CalDirResult<PathBuf> {
let dir = self.path()?;
std::fs::create_dir_all(&dir)?;
let event_slug = CalendarEvent::unique_slug_for(event, self)?;
let event_path = dir.join(format!("{}.ics", event_slug));
let calendar_event = CalendarEvent::new(event_path.clone(), event);
calendar_event.save()?;
Ok(event_path)
}
pub fn update_event(&self, uid: &str, event: &Event) -> CalDirResult<()> {
self.delete_event(uid, event.recurrence_id.as_ref())?;
self.create_event(event)?;
Ok(())
}
pub fn master_event_for(&self, uid: &str) -> CalDirResult<Option<Event>> {
let master = self
.events()?
.into_iter()
.find(|ce| ce.event.uid == uid && ce.event.recurrence.is_some())
.map(|ce| ce.event);
Ok(master)
}
pub fn split_recurring_series_at(
&self,
master_uid: &str,
split_start: EventTime,
split_end: EventTime,
new_recurrence: Option<Recurrence>,
) -> CalDirResult<Event> {
let all_events = self.events()?;
let master = all_events
.iter()
.find(|ce| ce.event.uid == master_uid && ce.event.recurrence_id.is_none())
.map(|ce| ce.event.clone())
.ok_or_else(|| {
CalDirError::Config(format!("Master event not found: {}", master_uid))
})?;
let master_recurrence = master
.recurrence
.as_ref()
.ok_or_else(|| CalDirError::Config(format!("Event {} is not recurring", master_uid)))?;
let truncated_recurrence =
truncate_recurrence_before(master_recurrence, &master.start, &split_start);
let truncated_master = Event {
recurrence: Some(truncated_recurrence),
updated: Some(Utc::now()),
sequence: master.sequence.map(|s| s + 1).or(Some(1)),
..master.clone()
};
self.update_event(&master.uid, &truncated_master)?;
let new_master = Event {
start: split_start.clone(),
end: split_end,
recurrence: new_recurrence,
recurrence_id: None,
updated: Some(Utc::now()),
sequence: None,
..master.with_new_uid()
};
self.create_event(&new_master)?;
let split_start_utc = split_start.to_utc();
for ce in &all_events {
if ce.event.uid != master_uid {
continue;
}
let Some(rid) = &ce.event.recurrence_id else {
continue;
};
if let (Some(rid_utc), Some(start_utc)) = (rid.to_utc(), split_start_utc)
&& rid_utc >= start_utc
{
self.delete_event(&ce.event.uid, Some(rid))?;
}
}
Ok(new_master)
}
pub fn delete_event(&self, uid: &str, recurrence_id: Option<&EventTime>) -> CalDirResult<()> {
if let Some(local) = self
.events()?
.into_iter()
.find(|e| e.event.uid == uid && e.event.recurrence_id.as_ref() == recurrence_id)
{
std::fs::remove_file(&local.path)?;
}
Ok(())
}
}
impl fmt::Display for Calendar {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.slug)
}
}
pub fn events_in_range_from_events<I>(
all_events: I,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> CalDirResult<Vec<Event>>
where
I: IntoIterator<Item = Event>,
{
events_in_range_from_events_in_zone(all_events, from, to, rrule::Tz::LOCAL)
}
fn events_in_range_from_events_in_zone<I>(
all_events: I,
from: DateTime<Utc>,
to: DateTime<Utc>,
host_tz: rrule::Tz,
) -> CalDirResult<Vec<Event>>
where
I: IntoIterator<Item = Event>,
{
let mut singles: Vec<Event> = Vec::new();
let mut masters: Vec<Event> = Vec::new();
let mut overrides: HashMap<String, HashMap<String, Event>> = HashMap::new();
for event in all_events {
if event.recurrence.is_some() {
masters.push(event);
} else if let Some(ref rid) = event.recurrence_id {
overrides
.entry(event.uid.clone())
.or_default()
.insert(rid.to_ics_string(), event);
} else {
singles.push(event);
}
}
let mut result: Vec<Event> = Vec::new();
for event in singles {
if event.starts_in_range(from, to, &host_tz) {
result.push(event);
}
}
for master in &masters {
let uid_overrides = overrides.remove(&master.uid).unwrap_or_default();
let instances = expand_recurring_event(master, from, to, &uid_overrides, host_tz)?;
result.extend(instances);
}
for (_uid, orphans) in overrides {
for (_rid, event) in orphans {
if event.starts_in_range(from, to, &host_tz) {
result.push(event);
}
}
}
result.sort_by_key(|event| {
event
.start
.resolve_instant_in_zone(&host_tz)
.or_else(|| event.start.to_utc())
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
fn t(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, hour, min, 0)
.unwrap()
}
fn naive(year: i32, month: u32, day: u32, hour: u32, min: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_opt(hour, min, 0)
.unwrap()
}
#[test]
fn events_in_range_preserves_recurring_floating_wall_time() {
let pacific = rrule::Tz::US__Pacific;
let start = EventTime::DateTimeFloating(naive(2026, 1, 15, 9, 0));
let end = EventTime::DateTimeFloating(naive(2026, 1, 15, 10, 0));
let event = Event::new(
"Daily standup".into(),
start.clone(),
end.clone(),
None,
None,
Some(Recurrence {
rrule: "FREQ=DAILY;COUNT=1".into(),
exdates: vec![],
}),
vec![],
);
let events = events_in_range_from_events_in_zone(
vec![event],
t(2026, 1, 15, 16, 30),
t(2026, 1, 15, 17, 30),
pacific,
)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].start, start);
assert_eq!(events[0].end, end);
assert!(events[0].starts_in_range(
t(2026, 1, 15, 16, 30),
t(2026, 1, 15, 17, 30),
&pacific
));
}
}