mirror of
https://github.com/lennart-k/rustical.git
synced 2026-01-30 17:38:22 +00:00
Compare commits
5 Commits
291bd967da
...
v0.11.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b2e614a8 | ||
|
|
f26214abb9 | ||
|
|
276e65d41a | ||
|
|
7c3e9ecbc1 | ||
|
|
53f81a9433 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -2,7 +2,7 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["main", "dev"]
|
||||
release:
|
||||
types: ["published"]
|
||||
|
||||
|
||||
59
Cargo.lock
generated
59
Cargo.lock
generated
@@ -1771,7 +1771,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ical"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/lennart-k/ical-rs?branch=dev#64c342e7258ba445dc91b47cd8e20e0ac8ffc417"
|
||||
source = "git+https://github.com/lennart-k/ical-rs?branch=dev#b1edcdf2bb7db5a302a5df3650218a9a16aefe0c"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -1784,6 +1784,21 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ical"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/lennart-k/ical-rs#dcd3b106758a054f46a5172103abb17972ad032d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"derive_more",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"rrule",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -3317,7 +3332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -3328,6 +3343,7 @@ dependencies = [
|
||||
"figment",
|
||||
"headers",
|
||||
"http",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs?branch=dev)",
|
||||
"insta",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
@@ -3350,7 +3366,7 @@ dependencies = [
|
||||
"serde",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"toml 0.9.10+spec-1.1.0",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
@@ -3362,7 +3378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_caldav"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"async-trait",
|
||||
@@ -3375,7 +3391,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
"ical",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs)",
|
||||
"insta",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
@@ -3404,7 +3420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_carddav"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3414,7 +3430,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"futures-util",
|
||||
"http",
|
||||
"ical",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs)",
|
||||
"insta",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
@@ -3438,7 +3454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_dav"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3447,7 +3463,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
"ical",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs)",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"matchit 0.9.1",
|
||||
@@ -3464,7 +3480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_dav_push"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3489,7 +3505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_frontend"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"askama_web",
|
||||
@@ -3525,13 +3541,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_ical"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"derive_more",
|
||||
"ical",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs)",
|
||||
"regex",
|
||||
"rrule",
|
||||
"rstest",
|
||||
@@ -3544,7 +3560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_oidc"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3560,7 +3576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_store"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -3572,7 +3588,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"headers",
|
||||
"http",
|
||||
"ical",
|
||||
"ical 0.11.0 (git+https://github.com/lennart-k/ical-rs)",
|
||||
"regex",
|
||||
"rrule",
|
||||
"rstest",
|
||||
@@ -3593,13 +3609,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_store_sqlite"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"derive_more",
|
||||
"ical",
|
||||
"password-auth",
|
||||
"password-hash",
|
||||
"pbkdf2",
|
||||
@@ -3617,7 +3632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustical_xml"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"thiserror 2.0.17",
|
||||
@@ -4494,9 +4509,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.10+spec-1.1.0"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
|
||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
@@ -5441,7 +5456,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xml_derive"
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"heck",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.10"
|
||||
version = "0.11.11"
|
||||
rust-version = "1.92"
|
||||
edition = "2024"
|
||||
description = "A CalDAV server"
|
||||
@@ -107,7 +107,7 @@ strum = "0.27"
|
||||
strum_macros = "0.27"
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
|
||||
ical = { git = "https://github.com/lennart-k/ical-rs", branch = "dev", features = [
|
||||
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
|
||||
"chrono-tz",
|
||||
] }
|
||||
toml = "0.9"
|
||||
@@ -201,3 +201,7 @@ tower-http.workspace = true
|
||||
axum-extra.workspace = true
|
||||
headers.workspace = true
|
||||
http.workspace = true
|
||||
# TODO: Remove in next major release
|
||||
ical_dev = { package = "ical", git = "https://github.com/lennart-k/ical-rs", branch = "dev", features = [
|
||||
"chrono-tz",
|
||||
] }
|
||||
|
||||
@@ -5,11 +5,13 @@ use axum::extract::State;
|
||||
use axum::{extract::Path, response::Response};
|
||||
use headers::{ContentType, HeaderMapExt};
|
||||
use http::{HeaderValue, Method, StatusCode, header};
|
||||
use ical::component::IcalCalendar;
|
||||
use ical::builder::calendar::IcalCalendarBuilder;
|
||||
use ical::generator::Emitter;
|
||||
use ical::property::ContentLine;
|
||||
use ical::property::Property;
|
||||
use percent_encoding::{CONTROLS, utf8_percent_encode};
|
||||
use rustical_ical::{CalendarObjectComponent, EventObject};
|
||||
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use tracing::instrument;
|
||||
|
||||
@@ -31,45 +33,60 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
|
||||
return Err(crate::Error::Unauthorized);
|
||||
}
|
||||
|
||||
let objects = cal_store
|
||||
.get_objects(&principal, &calendar_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(_, object)| object.into())
|
||||
.collect();
|
||||
|
||||
let mut props = vec![];
|
||||
let mut vtimezones = HashMap::new();
|
||||
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
|
||||
|
||||
let mut ical_calendar_builder = IcalCalendarBuilder::version("2.0")
|
||||
.gregorian()
|
||||
.prodid("RustiCal");
|
||||
if let Some(displayname) = calendar.meta.displayname {
|
||||
props.push(ContentLine {
|
||||
ical_calendar_builder = ical_calendar_builder.set(Property {
|
||||
name: "X-WR-CALNAME".to_owned(),
|
||||
value: Some(displayname),
|
||||
params: vec![].into(),
|
||||
params: vec![],
|
||||
});
|
||||
}
|
||||
if let Some(description) = calendar.meta.description {
|
||||
props.push(ContentLine {
|
||||
ical_calendar_builder = ical_calendar_builder.set(Property {
|
||||
name: "X-WR-CALDESC".to_owned(),
|
||||
value: Some(description),
|
||||
params: vec![].into(),
|
||||
});
|
||||
}
|
||||
if let Some(color) = calendar.meta.color {
|
||||
props.push(ContentLine {
|
||||
name: "X-WR-CALCOLOR".to_owned(),
|
||||
value: Some(color),
|
||||
params: vec![].into(),
|
||||
params: vec![],
|
||||
});
|
||||
}
|
||||
if let Some(timezone_id) = calendar.timezone_id {
|
||||
props.push(ContentLine {
|
||||
ical_calendar_builder = ical_calendar_builder.set(Property {
|
||||
name: "X-WR-TIMEZONE".to_owned(),
|
||||
value: Some(timezone_id),
|
||||
params: vec![].into(),
|
||||
params: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let export_calendar = IcalCalendar::from_objects("RustiCal Export".to_owned(), objects, props);
|
||||
for object in &objects {
|
||||
vtimezones.extend(object.get_vtimezones());
|
||||
match object.get_data() {
|
||||
CalendarObjectComponent::Event(EventObject { event, .. }, overrides) => {
|
||||
ical_calendar_builder = ical_calendar_builder
|
||||
.add_event(event.clone())
|
||||
.add_events(overrides.iter().map(|ev| ev.event.clone()));
|
||||
}
|
||||
CalendarObjectComponent::Todo(todo, overrides) => {
|
||||
ical_calendar_builder = ical_calendar_builder
|
||||
.add_todo(todo.clone())
|
||||
.add_todos(overrides.iter().cloned());
|
||||
}
|
||||
CalendarObjectComponent::Journal(journal, overrides) => {
|
||||
ical_calendar_builder = ical_calendar_builder
|
||||
.add_journal(journal.clone())
|
||||
.add_journals(overrides.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ical_calendar_builder = ical_calendar_builder.add_timezones(vtimezones.into_values().cloned());
|
||||
|
||||
let ical_calendar = ical_calendar_builder
|
||||
.build()
|
||||
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
|
||||
|
||||
let mut resp = Response::builder().status(StatusCode::OK);
|
||||
let hdrs = resp.headers_mut().unwrap();
|
||||
@@ -87,6 +104,6 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
|
||||
if matches!(method, Method::HEAD) {
|
||||
Ok(resp.body(Body::empty()).unwrap())
|
||||
} else {
|
||||
Ok(resp.body(Body::new(export_calendar.generate())).unwrap())
|
||||
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http::StatusCode;
|
||||
use ical::parser::{Component, ComponentMut};
|
||||
use ical::{
|
||||
generator::Emitter,
|
||||
parser::{Component, ComponentMut},
|
||||
};
|
||||
use rustical_dav::header::Overwrite;
|
||||
use rustical_ical::CalendarObjectType;
|
||||
use rustical_ical::{CalendarObject, CalendarObjectType};
|
||||
use rustical_store::{
|
||||
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
|
||||
};
|
||||
use std::{collections::HashMap, io::BufReader};
|
||||
use std::io::BufReader;
|
||||
use tracing::instrument;
|
||||
|
||||
#[instrument(skip(resource_service))]
|
||||
@@ -26,11 +29,18 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
|
||||
let parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
|
||||
let mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
|
||||
let mut cal = parser
|
||||
.expect_one()
|
||||
.map_err(rustical_ical::Error::ParserError)?
|
||||
.next()
|
||||
.expect("input must contain calendar")
|
||||
.unwrap()
|
||||
.mutable();
|
||||
if parser.next().is_some() {
|
||||
return Err(rustical_ical::Error::InvalidData(
|
||||
"multiple calendars, only one allowed".to_owned(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Extract calendar metadata
|
||||
let displayname = cal
|
||||
@@ -39,19 +49,14 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
|
||||
let description = cal
|
||||
.get_property("X-WR-CALDESC")
|
||||
.and_then(|prop| prop.value.clone());
|
||||
let color = cal
|
||||
.get_property("X-WR-CALCOLOR")
|
||||
.and_then(|prop| prop.value.clone());
|
||||
let timezone_id = cal
|
||||
.get_property("X-WR-TIMEZONE")
|
||||
.and_then(|prop| prop.value.clone());
|
||||
// These properties should not appear in the expanded calendar objects
|
||||
cal.remove_property("X-WR-CALNAME");
|
||||
cal.remove_property("X-WR-CALDESC");
|
||||
cal.remove_property("X-WR-CALCOLOR");
|
||||
cal.remove_property("X-WR-TIMEZONE");
|
||||
let cal = cal.build(&HashMap::new()).unwrap();
|
||||
|
||||
let cal = cal.verify().unwrap();
|
||||
// Make sure timezone is valid
|
||||
if let Some(timezone_id) = timezone_id.as_ref() {
|
||||
assert!(
|
||||
@@ -59,7 +64,8 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
|
||||
"Invalid calendar timezone id"
|
||||
);
|
||||
}
|
||||
// // Extract necessary component types
|
||||
|
||||
// Extract necessary component types
|
||||
let mut cal_components = vec![];
|
||||
if !cal.events.is_empty() {
|
||||
cal_components.push(CalendarObjectType::Event);
|
||||
@@ -71,12 +77,13 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
|
||||
cal_components.push(CalendarObjectType::Todo);
|
||||
}
|
||||
|
||||
let objects = cal
|
||||
.into_objects()
|
||||
.map_err(rustical_ical::Error::ParserError)?
|
||||
let expanded_cals = cal.expand_calendar();
|
||||
// Janky way to convert between IcalCalendar and CalendarObject
|
||||
let objects = expanded_cals
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
.map(|cal| cal.generate())
|
||||
.map(|ics| CalendarObject::from_ics(ics, None))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let new_cal = Calendar {
|
||||
principal,
|
||||
id: cal_id,
|
||||
@@ -84,7 +91,7 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
|
||||
displayname,
|
||||
order: 0,
|
||||
description,
|
||||
color,
|
||||
color: None,
|
||||
},
|
||||
timezone_id,
|
||||
deleted_at: None,
|
||||
|
||||
@@ -92,11 +92,10 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
|
||||
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?
|
||||
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?;
|
||||
|
||||
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
|
||||
let timezone = calendar.timezones.first().ok_or_else(|| {
|
||||
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
|
||||
})?;
|
||||
let timezone: Option<chrono_tz::Tz> = timezone.into();
|
||||
let timezone = timezone.ok_or_else(|| {
|
||||
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
|
||||
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned())
|
||||
})?;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
store: &C,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>), Error> {
|
||||
) -> Result<(Vec<CalendarObject>, Vec<String>), Error> {
|
||||
let mut result = vec![];
|
||||
let mut not_found = vec![];
|
||||
|
||||
@@ -32,7 +32,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
|
||||
let filename = filename.trim_start_matches('/');
|
||||
if let Some(object_id) = filename.strip_suffix(".ics") {
|
||||
match store.get_object(principal, cal_id, object_id, false).await {
|
||||
Ok(object) => result.push((object_id.to_owned(), object)),
|
||||
Ok(object) => result.push(object),
|
||||
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use crate::calendar::methods::report::calendar_query::{
|
||||
TimeRangeElement, prop_filter::PropFilterElement,
|
||||
};
|
||||
use ical::{
|
||||
component::IcalCalendarObject,
|
||||
parser::{Component, ical::component::IcalTimeZone},
|
||||
TimeRangeElement,
|
||||
prop_filter::{PropFilterElement, PropFilterable},
|
||||
};
|
||||
use ical::parser::ical::component::IcalTimeZone;
|
||||
use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType};
|
||||
use rustical_xml::XmlDeserialize;
|
||||
|
||||
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
|
||||
@@ -25,7 +24,9 @@ pub struct CompFilterElement {
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
pub trait CompFilterable: Component + Sized {
|
||||
pub trait CompFilterable: PropFilterable + Sized {
|
||||
fn get_comp_name(&self) -> &'static str;
|
||||
|
||||
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool;
|
||||
|
||||
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool;
|
||||
@@ -67,7 +68,11 @@ pub trait CompFilterable: Component + Sized {
|
||||
}
|
||||
}
|
||||
|
||||
impl CompFilterable for IcalCalendarObject {
|
||||
impl CompFilterable for CalendarObject {
|
||||
fn get_comp_name(&self) -> &'static str {
|
||||
"VCALENDAR"
|
||||
}
|
||||
|
||||
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
|
||||
// VCALENDAR has no concept of time range
|
||||
false
|
||||
@@ -78,7 +83,7 @@ impl CompFilterable for IcalCalendarObject {
|
||||
.get_vtimezones()
|
||||
.values()
|
||||
.map(|tz| tz.matches(comp_filter))
|
||||
.chain([self.matches(comp_filter)]);
|
||||
.chain([self.get_data().matches(comp_filter)]);
|
||||
|
||||
if comp_filter.is_not_defined.is_some() {
|
||||
matches.all(|x| x)
|
||||
@@ -89,6 +94,10 @@ impl CompFilterable for IcalCalendarObject {
|
||||
}
|
||||
|
||||
impl CompFilterable for IcalTimeZone {
|
||||
fn get_comp_name(&self) -> &'static str {
|
||||
"VTIMEZONE"
|
||||
}
|
||||
|
||||
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -98,6 +107,33 @@ impl CompFilterable for IcalTimeZone {
|
||||
}
|
||||
}
|
||||
|
||||
impl CompFilterable for CalendarObjectComponent {
|
||||
fn get_comp_name(&self) -> &'static str {
|
||||
CalendarObjectType::from(self).as_str()
|
||||
}
|
||||
|
||||
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool {
|
||||
if let Some(start) = &time_range.start
|
||||
&& let Some(last_occurence) = self.get_last_occurence().unwrap_or(None)
|
||||
&& **start > last_occurence.utc()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(end) = &time_range.end
|
||||
&& let Some(first_occurence) = self.get_first_occurence().unwrap_or(None)
|
||||
&& **end < first_occurence.utc()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool {
|
||||
// TODO: Properly check subcomponents
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
@@ -130,7 +166,7 @@ END:VCALENDAR";
|
||||
|
||||
#[test]
|
||||
fn test_comp_filter_matching() {
|
||||
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
|
||||
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: Some(()),
|
||||
@@ -139,10 +175,7 @@ END:VCALENDAR";
|
||||
prop_filter: vec![],
|
||||
comp_filter: vec![],
|
||||
};
|
||||
assert!(
|
||||
!object.get_inner().matches(&comp_filter),
|
||||
"filter: wants no VCALENDAR"
|
||||
);
|
||||
assert!(!object.matches(&comp_filter), "filter: wants no VCALENDAR");
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: None,
|
||||
@@ -157,10 +190,7 @@ END:VCALENDAR";
|
||||
comp_filter: vec![],
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
!object.get_inner().matches(&comp_filter),
|
||||
"filter matches VTODO"
|
||||
);
|
||||
assert!(!object.matches(&comp_filter), "filter matches VTODO");
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: None,
|
||||
@@ -175,10 +205,7 @@ END:VCALENDAR";
|
||||
comp_filter: vec![],
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
object.get_inner().matches(&comp_filter),
|
||||
"filter matches VEVENT"
|
||||
);
|
||||
assert!(object.matches(&comp_filter), "filter matches VEVENT");
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: None,
|
||||
@@ -225,13 +252,13 @@ END:VCALENDAR";
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
object.get_inner().matches(&comp_filter),
|
||||
object.matches(&comp_filter),
|
||||
"Some prop filters on VCALENDAR and VEVENT"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_comp_filter_time_range() {
|
||||
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
|
||||
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: None,
|
||||
@@ -254,7 +281,7 @@ END:VCALENDAR";
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
object.get_inner().matches(&comp_filter),
|
||||
object.matches(&comp_filter),
|
||||
"event should lie in time range"
|
||||
);
|
||||
|
||||
@@ -279,14 +306,14 @@ END:VCALENDAR";
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
!object.get_inner().matches(&comp_filter),
|
||||
!object.matches(&comp_filter),
|
||||
"event should not lie in time range"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_timezone() {
|
||||
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
|
||||
let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap();
|
||||
|
||||
let comp_filter = CompFilterElement {
|
||||
is_not_defined: None,
|
||||
@@ -313,7 +340,7 @@ END:VCALENDAR";
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
object.get_inner().matches(&comp_filter),
|
||||
object.matches(&comp_filter),
|
||||
"Timezone should be Europe/Berlin"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::comp_filter::{CompFilterElement, CompFilterable};
|
||||
use crate::calendar_object::CalendarObjectPropWrapperName;
|
||||
use ical::{component::IcalCalendarObject, property::ContentLine};
|
||||
use ical::property::Property;
|
||||
use rustical_dav::xml::{PropfindType, TextMatchElement};
|
||||
use rustical_ical::UtcDateTime;
|
||||
use rustical_ical::{CalendarObject, UtcDateTime};
|
||||
use rustical_store::calendar_store::CalendarQuery;
|
||||
use rustical_xml::{XmlDeserialize, XmlRootTag};
|
||||
|
||||
@@ -30,8 +30,8 @@ pub struct ParamFilterElement {
|
||||
|
||||
impl ParamFilterElement {
|
||||
#[must_use]
|
||||
pub fn match_property(&self, prop: &ContentLine) -> bool {
|
||||
let Some(param) = prop.params.get_param(&self.name) else {
|
||||
pub fn match_property(&self, prop: &Property) -> bool {
|
||||
let Some(param) = prop.get_param(&self.name) else {
|
||||
return self.is_not_defined.is_some();
|
||||
};
|
||||
if self.is_not_defined.is_some() {
|
||||
@@ -57,7 +57,7 @@ pub struct FilterElement {
|
||||
|
||||
impl FilterElement {
|
||||
#[must_use]
|
||||
pub fn matches(&self, cal_object: &IcalCalendarObject) -> bool {
|
||||
pub fn matches(&self, cal_object: &CalendarObject) -> bool {
|
||||
cal_object.matches(&self.comp_filter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,19 @@ mod tests;
|
||||
pub use comp_filter::{CompFilterElement, CompFilterable};
|
||||
pub use elements::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use prop_filter::PropFilterElement;
|
||||
pub use prop_filter::{PropFilterElement, PropFilterable};
|
||||
|
||||
pub async fn get_objects_calendar_query<C: CalendarStore>(
|
||||
cal_query: &CalendarQueryRequest,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
store: &C,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
let mut objects = store
|
||||
.calendar_query(principal, cal_id, cal_query.into())
|
||||
.await?;
|
||||
if let Some(filter) = &cal_query.filter {
|
||||
objects.retain(|(_id, object)| filter.matches(object.get_inner()));
|
||||
objects.retain(|object| filter.matches(object));
|
||||
}
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
use super::{ParamFilterElement, TimeRangeElement};
|
||||
use ical::{parser::Component, property::ContentLine, types::CalDateTime};
|
||||
use ical::{
|
||||
generator::{IcalCalendar, IcalEvent},
|
||||
parser::{
|
||||
Component,
|
||||
ical::component::{IcalJournal, IcalTimeZone, IcalTodo},
|
||||
},
|
||||
property::Property,
|
||||
};
|
||||
use rustical_dav::xml::TextMatchElement;
|
||||
use rustical_ical::UtcDateTime;
|
||||
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectComponent, UtcDateTime};
|
||||
use rustical_xml::XmlDeserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -24,7 +31,7 @@ pub struct PropFilterElement {
|
||||
|
||||
impl PropFilterElement {
|
||||
#[must_use]
|
||||
pub fn match_property(&self, property: &ContentLine) -> bool {
|
||||
pub fn match_property(&self, property: &Property) -> bool {
|
||||
if let Some(TimeRangeElement { start, end }) = &self.time_range {
|
||||
// TODO: Respect timezones
|
||||
let Ok(timestamp) = CalDateTime::parse_prop(property, &HashMap::default()) else {
|
||||
@@ -61,7 +68,7 @@ impl PropFilterElement {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn match_component(&self, comp: &impl Component) -> bool {
|
||||
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
|
||||
let properties = comp.get_named_properties(&self.name);
|
||||
if self.is_not_defined.is_some() {
|
||||
return properties.is_empty();
|
||||
@@ -72,3 +79,53 @@ impl PropFilterElement {
|
||||
properties.iter().any(|prop| self.match_property(prop))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PropFilterable {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property>;
|
||||
}
|
||||
|
||||
impl PropFilterable for CalendarObject {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Self::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for IcalEvent {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Component::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for IcalTodo {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Component::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for IcalJournal {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Component::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for IcalCalendar {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Component::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for IcalTimeZone {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
Component::get_named_properties(self, name)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropFilterable for CalendarObjectComponent {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
match self {
|
||||
Self::Event(event, _) => PropFilterable::get_named_properties(&event.event, name),
|
||||
Self::Todo(todo, _) => PropFilterable::get_named_properties(todo, name),
|
||||
Self::Journal(journal, _) => PropFilterable::get_named_properties(journal, name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ const FILTER_2: &str = r#"
|
||||
#[case(ICS_1, FILTER_1, true)]
|
||||
#[case(ICS_1, FILTER_2, false)]
|
||||
fn yeet(#[case] ics: &str, #[case] filter: &str, #[case] matches: bool) {
|
||||
let obj = CalendarObject::from_ics(ics.to_owned()).unwrap();
|
||||
let obj = CalendarObject::from_ics(ics.to_owned(), None).unwrap();
|
||||
let filter = FilterElement::parse_str(filter).unwrap();
|
||||
assert_eq!(matches, filter.matches(obj.get_inner()));
|
||||
assert_eq!(matches, filter.matches(&obj));
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ impl ReportRequest {
|
||||
}
|
||||
|
||||
fn objects_response(
|
||||
objects: Vec<(String, CalendarObject)>,
|
||||
objects: Vec<CalendarObject>,
|
||||
not_found: Vec<String>,
|
||||
path: &str,
|
||||
principal: &str,
|
||||
@@ -60,12 +60,11 @@ fn objects_response(
|
||||
prop: &PropfindType<CalendarObjectPropWrapperName>,
|
||||
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
|
||||
let mut responses = Vec::new();
|
||||
for (object_id, object) in objects {
|
||||
let path = format!("{path}/{object_id}.ics");
|
||||
for object in objects {
|
||||
let path = format!("{}/{}.ics", path, object.get_id());
|
||||
responses.push(
|
||||
CalendarObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, prop, None, puri, user)?,
|
||||
|
||||
@@ -32,12 +32,11 @@ pub async fn handle_sync_collection<C: CalendarStore>(
|
||||
.await?;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for (object_id, object) in new_objects {
|
||||
let path = format!("{}/{}.ics", path, &object_id);
|
||||
for object in new_objects {
|
||||
let path = format!("{}/{}.ics", path, object.get_id());
|
||||
responses.push(
|
||||
CalendarObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, &sync_collection.prop, None, puri, user)?,
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
|
||||
use chrono::{DateTime, Utc};
|
||||
use derive_more::derive::{From, Into};
|
||||
use ical::IcalParser;
|
||||
use ical::types::CalDateTime;
|
||||
use rustical_dav::extensions::{
|
||||
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
|
||||
};
|
||||
@@ -12,6 +11,7 @@ use rustical_dav::privileges::UserPrivilegeSet;
|
||||
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
|
||||
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
|
||||
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
|
||||
use rustical_ical::CalDateTime;
|
||||
use rustical_store::Calendar;
|
||||
use rustical_store::auth::Principal;
|
||||
use rustical_xml::{EnumVariants, PropName};
|
||||
@@ -215,13 +215,13 @@ impl Resource for CalendarResource {
|
||||
)
|
||||
})?;
|
||||
|
||||
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
|
||||
let timezone = calendar.timezones.first().ok_or_else(|| {
|
||||
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
|
||||
})?;
|
||||
let timezone: Option<chrono_tz::Tz> = timezone.into();
|
||||
let timezone = timezone.ok_or_else(|| {
|
||||
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
|
||||
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
|
||||
})?;
|
||||
|
||||
self.cal.timezone_id = Some(timezone.name().to_owned());
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -78,9 +78,8 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
|
||||
.get_objects(principal, cal_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(object_id, object)| CalendarObjectResource {
|
||||
.map(|object| CalendarObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
.collect())
|
||||
@@ -92,7 +91,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
|
||||
file: Self::Resource,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.cal_store
|
||||
.update_calendar(principal, cal_id, file.into())
|
||||
.update_calendar(principal.to_owned(), cal_id.to_owned(), file.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ pub async fn put_event<C: CalendarStore>(
|
||||
true
|
||||
};
|
||||
|
||||
let Ok(object) = CalendarObject::from_ics(body.clone()) else {
|
||||
let Ok(object) = CalendarObject::from_ics(body.clone(), Some(object_id)) else {
|
||||
debug!("invalid calendar data:\n{body}");
|
||||
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
|
||||
};
|
||||
let etag = object.get_etag();
|
||||
cal_store
|
||||
.put_object(&principal, &calendar_id, &object_id, object, overwrite)
|
||||
.put_object(principal, calendar_id, object, overwrite)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::prop::{
|
||||
CalendarData, CalendarObjectProp, CalendarObjectPropName, CalendarObjectPropWrapper,
|
||||
CalendarObjectPropWrapperName,
|
||||
};
|
||||
use crate::Error;
|
||||
use derive_more::derive::{From, Into};
|
||||
use ical::generator::Emitter;
|
||||
use rustical_dav::{
|
||||
extensions::CommonPropertiesExtension,
|
||||
privileges::UserPrivilegeSet,
|
||||
@@ -13,18 +14,16 @@ use rustical_dav::{
|
||||
};
|
||||
use rustical_ical::CalendarObject;
|
||||
use rustical_store::auth::Principal;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[derive(Clone, From, Into)]
|
||||
pub struct CalendarObjectResource {
|
||||
pub object: CalendarObject,
|
||||
pub object_id: String,
|
||||
pub principal: String,
|
||||
}
|
||||
|
||||
impl ResourceName for CalendarObjectResource {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from(format!("{}.ics", self.object_id))
|
||||
Cow::from(format!("{}.ics", self.object.get_id()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,18 +53,14 @@ impl Resource for CalendarObjectResource {
|
||||
CalendarObjectProp::Getetag(self.object.get_etag())
|
||||
}
|
||||
CalendarObjectPropName::CalendarData(CalendarData { expand, .. }) => {
|
||||
CalendarObjectProp::CalendarData(expand.as_ref().map_or_else(
|
||||
|| self.object.get_ics().to_owned(),
|
||||
|expand| {
|
||||
self.object
|
||||
.get_inner()
|
||||
.expand_recurrence(
|
||||
Some(expand.start.to_utc()),
|
||||
Some(expand.end.to_utc()),
|
||||
)
|
||||
.generate()
|
||||
},
|
||||
))
|
||||
CalendarObjectProp::CalendarData(if let Some(expand) = expand.as_ref() {
|
||||
self.object.expand_recurrence(
|
||||
Some(expand.start.to_utc()),
|
||||
Some(expand.end.to_utc()),
|
||||
)?
|
||||
} else {
|
||||
self.object.get_ics().to_owned()
|
||||
})
|
||||
}
|
||||
CalendarObjectPropName::Getcontenttype => {
|
||||
CalendarObjectProp::Getcontenttype("text/calendar;charset=utf-8")
|
||||
|
||||
@@ -66,7 +66,6 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
|
||||
.await?;
|
||||
Ok(CalendarObjectResource {
|
||||
object,
|
||||
object_id: object_id.to_owned(),
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,10 +103,10 @@ pub async fn put_object<AS: AddressbookStore>(
|
||||
true
|
||||
};
|
||||
|
||||
let object = AddressObject::from_vcf(body)?;
|
||||
let object = AddressObject::from_vcf(object_id, body)?;
|
||||
let etag = object.get_etag();
|
||||
addr_store
|
||||
.put_object(&principal, &addressbook_id, &object_id, object, overwrite)
|
||||
.put_object(principal, addressbook_id, object, overwrite)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -21,12 +21,11 @@ use rustical_store::auth::Principal;
|
||||
pub struct AddressObjectResource {
|
||||
pub object: AddressObject,
|
||||
pub principal: String,
|
||||
pub object_id: String,
|
||||
}
|
||||
|
||||
impl ResourceName for AddressObjectResource {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from(format!("{}.vcf", self.object_id))
|
||||
Cow::from(format!("{}.vcf", self.object.get_id()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +69,7 @@ impl Resource for AddressObjectResource {
|
||||
}
|
||||
|
||||
fn get_displayname(&self) -> Option<&str> {
|
||||
todo!()
|
||||
// self.object.get_full_name()
|
||||
self.object.get_full_name()
|
||||
}
|
||||
|
||||
fn get_owner(&self) -> Option<&str> {
|
||||
|
||||
@@ -57,7 +57,6 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
|
||||
.await?;
|
||||
Ok(AddressObjectResource {
|
||||
object,
|
||||
object_id: object_id.to_owned(),
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use http::{HeaderValue, Method, StatusCode, header};
|
||||
use percent_encoding::{CONTROLS, utf8_percent_encode};
|
||||
use rustical_dav::privileges::UserPrivilege;
|
||||
use rustical_dav::resource::Resource;
|
||||
use rustical_ical::AddressObject;
|
||||
use rustical_store::auth::Principal;
|
||||
use rustical_store::{AddressbookStore, SubscriptionStore};
|
||||
use std::str::FromStr;
|
||||
@@ -39,7 +40,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
|
||||
let objects = addr_store.get_objects(&principal, &addressbook_id).await?;
|
||||
let vcf = objects
|
||||
.iter()
|
||||
.map(|(_id, obj)| obj.get_vcf())
|
||||
.map(AddressObject::get_vcf)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashMap, io::BufReader};
|
||||
use std::io::BufReader;
|
||||
|
||||
use crate::Error;
|
||||
use crate::addressbook::AddressbookResourceService;
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
use http::StatusCode;
|
||||
use ical::{
|
||||
parser::{Component, ComponentMut, vcard},
|
||||
property::ContentLine,
|
||||
property::Property,
|
||||
};
|
||||
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
|
||||
use tracing::instrument;
|
||||
@@ -33,16 +33,15 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
|
||||
let uid = card.get_uid();
|
||||
if uid.is_none() {
|
||||
let mut card_mut = card.mutable();
|
||||
card_mut.add_content_line(ContentLine {
|
||||
card_mut.set_property(Property {
|
||||
name: "UID".to_owned(),
|
||||
value: Some(uuid::Uuid::new_v4().to_string()),
|
||||
params: vec![].into(),
|
||||
params: vec![],
|
||||
});
|
||||
card = card_mut.build(&HashMap::new()).unwrap();
|
||||
card = card_mut.verify().unwrap();
|
||||
}
|
||||
// TODO: Make nicer
|
||||
let uid = card.get_uid().unwrap();
|
||||
objects.push((uid.to_owned(), card.into()));
|
||||
|
||||
objects.push(card.try_into().unwrap());
|
||||
}
|
||||
|
||||
if objects.is_empty() {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
store: &AS,
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>), Error> {
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>), Error> {
|
||||
let mut result = vec![];
|
||||
let mut not_found = vec![];
|
||||
|
||||
@@ -43,7 +43,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
|
||||
.get_object(principal, addressbook_id, object_id, false)
|
||||
.await
|
||||
{
|
||||
Ok(object) => result.push((object_id.to_owned(), object)),
|
||||
Ok(object) => result.push(object),
|
||||
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
@@ -74,12 +74,11 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
|
||||
.await?;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for (object_id, object) in objects {
|
||||
let path = format!("{path}/{object_id}.vcf");
|
||||
for object in objects {
|
||||
let path = format!("{}/{}.vcf", path, object.get_id());
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, prop, None, puri, user)?,
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
addressbook::methods::report::addressbook_query::PropFilterElement,
|
||||
};
|
||||
use derive_more::{From, Into};
|
||||
use ical::property::ContentLine;
|
||||
use ical::property::Property;
|
||||
use rustical_dav::xml::{PropfindType, TextMatchElement};
|
||||
use rustical_ical::{AddressObject, UtcDateTime};
|
||||
use rustical_xml::{ValueDeserialize, XmlDeserialize, XmlRootTag};
|
||||
@@ -32,8 +32,8 @@ pub struct ParamFilterElement {
|
||||
|
||||
impl ParamFilterElement {
|
||||
#[must_use]
|
||||
pub fn match_property(&self, prop: &ContentLine) -> bool {
|
||||
let Some(param) = prop.params.get_param(&self.name) else {
|
||||
pub fn match_property(&self, prop: &Property) -> bool {
|
||||
let Some(param) = prop.get_param(&self.name) else {
|
||||
return self.is_not_defined.is_some();
|
||||
};
|
||||
if self.is_not_defined.is_some() {
|
||||
|
||||
@@ -15,8 +15,8 @@ pub async fn get_objects_addressbook_query<AS: AddressbookStore>(
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
store: &AS,
|
||||
) -> Result<Vec<(String, AddressObject)>, Error> {
|
||||
) -> Result<Vec<AddressObject>, Error> {
|
||||
let mut objects = store.get_objects(principal, addressbook_id).await?;
|
||||
objects.retain(|(_id, object)| addr_query.filter.matches(object));
|
||||
objects.retain(|object| addr_query.filter.matches(object));
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{Allof, ParamFilterElement};
|
||||
use ical::{parser::Component, property::ContentLine};
|
||||
use ical::{parser::Component, property::Property};
|
||||
use rustical_dav::xml::TextMatchElement;
|
||||
use rustical_ical::AddressObject;
|
||||
use rustical_xml::XmlDeserialize;
|
||||
@@ -31,7 +31,7 @@ pub struct PropFilterElement {
|
||||
|
||||
impl PropFilterElement {
|
||||
#[must_use]
|
||||
pub fn match_property(&self, property: &ContentLine) -> bool {
|
||||
pub fn match_property(&self, property: &Property) -> bool {
|
||||
if self.param_filter.is_empty() && self.text_match.is_empty() {
|
||||
// Filter empty
|
||||
return true;
|
||||
@@ -67,11 +67,11 @@ impl PropFilterElement {
|
||||
}
|
||||
|
||||
pub trait PropFilterable {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&ContentLine>;
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property>;
|
||||
}
|
||||
|
||||
impl PropFilterable for AddressObject {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&ContentLine> {
|
||||
fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
self.get_vcard().get_named_properties(name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const FILTER_2: &str = r#"
|
||||
#[case(VCF_2, FILTER_2, true)]
|
||||
fn test_filter(#[case] vcf: &str, #[case] filter: &str, #[case] matches: bool) {
|
||||
dbg!(vcf);
|
||||
let obj = AddressObject::from_vcf(vcf.to_owned()).unwrap();
|
||||
let obj = AddressObject::from_vcf(String::new(), vcf.to_owned()).unwrap();
|
||||
let filter = FilterElement::parse_str(filter).unwrap();
|
||||
assert_eq!(matches, filter.matches(&obj));
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ReportRequest {
|
||||
}
|
||||
|
||||
fn objects_response(
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
objects: Vec<AddressObject>,
|
||||
not_found: Vec<String>,
|
||||
path: &str,
|
||||
principal: &str,
|
||||
@@ -64,12 +64,11 @@ fn objects_response(
|
||||
prop: &PropfindType<AddressObjectPropWrapperName>,
|
||||
) -> Result<MultistatusElement<AddressObjectPropWrapper, String>, Error> {
|
||||
let mut responses = Vec::new();
|
||||
for (object_id, object) in objects {
|
||||
let path = format!("{}/{}.vcf", path, &object_id);
|
||||
for object in objects {
|
||||
let path = format!("{}/{}.vcf", path, object.get_id());
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, prop, None, puri, user)?,
|
||||
|
||||
@@ -32,12 +32,11 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
|
||||
.await?;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for (object_id, object) in new_objects {
|
||||
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object_id);
|
||||
for object in new_objects {
|
||||
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object.get_id());
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, &sync_collection.prop, None, puri, user)?,
|
||||
|
||||
@@ -78,8 +78,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
|
||||
.get_objects(principal, addressbook_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(object_id, object)| AddressObjectResource {
|
||||
object_id,
|
||||
.map(|object| AddressObjectResource {
|
||||
object,
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
@@ -92,7 +91,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
|
||||
file: Self::Resource,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.addr_store
|
||||
.update_addressbook(principal, addressbook_id, file.into())
|
||||
.update_addressbook(principal.to_owned(), addressbook_id.to_owned(), file.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use ical::property::ContentLine;
|
||||
use ical::property::Property;
|
||||
use rustical_xml::{ValueDeserialize, XmlDeserialize};
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -128,7 +128,7 @@ impl TextMatchElement {
|
||||
negate_condition.0 ^ matches
|
||||
}
|
||||
#[must_use]
|
||||
pub fn match_property(&self, property: &ContentLine) -> bool {
|
||||
pub fn match_property(&self, property: &Property) -> bool {
|
||||
let text = property.value.as_deref().unwrap_or("");
|
||||
self.match_text(text)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,59 @@
|
||||
use crate::{CalDateTime, LOCAL_DATE};
|
||||
use crate::{CalendarObject, Error};
|
||||
use chrono::Datelike;
|
||||
use ical::generator::Emitter;
|
||||
use ical::parser::vcard::{self, component::VcardContact};
|
||||
use ical::parser::{
|
||||
Component,
|
||||
vcard::{self, component::VcardContact},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::HashMap, io::BufReader};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AddressObject {
|
||||
id: String,
|
||||
vcf: String,
|
||||
vcard: VcardContact,
|
||||
}
|
||||
|
||||
impl From<VcardContact> for AddressObject {
|
||||
fn from(vcard: VcardContact) -> Self {
|
||||
impl TryFrom<VcardContact> for AddressObject {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(vcard: VcardContact) -> Result<Self, Self::Error> {
|
||||
let uid = vcard
|
||||
.get_uid()
|
||||
.ok_or_else(|| Error::InvalidData("missing UID".to_owned()))?
|
||||
.to_owned();
|
||||
let vcf = vcard.generate();
|
||||
Self { vcf, vcard }
|
||||
Ok(Self {
|
||||
vcf,
|
||||
vcard,
|
||||
id: uid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressObject {
|
||||
pub fn from_vcf(vcf: String) -> Result<Self, Error> {
|
||||
let parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
|
||||
let vcard = parser.expect_one()?;
|
||||
Ok(Self { vcf, vcard })
|
||||
pub fn from_vcf(id: String, vcf: String) -> Result<Self, Error> {
|
||||
let mut parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
|
||||
let vcard = parser.next().ok_or(Error::MissingContact)??;
|
||||
if parser.next().is_some() {
|
||||
return Err(Error::InvalidData(
|
||||
"multiple vcards, only one allowed".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self { id, vcf, vcard })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_etag(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.get_id());
|
||||
hasher.update(self.get_vcf());
|
||||
format!("\"{:x}\"", hasher.finalize())
|
||||
}
|
||||
@@ -36,12 +63,109 @@ impl AddressObject {
|
||||
&self.vcf
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_anniversary(&self) -> Option<(CalDateTime, bool)> {
|
||||
let prop = self.vcard.get_property("ANNIVERSARY")?.value.as_deref()?;
|
||||
CalDateTime::parse_vcard(prop).ok()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_birthday(&self) -> Option<(CalDateTime, bool)> {
|
||||
let prop = self.vcard.get_property("BDAY")?.value.as_deref()?;
|
||||
CalDateTime::parse_vcard(prop).ok()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_full_name(&self) -> Option<&str> {
|
||||
let prop = self.vcard.get_property("FN")?;
|
||||
prop.value.as_deref()
|
||||
}
|
||||
|
||||
pub fn get_anniversary_object(&self) -> Result<Option<CalendarObject>, Error> {
|
||||
todo!();
|
||||
Ok(
|
||||
if let Some((anniversary, contains_year)) = self.get_anniversary() {
|
||||
let Some(fullname) = self.get_full_name() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let anniversary = anniversary.date();
|
||||
let year = contains_year.then_some(anniversary.year());
|
||||
let anniversary_start = anniversary.format(LOCAL_DATE);
|
||||
let anniversary_end = anniversary
|
||||
.succ_opt()
|
||||
.unwrap_or(anniversary)
|
||||
.format(LOCAL_DATE);
|
||||
let uid = format!("{}-anniversary", self.get_id());
|
||||
|
||||
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
|
||||
Some(CalendarObject::from_ics(
|
||||
format!(
|
||||
r"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:{anniversary_start}
|
||||
DTEND;VALUE=DATE:{anniversary_end}
|
||||
UID:{uid}
|
||||
RRULE:FREQ=YEARLY
|
||||
SUMMARY:💍 {fullname}{year_suffix}
|
||||
TRANSP:TRANSPARENT
|
||||
BEGIN:VALARM
|
||||
TRIGGER;VALUE=DURATION:-PT0M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:💍 {fullname}{year_suffix}
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR",
|
||||
),
|
||||
None,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_birthday_object(&self) -> Result<Option<CalendarObject>, Error> {
|
||||
todo!();
|
||||
Ok(
|
||||
if let Some((birthday, contains_year)) = self.get_birthday() {
|
||||
let Some(fullname) = self.get_full_name() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let birthday = birthday.date();
|
||||
let year = contains_year.then_some(birthday.year());
|
||||
let birthday_start = birthday.format(LOCAL_DATE);
|
||||
let birthday_end = birthday.succ_opt().unwrap_or(birthday).format(LOCAL_DATE);
|
||||
let uid = format!("{}-birthday", self.get_id());
|
||||
|
||||
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
|
||||
Some(CalendarObject::from_ics(
|
||||
format!(
|
||||
r"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:{birthday_start}
|
||||
DTEND;VALUE=DATE:{birthday_end}
|
||||
UID:{uid}
|
||||
RRULE:FREQ=YEARLY
|
||||
SUMMARY:🎂 {fullname}{year_suffix}
|
||||
TRANSP:TRANSPARENT
|
||||
BEGIN:VALARM
|
||||
TRIGGER;VALUE=DURATION:-PT0M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:🎂 {fullname}{year_suffix}
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR",
|
||||
),
|
||||
None,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Get significant dates associated with this address object
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
use crate::Error;
|
||||
use derive_more::Display;
|
||||
use ical::component::CalendarInnerData;
|
||||
use ical::component::IcalCalendarObject;
|
||||
use ical::generator::Emitter;
|
||||
use ical::parser::ComponentParser;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
|
||||
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
|
||||
pub enum CalendarObjectType {
|
||||
#[serde(rename = "VEVENT")]
|
||||
Event = 0,
|
||||
#[serde(rename = "VTODO")]
|
||||
Todo = 1,
|
||||
#[serde(rename = "VJOURNAL")]
|
||||
Journal = 2,
|
||||
}
|
||||
|
||||
impl From<&IcalCalendarObject> for CalendarObjectType {
|
||||
fn from(value: &IcalCalendarObject) -> Self {
|
||||
match value.get_inner() {
|
||||
CalendarInnerData::Event(_, _) => Self::Event,
|
||||
CalendarInnerData::Todo(_, _) => Self::Todo,
|
||||
CalendarInnerData::Journal(_, _) => Self::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarObjectType {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Event => "VEVENT",
|
||||
Self::Todo => "VTODO",
|
||||
Self::Journal => "VJOURNAL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rustical_xml::ValueSerialize for CalendarObjectType {
|
||||
fn serialize(&self) -> String {
|
||||
self.as_str().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustical_xml::ValueDeserialize for CalendarObjectType {
|
||||
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
|
||||
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
|
||||
"VEVENT" => Ok(Self::Event),
|
||||
"VTODO" => Ok(Self::Todo),
|
||||
"VJOURNAL" => Ok(Self::Journal),
|
||||
_ => Err(rustical_xml::XmlError::InvalidValue(
|
||||
rustical_xml::ParseValueError::Other(format!(
|
||||
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
|
||||
)),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarObject {
|
||||
inner: IcalCalendarObject,
|
||||
ics: String,
|
||||
}
|
||||
|
||||
impl CalendarObject {
|
||||
pub fn from_ics(ics: String) -> Result<Self, Error> {
|
||||
let parser: ComponentParser<_, IcalCalendarObject> = ComponentParser::new(ics.as_bytes());
|
||||
let inner = parser.expect_one()?;
|
||||
|
||||
Ok(Self { inner, ics })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get_inner(&self) -> &IcalCalendarObject {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_uid(&self) -> &str {
|
||||
self.inner.get_uid()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_etag(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.get_uid());
|
||||
hasher.update(self.get_ics());
|
||||
format!("\"{:x}\"", hasher.finalize())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_ics(&self) -> &str {
|
||||
&self.ics
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_object_type(&self) -> CalendarObjectType {
|
||||
(&self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CalendarObject> for IcalCalendarObject {
|
||||
fn from(value: CalendarObject) -> Self {
|
||||
value.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IcalCalendarObject> for CalendarObject {
|
||||
fn from(value: IcalCalendarObject) -> Self {
|
||||
Self {
|
||||
ics: value.generate(),
|
||||
inner: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
use crate::CalDateTimeError;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("Invalid ics/vcf input: {0}")]
|
||||
@@ -13,6 +15,12 @@ pub enum Error {
|
||||
|
||||
#[error(transparent)]
|
||||
ParserError(#[from] ical::parser::ParserError),
|
||||
|
||||
#[error(transparent)]
|
||||
CalDateTimeError(#[from] CalDateTimeError),
|
||||
|
||||
#[error(transparent)]
|
||||
RRuleError(#[from] rrule::RRuleError),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -22,7 +30,7 @@ impl Error {
|
||||
Self::InvalidData(_) | Self::MissingCalendar | Self::MissingContact => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Self::ParserError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
385
crates/ical/src/icalendar/event.rs
Normal file
385
crates/ical/src/icalendar/event.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use crate::CalDateTime;
|
||||
use crate::Error;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use ical::parser::ComponentMut;
|
||||
use ical::{generator::IcalEvent, parser::Component, property::Property};
|
||||
use rrule::{RRule, RRuleSet};
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EventObject {
|
||||
pub event: IcalEvent,
|
||||
// If a timezone is None that means that in the VCALENDAR object there's a timezone defined
|
||||
// with that name but its not from the Olson DB
|
||||
pub timezones: HashMap<String, Option<chrono_tz::Tz>>,
|
||||
}
|
||||
|
||||
impl EventObject {
|
||||
#[must_use]
|
||||
pub fn get_uid(&self) -> &str {
|
||||
self.event.get_uid()
|
||||
}
|
||||
|
||||
pub fn get_dtstart(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
if let Some(dtstart) = self.event.get_dtstart() {
|
||||
Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_dtend(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
if let Some(dtend) = self.event.get_dtend() {
|
||||
Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
if self.event.get_rrule().is_some() {
|
||||
// TODO: understand recurrence rules
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(dtend) = self.get_dtend()? {
|
||||
return Ok(Some(dtend));
|
||||
}
|
||||
|
||||
let duration = self.event.get_duration().unwrap_or(Duration::days(1));
|
||||
|
||||
let first_occurence = self.get_dtstart()?;
|
||||
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
|
||||
}
|
||||
|
||||
pub fn recurrence_ruleset(&self) -> Result<Option<rrule::RRuleSet>, Error> {
|
||||
let dtstart: DateTime<rrule::Tz> = if let Some(dtstart) = self.get_dtstart()? {
|
||||
if let Some(dtend) = self.get_dtend()? {
|
||||
// DTSTART and DTEND MUST have the same timezone
|
||||
assert_eq!(dtstart.timezone(), dtend.timezone());
|
||||
}
|
||||
|
||||
dtstart
|
||||
.as_datetime()
|
||||
.with_timezone(&dtstart.timezone().into())
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut rrule_set = RRuleSet::new(dtstart);
|
||||
// TODO: Make nice, this is just a bodge to get correct behaviour
|
||||
let mut empty = true;
|
||||
|
||||
for prop in &self.event.properties {
|
||||
rrule_set = match prop.name.as_str() {
|
||||
"RRULE" => {
|
||||
let rrule = RRule::from_str(prop.value.as_ref().ok_or_else(|| {
|
||||
Error::RRuleError(rrule::ParseError::MissingDateGenerationRules.into())
|
||||
})?)?
|
||||
.validate(dtstart)
|
||||
.unwrap();
|
||||
empty = false;
|
||||
rrule_set.rrule(rrule)
|
||||
}
|
||||
"RDATE" => {
|
||||
let rdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
|
||||
empty = false;
|
||||
rrule_set.rdate(rdate)
|
||||
}
|
||||
"EXDATE" => {
|
||||
let exdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
|
||||
empty = false;
|
||||
rrule_set.exdate(exdate)
|
||||
}
|
||||
_ => rrule_set,
|
||||
}
|
||||
}
|
||||
if empty {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(rrule_set))
|
||||
}
|
||||
|
||||
// The returned calendar components MUST NOT use recurrence
|
||||
// properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT
|
||||
// have reference to or include VTIMEZONE components. Date and local
|
||||
// time with reference to time zone information MUST be converted
|
||||
// into date with UTC time.
|
||||
pub fn expand_recurrence(
|
||||
&self,
|
||||
start: Option<DateTime<Utc>>,
|
||||
end: Option<DateTime<Utc>>,
|
||||
overrides: &[Self],
|
||||
) -> Result<Vec<IcalEvent>, Error> {
|
||||
let mut events = vec![];
|
||||
let dtstart = self.get_dtstart()?.expect("We must have a DTSTART here");
|
||||
let computed_duration = self
|
||||
.get_dtend()?
|
||||
.map(|dtend| dtend.as_datetime().into_owned() - dtstart.as_datetime().as_ref());
|
||||
|
||||
let Some(mut rrule_set) = self.recurrence_ruleset()? else {
|
||||
// If ruleset empty simply return main event AND all overrides
|
||||
return Ok(std::iter::once(self.clone())
|
||||
.chain(overrides.iter().cloned())
|
||||
.map(|event| event.event)
|
||||
.collect());
|
||||
};
|
||||
if let Some(start) = start {
|
||||
rrule_set = rrule_set.after(start.with_timezone(&rrule::Tz::UTC));
|
||||
}
|
||||
if let Some(end) = end {
|
||||
rrule_set = rrule_set.before(end.with_timezone(&rrule::Tz::UTC));
|
||||
}
|
||||
let dates = rrule_set.all(2048).dates;
|
||||
'recurrence: for date in dates {
|
||||
let date = CalDateTime::from(date.to_utc());
|
||||
let recurrence_id = if dtstart.is_date() {
|
||||
date.format_date()
|
||||
} else {
|
||||
date.format()
|
||||
};
|
||||
|
||||
for ev_override in overrides {
|
||||
if let Some(override_id) = &ev_override
|
||||
.event
|
||||
.get_recurrence_id()
|
||||
.as_ref()
|
||||
.expect("overrides have a recurrence id")
|
||||
.value
|
||||
&& override_id == &recurrence_id
|
||||
{
|
||||
// We have an override for this occurence
|
||||
//
|
||||
events.push(ev_override.event.clone());
|
||||
continue 'recurrence;
|
||||
}
|
||||
}
|
||||
|
||||
let mut ev = self.event.clone().mutable();
|
||||
ev.remove_property("RRULE");
|
||||
ev.remove_property("RDATE");
|
||||
ev.remove_property("EXDATE");
|
||||
ev.remove_property("EXRULE");
|
||||
let dtstart_prop = ev
|
||||
.get_property("DTSTART")
|
||||
.expect("We must have a DTSTART here")
|
||||
.clone();
|
||||
ev.remove_property("DTSTART");
|
||||
ev.remove_property("DTEND");
|
||||
|
||||
ev.set_property(Property {
|
||||
name: "RECURRENCE-ID".to_string(),
|
||||
value: Some(recurrence_id.clone()),
|
||||
params: vec![],
|
||||
});
|
||||
ev.set_property(Property {
|
||||
name: "DTSTART".to_string(),
|
||||
value: Some(recurrence_id),
|
||||
params: vec![],
|
||||
});
|
||||
if let Some(duration) = computed_duration {
|
||||
let dtend = date + duration;
|
||||
let dtendformat = if dtstart.is_date() {
|
||||
dtend.format_date()
|
||||
} else {
|
||||
dtend.format()
|
||||
};
|
||||
ev.set_property(Property {
|
||||
name: "DTEND".to_string(),
|
||||
value: Some(dtendformat),
|
||||
params: dtstart_prop.params,
|
||||
});
|
||||
}
|
||||
events.push(ev.verify()?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{CalDateTime, CalendarObject};
|
||||
use chrono::{DateTime, Utc};
|
||||
use ical::generator::Emitter;
|
||||
use rstest::rstest;
|
||||
|
||||
const ICS_1: &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";
|
||||
|
||||
const EXPANDED_1: &[&str] = &[
|
||||
"BEGIN:VEVENT\r
|
||||
UID:318ec6503573d9576818daf93dac07317058d95c\r
|
||||
DTSTAMP:20250502T132758Z\r
|
||||
SEQUENCE:2\r
|
||||
SUMMARY:weekly stuff\r
|
||||
TRANSP:OPAQUE\r
|
||||
RECURRENCE-ID:20250506T070000Z\r
|
||||
DTSTART:20250506T070000Z\r
|
||||
DTEND:20250506T072500Z\r
|
||||
END:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r
|
||||
UID:318ec6503573d9576818daf93dac07317058d95c\r
|
||||
DTSTAMP:20250502T132758Z\r
|
||||
SEQUENCE:2\r
|
||||
SUMMARY:weekly stuff\r
|
||||
TRANSP:OPAQUE\r
|
||||
RECURRENCE-ID:20250508T070000Z\r
|
||||
DTSTART:20250508T070000Z\r
|
||||
DTEND:20250508T072500Z\r
|
||||
END:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r
|
||||
UID:318ec6503573d9576818daf93dac07317058d95c\r
|
||||
DTSTAMP:20250502T132758Z\r
|
||||
SEQUENCE:2\r
|
||||
SUMMARY:weekly stuff\r
|
||||
TRANSP:OPAQUE\r
|
||||
RECURRENCE-ID:20250511T090000\r
|
||||
DTSTART:20250511T070000Z\r
|
||||
DTEND:20250511T072500Z\r
|
||||
END:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r
|
||||
UID:318ec6503573d9576818daf93dac07317058d95c\r
|
||||
DTSTAMP:20250502T132758Z\r
|
||||
SEQUENCE:2\r
|
||||
SUMMARY:weekly stuff\r
|
||||
TRANSP:OPAQUE\r
|
||||
RECURRENCE-ID:20250520T090000\r
|
||||
DTSTA:20250520T070000Z\r
|
||||
DTEND:20250520T072500Z\r
|
||||
END:VEVENT\r\n",
|
||||
];
|
||||
|
||||
const ICS_2: &str = r"BEGIN:VCALENDAR
|
||||
CALSCALE:GREGORIAN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:US/Eastern
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20060206T001121Z
|
||||
DTSTART;TZID=US/Eastern:20060102T120000
|
||||
DURATION:PT1H
|
||||
RRULE:FREQ=DAILY;COUNT=5
|
||||
SUMMARY:Event #2
|
||||
UID:abcd2
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20060206T001121Z
|
||||
DTSTART;TZID=US/Eastern:20060104T140000
|
||||
DURATION:PT1H
|
||||
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
|
||||
SUMMARY:Event #2 bis
|
||||
UID:abcd2
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
";
|
||||
|
||||
const EXPANDED_2: &[&str] = &[
|
||||
"BEGIN:VEVENT\r
|
||||
DTSTAMP:20060206T001121Z\r
|
||||
DURATION:PT1H\r
|
||||
SUMMARY:Event #2\r
|
||||
UID:abcd2\r
|
||||
RECURRENCE-ID:20060103T170000\r
|
||||
DTSTART:20060103T170000\r
|
||||
END:VEVENT\r\n",
|
||||
"BEGIN:VEVENT\r
|
||||
DTSTAMP:20060206T001121Z\r
|
||||
DURATION:PT1H\r
|
||||
SUMMARY:Event #2 bis\r
|
||||
UID:abcd2\r
|
||||
RECURRENCE-ID:20060104T170000\r
|
||||
DTSTART:20060104T190000\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r\n",
|
||||
];
|
||||
|
||||
const ICS_3: &str = r"BEGIN:VCALENDAR
|
||||
CALSCALE:GREGORIAN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:US/Eastern
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
|
||||
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
|
||||
DTSTAMP:20060206T001220Z
|
||||
DTSTART;TZID=US/Eastern:20060104T100000
|
||||
DURATION:PT1H
|
||||
LAST-MODIFIED:20060206T001330Z
|
||||
ORGANIZER:mailto:cyrus@example.com
|
||||
SEQUENCE:1
|
||||
STATUS:TENTATIVE
|
||||
SUMMARY:Event #3
|
||||
UID:abcd3
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
";
|
||||
|
||||
const EXPANDED_3: &[&str] = &["BEGIN:VEVENT
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
|
||||
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
|
||||
DTSTAMP:20060206T001220Z
|
||||
DTSTART:20060104T150000
|
||||
DURATION:PT1H
|
||||
LAST-MODIFIED:20060206T001330Z
|
||||
ORGANIZER:mailto:cyrus@example.com
|
||||
SEQUENCE:1
|
||||
STATUS:TENTATIVE
|
||||
SUMMARY:Event #3
|
||||
UID:abcd3
|
||||
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
|
||||
END:VEVENT"];
|
||||
|
||||
#[rstest]
|
||||
#[case(ICS_1, EXPANDED_1, None, None)]
|
||||
// from https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.3
|
||||
#[case(ICS_2, EXPANDED_2,
|
||||
Some(CalDateTime::parse("20060103T000000Z", Some(chrono_tz::US::Eastern)).unwrap().utc()),
|
||||
Some(CalDateTime::parse("20060105T000000Z", Some(chrono_tz::US::Eastern)).unwrap().utc())
|
||||
)]
|
||||
#[case(ICS_3, EXPANDED_3,
|
||||
Some(CalDateTime::parse("20060103T000000Z", Some(chrono_tz::US::Eastern)).unwrap().utc()),
|
||||
Some(CalDateTime::parse("20060105T000000Z", Some(chrono_tz::US::Eastern)).unwrap().utc())
|
||||
)]
|
||||
fn test_expand_recurrence(
|
||||
#[case] ics: &'static str,
|
||||
#[case] expanded: &[&str],
|
||||
#[case] from: Option<DateTime<Utc>>,
|
||||
#[case] to: Option<DateTime<Utc>>,
|
||||
) {
|
||||
let event = CalendarObject::from_ics(ics.to_string(), None).unwrap();
|
||||
let crate::CalendarObjectComponent::Event(event, overrides) = event.get_data() else {
|
||||
panic!()
|
||||
};
|
||||
|
||||
let events: Vec<String> = event
|
||||
.expand_recurrence(from, to, overrides)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|event| Emitter::generate(&event))
|
||||
.collect();
|
||||
assert_eq!(events.len(), expanded.len());
|
||||
for (output, reference) in events.iter().zip(expanded) {
|
||||
similar_asserts::assert_eq!(output, reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
crates/ical/src/icalendar/mod.rs
Normal file
5
crates/ical/src/icalendar/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod event;
|
||||
mod object;
|
||||
|
||||
pub use event::*;
|
||||
pub use object::*;
|
||||
366
crates/ical/src/icalendar/object.rs
Normal file
366
crates/ical/src/icalendar/object.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use super::EventObject;
|
||||
use crate::CalDateTime;
|
||||
use crate::Error;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use derive_more::Display;
|
||||
use ical::generator::{Emitter, IcalCalendar};
|
||||
use ical::parser::ical::component::IcalJournal;
|
||||
use ical::parser::ical::component::IcalTimeZone;
|
||||
use ical::parser::ical::component::IcalTodo;
|
||||
use ical::property::Property;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::HashMap, io::BufReader};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
|
||||
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
|
||||
pub enum CalendarObjectType {
|
||||
#[serde(rename = "VEVENT")]
|
||||
Event = 0,
|
||||
#[serde(rename = "VTODO")]
|
||||
Todo = 1,
|
||||
#[serde(rename = "VJOURNAL")]
|
||||
Journal = 2,
|
||||
}
|
||||
|
||||
impl CalendarObjectType {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Event => "VEVENT",
|
||||
Self::Todo => "VTODO",
|
||||
Self::Journal => "VJOURNAL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rustical_xml::ValueSerialize for CalendarObjectType {
|
||||
fn serialize(&self) -> String {
|
||||
self.as_str().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustical_xml::ValueDeserialize for CalendarObjectType {
|
||||
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
|
||||
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
|
||||
"VEVENT" => Ok(Self::Event),
|
||||
"VTODO" => Ok(Self::Todo),
|
||||
"VJOURNAL" => Ok(Self::Journal),
|
||||
_ => Err(rustical_xml::XmlError::InvalidValue(
|
||||
rustical_xml::ParseValueError::Other(format!(
|
||||
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
|
||||
)),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CalendarObjectComponent {
|
||||
Event(EventObject, Vec<EventObject>),
|
||||
Todo(IcalTodo, Vec<IcalTodo>),
|
||||
Journal(IcalJournal, Vec<IcalJournal>),
|
||||
}
|
||||
|
||||
impl CalendarObjectComponent {
|
||||
#[must_use]
|
||||
pub fn get_uid(&self) -> &str {
|
||||
match &self {
|
||||
// We've made sure before that the first component exists and all components share the
|
||||
// same UID
|
||||
Self::Todo(todo, _) => todo.get_uid(),
|
||||
Self::Event(event, _) => event.event.get_uid(),
|
||||
Self::Journal(journal, _) => journal.get_uid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CalendarObjectComponent> for CalendarObjectType {
|
||||
fn from(value: &CalendarObjectComponent) -> Self {
|
||||
match value {
|
||||
CalendarObjectComponent::Event(..) => Self::Event,
|
||||
CalendarObjectComponent::Todo(..) => Self::Todo,
|
||||
CalendarObjectComponent::Journal(..) => Self::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarObjectComponent {
|
||||
fn from_events(mut events: Vec<EventObject>) -> Result<Self, Error> {
|
||||
// A calendar object does not necessarily have to contain a main VOBJECT
|
||||
if events.is_empty() {
|
||||
return Err(Error::MissingCalendar);
|
||||
}
|
||||
#[allow(clippy::option_if_let_else)]
|
||||
let main_event = if let Some(main) = events
|
||||
.extract_if(.., |event| event.event.get_recurrence_id().is_none())
|
||||
.next()
|
||||
{
|
||||
main
|
||||
} else {
|
||||
events.remove(0)
|
||||
};
|
||||
let overrides = events;
|
||||
for event in &overrides {
|
||||
if event.get_uid() != main_event.get_uid() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object contains multiple UIDs".to_owned(),
|
||||
));
|
||||
}
|
||||
if event.event.get_recurrence_id().is_none() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object can only contain one main component".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self::Event(main_event, overrides))
|
||||
}
|
||||
fn from_todos(mut todos: Vec<IcalTodo>) -> Result<Self, Error> {
|
||||
// A calendar object does not necessarily have to contain a main VOBJECT
|
||||
if todos.is_empty() {
|
||||
return Err(Error::MissingCalendar);
|
||||
}
|
||||
#[allow(clippy::option_if_let_else)]
|
||||
let main_todo = if let Some(main) = todos
|
||||
.extract_if(.., |todo| todo.get_recurrence_id().is_none())
|
||||
.next()
|
||||
{
|
||||
main
|
||||
} else {
|
||||
todos.remove(0)
|
||||
};
|
||||
let overrides = todos;
|
||||
for todo in &overrides {
|
||||
if todo.get_uid() != main_todo.get_uid() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object contains multiple UIDs".to_owned(),
|
||||
));
|
||||
}
|
||||
if todo.get_recurrence_id().is_none() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object can only contain one main component".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self::Todo(main_todo, overrides))
|
||||
}
|
||||
fn from_journals(mut journals: Vec<IcalJournal>) -> Result<Self, Error> {
|
||||
// A calendar object does not necessarily have to contain a main VOBJECT
|
||||
if journals.is_empty() {
|
||||
return Err(Error::MissingCalendar);
|
||||
}
|
||||
#[allow(clippy::option_if_let_else)]
|
||||
let main_journal = if let Some(main) = journals
|
||||
.extract_if(.., |journal| journal.get_recurrence_id().is_none())
|
||||
.next()
|
||||
{
|
||||
main
|
||||
} else {
|
||||
journals.remove(0)
|
||||
};
|
||||
let overrides = journals;
|
||||
for journal in &overrides {
|
||||
if journal.get_uid() != main_journal.get_uid() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object contains multiple UIDs".to_owned(),
|
||||
));
|
||||
}
|
||||
if journal.get_recurrence_id().is_none() {
|
||||
return Err(Error::InvalidData(
|
||||
"Calendar object can only contain one main component".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self::Journal(main_journal, overrides))
|
||||
}
|
||||
|
||||
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
match &self {
|
||||
Self::Event(main_event, overrides) => Ok(overrides
|
||||
.iter()
|
||||
.chain(std::iter::once(main_event))
|
||||
.map(super::event::EventObject::get_dtstart)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
match &self {
|
||||
Self::Event(main_event, overrides) => Ok(overrides
|
||||
.iter()
|
||||
.chain(std::iter::once(main_event))
|
||||
.map(super::event::EventObject::get_last_occurence)
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.max()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CalendarObject {
|
||||
data: CalendarObjectComponent,
|
||||
properties: Vec<Property>,
|
||||
id: String,
|
||||
ics: String,
|
||||
vtimezones: HashMap<String, IcalTimeZone>,
|
||||
}
|
||||
|
||||
impl CalendarObject {
|
||||
pub fn from_ics(ics: String, id: Option<String>) -> Result<Self, Error> {
|
||||
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
|
||||
let cal = parser.next().ok_or(Error::MissingCalendar)??;
|
||||
if parser.next().is_some() {
|
||||
return Err(Error::InvalidData(
|
||||
"multiple calendars, only one allowed".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
if u8::from(!cal.events.is_empty())
|
||||
+ u8::from(!cal.todos.is_empty())
|
||||
+ u8::from(!cal.journals.is_empty())
|
||||
+ u8::from(!cal.free_busys.is_empty())
|
||||
!= 1
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc4791#section-4.1
|
||||
return Err(Error::InvalidData(
|
||||
"iCalendar object must have exactly one component type".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let timezones: HashMap<String, Option<chrono_tz::Tz>> = cal
|
||||
.timezones
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
|
||||
.collect();
|
||||
|
||||
let vtimezones = cal
|
||||
.timezones
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|timezone| (timezone.get_tzid().to_owned(), timezone))
|
||||
.collect();
|
||||
|
||||
let data = if !cal.events.is_empty() {
|
||||
CalendarObjectComponent::from_events(
|
||||
cal.events
|
||||
.into_iter()
|
||||
.map(|event| EventObject {
|
||||
event,
|
||||
timezones: timezones.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)?
|
||||
} else if !cal.todos.is_empty() {
|
||||
CalendarObjectComponent::from_todos(cal.todos)?
|
||||
} else if !cal.journals.is_empty() {
|
||||
CalendarObjectComponent::from_journals(cal.journals)?
|
||||
} else {
|
||||
return Err(Error::InvalidData(
|
||||
"iCalendar component type not supported :(".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
id: id.unwrap_or_else(|| data.get_uid().to_owned()),
|
||||
data,
|
||||
properties: cal.properties,
|
||||
ics,
|
||||
vtimezones,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get_vtimezones(&self) -> &HashMap<String, IcalTimeZone> {
|
||||
&self.vtimezones
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get_data(&self) -> &CalendarObjectComponent {
|
||||
&self.data
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_uid(&self) -> &str {
|
||||
self.data.get_uid()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_etag(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.get_uid());
|
||||
hasher.update(self.get_ics());
|
||||
format!("\"{:x}\"", hasher.finalize())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_ics(&self) -> &str {
|
||||
&self.ics
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_component_name(&self) -> &str {
|
||||
self.get_object_type().as_str()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_object_type(&self) -> CalendarObjectType {
|
||||
(&self.data).into()
|
||||
}
|
||||
|
||||
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
self.data.get_first_occurence()
|
||||
}
|
||||
|
||||
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
|
||||
self.data.get_last_occurence()
|
||||
}
|
||||
|
||||
pub fn expand_recurrence(
|
||||
&self,
|
||||
start: Option<DateTime<Utc>>,
|
||||
end: Option<DateTime<Utc>>,
|
||||
) -> Result<String, Error> {
|
||||
// Only events can be expanded
|
||||
match &self.data {
|
||||
CalendarObjectComponent::Event(main_event, overrides) => {
|
||||
let cal = IcalCalendar {
|
||||
properties: self.properties.clone(),
|
||||
events: main_event.expand_recurrence(start, end, overrides)?,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(cal.generate())
|
||||
}
|
||||
_ => Ok(self.get_ics().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_property(&self, name: &str) -> Option<&Property> {
|
||||
self.properties
|
||||
.iter()
|
||||
.find(|property| property.name == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_named_properties(&self, name: &str) -> Vec<&Property> {
|
||||
self.properties
|
||||
.iter()
|
||||
.filter(|property| property.name == name)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
|
||||
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
|
||||
mod timestamp;
|
||||
mod timezone;
|
||||
pub use timestamp::*;
|
||||
pub use timezone::*;
|
||||
|
||||
mod calendar_object;
|
||||
pub use calendar_object::*;
|
||||
mod icalendar;
|
||||
pub use icalendar::*;
|
||||
|
||||
mod error;
|
||||
pub use error::Error;
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use super::timezone::ICalTimezone;
|
||||
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use derive_more::derive::Deref;
|
||||
use ical::property::Property;
|
||||
use rustical_xml::{ValueDeserialize, ValueSerialize};
|
||||
use std::{borrow::Cow, collections::HashMap, ops::Add, sync::LazyLock};
|
||||
|
||||
static RE_VCARD_DATE_MM_DD: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| 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, PartialEq, Eq)]
|
||||
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, Eq, Hash)]
|
||||
pub struct UtcDateTime(pub DateTime<Utc>);
|
||||
@@ -27,3 +54,375 @@ impl ValueSerialize for UtcDateTime {
|
||||
format!("{}", self.0.format(UTC_DATE_TIME))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CalDateTime {
|
||||
// Form 1, example: 19980118T230000 -> Local
|
||||
// Form 2, example: 19980119T070000Z -> UTC
|
||||
// Form 3, example: TZID=America/New_York:19980119T020000 -> Olson
|
||||
// https://en.wikipedia.org/wiki/Tz_database
|
||||
DateTime(DateTime<ICalTimezone>),
|
||||
Date(NaiveDate, ICalTimezone),
|
||||
}
|
||||
|
||||
impl From<CalDateTime> for DateTime<rrule::Tz> {
|
||||
fn from(value: CalDateTime) -> Self {
|
||||
value
|
||||
.as_datetime()
|
||||
.into_owned()
|
||||
.with_timezone(&value.timezone().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<rrule::Tz>> for CalDateTime {
|
||||
fn from(value: DateTime<rrule::Tz>) -> Self {
|
||||
Self::DateTime(value.with_timezone(&value.timezone().into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for CalDateTime {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for CalDateTime {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match (&self, &other) {
|
||||
(Self::DateTime(a), Self::DateTime(b)) => a.cmp(b),
|
||||
(Self::DateTime(a), Self::Date(..)) => a.cmp(&other.as_datetime()),
|
||||
(Self::Date(..), Self::DateTime(b)) => self.as_datetime().as_ref().cmp(b),
|
||||
(Self::Date(..), Self::Date(..)) => self.as_datetime().cmp(&other.as_datetime()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Local>> for CalDateTime {
|
||||
fn from(value: DateTime<Local>) -> Self {
|
||||
Self::DateTime(value.with_timezone(&ICalTimezone::Local))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTime<Utc>> for CalDateTime {
|
||||
fn from(value: DateTime<Utc>) -> Self {
|
||||
Self::DateTime(value.with_timezone(&ICalTimezone::Olson(chrono_tz::UTC)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<Duration> for CalDateTime {
|
||||
type Output = Self;
|
||||
|
||||
fn add(self, duration: Duration) -> Self::Output {
|
||||
match self {
|
||||
Self::DateTime(datetime) => Self::DateTime(datetime + duration),
|
||||
Self::Date(date, tz) => Self::DateTime(
|
||||
date.and_time(NaiveTime::default())
|
||||
.and_local_timezone(tz)
|
||||
.earliest()
|
||||
.expect("Local timezone has constant offset")
|
||||
+ duration,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CalDateTime {
|
||||
pub fn parse_prop(
|
||||
prop: &Property,
|
||||
timezones: &HashMap<String, Option<chrono_tz::Tz>>,
|
||||
) -> Result<Self, CalDateTimeError> {
|
||||
let prop_value = prop
|
||||
.value
|
||||
.as_ref()
|
||||
.ok_or_else(|| CalDateTimeError::InvalidDatetimeFormat("empty property".into()))?;
|
||||
|
||||
let timezone = if let Some(tzid) = prop.get_param("TZID") {
|
||||
if let Some(timezone) = timezones.get(tzid) {
|
||||
timezone.to_owned()
|
||||
} 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
|
||||
// We will stick to this default as documented in https://github.com/lennart-k/rustical/issues/102
|
||||
None
|
||||
};
|
||||
|
||||
Self::parse(prop_value, timezone)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn format(&self) -> String {
|
||||
match self {
|
||||
Self::DateTime(datetime) => match datetime.timezone() {
|
||||
ICalTimezone::Olson(chrono_tz::UTC) => datetime.format(UTC_DATE_TIME).to_string(),
|
||||
_ => datetime.format(LOCAL_DATE_TIME).to_string(),
|
||||
},
|
||||
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn format_date(&self) -> String {
|
||||
match self {
|
||||
Self::DateTime(datetime) => datetime.format(LOCAL_DATE).to_string(),
|
||||
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn date(&self) -> NaiveDate {
|
||||
match self {
|
||||
Self::DateTime(datetime) => datetime.date_naive(),
|
||||
Self::Date(date, _) => date.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_date(&self) -> bool {
|
||||
matches!(&self, Self::Date(_, _))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_datetime(&self) -> Cow<'_, DateTime<ICalTimezone>> {
|
||||
match self {
|
||||
Self::DateTime(datetime) => Cow::Borrowed(datetime),
|
||||
Self::Date(date, tz) => Cow::Owned(
|
||||
date.and_time(NaiveTime::default())
|
||||
.and_local_timezone(tz.to_owned())
|
||||
.earliest()
|
||||
.expect("Midnight always exists"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timezone(&self, tz: &ICalTimezone) -> Self {
|
||||
match self {
|
||||
Self::DateTime(datetime) => Self::DateTime(datetime.with_timezone(tz)),
|
||||
Self::Date(date, _) => Self::Date(date.to_owned(), tz.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
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(Self::DateTime(
|
||||
datetime
|
||||
.and_local_timezone(timezone.into())
|
||||
.earliest()
|
||||
.ok_or(CalDateTimeError::LocalTimeGap)?,
|
||||
));
|
||||
}
|
||||
return Ok(Self::DateTime(
|
||||
datetime
|
||||
.and_local_timezone(ICalTimezone::Local)
|
||||
.earliest()
|
||||
.ok_or(CalDateTimeError::LocalTimeGap)?,
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
|
||||
return Ok(datetime.and_utc().into());
|
||||
}
|
||||
let timezone = timezone.map_or(ICalTimezone::Local, ICalTimezone::Olson);
|
||||
if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) {
|
||||
return Ok(Self::Date(date, timezone));
|
||||
}
|
||||
|
||||
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
|
||||
return Ok(Self::Date(date, timezone));
|
||||
}
|
||||
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") {
|
||||
return Ok(Self::Date(date, timezone));
|
||||
}
|
||||
|
||||
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
|
||||
}
|
||||
|
||||
// Also returns whether the date contains a year
|
||||
pub fn parse_vcard(value: &str) -> Result<(Self, bool), CalDateTimeError> {
|
||||
if let Ok(datetime) = Self::parse(value, None) {
|
||||
return Ok((datetime, true));
|
||||
}
|
||||
|
||||
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((
|
||||
Self::Date(
|
||||
NaiveDate::from_ymd_opt(year, month, day)
|
||||
.ok_or_else(|| CalDateTimeError::ParseError(value.to_string()))?,
|
||||
ICalTimezone::Local,
|
||||
),
|
||||
false,
|
||||
));
|
||||
}
|
||||
Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn utc(&self) -> DateTime<Utc> {
|
||||
self.as_datetime().to_utc()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn timezone(&self) -> ICalTimezone {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.timezone(),
|
||||
Self::Date(_, tz) => tz.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CalDateTime> for DateTime<Utc> {
|
||||
fn from(value: CalDateTime) -> Self {
|
||||
value.utc()
|
||||
}
|
||||
}
|
||||
|
||||
impl Datelike for CalDateTime {
|
||||
fn year(&self) -> i32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.year(),
|
||||
Self::Date(date, _) => date.year(),
|
||||
}
|
||||
}
|
||||
fn month(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.month(),
|
||||
Self::Date(date, _) => date.month(),
|
||||
}
|
||||
}
|
||||
|
||||
fn month0(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.month0(),
|
||||
Self::Date(date, _) => date.month0(),
|
||||
}
|
||||
}
|
||||
fn day(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.day(),
|
||||
Self::Date(date, _) => date.day(),
|
||||
}
|
||||
}
|
||||
fn day0(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.day0(),
|
||||
Self::Date(date, _) => date.day0(),
|
||||
}
|
||||
}
|
||||
fn ordinal(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.ordinal(),
|
||||
Self::Date(date, _) => date.ordinal(),
|
||||
}
|
||||
}
|
||||
fn ordinal0(&self) -> u32 {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.ordinal0(),
|
||||
Self::Date(date, _) => date.ordinal0(),
|
||||
}
|
||||
}
|
||||
fn weekday(&self) -> chrono::Weekday {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.weekday(),
|
||||
Self::Date(date, _) => date.weekday(),
|
||||
}
|
||||
}
|
||||
fn iso_week(&self) -> chrono::IsoWeek {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => datetime.iso_week(),
|
||||
Self::Date(date, _) => date.iso_week(),
|
||||
}
|
||||
}
|
||||
fn with_year(&self, year: i32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_year(year)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_year(year)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_month(&self, month: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month(month)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_month(month)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_month0(&self, month0: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_month0(month0)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_month0(month0)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_day(&self, day: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day(day)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_day(day)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_day0(&self, day0: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_day0(day0)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_day0(day0)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_ordinal(&self, ordinal: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal(ordinal)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal(ordinal)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
fn with_ordinal0(&self, ordinal0: u32) -> Option<Self> {
|
||||
match &self {
|
||||
Self::DateTime(datetime) => Some(Self::DateTime(datetime.with_ordinal0(ordinal0)?)),
|
||||
Self::Date(date, tz) => Some(Self::Date(date.with_ordinal0(ordinal0)?, tz.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::CalDateTime;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
#[test]
|
||||
fn test_vcard_date() {
|
||||
assert_eq!(
|
||||
CalDateTime::parse_vcard("19850412").unwrap(),
|
||||
(
|
||||
CalDateTime::Date(
|
||||
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
|
||||
crate::ICalTimezone::Local
|
||||
),
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
CalDateTime::parse_vcard("1985-04-12").unwrap(),
|
||||
(
|
||||
CalDateTime::Date(
|
||||
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
|
||||
crate::ICalTimezone::Local
|
||||
),
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
CalDateTime::parse_vcard("--0412").unwrap(),
|
||||
(
|
||||
CalDateTime::Date(
|
||||
NaiveDate::from_ymd_opt(1972, 4, 12).unwrap(),
|
||||
crate::ICalTimezone::Local
|
||||
),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
92
crates/ical/src/timezone.rs
Normal file
92
crates/ical/src/timezone.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
|
||||
use chrono_tz::Tz;
|
||||
use derive_more::{Display, From};
|
||||
|
||||
#[derive(Debug, Clone, From, PartialEq, Eq)]
|
||||
pub enum ICalTimezone {
|
||||
Local,
|
||||
Olson(Tz),
|
||||
}
|
||||
|
||||
impl From<ICalTimezone> for rrule::Tz {
|
||||
fn from(value: ICalTimezone) -> Self {
|
||||
match value {
|
||||
ICalTimezone::Local => Self::LOCAL,
|
||||
ICalTimezone::Olson(tz) => Self::Tz(tz),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rrule::Tz> for ICalTimezone {
|
||||
fn from(value: rrule::Tz) -> Self {
|
||||
match value {
|
||||
rrule::Tz::Local(_) => Self::Local,
|
||||
rrule::Tz::Tz(tz) => Self::Olson(tz),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Display)]
|
||||
pub enum CalTimezoneOffset {
|
||||
Local(chrono::FixedOffset),
|
||||
Olson(chrono_tz::TzOffset),
|
||||
}
|
||||
|
||||
impl chrono::Offset for CalTimezoneOffset {
|
||||
fn fix(&self) -> chrono::FixedOffset {
|
||||
match self {
|
||||
Self::Local(local) => local.fix(),
|
||||
Self::Olson(olson) => olson.fix(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeZone for ICalTimezone {
|
||||
type Offset = CalTimezoneOffset;
|
||||
|
||||
fn from_offset(offset: &Self::Offset) -> Self {
|
||||
match offset {
|
||||
CalTimezoneOffset::Local(_) => Self::Local,
|
||||
CalTimezoneOffset::Olson(offset) => Self::Olson(Tz::from_offset(offset)),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_from_local_date(&self, local: &NaiveDate) -> chrono::MappedLocalTime<Self::Offset> {
|
||||
match self {
|
||||
Self::Local => Local
|
||||
.offset_from_local_date(local)
|
||||
.map(CalTimezoneOffset::Local),
|
||||
Self::Olson(tz) => tz
|
||||
.offset_from_local_date(local)
|
||||
.map(CalTimezoneOffset::Olson),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_from_local_datetime(
|
||||
&self,
|
||||
local: &NaiveDateTime,
|
||||
) -> chrono::MappedLocalTime<Self::Offset> {
|
||||
match self {
|
||||
Self::Local => Local
|
||||
.offset_from_local_datetime(local)
|
||||
.map(CalTimezoneOffset::Local),
|
||||
Self::Olson(tz) => tz
|
||||
.offset_from_local_datetime(local)
|
||||
.map(CalTimezoneOffset::Olson),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
|
||||
match self {
|
||||
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_datetime(utc)),
|
||||
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_datetime(utc)),
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
|
||||
match self {
|
||||
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_date(utc)),
|
||||
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_date(utc)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,6 @@ END:VCALENDAR
|
||||
|
||||
#[test]
|
||||
fn parse_calendar_object() {
|
||||
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string()).unwrap();
|
||||
object.get_inner().expand_recurrence(None, None);
|
||||
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string(), None).unwrap();
|
||||
object.expand_recurrence(None, None).unwrap();
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
|
||||
async fn update_addressbook(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
addressbook: Addressbook,
|
||||
) -> Result<(), Error>;
|
||||
async fn insert_addressbook(&self, addressbook: Addressbook) -> Result<(), Error>;
|
||||
@@ -33,7 +33,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), Error>;
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
|
||||
|
||||
async fn addressbook_metadata(
|
||||
&self,
|
||||
@@ -45,7 +45,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<(String, AddressObject)>, Error>;
|
||||
) -> Result<Vec<AddressObject>, Error>;
|
||||
async fn get_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
@@ -55,9 +55,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
) -> Result<AddressObject, Error>;
|
||||
async fn put_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
principal: String,
|
||||
addressbook_id: String,
|
||||
object: AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error>;
|
||||
@@ -78,7 +77,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
async fn import_addressbook(
|
||||
&self,
|
||||
addressbook: Addressbook,
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
objects: Vec<AddressObject>,
|
||||
merge_existing: bool,
|
||||
) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ pub trait CalendarStore: Send + Sync + 'static {
|
||||
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
calendar: Calendar,
|
||||
) -> Result<(), Error>;
|
||||
async fn insert_calendar(&self, calendar: Calendar) -> Result<(), Error>;
|
||||
@@ -46,7 +46,7 @@ pub trait CalendarStore: Send + Sync + 'static {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error>;
|
||||
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error>;
|
||||
|
||||
/// Since the <calendar-query> rules are rather complex this function
|
||||
/// is only meant to do some prefiltering
|
||||
@@ -55,7 +55,7 @@ pub trait CalendarStore: Send + Sync + 'static {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
_query: CalendarQuery,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
self.get_objects(principal, cal_id).await
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub trait CalendarStore: Send + Sync + 'static {
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error>;
|
||||
) -> Result<Vec<CalendarObject>, Error>;
|
||||
async fn get_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
@@ -79,26 +79,20 @@ pub trait CalendarStore: Send + Sync + 'static {
|
||||
) -> Result<CalendarObject, Error>;
|
||||
async fn put_objects(
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
objects: Vec<(String, CalendarObject)>,
|
||||
principal: String,
|
||||
cal_id: String,
|
||||
objects: Vec<CalendarObject>,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error>;
|
||||
async fn put_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
object_id: &str,
|
||||
principal: String,
|
||||
cal_id: String,
|
||||
object: CalendarObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error> {
|
||||
self.put_objects(
|
||||
principal,
|
||||
cal_id,
|
||||
vec![(object_id.to_owned(), object)],
|
||||
overwrite,
|
||||
)
|
||||
.await
|
||||
self.put_objects(principal, cal_id, vec![object], overwrite)
|
||||
.await
|
||||
}
|
||||
async fn delete_object(
|
||||
&self,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::{Calendar, CalendarStore, calendar_store::CalendarQuery};
|
||||
use crate::CalendarStore;
|
||||
use async_trait::async_trait;
|
||||
use rustical_ical::CalendarObject;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
pub trait PrefixedCalendarStore: CalendarStore {
|
||||
@@ -52,11 +51,11 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
calendar: Calendar,
|
||||
principal: String,
|
||||
id: String,
|
||||
calendar: crate::Calendar,
|
||||
) -> Result<(), crate::Error> {
|
||||
self.store_for_id(id)
|
||||
self.store_for_id(&id)
|
||||
.update_calendar(principal, id, calendar)
|
||||
.await
|
||||
}
|
||||
@@ -89,7 +88,7 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), crate::Error> {
|
||||
) -> Result<(Vec<rustical_ical::CalendarObject>, Vec<String>, i64), crate::Error> {
|
||||
self.store_for_id(cal_id)
|
||||
.sync_changes(principal, cal_id, synctoken)
|
||||
.await
|
||||
@@ -98,7 +97,7 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
async fn import_calendar(
|
||||
&self,
|
||||
calendar: crate::Calendar,
|
||||
objects: Vec<CalendarObject>,
|
||||
objects: Vec<rustical_ical::CalendarObject>,
|
||||
merge_existing: bool,
|
||||
) -> Result<(), crate::Error> {
|
||||
self.store_for_id(&calendar.id)
|
||||
@@ -110,8 +109,8 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
query: CalendarQuery,
|
||||
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
|
||||
query: crate::calendar_store::CalendarQuery,
|
||||
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
|
||||
self.store_for_id(cal_id)
|
||||
.calendar_query(principal, cal_id, query)
|
||||
.await
|
||||
@@ -142,7 +141,7 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<(String, CalendarObject)>, crate::Error> {
|
||||
) -> Result<Vec<rustical_ical::CalendarObject>, crate::Error> {
|
||||
self.store_for_id(cal_id)
|
||||
.get_objects(principal, cal_id)
|
||||
.await
|
||||
@@ -150,12 +149,12 @@ impl CalendarStore for CombinedCalendarStore {
|
||||
|
||||
async fn put_objects(
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
objects: Vec<(String, CalendarObject)>,
|
||||
principal: String,
|
||||
cal_id: String,
|
||||
objects: Vec<rustical_ical::CalendarObject>,
|
||||
overwrite: bool,
|
||||
) -> Result<(), crate::Error> {
|
||||
self.store_for_id(cal_id)
|
||||
self.store_for_id(&cal_id)
|
||||
.put_objects(principal, cal_id, objects, overwrite)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ rstest.workspace = true
|
||||
criterion.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ical.workspace = true
|
||||
tokio.workspace = true
|
||||
rustical_store.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -34,19 +34,16 @@ fn benchmark(c: &mut Criterion) {
|
||||
cal_store
|
||||
});
|
||||
|
||||
let row = (
|
||||
"asd".to_owned(),
|
||||
CalendarObject::from_ics(include_str!("ical_event.ics").to_owned()).unwrap(),
|
||||
);
|
||||
let object = CalendarObject::from_ics(include_str!("ical_event.ics").to_owned(), None).unwrap();
|
||||
|
||||
let batch_size = 1000;
|
||||
let objects: Vec<_> = std::iter::repeat_n(row.clone(), batch_size).collect();
|
||||
let objects: Vec<_> = std::iter::repeat_n(object.clone(), batch_size).collect();
|
||||
|
||||
c.bench_function("put_batch", |b| {
|
||||
b.to_async(&runtime).iter(async || {
|
||||
// yeet
|
||||
cal_store
|
||||
.put_objects("user", "okwow", objects.clone(), true)
|
||||
.put_objects("user".to_owned(), "okwow".to_owned(), objects.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
@@ -57,7 +54,7 @@ fn benchmark(c: &mut Criterion) {
|
||||
// yeet
|
||||
for _ in 0..1000 {
|
||||
cal_store
|
||||
.put_object("user", "okwow", &row.0, row.1.clone(), true)
|
||||
.put_object("user".to_owned(), "okwow".to_owned(), object.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::addressbook_store::SqliteAddressbookStore;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use rustical_ical::{CalendarObject, CalendarObjectType};
|
||||
use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType};
|
||||
use rustical_store::{
|
||||
Addressbook, AddressbookStore, Calendar, CalendarMetadata, CalendarStore, CollectionMetadata,
|
||||
Error, PrefixedCalendarStore,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Executor, Sqlite};
|
||||
use std::collections::HashMap;
|
||||
use tracing::instrument;
|
||||
|
||||
pub const BIRTHDAYS_PREFIX: &str = "_birthdays_";
|
||||
@@ -268,11 +269,10 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
mut calendar: Calendar,
|
||||
) -> Result<(), Error> {
|
||||
assert_eq!(principal, calendar.principal);
|
||||
assert_eq!(id, calendar.id);
|
||||
calendar.id = calendar
|
||||
.id
|
||||
@@ -324,20 +324,19 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
|
||||
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
|
||||
let cal_id = cal_id
|
||||
.strip_prefix(BIRTHDAYS_PREFIX)
|
||||
.ok_or(Error::NotFound)?;
|
||||
let (objects, deleted_objects, new_synctoken) =
|
||||
AddressbookStore::sync_changes(self, principal, cal_id, synctoken).await?;
|
||||
todo!();
|
||||
// let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
|
||||
// .iter()
|
||||
// .map(AddressObject::get_birthday_object)
|
||||
// .collect();
|
||||
// let objects = objects?.into_iter().flatten().collect();
|
||||
//
|
||||
// Ok((objects, deleted_objects, new_synctoken))
|
||||
let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
|
||||
.iter()
|
||||
.map(AddressObject::get_birthday_object)
|
||||
.collect();
|
||||
let objects = objects?.into_iter().flatten().collect();
|
||||
|
||||
Ok((objects, deleted_objects, new_synctoken))
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
@@ -357,23 +356,22 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
todo!()
|
||||
// let cal_id = cal_id
|
||||
// .strip_prefix(BIRTHDAYS_PREFIX)
|
||||
// .ok_or(Error::NotFound)?;
|
||||
// let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
|
||||
// AddressbookStore::get_objects(self, principal, cal_id)
|
||||
// .await?
|
||||
// .iter()
|
||||
// .map(AddressObject::get_significant_dates)
|
||||
// .collect();
|
||||
// let objects = objects?
|
||||
// .into_iter()
|
||||
// .flat_map(HashMap::into_values)
|
||||
// .collect();
|
||||
//
|
||||
// Ok(objects)
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
let cal_id = cal_id
|
||||
.strip_prefix(BIRTHDAYS_PREFIX)
|
||||
.ok_or(Error::NotFound)?;
|
||||
let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
|
||||
AddressbookStore::get_objects(self, principal, cal_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(AddressObject::get_significant_dates)
|
||||
.collect();
|
||||
let objects = objects?
|
||||
.into_iter()
|
||||
.flat_map(HashMap::into_values)
|
||||
.collect();
|
||||
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
@@ -398,9 +396,9 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn put_objects(
|
||||
&self,
|
||||
_principal: &str,
|
||||
_cal_id: &str,
|
||||
_objects: Vec<(String, CalendarObject)>,
|
||||
_principal: String,
|
||||
_cal_id: String,
|
||||
_objects: Vec<CalendarObject>,
|
||||
_overwrite: bool,
|
||||
) -> Result<(), Error> {
|
||||
Err(Error::ReadOnly)
|
||||
|
||||
@@ -19,11 +19,11 @@ struct AddressObjectRow {
|
||||
vcf: String,
|
||||
}
|
||||
|
||||
impl TryFrom<AddressObjectRow> for (String, AddressObject) {
|
||||
impl TryFrom<AddressObjectRow> for AddressObject {
|
||||
type Error = rustical_store::Error;
|
||||
|
||||
fn try_from(value: AddressObjectRow) -> Result<Self, Self::Error> {
|
||||
Ok((value.id, AddressObject::from_vcf(value.vcf)?))
|
||||
Ok(Self::from_vcf(value.id, value.vcf)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ impl SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
|
||||
struct Row {
|
||||
object_id: String,
|
||||
synctoken: i64,
|
||||
@@ -318,7 +318,7 @@ impl SqliteAddressbookStore {
|
||||
for Row { object_id, .. } in changes {
|
||||
match Self::_get_object(&mut *conn, principal, addressbook_id, &object_id, false).await
|
||||
{
|
||||
Ok(object) => objects.push((object_id, object)),
|
||||
Ok(object) => objects.push(object),
|
||||
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
@@ -353,7 +353,7 @@ impl SqliteAddressbookStore {
|
||||
executor: E,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
|
||||
) -> Result<Vec<AddressObject>, rustical_store::Error> {
|
||||
sqlx::query_as!(
|
||||
AddressObjectRow,
|
||||
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
|
||||
@@ -374,7 +374,7 @@ impl SqliteAddressbookStore {
|
||||
object_id: &str,
|
||||
show_deleted: bool,
|
||||
) -> Result<AddressObject, rustical_store::Error> {
|
||||
let (id, object) = sqlx::query_as!(
|
||||
sqlx::query_as!(
|
||||
AddressObjectRow,
|
||||
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
|
||||
principal,
|
||||
@@ -385,20 +385,17 @@ impl SqliteAddressbookStore {
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(crate::Error::from)?
|
||||
.try_into()?;
|
||||
assert_eq!(id, object_id);
|
||||
Ok(object)
|
||||
.try_into()
|
||||
}
|
||||
|
||||
async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>(
|
||||
executor: E,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
object: &AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
let vcf = object.get_vcf();
|
||||
let (object_id, vcf) = (object.get_id(), object.get_vcf());
|
||||
|
||||
(if overwrite {
|
||||
sqlx::query!(
|
||||
@@ -503,12 +500,10 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn update_addressbook(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
addressbook: Addressbook,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
assert_eq!(principal, &addressbook.principal);
|
||||
assert_eq!(id, &addressbook.id);
|
||||
Self::_update_addressbook(&self.db, &principal, &id, &addressbook).await
|
||||
}
|
||||
|
||||
@@ -574,7 +569,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
|
||||
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
|
||||
}
|
||||
|
||||
@@ -606,7 +601,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
|
||||
) -> Result<Vec<AddressObject>, rustical_store::Error> {
|
||||
Self::_get_objects(&self.db, principal, addressbook_id).await
|
||||
}
|
||||
|
||||
@@ -624,9 +619,8 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn put_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
principal: String,
|
||||
addressbook_id: String,
|
||||
object: AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
@@ -636,15 +630,9 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
.await
|
||||
.map_err(crate::Error::from)?;
|
||||
|
||||
Self::_put_object(
|
||||
&mut *tx,
|
||||
principal,
|
||||
addressbook_id,
|
||||
object_id,
|
||||
&object,
|
||||
overwrite,
|
||||
)
|
||||
.await?;
|
||||
let object_id = object.get_id().to_owned();
|
||||
|
||||
Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?;
|
||||
|
||||
let sync_token = Self::log_object_operation(
|
||||
&mut tx,
|
||||
@@ -660,7 +648,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
|
||||
self.send_push_notification(
|
||||
CollectionOperationInfo::Content { sync_token },
|
||||
self.get_addressbook(principal, addressbook_id, false)
|
||||
self.get_addressbook(&principal, &addressbook_id, false)
|
||||
.await?
|
||||
.push_topic,
|
||||
);
|
||||
@@ -745,7 +733,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
async fn import_addressbook(
|
||||
&self,
|
||||
addressbook: Addressbook,
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
objects: Vec<AddressObject>,
|
||||
merge_existing: bool,
|
||||
) -> Result<(), Error> {
|
||||
let mut tx = self
|
||||
@@ -770,12 +758,11 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
}
|
||||
|
||||
let mut sync_token = None;
|
||||
for (object_id, object) in objects {
|
||||
for object in objects {
|
||||
Self::_put_object(
|
||||
&mut *tx,
|
||||
&addressbook.principal,
|
||||
&addressbook.id,
|
||||
&object_id,
|
||||
&object,
|
||||
false,
|
||||
)
|
||||
@@ -786,7 +773,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
&mut tx,
|
||||
&addressbook.principal,
|
||||
&addressbook.id,
|
||||
&object_id,
|
||||
object.get_id(),
|
||||
ChangeOperation::Add,
|
||||
)
|
||||
.await?,
|
||||
|
||||
@@ -3,8 +3,7 @@ use crate::BEGIN_IMMEDIATE;
|
||||
use async_trait::async_trait;
|
||||
use chrono::TimeDelta;
|
||||
use derive_more::derive::Constructor;
|
||||
use ical::types::CalDateTime;
|
||||
use rustical_ical::{CalendarObject, CalendarObjectType};
|
||||
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
|
||||
use rustical_store::calendar_store::CalendarQuery;
|
||||
use rustical_store::synctoken::format_synctoken;
|
||||
use rustical_store::{Calendar, CalendarMetadata, CalendarStore, CollectionMetadata, Error};
|
||||
@@ -21,11 +20,11 @@ struct CalendarObjectRow {
|
||||
uid: String,
|
||||
}
|
||||
|
||||
impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
|
||||
impl TryFrom<CalendarObjectRow> for CalendarObject {
|
||||
type Error = rustical_store::Error;
|
||||
|
||||
fn try_from(value: CalendarObjectRow) -> Result<Self, Self::Error> {
|
||||
let object = CalendarObject::from_ics(value.ics)?;
|
||||
let object = Self::from_ics(value.ics, Some(value.id))?;
|
||||
if object.get_uid() != value.uid {
|
||||
return Err(rustical_store::Error::IcalError(
|
||||
rustical_ical::Error::InvalidData(format!(
|
||||
@@ -35,7 +34,7 @@ impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
|
||||
)),
|
||||
));
|
||||
}
|
||||
Ok((value.id, object))
|
||||
Ok(object)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +279,8 @@ impl SqliteCalendarStore {
|
||||
|
||||
async fn _update_calendar<'e, E: Executor<'e, Database = Sqlite>>(
|
||||
executor: E,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
calendar: Calendar,
|
||||
) -> Result<(), Error> {
|
||||
let comp_event = calendar.components.contains(&CalendarObjectType::Event);
|
||||
@@ -379,7 +378,7 @@ impl SqliteCalendarStore {
|
||||
executor: E,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
sqlx::query_as!(
|
||||
CalendarObjectRow,
|
||||
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
|
||||
@@ -398,7 +397,7 @@ impl SqliteCalendarStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
query: CalendarQuery,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
// We extend our query interval by one day in each direction since we really don't want to
|
||||
// miss any objects because of timezone differences
|
||||
// I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these
|
||||
@@ -434,7 +433,7 @@ impl SqliteCalendarStore {
|
||||
object_id: &str,
|
||||
show_deleted: bool,
|
||||
) -> Result<CalendarObject, Error> {
|
||||
let (row_id, object) = sqlx::query_as!(
|
||||
sqlx::query_as!(
|
||||
CalendarObjectRow,
|
||||
"SELECT id, uid, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
|
||||
principal,
|
||||
@@ -445,9 +444,7 @@ impl SqliteCalendarStore {
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(crate::Error::from)?
|
||||
.try_into()?;
|
||||
assert_eq!(object_id, row_id);
|
||||
Ok(object)
|
||||
.try_into()
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
@@ -455,24 +452,23 @@ impl SqliteCalendarStore {
|
||||
executor: E,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
object_id: &str,
|
||||
object: &CalendarObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error> {
|
||||
let (uid, ics) = (object.get_uid(), object.get_ics());
|
||||
let (object_id, uid, ics) = (object.get_id(), object.get_uid(), object.get_ics());
|
||||
|
||||
let first_occurence = object
|
||||
.get_inner()
|
||||
.get_inner()
|
||||
.get_first_occurence()
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_ref()
|
||||
.map(CalDateTime::date_floor);
|
||||
.map(CalDateTime::date);
|
||||
let last_occurence = object
|
||||
.get_inner()
|
||||
.get_inner()
|
||||
.get_last_occurence()
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_ref()
|
||||
.map(CalDateTime::date_ceil);
|
||||
.map(CalDateTime::date);
|
||||
let etag = object.get_etag();
|
||||
let object_type = object.get_object_type() as u8;
|
||||
|
||||
@@ -564,7 +560,7 @@ impl SqliteCalendarStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
|
||||
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
|
||||
struct Row {
|
||||
object_id: String,
|
||||
synctoken: i64,
|
||||
@@ -591,7 +587,7 @@ impl SqliteCalendarStore {
|
||||
|
||||
for Row { object_id, .. } in changes {
|
||||
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
|
||||
Ok(object) => objects.push((object_id, object)),
|
||||
Ok(object) => objects.push(object),
|
||||
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
@@ -631,8 +627,8 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
#[instrument]
|
||||
async fn update_calendar(
|
||||
&self,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
principal: String,
|
||||
id: String,
|
||||
calendar: Calendar,
|
||||
) -> Result<(), Error> {
|
||||
Self::_update_calendar(&self.db, principal, id, calendar).await
|
||||
@@ -700,23 +696,14 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
|
||||
let mut sync_token = None;
|
||||
for object in objects {
|
||||
let object_id = object.get_uid();
|
||||
Self::_put_object(
|
||||
&mut *tx,
|
||||
&calendar.principal,
|
||||
&calendar.id,
|
||||
object_id,
|
||||
&object,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Self::_put_object(&mut *tx, &calendar.principal, &calendar.id, &object, false).await?;
|
||||
|
||||
sync_token = Some(
|
||||
Self::log_object_operation(
|
||||
&mut tx,
|
||||
&calendar.principal,
|
||||
&calendar.id,
|
||||
object_id,
|
||||
object.get_id(),
|
||||
ChangeOperation::Add,
|
||||
)
|
||||
.await?,
|
||||
@@ -742,7 +729,7 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
query: CalendarQuery,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
Self::_calendar_query(&self.db, principal, cal_id, query).await
|
||||
}
|
||||
|
||||
@@ -773,7 +760,7 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<(String, CalendarObject)>, Error> {
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
Self::_get_objects(&self.db, principal, cal_id).await
|
||||
}
|
||||
|
||||
@@ -791,9 +778,9 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
#[instrument]
|
||||
async fn put_objects(
|
||||
&self,
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
objects: Vec<(String, CalendarObject)>,
|
||||
principal: String,
|
||||
cal_id: String,
|
||||
objects: Vec<CalendarObject>,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error> {
|
||||
let mut tx = self
|
||||
@@ -809,21 +796,18 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
}
|
||||
|
||||
let mut sync_token = None;
|
||||
for (object_id, object) in objects {
|
||||
for object in objects {
|
||||
sync_token = Some(
|
||||
Self::log_object_operation(
|
||||
&mut tx,
|
||||
&principal,
|
||||
&cal_id,
|
||||
&object_id,
|
||||
object.get_id(),
|
||||
ChangeOperation::Add,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
Self::_put_object(
|
||||
&mut *tx, &principal, &cal_id, &object_id, &object, overwrite,
|
||||
)
|
||||
.await?;
|
||||
Self::_put_object(&mut *tx, &principal, &cal_id, &object, overwrite).await?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(crate::Error::from)?;
|
||||
@@ -901,7 +885,7 @@ impl CalendarStore for SqliteCalendarStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
|
||||
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
|
||||
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
source: src/integration_tests/caldav/calendar.rs
|
||||
assertion_line: 145
|
||||
expression: body
|
||||
---
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:RustiCal Export
|
||||
CALSCALE:GREGORIAN
|
||||
X-WR-CALNAME:Calendar
|
||||
X-WR-CALDESC:Description
|
||||
X-WR-CALCOLOR:#00FF00
|
||||
X-WR-TIMEZONE:US/Eastern
|
||||
END:VCALENDAR
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
source: src/integration_tests/carddav/addressbook.rs
|
||||
assertion_line: 446
|
||||
expression: body
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
|
||||
<response>
|
||||
<href>/carddav/principal/user/contacts/newcard.vcf</href>
|
||||
<propstat>
|
||||
<prop>
|
||||
<getetag>"ea0bf4a2ce7ef84606a4cf9235776dbc11b3e7ce351ddf35f27cbc0088acca7e"</getetag>
|
||||
<CARD:address-data>BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Cyrus Daboo
|
||||
N:Daboo;Cyrus
|
||||
ADR;TYPE=POSTAL:;2822 Email HQ;Suite 2821;RFCVille;PA;15213;USA
|
||||
EMAIL;TYPE=INTERNET,PREF:cyrus@example.com
|
||||
NICKNAME:me
|
||||
NOTE:Example VCard.
|
||||
ORG:Self Employed
|
||||
TEL;TYPE=WORK,VOICE:412 605 0499
|
||||
TEL;TYPE=FAX:412 605 0705
|
||||
URL:http://www.example.com
|
||||
UID:1234-5678-9000-1
|
||||
END:VCARD
|
||||
</CARD:address-data>
|
||||
</prop>
|
||||
<status>HTTP/1.1 200 OK</status>
|
||||
</propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/home/bernard/addressbook/vcf1.vcf</href>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</response>
|
||||
</multistatus>
|
||||
11
src/main.rs
11
src/main.rs
@@ -25,7 +25,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tower::Layer;
|
||||
use tower_http::normalize_path::NormalizePathLayer;
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
mod app;
|
||||
mod commands;
|
||||
@@ -34,6 +34,9 @@ mod config;
|
||||
pub mod integration_tests;
|
||||
mod setup_tracing;
|
||||
|
||||
mod migration_0_12;
|
||||
use migration_0_12::{validate_address_objects_0_12, validate_calendar_objects_0_12};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
@@ -115,6 +118,12 @@ async fn main() -> Result<()> {
|
||||
let (addr_store, cal_store, subscription_store, principal_store, update_recv) =
|
||||
get_data_stores(!args.no_migrations, &config.data_store).await?;
|
||||
|
||||
warn!(
|
||||
"Validating calendar data against the next-version ical parser.\nIn the next major release these will be rejected and cause errors.\nIf any errors occur, please open an issue so they can be fixed before the next major release."
|
||||
);
|
||||
validate_calendar_objects_0_12(principal_store.as_ref(), cal_store.as_ref()).await?;
|
||||
validate_address_objects_0_12(principal_store.as_ref(), addr_store.as_ref()).await?;
|
||||
|
||||
let mut tasks = vec![];
|
||||
|
||||
if config.dav_push.enabled {
|
||||
|
||||
84
src/migration_0_12.rs
Normal file
84
src/migration_0_12.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use rustical_store::{AddressbookStore, CalendarStore, auth::AuthenticationProvider};
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn validate_calendar_objects_0_12(
|
||||
principal_store: &impl AuthenticationProvider,
|
||||
cal_store: &impl CalendarStore,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
let mut success = true;
|
||||
for principal in principal_store.get_principals().await? {
|
||||
for calendar in cal_store.get_calendars(&principal.id).await? {
|
||||
for object in cal_store
|
||||
.get_objects(&calendar.principal, &calendar.id)
|
||||
.await?
|
||||
{
|
||||
if let Err(err) =
|
||||
ical_dev::parser::ical::IcalObjectParser::new(object.get_ics().as_bytes())
|
||||
.expect_one()
|
||||
{
|
||||
if ical_dev::parser::ParserError::InvalidVersion == err {
|
||||
// This is a known issue that might cause a lot of spam in the logs
|
||||
continue;
|
||||
}
|
||||
success = false;
|
||||
error!(
|
||||
"An error occured parsing a calendar object: principal={principal}, calendar={calendar}, object_id={object_id}: {err}",
|
||||
principal = principal.id,
|
||||
calendar = calendar.id,
|
||||
object_id = object.get_id()
|
||||
);
|
||||
println!("{}", object.get_ics());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if success {
|
||||
info!("Your calendar data seems to be valid in the next major version.");
|
||||
} else {
|
||||
error!(
|
||||
"Not all calendar objects will be successfully parsed in the next major version (v0.12).
|
||||
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
|
||||
https://github.com/lennart-k/rustical/issues/165"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_address_objects_0_12(
|
||||
principal_store: &impl AuthenticationProvider,
|
||||
addr_store: &impl AddressbookStore,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
let mut success = true;
|
||||
for principal in principal_store.get_principals().await? {
|
||||
for addressbook in addr_store.get_addressbooks(&principal.id).await? {
|
||||
for object in addr_store
|
||||
.get_objects(&addressbook.principal, &addressbook.id)
|
||||
.await?
|
||||
{
|
||||
if let Err(err) =
|
||||
ical_dev::parser::vcard::VcardParser::new(object.get_vcf().as_bytes())
|
||||
.expect_one()
|
||||
{
|
||||
success = false;
|
||||
error!(
|
||||
"An error occured parsing an address object: principal={principal}, addressbook={addressbook}, object_id={object_id}: {err}",
|
||||
principal = principal.id,
|
||||
addressbook = addressbook.id,
|
||||
object_id = object.get_id()
|
||||
);
|
||||
println!("{}", object.get_vcf());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if success {
|
||||
info!("Your addressbook data seems to be valid in the next major version.");
|
||||
} else {
|
||||
error!(
|
||||
"Not all address objects will be successfully parsed in the next major version (v0.12).
|
||||
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
|
||||
https://github.com/lennart-k/rustical/issues/165"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user