Some groundwork for recurrence expansion

This commit is contained in:
Lennart
2025-05-02 17:43:46 +02:00
parent d826cb5fdd
commit e611e67692
5 changed files with 291 additions and 0 deletions

21
Cargo.lock generated
View File

@@ -3206,6 +3206,8 @@ dependencies = [
"rustical_xml",
"serde",
"sha2",
"strum",
"strum_macros",
"thiserror 2.0.12",
"tokio",
"tracing",
@@ -3785,6 +3787,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
[[package]]
name = "strum_macros"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"

View File

@@ -85,6 +85,8 @@ sqlx = { version = "0.8", default-features = false, features = [
"migrate",
"json",
] }
strum = "0.27"
strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { version = "0.11", features = ["generator", "serde"] }

View File

@@ -28,6 +28,8 @@ rand.workspace = true
uuid.workspace = true
clap.workspace = true
rustical_dav.workspace = true
strum.workspace = true
strum_macros.workspace = true
[dev-dependencies]
rstest = { workspace = true }

View File

@@ -2,6 +2,7 @@ mod calendar;
mod event;
mod journal;
mod object;
mod rrule;
mod timestamp;
mod todo;

View File

@@ -0,0 +1,265 @@
use super::CalDateTime;
use std::{num::ParseIntError, str::FromStr};
use strum_macros::EnumString;
#[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),
// A little dumb :(
#[error(transparent)]
StoreError(#[from] crate::Error),
}
#[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(())
}
}