mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 22:52:22 +00:00
WIP: Preparation for recurrence expansion
This commit is contained in:
@@ -2,7 +2,7 @@ use super::{
|
||||
CalDateTime, parse_duration,
|
||||
rrule::{ParserError, RecurrenceRule},
|
||||
};
|
||||
use crate::Error;
|
||||
use crate::{Error, calendar::ComponentMut};
|
||||
use chrono::Duration;
|
||||
use ical::{
|
||||
generator::IcalEvent,
|
||||
@@ -15,12 +15,13 @@ use std::collections::HashMap;
|
||||
pub struct EventObject {
|
||||
pub(crate) event: IcalEvent,
|
||||
pub(crate) timezones: HashMap<String, IcalTimeZone>,
|
||||
pub(crate) ics: String,
|
||||
}
|
||||
|
||||
impl EventObject {
|
||||
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
if let Some(dtstart) = self.event.get_property("DTSTART") {
|
||||
CalDateTime::parse_prop(dtstart, &self.timezones)
|
||||
Ok(CalDateTime::parse_prop(dtstart, &self.timezones)?)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -33,21 +34,25 @@ impl EventObject {
|
||||
}
|
||||
|
||||
if let Some(dtend) = self.event.get_property("DTEND") {
|
||||
return CalDateTime::parse_prop(dtend, &self.timezones);
|
||||
return Ok(CalDateTime::parse_prop(dtend, &self.timezones)?);
|
||||
};
|
||||
|
||||
let duration = if let Some(Property {
|
||||
let duration = self.get_duration()?.unwrap_or(Duration::days(1));
|
||||
|
||||
let first_occurence = self.get_first_occurence()?;
|
||||
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
|
||||
}
|
||||
|
||||
pub fn get_duration(&self) -> Result<Option<Duration>, Error> {
|
||||
if let Some(Property {
|
||||
value: Some(duration),
|
||||
..
|
||||
}) = self.event.get_property("DURATION")
|
||||
{
|
||||
parse_duration(duration)?
|
||||
Ok(Some(parse_duration(duration)?))
|
||||
} else {
|
||||
Duration::days(1)
|
||||
};
|
||||
|
||||
let first_occurence = self.get_first_occurence()?;
|
||||
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recurrence_rule(&self) -> Result<Option<RecurrenceRule>, ParserError> {
|
||||
@@ -62,10 +67,73 @@ impl EventObject {
|
||||
RecurrenceRule::parse(rrule).map(Some)
|
||||
}
|
||||
|
||||
pub fn expand_recurrence(&self) -> Result<(), Error> {
|
||||
let rrule = self.event.get_property("RRULE").unwrap();
|
||||
dbg!(rrule);
|
||||
Ok(())
|
||||
pub fn expand_recurrence(&self) -> Result<Vec<IcalEvent>, Error> {
|
||||
if let Some(rrule) = self.recurrence_rule()? {
|
||||
let mut events = vec![];
|
||||
let first_occurence = self.get_first_occurence()?.unwrap();
|
||||
let dates = rrule.between(first_occurence, None);
|
||||
|
||||
for date in dates {
|
||||
let dtstart_utc = date.cal_utc();
|
||||
let mut ev = self.event.clone();
|
||||
ev.remove_property("RRULE");
|
||||
ev.set_property(Property {
|
||||
name: "RECURRENCE-ID".to_string(),
|
||||
value: Some(dtstart_utc.format()),
|
||||
params: None,
|
||||
});
|
||||
ev.set_property(Property {
|
||||
name: "DTSTART".to_string(),
|
||||
value: Some(dtstart_utc.format()),
|
||||
params: None,
|
||||
});
|
||||
if let Some(duration) = self.get_duration()? {
|
||||
ev.set_property(Property {
|
||||
name: "DTEND".to_string(),
|
||||
value: Some((dtstart_utc + duration).format()),
|
||||
params: None,
|
||||
});
|
||||
}
|
||||
events.push(ev);
|
||||
}
|
||||
Ok(events)
|
||||
} else {
|
||||
Ok(vec![self.event.clone()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::CalendarObject;
|
||||
|
||||
const ICS: &str = r#"BEGIN:VCALENDAR
|
||||
CALSCALE:GREGORIAN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Berlin
|
||||
X-LIC-LOCATION:Europe/Berlin
|
||||
END:VTIMEZONE
|
||||
|
||||
BEGIN:VEVENT
|
||||
UID:318ec6503573d9576818daf93dac07317058d95c
|
||||
DTSTAMP:20250502T132758Z
|
||||
DTSTART;TZID=Europe/Berlin:20250506T090000
|
||||
DTEND;TZID=Europe/Berlin:20250506T092500
|
||||
SEQUENCE:2
|
||||
SUMMARY:weekly stuff
|
||||
TRANSP:OPAQUE
|
||||
RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU
|
||||
END:VEVENT
|
||||
END:VCALENDAR"#;
|
||||
|
||||
#[test]
|
||||
fn test_expand_recurrence() {
|
||||
let event = CalendarObject::from_ics(
|
||||
"318ec6503573d9576818daf93dac07317058d95c".to_string(),
|
||||
ICS.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(event.expand_recurrence().unwrap(), "asd".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use ical::{generator::IcalEvent, property::Property};
|
||||
|
||||
pub trait IcalProperty {
|
||||
fn get_param(&self, name: &str) -> Option<Vec<&str>>;
|
||||
fn get_value_type(&self) -> Option<&str>;
|
||||
@@ -23,3 +25,22 @@ impl IcalProperty for ical::property::Property {
|
||||
.and_then(|params| params.into_iter().next())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ComponentMut {
|
||||
fn remove_property(&mut self, name: &str);
|
||||
fn set_property(&mut self, prop: Property);
|
||||
fn push_property(&mut self, prop: Property);
|
||||
}
|
||||
|
||||
impl ComponentMut for IcalEvent {
|
||||
fn remove_property(&mut self, name: &str) {
|
||||
self.properties.retain(|prop| prop.name != name);
|
||||
}
|
||||
fn set_property(&mut self, prop: Property) {
|
||||
self.remove_property(&prop.name);
|
||||
self.push_property(prop);
|
||||
}
|
||||
fn push_property(&mut self, prop: Property) {
|
||||
self.properties.push(prop);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ use ical::parser::ical::component::IcalJournal;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JournalObject {
|
||||
pub journal: IcalJournal,
|
||||
pub(crate) ics: String,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ mod event;
|
||||
mod ical;
|
||||
mod journal;
|
||||
mod object;
|
||||
mod rrule;
|
||||
pub mod rrule;
|
||||
mod timestamp;
|
||||
mod todo;
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::{CalDateTime, EventObject, JournalObject, TodoObject};
|
||||
use crate::Error;
|
||||
use ical::parser::{Component, ical::component::IcalTimeZone};
|
||||
use ical::{
|
||||
generator::{Emitter, IcalCalendar},
|
||||
parser::{Component, ical::component::IcalTimeZone},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::HashMap, io::BufReader};
|
||||
@@ -55,8 +58,8 @@ pub enum CalendarObjectComponent {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarObject {
|
||||
id: String,
|
||||
ics: String,
|
||||
data: CalendarObjectComponent,
|
||||
cal: IcalCalendar,
|
||||
}
|
||||
|
||||
impl CalendarObject {
|
||||
@@ -95,26 +98,31 @@ impl CalendarObject {
|
||||
if let Some(event) = cal.events.first() {
|
||||
return Ok(CalendarObject {
|
||||
id: object_id,
|
||||
ics,
|
||||
cal: cal.clone(),
|
||||
data: CalendarObjectComponent::Event(EventObject {
|
||||
event: event.clone(),
|
||||
timezones,
|
||||
ics,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if let Some(todo) = cal.todos.first() {
|
||||
return Ok(CalendarObject {
|
||||
id: object_id,
|
||||
ics,
|
||||
data: CalendarObjectComponent::Todo(TodoObject { todo: todo.clone() }),
|
||||
cal: cal.clone(),
|
||||
data: CalendarObjectComponent::Todo(TodoObject {
|
||||
todo: todo.clone(),
|
||||
ics,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if let Some(journal) = cal.journals.first() {
|
||||
return Ok(CalendarObject {
|
||||
id: object_id,
|
||||
ics,
|
||||
cal: cal.clone(),
|
||||
data: CalendarObjectComponent::Journal(JournalObject {
|
||||
journal: journal.clone(),
|
||||
ics,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -135,7 +143,11 @@ impl CalendarObject {
|
||||
}
|
||||
|
||||
pub fn get_ics(&self) -> &str {
|
||||
&self.ics
|
||||
match &self.data {
|
||||
CalendarObjectComponent::Todo(todo) => &todo.ics,
|
||||
CalendarObjectComponent::Event(event) => &event.ics,
|
||||
CalendarObjectComponent::Journal(journal) => &journal.ics,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_component_name(&self) -> &str {
|
||||
@@ -167,4 +179,16 @@ impl CalendarObject {
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_recurrence(&self) -> Result<String, Error> {
|
||||
// Only events can be expanded
|
||||
match &self.data {
|
||||
CalendarObjectComponent::Event(event) => {
|
||||
let mut cal = self.cal.clone();
|
||||
cal.events = event.expand_recurrence()?;
|
||||
Ok(cal.generate())
|
||||
}
|
||||
_ => Ok(self.get_ics().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
crates/store/src/calendar/rrule/iter.rs
Normal file
41
crates/store/src/calendar/rrule/iter.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::calendar::CalDateTime;
|
||||
|
||||
use super::{RecurrenceLimit, RecurrenceRule};
|
||||
|
||||
impl RecurrenceRule {
|
||||
pub fn between(
|
||||
&self,
|
||||
start: CalDateTime,
|
||||
end: Option<CalDateTime>,
|
||||
) -> impl IntoIterator<Item = CalDateTime> {
|
||||
let start = start.cal_utc();
|
||||
// Terrible code, should clean this up later.
|
||||
let mut end = end.map(|end| CalDateTime::cal_utc(&end));
|
||||
if let Some(RecurrenceLimit::Until(until)) = &self.limit {
|
||||
let until = until.cal_utc();
|
||||
let mut _end = end.unwrap_or(until.clone());
|
||||
if until.utc() < _end.utc() {
|
||||
_end = until;
|
||||
}
|
||||
end = Some(_end);
|
||||
}
|
||||
let count = if let Some(RecurrenceLimit::Count(count)) = &self.limit {
|
||||
*count
|
||||
} else {
|
||||
2048
|
||||
};
|
||||
|
||||
let mut datetimes = vec![start.clone()];
|
||||
let mut datetime_utc = start.utc();
|
||||
while datetimes.len() < count {
|
||||
if let Some(end) = &end {
|
||||
if datetime_utc > end.utc() {
|
||||
break;
|
||||
}
|
||||
datetimes.push(CalDateTime::Utc(datetime_utc));
|
||||
}
|
||||
}
|
||||
|
||||
datetimes
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::CalDateTime;
|
||||
use super::{CalDateTime, CalDateTimeError};
|
||||
use std::{num::ParseIntError, str::FromStr};
|
||||
use strum_macros::EnumString;
|
||||
|
||||
mod iter;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParserError {
|
||||
#[error("Missing RRULE FREQ")]
|
||||
@@ -12,9 +14,8 @@ pub enum ParserError {
|
||||
StrumError(#[from] strum::ParseError),
|
||||
#[error(transparent)]
|
||||
ParseIntError(#[from] ParseIntError),
|
||||
// A little dumb :(
|
||||
#[error(transparent)]
|
||||
StoreError(#[from] crate::Error),
|
||||
CalDateTimeError(#[from] CalDateTimeError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, EnumString, Default, PartialEq)]
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::IcalProperty;
|
||||
use crate::Error;
|
||||
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use derive_more::derive::Deref;
|
||||
use ical::{
|
||||
@@ -22,6 +21,24 @@ const LOCAL_DATE_TIME: &str = "%Y%m%dT%H%M%S";
|
||||
const UTC_DATE_TIME: &str = "%Y%m%dT%H%M%SZ";
|
||||
pub const LOCAL_DATE: &str = "%Y%m%d";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CalDateTimeError {
|
||||
#[error(
|
||||
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {0} is invalid"
|
||||
)]
|
||||
InvalidOlson(String),
|
||||
#[error("TZID {0} does not refer to a valid timezone")]
|
||||
InvalidTZID(String),
|
||||
#[error("Timestamp doesn't exist because of gap in local time")]
|
||||
LocalTimeGap,
|
||||
#[error("Datetime string {0} has an invalid format")]
|
||||
InvalidDatetimeFormat(String),
|
||||
#[error("Could not parse datetime {0}")]
|
||||
ParseError(String),
|
||||
#[error("Duration string {0} has an invalid format")]
|
||||
InvalidDurationFormat(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deref, PartialEq)]
|
||||
pub struct UtcDateTime(DateTime<Utc>);
|
||||
|
||||
@@ -49,7 +66,7 @@ impl ValueSerialize for UtcDateTime {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CalDateTime {
|
||||
// Form 1, example: 19980118T230000
|
||||
Local(NaiveDateTime),
|
||||
Local(DateTime<Local>),
|
||||
// Form 2, example: 19980119T070000Z
|
||||
Utc(DateTime<Utc>),
|
||||
// Form 3, example: TZID=America/New_York:19980119T020000
|
||||
@@ -66,7 +83,13 @@ impl Add<Duration> for CalDateTime {
|
||||
Self::Local(datetime) => Self::Local(datetime + duration),
|
||||
Self::Utc(datetime) => Self::Utc(datetime + duration),
|
||||
Self::OlsonTZ(datetime) => Self::OlsonTZ(datetime + duration),
|
||||
Self::Date(date) => Self::Local(date.and_time(NaiveTime::default()) + duration),
|
||||
Self::Date(date) => Self::Local(
|
||||
date.and_time(NaiveTime::default())
|
||||
.and_local_timezone(Local)
|
||||
.earliest()
|
||||
.expect("Local timezone has constant offset")
|
||||
+ duration,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +98,7 @@ impl CalDateTime {
|
||||
pub fn parse_prop(
|
||||
prop: &Property,
|
||||
timezones: &HashMap<String, IcalTimeZone>,
|
||||
) -> Result<Option<Self>, Error> {
|
||||
) -> Result<Option<Self>, CalDateTimeError> {
|
||||
let prop_value = if let Some(value) = prop.value.as_ref() {
|
||||
value
|
||||
} else {
|
||||
@@ -95,9 +118,7 @@ impl CalDateTime {
|
||||
if let Ok(tz) = olson_name.parse::<Tz>() {
|
||||
Some(tz)
|
||||
} else {
|
||||
return Err(Error::InvalidData(format!(
|
||||
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {olson_name} is invalid"
|
||||
)));
|
||||
return Err(CalDateTimeError::InvalidOlson(olson_name));
|
||||
}
|
||||
} else {
|
||||
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
|
||||
@@ -108,9 +129,7 @@ impl CalDateTime {
|
||||
}
|
||||
} else {
|
||||
// TZID refers to timezone that does not exist
|
||||
return Err(Error::InvalidData(format!(
|
||||
"Timezone {tzid} does not exist"
|
||||
)));
|
||||
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
|
||||
}
|
||||
} else {
|
||||
// No explicit timezone specified.
|
||||
@@ -134,25 +153,27 @@ impl CalDateTime {
|
||||
match self {
|
||||
Self::Utc(utc) => utc.date_naive(),
|
||||
Self::Date(date) => date.to_owned(),
|
||||
Self::Local(datetime) => datetime.date(),
|
||||
Self::Local(datetime) => datetime.date_naive(),
|
||||
Self::OlsonTZ(datetime) => datetime.date_naive(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, Error> {
|
||||
pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, CalDateTimeError> {
|
||||
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) {
|
||||
if let Some(timezone) = timezone {
|
||||
let result = datetime.and_local_timezone(timezone);
|
||||
if let Some(datetime) = result.earliest() {
|
||||
return Ok(CalDateTime::OlsonTZ(datetime));
|
||||
} else {
|
||||
// This time does not exist because there's a gap in local time
|
||||
return Err(Error::InvalidData(
|
||||
"Timestamp doesn't exist because of gap in local time".to_owned(),
|
||||
));
|
||||
}
|
||||
return Ok(CalDateTime::OlsonTZ(
|
||||
datetime
|
||||
.and_local_timezone(timezone)
|
||||
.earliest()
|
||||
.ok_or(CalDateTimeError::LocalTimeGap)?,
|
||||
));
|
||||
}
|
||||
return Ok(CalDateTime::Local(datetime));
|
||||
return Ok(CalDateTime::Local(
|
||||
datetime
|
||||
.and_local_timezone(chrono::Local)
|
||||
.earliest()
|
||||
.ok_or(CalDateTimeError::LocalTimeGap)?,
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
|
||||
@@ -177,21 +198,25 @@ impl CalDateTime {
|
||||
|
||||
return Ok(CalDateTime::Date(
|
||||
NaiveDate::from_ymd_opt(year, month, day)
|
||||
.ok_or(Error::InvalidData(format!("Could not parse date {value}")))?,
|
||||
.ok_or(CalDateTimeError::ParseError(value.to_string()))?,
|
||||
));
|
||||
}
|
||||
|
||||
Err(Error::InvalidData("Invalid datetime format".to_owned()))
|
||||
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
|
||||
}
|
||||
|
||||
pub fn utc(&self) -> DateTime<Utc> {
|
||||
match &self {
|
||||
CalDateTime::Local(local_datetime) => local_datetime.and_utc(),
|
||||
CalDateTime::Local(local_datetime) => local_datetime.to_utc(),
|
||||
CalDateTime::Utc(utc_datetime) => utc_datetime.to_owned(),
|
||||
CalDateTime::OlsonTZ(datetime) => datetime.to_utc(),
|
||||
CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cal_utc(&self) -> Self {
|
||||
Self::Utc(self.utc())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CalDateTime> for DateTime<Utc> {
|
||||
@@ -200,10 +225,10 @@ impl From<CalDateTime> for DateTime<Utc> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_duration(string: &str) -> Result<Duration, Error> {
|
||||
pub fn parse_duration(string: &str) -> Result<Duration, CalDateTimeError> {
|
||||
let captures = RE_DURATION
|
||||
.captures(string)
|
||||
.ok_or(Error::InvalidData("Invalid duration format".to_owned()))?;
|
||||
.ok_or(CalDateTimeError::InvalidDurationFormat(string.to_string()))?;
|
||||
|
||||
let mut duration = Duration::zero();
|
||||
if let Some(weeks) = captures.name("W") {
|
||||
|
||||
@@ -3,4 +3,5 @@ use ical::parser::ical::component::IcalTodo;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TodoObject {
|
||||
pub todo: IcalTodo,
|
||||
pub(crate) ics: String,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use actix_web::{http::StatusCode, ResponseError};
|
||||
use actix_web::{ResponseError, http::StatusCode};
|
||||
|
||||
use crate::calendar::CalDateTimeError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
@@ -11,6 +13,9 @@ pub enum Error {
|
||||
#[error("Invalid ics/vcf input: {0}")]
|
||||
InvalidData(String),
|
||||
|
||||
#[error(transparent)]
|
||||
RRuleParserError(#[from] crate::calendar::rrule::ParserError),
|
||||
|
||||
#[error("Read-only")]
|
||||
ReadOnly,
|
||||
|
||||
@@ -25,6 +30,9 @@ pub enum Error {
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
CalDateTimeError(#[from] CalDateTimeError),
|
||||
}
|
||||
|
||||
impl ResponseError for Error {
|
||||
|
||||
Reference in New Issue
Block a user