Move ical-related stuff to dedicated rustical_ical crate

This commit is contained in:
Lennart
2025-05-18 13:46:08 +02:00
parent 3c7ee09116
commit 5ebcab7a19
21 changed files with 249 additions and 97 deletions

View File

@@ -30,6 +30,7 @@ clap.workspace = true
rustical_dav.workspace = true
strum.workspace = true
strum_macros.workspace = true
rustical_ical.workspace = true
[dev-dependencies]
rstest = { workspace = true }

View File

@@ -1,15 +1,12 @@
use std::{collections::HashMap, io::BufReader};
use crate::{
calendar::{CalDateTime, LOCAL_DATE},
CalendarObject, Error,
};
use crate::{CalendarObject, Error};
use chrono::Datelike;
use ical::parser::{
vcard::{self, component::VcardContact},
Component,
vcard::{self, component::VcardContact},
};
use rustical_ical::{CalDateTime, LOCAL_DATE};
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone)]
pub struct AddressObject {

View File

@@ -1,14 +1,14 @@
use super::{
CalDateTime, parse_duration,
rrule::{ParserError, RecurrenceRule},
};
use crate::{Error, calendar::ComponentMut};
use crate::Error;
use chrono::Duration;
use ical::{
generator::IcalEvent,
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use rustical_ical::{
CalDateTime, ComponentMut, parse_duration,
rrule::{ParserError, RecurrenceRule},
};
use std::collections::HashMap;
#[derive(Debug, Clone)]
@@ -71,10 +71,10 @@ impl EventObject {
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);
let dates = rrule.between(first_occurence, None, None);
for date in dates {
let dtstart_utc = date.cal_utc();
let dtstart_utc = date;
let mut ev = self.event.clone();
ev.remove_property("RRULE");
ev.set_property(Property {

View File

@@ -1,46 +0,0 @@
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>;
fn get_tzid(&self) -> Option<&str>;
}
impl IcalProperty for ical::property::Property {
fn get_param(&self, name: &str) -> Option<Vec<&str>> {
self.params
.as_ref()?
.iter()
.find(|(key, _)| name == key)
.map(|(_, value)| value.iter().map(String::as_str).collect())
}
fn get_value_type(&self) -> Option<&str> {
self.get_param("VALUE")
.and_then(|params| params.into_iter().next())
}
fn get_tzid(&self) -> Option<&str> {
self.get_param("TZID")
.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);
}
}

View File

@@ -1,16 +1,11 @@
mod calendar;
mod event;
mod ical;
mod journal;
mod object;
pub mod rrule;
mod timestamp;
mod todo;
pub use calendar::*;
pub use event::*;
pub use ical::*;
pub use journal::*;
pub use object::*;
pub use timestamp::*;
pub use todo::*;

View File

@@ -1,9 +1,10 @@
use super::{CalDateTime, EventObject, JournalObject, TodoObject};
use super::{EventObject, JournalObject, TodoObject};
use crate::Error;
use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use rustical_ical::CalDateTime;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};

View File

@@ -1,41 +0,0 @@
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
}
}

View File

@@ -1,266 +0,0 @@
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")]
MissingFrequency,
#[error("Invalid RRULE part: {0}")]
InvalidPart(String),
#[error(transparent)]
StrumError(#[from] strum::ParseError),
#[error(transparent)]
ParseIntError(#[from] ParseIntError),
#[error(transparent)]
CalDateTimeError(#[from] CalDateTimeError),
}
#[derive(Debug, Clone, EnumString, Default, PartialEq)]
#[strum(serialize_all = "UPPERCASE")]
pub enum RecurrenceFrequency {
Secondly,
Minutely,
Hourly,
#[default]
Daily,
Weekly,
Monthly,
Yearly,
}
#[derive(Debug, Clone, EnumString, PartialEq)]
#[strum(serialize_all = "UPPERCASE")]
pub enum Weekday {
Mo,
Tu,
We,
Th,
Fr,
Sa,
Su,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RecurrenceLimit {
Count(usize),
Until(CalDateTime),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RecurrenceRule {
// Self-explanatory
pub frequency: RecurrenceFrequency,
pub limit: Option<RecurrenceLimit>,
// Repeat every n-th time
pub interval: Option<usize>,
pub bysecond: Option<Vec<usize>>,
pub byminute: Option<Vec<usize>>,
pub byhour: Option<Vec<usize>>,
pub byday: Option<Vec<(Option<i64>, Weekday)>>,
pub bymonthday: Option<Vec<i8>>,
pub byyearday: Option<Vec<i64>>,
pub byweekno: Option<Vec<i8>>,
pub bymonth: Option<Vec<i8>>,
pub week_start: Option<Weekday>,
// Selects the n-th occurence within an a recurrence rule
pub bysetpos: Option<Vec<i64>>,
}
impl RecurrenceRule {
pub fn parse(rule: &str) -> Result<Self, ParserError> {
let mut frequency = None;
let mut limit = None;
let mut interval = None;
let mut bysecond = None;
let mut byminute = None;
let mut byhour = None;
let mut byday = None;
let mut bymonthday = None;
let mut byyearday = None;
let mut byweekno = None;
let mut bymonth = None;
let mut week_start = None;
let mut bysetpos = None;
for part in rule.split(';') {
match part
.split_once('=')
.ok_or(ParserError::InvalidPart(part.to_owned()))?
{
("FREQ", val) => {
frequency = Some(RecurrenceFrequency::from_str(val)?);
}
("COUNT", val) => limit = Some(RecurrenceLimit::Count(val.parse()?)),
("UNTIL", val) => {
limit = Some(RecurrenceLimit::Until(CalDateTime::parse(val, None)?))
}
("INTERVAL", val) => interval = Some(val.parse()?),
("BYSECOND", val) => {
bysecond = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYMINUTE", val) => {
byminute = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYHOUR", val) => {
byhour = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYDAY", val) => {
byday = Some(
val.split(',')
.map(|val| {
assert!(val.len() >= 2);
let weekday =
Weekday::from_str(val.get((val.len() - 2)..).unwrap())?;
let prefix = if val.len() > 2 {
Some(val.get(..(val.len() - 2)).unwrap().parse()?)
} else {
None
};
Ok((prefix, weekday))
})
.collect::<Result<Vec<_>, ParserError>>()?,
);
}
("BYMONTHDAY", val) => {
bymonthday = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYYEARDAY", val) => {
byyearday = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYWEEKNO", val) => {
byweekno = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("BYMONTH", val) => {
bymonth = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
("WKST", val) => week_start = Some(Weekday::from_str(val)?),
("BYSETPOS", val) => {
bysetpos = Some(
val.split(',')
.map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?,
);
}
(name, val) => panic!("Cannot handle {name}={val}"),
}
}
Ok(Self {
frequency: frequency.ok_or(ParserError::MissingFrequency)?,
limit,
interval,
bysecond,
byminute,
byhour,
byday,
bymonthday,
byyearday,
byweekno,
bymonth,
week_start,
bysetpos,
})
}
}
#[cfg(test)]
mod tests {
use crate::calendar::{
CalDateTime,
rrule::{RecurrenceFrequency, RecurrenceLimit, Weekday},
};
use super::{ParserError, RecurrenceRule};
#[test]
fn parse_recurrence_rule() -> Result<(), ParserError> {
assert_eq!(
RecurrenceRule::parse("FREQ=DAILY;UNTIL=20250516T133000Z;INTERVAL=3")?,
RecurrenceRule {
frequency: RecurrenceFrequency::Daily,
limit: Some(RecurrenceLimit::Until(
CalDateTime::parse("20250516T133000Z", None).unwrap()
)),
interval: Some(3),
..Default::default()
}
);
assert_eq!(
RecurrenceRule::parse("FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU")?,
RecurrenceRule {
frequency: RecurrenceFrequency::Weekly,
limit: Some(RecurrenceLimit::Count(4)),
interval: Some(2),
byday: Some(vec![
(None, Weekday::Tu),
(None, Weekday::Th),
(None, Weekday::Su),
]),
..Default::default()
}
);
// Example: Last workday of the month
assert_eq!(
RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1")?,
RecurrenceRule {
frequency: RecurrenceFrequency::Monthly,
byday: Some(vec![
(None, Weekday::Mo),
(None, Weekday::Tu),
(None, Weekday::We),
(None, Weekday::Th),
(None, Weekday::Fr),
]),
bysetpos: Some(vec![-1]),
..Default::default()
}
);
// Every last Sunday of March
assert_eq!(
RecurrenceRule::parse("FREQ=YEARLY;UNTIL=20370329T010000Z;BYDAY=-1SU;BYMONTH=3")?,
RecurrenceRule {
frequency: RecurrenceFrequency::Yearly,
limit: Some(RecurrenceLimit::Until(
CalDateTime::parse("20370329T010000Z", None).unwrap()
)),
byday: Some(vec![(Some(-1), Weekday::Su)]),
bymonth: Some(vec![3]),
..Default::default()
}
);
Ok(())
}
}

View File

@@ -1,281 +0,0 @@
use super::IcalProperty;
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use derive_more::derive::Deref;
use ical::{
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use lazy_static::lazy_static;
use rustical_xml::{ValueDeserialize, ValueSerialize};
use std::{collections::HashMap, ops::Add};
lazy_static! {
static ref RE_DURATION: regex::Regex = regex::Regex::new(r"^(?<sign>[+-])?P((?P<W>\d+)W)?((?P<D>\d+)D)?(T((?P<H>\d+)H)?((?P<M>\d+)M)?((?P<S>\d+)S)?)?$").unwrap();
static ref RE_VCARD_DATE_MM_DD: regex::Regex =
regex::Regex::new(r"^--(?<m>\d{2})(?<d>\d{2})$").unwrap();
}
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>);
impl ValueDeserialize for UtcDateTime {
fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
let input = <String as ValueDeserialize>::deserialize(val)?;
Ok(Self(
NaiveDateTime::parse_from_str(&input, UTC_DATE_TIME)
.map_err(|_| {
rustical_xml::XmlError::InvalidValue(rustical_xml::ParseValueError::Other(
"Could not parse as UTC timestamp".to_owned(),
))
})?
.and_utc(),
))
}
}
impl ValueSerialize for UtcDateTime {
fn serialize(&self) -> String {
format!("{}", self.0.format(UTC_DATE_TIME))
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CalDateTime {
// Form 1, example: 19980118T230000
Local(DateTime<Local>),
// Form 2, example: 19980119T070000Z
Utc(DateTime<Utc>),
// Form 3, example: TZID=America/New_York:19980119T020000
// https://en.wikipedia.org/wiki/Tz_database
OlsonTZ(DateTime<Tz>),
Date(NaiveDate),
}
impl Add<Duration> for CalDateTime {
type Output = Self;
fn add(self, duration: Duration) -> Self::Output {
match self {
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())
.and_local_timezone(Local)
.earliest()
.expect("Local timezone has constant offset")
+ duration,
),
}
}
}
impl CalDateTime {
pub fn parse_prop(
prop: &Property,
timezones: &HashMap<String, IcalTimeZone>,
) -> Result<Option<Self>, CalDateTimeError> {
let prop_value = if let Some(value) = prop.value.as_ref() {
value
} else {
return Ok(None);
};
// Use the TZID parameter from the property
let timezone = if let Some(tzid) = prop.get_tzid() {
if let Some(timezone) = timezones.get(tzid) {
// X-LIC-LOCATION is often used to refer to a standardised timezone from the Olson
// database
if let Some(olson_name) = timezone
.get_property("X-LIC-LOCATION")
.map(|prop| prop.value.to_owned())
.unwrap_or_default()
{
if let Ok(tz) = olson_name.parse::<Tz>() {
Some(tz)
} else {
return Err(CalDateTimeError::InvalidOlson(olson_name));
}
} else {
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
// guess that we can just use it
tzid.parse::<Tz>().ok()
// TODO: If None: Too bad, we need to manually parse it
// For now it's just treated as localtime
}
} else {
// TZID refers to timezone that does not exist
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
}
} else {
// No explicit timezone specified.
// This is valid and will be localtime or UTC depending on the value
None
};
Self::parse(prop_value, timezone).map(Some)
}
pub fn format(&self) -> String {
match self {
Self::Utc(utc) => utc.format(UTC_DATE_TIME).to_string(),
Self::Date(date) => date.format(LOCAL_DATE).to_string(),
Self::Local(datetime) => datetime.format(LOCAL_DATE_TIME).to_string(),
Self::OlsonTZ(datetime) => datetime.format(LOCAL_DATE_TIME).to_string(),
}
}
pub fn date(&self) -> NaiveDate {
match self {
Self::Utc(utc) => utc.date_naive(),
Self::Date(date) => date.to_owned(),
Self::Local(datetime) => datetime.date_naive(),
Self::OlsonTZ(datetime) => datetime.date_naive(),
}
}
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 {
return Ok(CalDateTime::OlsonTZ(
datetime
.and_local_timezone(timezone)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
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) {
return Ok(CalDateTime::Utc(datetime.and_utc()));
}
if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) {
return Ok(CalDateTime::Date(date));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
return Ok(CalDateTime::Date(date));
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") {
return Ok(CalDateTime::Date(date));
}
if let Some(captures) = RE_VCARD_DATE_MM_DD.captures(value) {
// Because 1972 is a leap year
let year = 1972;
// Cannot fail because of the regex
let month = captures.name("m").unwrap().as_str().parse().ok().unwrap();
let day = captures.name("d").unwrap().as_str().parse().ok().unwrap();
return Ok(CalDateTime::Date(
NaiveDate::from_ymd_opt(year, month, day)
.ok_or(CalDateTimeError::ParseError(value.to_string()))?,
));
}
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
}
pub fn utc(&self) -> DateTime<Utc> {
match &self {
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> {
fn from(value: CalDateTime) -> Self {
value.utc()
}
}
pub fn parse_duration(string: &str) -> Result<Duration, CalDateTimeError> {
let captures = RE_DURATION
.captures(string)
.ok_or(CalDateTimeError::InvalidDurationFormat(string.to_string()))?;
let mut duration = Duration::zero();
if let Some(weeks) = captures.name("W") {
duration += Duration::weeks(weeks.as_str().parse().unwrap());
}
if let Some(days) = captures.name("D") {
duration += Duration::days(days.as_str().parse().unwrap());
}
if let Some(hours) = captures.name("H") {
duration += Duration::hours(hours.as_str().parse().unwrap());
}
if let Some(minutes) = captures.name("M") {
duration += Duration::minutes(minutes.as_str().parse().unwrap());
}
if let Some(seconds) = captures.name("S") {
duration += Duration::seconds(seconds.as_str().parse().unwrap());
}
if let Some(sign) = captures.name("sign") {
if sign.as_str() == "-" {
duration = -duration;
}
}
Ok(duration)
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("P12W").unwrap(), Duration::weeks(12));
assert_eq!(parse_duration("P12D").unwrap(), Duration::days(12));
assert_eq!(parse_duration("PT12H").unwrap(), Duration::hours(12));
assert_eq!(parse_duration("PT12M").unwrap(), Duration::minutes(12));
assert_eq!(parse_duration("PT12S").unwrap(), Duration::seconds(12));
}
#[test]
fn test_vcard_date() {
assert_eq!(
CalDateTime::parse("19850412", None).unwrap(),
CalDateTime::Date(NaiveDate::from_ymd_opt(1985, 4, 12).unwrap())
);
assert_eq!(
CalDateTime::parse("1985-04-12", None).unwrap(),
CalDateTime::Date(NaiveDate::from_ymd_opt(1985, 4, 12).unwrap())
);
assert_eq!(
CalDateTime::parse("--0412", None).unwrap(),
CalDateTime::Date(NaiveDate::from_ymd_opt(1972, 4, 12).unwrap())
);
}

View File

@@ -1,6 +1,5 @@
use actix_web::{ResponseError, http::StatusCode};
use crate::calendar::CalDateTimeError;
use rustical_ical::CalDateTimeError;
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -14,7 +13,7 @@ pub enum Error {
InvalidData(String),
#[error(transparent)]
RRuleParserError(#[from] crate::calendar::rrule::ParserError),
RRuleParserError(#[from] rustical_ical::rrule::ParserError),
#[error("Read-only")]
ReadOnly,