Calendar data model: Switch to only saving timezone id

This commit is contained in:
Lennart
2025-07-25 22:32:01 +02:00
parent 918f27e8c2
commit e1a10338e0
8 changed files with 87 additions and 46 deletions

1
Cargo.lock generated
View File

@@ -3282,6 +3282,7 @@ dependencies = [
"tower",
"tower-sessions",
"tracing",
"vtimezones-rs",
]
[[package]]

View File

@@ -4,6 +4,7 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal;
@@ -82,26 +83,38 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
request.displayname = None
}
let mut timezone = request.calendar_timezone;
if let Some(tzid) = request.calendar_timezone_id.as_ref() {
// Validate timezone id and set timezone accordingly
timezone = Some(
vtimezones_rs::VTIMEZONES
.get(tzid)
.ok_or(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)))?
.to_string(),
);
}
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
let timezone = calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
Some(timezone.name().to_owned())
} else {
None
};
let calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
order: request.calendar_order.unwrap_or(0),
displayname: request.displayname,
timezone,
timezone_id: request.calendar_timezone_id,
timezone_id,
color: request.calendar_color,
description: request.calendar_description,
deleted_at: None,

View File

@@ -3,6 +3,7 @@ use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
@@ -132,7 +133,9 @@ impl Resource for CalendarResource {
CalendarProp::CalendarDescription(self.cal.description.clone())
}
CalendarPropName::CalendarTimezone => {
CalendarProp::CalendarTimezone(self.cal.timezone.clone())
CalendarProp::CalendarTimezone(self.cal.timezone_id.as_ref().and_then(|tzid| {
vtimezones_rs::VTIMEZONES.get(tzid).map(|tz| tz.to_string())
}))
}
// chrono_tz uses the IANA database
CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet(
@@ -191,23 +194,42 @@ impl Resource for CalendarResource {
Ok(())
}
CalendarProp::CalendarTimezone(timezone) => {
// TODO: Ensure that timezone-id is also updated
// We probably want to prohibit non-IANA timezones
self.cal.timezone = timezone;
if let Some(tz) = timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?;
let timezone =
calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
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(())
}
CalendarProp::TimezoneServiceSet(_) => Err(rustical_dav::Error::PropReadOnly),
CalendarProp::CalendarTimezoneId(timezone_id) => {
if let Some(tzid) = &timezone_id {
// Validate timezone id and set timezone accordingly
self.cal.timezone = Some(
vtimezones_rs::VTIMEZONES
.get(tzid)
.ok_or(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)))?
.to_string(),
);
if !vtimezones_rs::VTIMEZONES.contains_key(tzid) {
return Err(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)));
}
}
self.cal.timezone_id = timezone_id;
Ok(())
@@ -248,15 +270,11 @@ impl Resource for CalendarResource {
self.cal.description = None;
Ok(())
}
CalendarPropName::CalendarTimezone => {
self.cal.timezone = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarTimezoneId => {
CalendarPropName::CalendarTimezone | CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarOrder => {
self.cal.order = 0;
Ok(())

View File

@@ -25,7 +25,7 @@
{% if let Some(timezone_id) = calendar.timezone_id %}
<p>{{ timezone_id }}</p>
{% endif %}
{% if let Some(timezone) = calendar.timezone %}
{% if let Some(timezone) = calendar.get_vtimezone() %}
<textarea rows="16" readonly>{{ timezone }}</textarea>
{% endif %}

View File

@@ -32,6 +32,7 @@ headers.workspace = true
tower.workspace = true
futures-core.workspace = true
tower-sessions.workspace = true
vtimezones-rs.workspace = true
[dev-dependencies]
rstest = { workspace = true }

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use crate::synctoken::format_synctoken;
use chrono::NaiveDateTime;
use rustical_ical::CalendarObjectType;
@@ -11,7 +13,6 @@ pub struct Calendar {
pub order: i64,
pub description: Option<String>,
pub color: Option<String>,
pub timezone: Option<String>,
pub timezone_id: Option<String>,
pub deleted_at: Option<NaiveDateTime>,
pub synctoken: i64,
@@ -24,4 +25,16 @@ impl Calendar {
pub fn format_synctoken(&self) -> String {
format_synctoken(self.synctoken)
}
pub fn get_timezone(&self) -> Option<chrono_tz::Tz> {
self.timezone_id
.as_ref()
.and_then(|tzid| chrono_tz::Tz::from_str(tzid).ok())
}
pub fn get_vtimezone(&self) -> Option<&'static str> {
self.timezone_id
.as_ref()
.and_then(|tzid| vtimezones_rs::VTIMEZONES.get(tzid).cloned())
}
}

View File

@@ -20,7 +20,6 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
order: 0,
description: None,
color: None,
timezone: None,
timezone_id: None,
deleted_at: addressbook.deleted_at,
synctoken: addressbook.synctoken,

View File

@@ -44,7 +44,6 @@ struct CalendarRow {
order: i64,
description: Option<String>,
color: Option<String>,
timezone: Option<String>,
timezone_id: Option<String>,
deleted_at: Option<NaiveDateTime>,
synctoken: i64,
@@ -74,7 +73,6 @@ impl From<CalendarRow> for Calendar {
order: value.order,
description: value.description,
color: value.color,
timezone: value.timezone,
timezone_id: value.timezone_id,
deleted_at: value.deleted_at,
synctoken: value.synctoken,
@@ -100,7 +98,7 @@ impl SqliteCalendarStore {
) -> Result<Calendar, Error> {
let cal = sqlx::query_as!(
CalendarRow,
r#"SELECT *
r#"SELECT principal, id, displayname, "order", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal
FROM calendars
WHERE (principal, id) = (?, ?)
AND ((deleted_at IS NULL) OR ?) "#,
@@ -120,7 +118,7 @@ impl SqliteCalendarStore {
) -> Result<Vec<Calendar>, Error> {
let cals = sqlx::query_as!(
CalendarRow,
r#"SELECT *
r#"SELECT principal, id, displayname, "order", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal
FROM calendars
WHERE principal = ? AND deleted_at IS NULL"#,
principal
@@ -137,7 +135,7 @@ impl SqliteCalendarStore {
) -> Result<Vec<Calendar>, Error> {
let cals = sqlx::query_as!(
CalendarRow,
r#"SELECT *
r#"SELECT principal, id, displayname, "order", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal
FROM calendars
WHERE principal = ? AND deleted_at IS NOT NULL"#,
principal
@@ -157,8 +155,8 @@ impl SqliteCalendarStore {
let comp_journal = calendar.components.contains(&CalendarObjectType::Journal);
sqlx::query!(
r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, subscription_url, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, subscription_url, timezone_id, push_topic, comp_event, comp_todo, comp_journal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
calendar.principal,
calendar.id,
calendar.displayname,
@@ -166,7 +164,6 @@ impl SqliteCalendarStore {
calendar.order,
calendar.color,
calendar.subscription_url,
calendar.timezone,
calendar.timezone_id,
calendar.push_topic,
comp_event, comp_todo, comp_journal
@@ -188,7 +185,7 @@ impl SqliteCalendarStore {
let comp_journal = calendar.components.contains(&CalendarObjectType::Journal);
let result = sqlx::query!(
r#"UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?
r#"UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?
WHERE (principal, id) = (?, ?)"#,
calendar.principal,
calendar.id,
@@ -196,7 +193,6 @@ impl SqliteCalendarStore {
calendar.description,
calendar.order,
calendar.color,
calendar.timezone,
calendar.timezone_id,
calendar.push_topic,
comp_event, comp_todo, comp_journal,