From 5878b93d62de35ca9caa8229737d7b6138c8011b Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:45:31 +0100 Subject: [PATCH 01/17] add birthday_calendar table migrations --- .../20251102192200_birthday_calendars.down.sql | 1 + .../20251102192200_birthday_calendars.up.sql | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 crates/store_sqlite/migrations/20251102192200_birthday_calendars.down.sql create mode 100644 crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql diff --git a/crates/store_sqlite/migrations/20251102192200_birthday_calendars.down.sql b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.down.sql new file mode 100644 index 0000000..d771fdb --- /dev/null +++ b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.down.sql @@ -0,0 +1 @@ +DROP TABLE birthday_calendars; diff --git a/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql new file mode 100644 index 0000000..0b83319 --- /dev/null +++ b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE birthday_calendars ( + principal TEXT NOT NULL, + id TEXT NOT NULL, + displayname TEXT, + description TEXT, + "order" INT DEFAULT 0 NOT NULL, + color TEXT, + timezone_id TEXT, + deleted_at DATETIME, + push_topic TEXT NOT NULL, + PRIMARY KEY (principal, id), + CONSTRAINT fk_birthdays_addressbooks FOREIGN KEY (principal, id) + REFERENCES addressbooks (principal, id) ON DELETE CASCADE + -- birthday calendar stores no meaningful data so we can cascade +) From c19c3492c3573093c7f539420062f1405c708824 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:45:58 +0100 Subject: [PATCH 02/17] frontend: Remove birthday calendar guard --- .../public/templates/components/sections/calendars_section.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/frontend/public/templates/components/sections/calendars_section.html b/crates/frontend/public/templates/components/sections/calendars_section.html index afbbee6..064e2fb 100644 --- a/crates/frontend/public/templates/components/sections/calendars_section.html +++ b/crates/frontend/public/templates/components/sections/calendars_section.html @@ -24,7 +24,6 @@
- {% if !calendar.id.starts_with("_birthdays_") %} - {% endif %}
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects From 547e477ecaa8cf1a419a677b5e1669b28f16b0f6 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:05:31 +0100 Subject: [PATCH 03/17] make sure a birthday calendar will be created for each addressbook --- .../20251102192200_birthday_calendars.up.sql | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql index 0b83319..34878d0 100644 --- a/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql +++ b/crates/store_sqlite/migrations/20251102192200_birthday_calendars.up.sql @@ -12,4 +12,15 @@ CREATE TABLE birthday_calendars ( CONSTRAINT fk_birthdays_addressbooks FOREIGN KEY (principal, id) REFERENCES addressbooks (principal, id) ON DELETE CASCADE -- birthday calendar stores no meaningful data so we can cascade -) +); + +INSERT INTO birthday_calendars +(principal, id, displayname, deleted_at, push_topic) +SELECT + principal, + id, + displayname || ' birthdays' AS displayname, + deleted_at, + push_topic || substr(printf('%d', random()), -4) AS push_topic + -- jank suffix to ensure that new push_topic is different :D +FROM addressbooks; From 5cdbb3b9d3c3d259f9227065ced5204f8ecd6318 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:06:43 +0100 Subject: [PATCH 04/17] migrate birthday store to sqlite --- Cargo.lock | 2 +- crates/store/Cargo.toml | 1 - crates/store/src/contact_birthday_store.rs | 209 ----------- crates/store/src/lib.rs | 4 +- crates/store_sqlite/Cargo.toml | 1 + .../addressbook_store/birthday_calendar.rs | 353 ++++++++++++++++++ .../mod.rs} | 37 +- src/app.rs | 11 +- src/main.rs | 6 +- 9 files changed, 388 insertions(+), 236 deletions(-) delete mode 100644 crates/store/src/contact_birthday_store.rs create mode 100644 crates/store_sqlite/src/addressbook_store/birthday_calendar.rs rename crates/store_sqlite/src/{addressbook_store.rs => addressbook_store/mod.rs} (96%) diff --git a/Cargo.lock b/Cargo.lock index ba16073..84cdb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3227,7 +3227,6 @@ dependencies = [ "rustical_store_sqlite", "rustical_xml", "serde", - "sha2", "thiserror 2.0.17", "tokio", "tower", @@ -3250,6 +3249,7 @@ dependencies = [ "rustical_ical", "rustical_store", "serde", + "sha2", "sqlx", "thiserror 2.0.17", "tokio", diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 98823af..2426311 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -11,7 +11,6 @@ publish = false anyhow = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } -sha2 = { workspace = true } ical = { workspace = true } chrono = { workspace = true } regex = { workspace = true } diff --git a/crates/store/src/contact_birthday_store.rs b/crates/store/src/contact_birthday_store.rs deleted file mode 100644 index 507f328..0000000 --- a/crates/store/src/contact_birthday_store.rs +++ /dev/null @@ -1,209 +0,0 @@ -use crate::{ - Addressbook, AddressbookStore, Calendar, CalendarStore, Error, calendar::CalendarMetadata, - combined_calendar_store::PrefixedCalendarStore, -}; -use async_trait::async_trait; -use derive_more::derive::Constructor; -use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType}; -use sha2::{Digest, Sha256}; -use std::{collections::HashMap, sync::Arc}; - -pub const BIRTHDAYS_PREFIX: &str = "_birthdays_"; - -#[derive(Constructor, Clone)] -pub struct ContactBirthdayStore(Arc); - -impl PrefixedCalendarStore for ContactBirthdayStore { - const PREFIX: &'static str = BIRTHDAYS_PREFIX; -} - -fn birthday_calendar(addressbook: Addressbook) -> Calendar { - Calendar { - principal: addressbook.principal, - id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id), - meta: CalendarMetadata { - displayname: addressbook - .displayname - .map(|name| format!("{name} birthdays")), - order: 0, - description: None, - color: None, - }, - timezone_id: None, - deleted_at: addressbook.deleted_at, - synctoken: addressbook.synctoken, - subscription_url: None, - push_topic: { - let mut hasher = Sha256::new(); - hasher.update("birthdays"); - hasher.update(addressbook.push_topic); - format!("{:x}", hasher.finalize()) - }, - components: vec![CalendarObjectType::Event], - } -} - -/// Objects are all prefixed with `BIRTHDAYS_PREFIX` -#[async_trait] -impl CalendarStore for ContactBirthdayStore { - async fn get_calendar( - &self, - principal: &str, - id: &str, - show_deleted: bool, - ) -> Result { - let id = id.strip_prefix(BIRTHDAYS_PREFIX).ok_or(Error::NotFound)?; - let addressbook = self.0.get_addressbook(principal, id, show_deleted).await?; - Ok(birthday_calendar(addressbook)) - } - - async fn get_calendars(&self, principal: &str) -> Result, Error> { - let addressbooks = self.0.get_addressbooks(principal).await?; - Ok(addressbooks.into_iter().map(birthday_calendar).collect()) - } - - async fn get_deleted_calendars(&self, principal: &str) -> Result, Error> { - let addressbooks = self.0.get_deleted_addressbooks(principal).await?; - Ok(addressbooks.into_iter().map(birthday_calendar).collect()) - } - - async fn update_calendar( - &self, - _principal: String, - _id: String, - _calendar: Calendar, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn insert_calendar(&self, _calendar: Calendar) -> Result<(), Error> { - Err(Error::ReadOnly) - } - async fn delete_calendar( - &self, - _principal: &str, - _name: &str, - _use_trashbin: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn restore_calendar(&self, _principal: &str, _name: &str) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn import_calendar( - &self, - _calendar: Calendar, - _objects: Vec, - _merge_existing: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn sync_changes( - &self, - principal: &str, - cal_id: &str, - synctoken: i64, - ) -> Result<(Vec, Vec, i64), Error> { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let (objects, deleted_objects, new_synctoken) = - self.0.sync_changes(principal, cal_id, synctoken).await?; - let objects: Result>, rustical_ical::Error> = objects - .iter() - .map(AddressObject::get_birthday_object) - .collect(); - let objects = objects?.into_iter().flatten().collect(); - - Ok((objects, deleted_objects, new_synctoken)) - } - - async fn calendar_metadata( - &self, - principal: &str, - cal_id: &str, - ) -> Result { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - self.0.addressbook_metadata(principal, cal_id).await - } - - async fn get_objects( - &self, - principal: &str, - cal_id: &str, - ) -> Result, Error> { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let objects: Result>, rustical_ical::Error> = - self.0 - .get_objects(principal, cal_id) - .await? - .iter() - .map(AddressObject::get_significant_dates) - .collect(); - let objects = objects? - .into_iter() - .flat_map(HashMap::into_values) - .collect(); - - Ok(objects) - } - - async fn get_object( - &self, - principal: &str, - cal_id: &str, - object_id: &str, - show_deleted: bool, - ) -> Result { - let cal_id = cal_id - .strip_prefix(BIRTHDAYS_PREFIX) - .ok_or(Error::NotFound)?; - let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?; - self.0 - .get_object(principal, cal_id, addressobject_id, show_deleted) - .await? - .get_significant_dates()? - .remove(date_type) - .ok_or(Error::NotFound) - } - - async fn put_object( - &self, - _principal: String, - _cal_id: String, - _object: CalendarObject, - _overwrite: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn delete_object( - &self, - _principal: &str, - _cal_id: &str, - _object_id: &str, - _use_trashbin: bool, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - async fn restore_object( - &self, - _principal: &str, - _cal_id: &str, - _object_id: &str, - ) -> Result<(), Error> { - Err(Error::ReadOnly) - } - - fn is_read_only(&self, _cal_id: &str) -> bool { - true - } -} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 73ac4d2..2dd9050 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -7,7 +7,6 @@ pub use error::Error; pub mod auth; mod calendar; mod combined_calendar_store; -mod contact_birthday_store; mod secret; mod subscription_store; pub mod synctoken; @@ -17,8 +16,7 @@ pub mod tests; pub use addressbook_store::AddressbookStore; pub use calendar_store::CalendarStore; -pub use combined_calendar_store::CombinedCalendarStore; -pub use contact_birthday_store::ContactBirthdayStore; +pub use combined_calendar_store::{CombinedCalendarStore, PrefixedCalendarStore}; pub use secret::Secret; pub use subscription_store::*; diff --git a/crates/store_sqlite/Cargo.toml b/crates/store_sqlite/Cargo.toml index e4b67f4..084c4cf 100644 --- a/crates/store_sqlite/Cargo.toml +++ b/crates/store_sqlite/Cargo.toml @@ -29,3 +29,4 @@ uuid.workspace = true pbkdf2.workspace = true rustical_ical.workspace = true rstest = { workspace = true, optional = true } +sha2.workspace = true diff --git a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs new file mode 100644 index 0000000..71ef5a6 --- /dev/null +++ b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs @@ -0,0 +1,353 @@ +use crate::addressbook_store::SqliteAddressbookStore; +use async_trait::async_trait; +use chrono::NaiveDateTime; +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_"; + +struct BirthdayCalendarJoinRow { + principal: String, + id: String, + displayname: Option, + description: Option, + order: i64, + color: Option, + timezone_id: Option, + deleted_at: Option, + push_topic: String, + + addr_synctoken: i64, +} + +impl From for Calendar { + fn from(value: BirthdayCalendarJoinRow) -> Self { + Self { + principal: value.principal, + id: format!("{}{}", BIRTHDAYS_PREFIX, value.id), + meta: CalendarMetadata { + displayname: value.displayname, + order: value.order, + description: value.description, + color: value.color, + }, + deleted_at: value.deleted_at, + components: vec![CalendarObjectType::Event], + timezone_id: value.timezone_id, + synctoken: value.addr_synctoken, + subscription_url: None, + push_topic: value.push_topic, + } + } +} + +impl PrefixedCalendarStore for SqliteAddressbookStore { + const PREFIX: &'static str = BIRTHDAYS_PREFIX; +} + +impl SqliteAddressbookStore { + #[instrument] + pub async fn _get_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + id: &str, + show_deleted: bool, + ) -> Result { + let cal = sqlx::query_as!( + BirthdayCalendarJoinRow, + r#"SELECT principal, id, displayname, description, "order", color, timezone_id, deleted_at, addr_synctoken, push_topic + FROM birthday_calendars + INNER JOIN ( + SELECT principal AS addr_principal, + id AS addr_id, + synctoken AS addr_synctoken + FROM addressbooks + ) ON (principal, id) = (addr_principal, addr_id) + WHERE (principal, id) = (?, ?) + AND ((deleted_at IS NULL) OR ?) + "#, + principal, + id, + show_deleted + ) + .fetch_one(executor) + .await + .map_err(crate::Error::from)?; + Ok(cal.into()) + } + + #[instrument] + pub async fn _get_birthday_calendars<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + deleted: bool, + ) -> Result, Error> { + Ok( + sqlx::query_as!( + BirthdayCalendarJoinRow, + r#"SELECT principal, id, displayname, description, "order", color, timezone_id, deleted_at, addr_synctoken, push_topic + FROM birthday_calendars + INNER JOIN ( + SELECT principal AS addr_principal, + id AS addr_id, + synctoken AS addr_synctoken + FROM addressbooks + ) ON (principal, id) = (addr_principal, addr_id) + WHERE principal = ? + AND ( + (deleted_at IS NULL AND NOT ?) -- not deleted, want not deleted + OR (deleted_at IS NOT NULL AND ?) -- deleted, want deleted + ) + "#, + principal, + deleted, + deleted + ) + .fetch_all(executor) + .await + .map_err(crate::Error::from).map(|cals| cals.into_iter().map(BirthdayCalendarJoinRow::into).collect())?) + } + + #[instrument] + pub async fn _insert_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + addressbook: Addressbook, + ) -> Result<(), rustical_store::Error> { + let birthday_name = addressbook + .displayname + .map(|name| format!("{name} birthdays")); + let birthday_push_topic = { + let mut hasher = Sha256::new(); + hasher.update("birthdays"); + hasher.update(addressbook.push_topic); + format!("{:x}", hasher.finalize()) + }; + + sqlx::query!( + r#"INSERT INTO birthday_calendars (principal, id, displayname, push_topic) + VALUES (?, ?, ?, ?)"#, + addressbook.principal, + addressbook.id, + birthday_name, + birthday_push_topic, + ) + .execute(executor) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + + #[instrument] + async fn _update_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + calendar: &Calendar, + ) -> Result<(), Error> { + let result = sqlx::query!( + r#"UPDATE birthday_calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone_id = ?, push_topic = ? + WHERE (principal, id) = (?, ?)"#, + calendar.principal, + calendar.id, + calendar.meta.displayname, + calendar.meta.description, + calendar.meta.order, + calendar.meta.color, + calendar.timezone_id, + calendar.push_topic, + principal, + calendar.id, + ).execute(executor).await.map_err(crate::Error::from)?; + if result.rows_affected() == 0 { + return Err(rustical_store::Error::NotFound); + } + Ok(()) + } +} + +#[async_trait] +impl CalendarStore for SqliteAddressbookStore { + #[instrument] + async fn get_calendar( + &self, + principal: &str, + id: &str, + show_deleted: bool, + ) -> Result { + let id = id.strip_prefix(BIRTHDAYS_PREFIX).ok_or(Error::NotFound)?; + Self::_get_birthday_calendar(&self.db, principal, id, show_deleted).await + } + + #[instrument] + async fn get_calendars(&self, principal: &str) -> Result, Error> { + Self::_get_birthday_calendars(&self.db, principal, false).await + } + + #[instrument] + async fn get_deleted_calendars(&self, principal: &str) -> Result, Error> { + Self::_get_birthday_calendars(&self.db, principal, true).await + } + + #[instrument] + async fn update_calendar( + &self, + principal: String, + id: String, + mut calendar: Calendar, + ) -> Result<(), Error> { + assert_eq!(id, calendar.id); + calendar.id = calendar + .id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)? + .to_string(); + Self::_update_birthday_calendar(&self.db, &principal, &calendar).await + } + + #[instrument] + async fn insert_calendar(&self, _calendar: Calendar) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn delete_calendar( + &self, + _principal: &str, + _name: &str, + _use_trashbin: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn restore_calendar(&self, _principal: &str, _name: &str) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn import_calendar( + &self, + _calendar: Calendar, + _objects: Vec, + _merge_existing: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn sync_changes( + &self, + principal: &str, + cal_id: &str, + synctoken: i64, + ) -> Result<(Vec, Vec, 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?; + let objects: Result>, 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] + async fn calendar_metadata( + &self, + principal: &str, + cal_id: &str, + ) -> Result { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + self.addressbook_metadata(principal, cal_id).await + } + + #[instrument] + async fn get_objects( + &self, + principal: &str, + cal_id: &str, + ) -> Result, Error> { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + let objects: Result>, 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] + async fn get_object( + &self, + principal: &str, + cal_id: &str, + object_id: &str, + show_deleted: bool, + ) -> Result { + let cal_id = cal_id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; + let (addressobject_id, date_type) = object_id.rsplit_once('-').ok_or(Error::NotFound)?; + AddressbookStore::get_object(self, principal, cal_id, addressobject_id, show_deleted) + .await? + .get_significant_dates()? + .remove(date_type) + .ok_or(Error::NotFound) + } + + #[instrument] + async fn put_object( + &self, + _principal: String, + _cal_id: String, + _object: CalendarObject, + _overwrite: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn delete_object( + &self, + _principal: &str, + _cal_id: &str, + _object_id: &str, + _use_trashbin: bool, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + #[instrument] + async fn restore_object( + &self, + _principal: &str, + _cal_id: &str, + _object_id: &str, + ) -> Result<(), Error> { + Err(Error::ReadOnly) + } + + fn is_read_only(&self, _cal_id: &str) -> bool { + true + } +} diff --git a/crates/store_sqlite/src/addressbook_store.rs b/crates/store_sqlite/src/addressbook_store/mod.rs similarity index 96% rename from crates/store_sqlite/src/addressbook_store.rs rename to crates/store_sqlite/src/addressbook_store/mod.rs index 497bfdb..e4794dd 100644 --- a/crates/store_sqlite/src/addressbook_store.rs +++ b/crates/store_sqlite/src/addressbook_store/mod.rs @@ -11,6 +11,8 @@ use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use tokio::sync::mpsc::Sender; use tracing::{error, instrument}; +pub mod birthday_calendar; + #[derive(Debug, Clone)] struct AddressObjectRow { id: String, @@ -116,7 +118,7 @@ impl SqliteAddressbookStore { async fn _insert_addressbook<'e, E: Executor<'e, Database = Sqlite>>( executor: E, - addressbook: Addressbook, + addressbook: &Addressbook, ) -> Result<(), rustical_store::Error> { sqlx::query!( r#"INSERT INTO addressbooks (principal, id, displayname, description, push_topic) @@ -283,9 +285,9 @@ impl SqliteAddressbookStore { async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>( executor: E, - principal: String, - addressbook_id: String, - object: AddressObject, + principal: &str, + addressbook_id: &str, + object: &AddressObject, overwrite: bool, ) -> Result<(), rustical_store::Error> { let (object_id, vcf) = (object.get_id(), object.get_vcf()); @@ -405,7 +407,15 @@ impl AddressbookStore for SqliteAddressbookStore { &self, addressbook: Addressbook, ) -> Result<(), rustical_store::Error> { - Self::_insert_addressbook(&self.db, addressbook).await + let mut tx = self + .db + .begin_with(BEGIN_IMMEDIATE) + .await + .map_err(crate::Error::from)?; + Self::_insert_addressbook(&mut *tx, &addressbook).await?; + Self::_insert_birthday_calendar(&mut *tx, addressbook).await?; + tx.commit().await.map_err(crate::Error::from)?; + Ok(()) } #[instrument] @@ -521,14 +531,7 @@ impl AddressbookStore for SqliteAddressbookStore { let object_id = object.get_id().to_owned(); - Self::_put_object( - &mut *tx, - principal.clone(), - addressbook_id.clone(), - object, - overwrite, - ) - .await?; + Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?; let sync_token = log_object_operation( &mut tx, @@ -659,15 +662,15 @@ impl AddressbookStore for SqliteAddressbookStore { return Err(Error::AlreadyExists); } if existing.is_none() { - Self::_insert_addressbook(&mut *tx, addressbook.clone()).await?; + Self::_insert_addressbook(&mut *tx, &addressbook).await?; } for object in objects { Self::_put_object( &mut *tx, - addressbook.principal.clone(), - addressbook.id.clone(), - object, + &addressbook.principal, + &addressbook.id, + &object, false, ) .await?; diff --git a/src/app.rs b/src/app.rs index 7634d2c..2a16b91 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,8 @@ use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_oidc::OidcConfig; use rustical_store::auth::AuthenticationProvider; use rustical_store::{ - AddressbookStore, CalendarStore, CombinedCalendarStore, ContactBirthdayStore, SubscriptionStore, + AddressbookStore, CalendarStore, CombinedCalendarStore, PrefixedCalendarStore, + SubscriptionStore, }; use std::sync::Arc; use std::time::Duration; @@ -33,7 +34,11 @@ use tracing::field::display; clippy::too_many_lines, clippy::cognitive_complexity )] -pub fn make_app( +pub fn make_app< + AS: AddressbookStore + PrefixedCalendarStore, + CS: CalendarStore, + S: SubscriptionStore, +>( addr_store: Arc, cal_store: Arc, subscription_store: Arc, @@ -45,7 +50,7 @@ pub fn make_app( session_cookie_samesite_strict: bool, payload_limit_mb: usize, ) -> Router<()> { - let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store.clone())); + let birthday_store = addr_store.clone(); let combined_cal_store = Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store)); diff --git a/src/main.rs b/src/main.rs index b4d88f3..706fa20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,9 @@ use figment::Figment; use figment::providers::{Env, Format, Toml}; use rustical_dav_push::DavPushController; use rustical_store::auth::AuthenticationProvider; -use rustical_store::{AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore}; +use rustical_store::{ + AddressbookStore, CalendarStore, CollectionOperation, PrefixedCalendarStore, SubscriptionStore, +}; use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore; use rustical_store_sqlite::calendar_store::SqliteCalendarStore; use rustical_store_sqlite::principal_store::SqlitePrincipalStore; @@ -56,7 +58,7 @@ async fn get_data_stores( migrate: bool, config: &DataStoreConfig, ) -> Result<( - Arc, + Arc, Arc, Arc, Arc, From 425d10cb991b9414c55c5b1eaf8046c7b435a902 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:07:06 +0100 Subject: [PATCH 05/17] CalendarStore::is_read_only now refers to its content only and not its metadata --- crates/caldav/src/calendar/resource.rs | 7 +------ crates/store/src/calendar_store.rs | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/caldav/src/calendar/resource.rs b/crates/caldav/src/calendar/resource.rs index 2c891eb..03bbe04 100644 --- a/crates/caldav/src/calendar/resource.rs +++ b/crates/caldav/src/calendar/resource.rs @@ -317,16 +317,11 @@ impl Resource for CalendarResource { } fn get_user_privileges(&self, user: &Principal) -> Result { - if self.cal.subscription_url.is_some() { + if self.cal.subscription_url.is_some() || self.read_only { return Ok(UserPrivilegeSet::owner_write_properties( user.is_principal(&self.cal.principal), )); } - if self.read_only { - return Ok(UserPrivilegeSet::owner_read( - user.is_principal(&self.cal.principal), - )); - } Ok(UserPrivilegeSet::owner_only( user.is_principal(&self.cal.principal), diff --git a/crates/store/src/calendar_store.rs b/crates/store/src/calendar_store.rs index 94dd83a..99b9c4e 100644 --- a/crates/store/src/calendar_store.rs +++ b/crates/store/src/calendar_store.rs @@ -98,5 +98,6 @@ pub trait CalendarStore: Send + Sync + 'static { object_id: &str, ) -> Result<(), Error>; + // read_only refers to objects, metadata may still be updated fn is_read_only(&self, cal_id: &str) -> bool; } From 381af1b877923a2d1577adc2e01811df84db2b9b Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:37:40 +0100 Subject: [PATCH 06/17] run .sqlx prepare --- ...1cc16f43768074ab9e7ab7b7783395384984e.json | 12 +++ ...809943f817abf8c8f9ae50073924bccdea2dc.json | 74 +++++++++++++++++++ ...110e1efbc7ce9332f10da4fa69f7594fb7455.json | 74 +++++++++++++++++++ ...1d32ac367c52ce41bd70394f754248b29749c.json | 12 +++ 4 files changed, 172 insertions(+) create mode 100644 .sqlx/query-4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e.json create mode 100644 .sqlx/query-525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc.json create mode 100644 .sqlx/query-66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455.json create mode 100644 .sqlx/query-bfdf662cd03e741b7a36f5e2ac01d32ac367c52ce41bd70394f754248b29749c.json diff --git a/.sqlx/query-4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e.json b/.sqlx/query-4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e.json new file mode 100644 index 0000000..9298694 --- /dev/null +++ b/.sqlx/query-4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE birthday_calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone_id = ?, push_topic = ?\n WHERE (principal, id) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e" +} diff --git a/.sqlx/query-525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc.json b/.sqlx/query-525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc.json new file mode 100644 index 0000000..794b59e --- /dev/null +++ b/.sqlx/query-525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT principal, id, displayname, description, \"order\", color, timezone_id, deleted_at, addr_synctoken, push_topic\n FROM birthday_calendars\n INNER JOIN (\n SELECT principal AS addr_principal,\n id AS addr_id,\n synctoken AS addr_synctoken\n FROM addressbooks\n ) ON (principal, id) = (addr_principal, addr_id)\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?)\n ", + "describe": { + "columns": [ + { + "name": "principal", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "displayname", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "order", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "color", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "timezone_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "addr_synctoken", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "push_topic", + "ordinal": 9, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc" +} diff --git a/.sqlx/query-66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455.json b/.sqlx/query-66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455.json new file mode 100644 index 0000000..e738181 --- /dev/null +++ b/.sqlx/query-66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT principal, id, displayname, description, \"order\", color, timezone_id, deleted_at, addr_synctoken, push_topic\n FROM birthday_calendars\n INNER JOIN (\n SELECT principal AS addr_principal,\n id AS addr_id,\n synctoken AS addr_synctoken\n FROM addressbooks\n ) ON (principal, id) = (addr_principal, addr_id)\n WHERE principal = ?\n AND (\n (deleted_at IS NULL AND NOT ?) -- not deleted, want not deleted\n OR (deleted_at IS NOT NULL AND ?) -- deleted, want deleted\n )\n ", + "describe": { + "columns": [ + { + "name": "principal", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "displayname", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "order", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "color", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "timezone_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "addr_synctoken", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "push_topic", + "ordinal": 9, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455" +} diff --git a/.sqlx/query-bfdf662cd03e741b7a36f5e2ac01d32ac367c52ce41bd70394f754248b29749c.json b/.sqlx/query-bfdf662cd03e741b7a36f5e2ac01d32ac367c52ce41bd70394f754248b29749c.json new file mode 100644 index 0000000..3d85443 --- /dev/null +++ b/.sqlx/query-bfdf662cd03e741b7a36f5e2ac01d32ac367c52ce41bd70394f754248b29749c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO birthday_calendars (principal, id, displayname, push_topic)\n VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "bfdf662cd03e741b7a36f5e2ac01d32ac367c52ce41bd70394f754248b29749c" +} From 7bf00da0e58375999b67f87a6e46782f8c23b5b6 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:56:17 +0100 Subject: [PATCH 07/17] implement deleting and restoring birthday calendars --- .../addressbook_store/birthday_calendar.rs | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs index 71ef5a6..3c310c8 100644 --- a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs +++ b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs @@ -144,6 +144,50 @@ impl SqliteAddressbookStore { Ok(()) } + async fn _delete_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + id: &str, + use_trashbin: bool, + ) -> Result<(), Error> { + if use_trashbin { + sqlx::query!( + r#"UPDATE birthday_calendars SET deleted_at = datetime() WHERE (principal, id) = (?, ?)"#, + principal, + id + ) + .execute(executor) + .await + .map_err(crate::Error::from)? + } else { + sqlx::query!( + r#"DELETE FROM birthday_calendars WHERE (principal, id) = (?, ?)"#, + principal, + id + ) + .execute(executor) + .await + .map_err(crate::Error::from)? + }; + Ok(()) + } + + async fn _restore_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + principal: &str, + id: &str, + ) -> Result<(), Error> { + sqlx::query!( + r"UPDATE birthday_calendars SET deleted_at = NULL WHERE (principal, id) = (?, ?)", + principal, + id + ) + .execute(executor) + .await + .map_err(crate::Error::from)?; + Ok(()) + } + #[instrument] async fn _update_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( executor: E, @@ -218,16 +262,22 @@ impl CalendarStore for SqliteAddressbookStore { #[instrument] async fn delete_calendar( &self, - _principal: &str, - _name: &str, - _use_trashbin: bool, + principal: &str, + id: &str, + use_trashbin: bool, ) -> Result<(), Error> { - Err(Error::ReadOnly) + let Some(id) = id.strip_prefix(BIRTHDAYS_PREFIX) else { + return Ok(()); + }; + Self::_delete_birthday_calendar(&self.db, principal, id, use_trashbin).await } #[instrument] - async fn restore_calendar(&self, _principal: &str, _name: &str) -> Result<(), Error> { - Err(Error::ReadOnly) + async fn restore_calendar(&self, principal: &str, id: &str) -> Result<(), Error> { + let Some(id) = id.strip_prefix(BIRTHDAYS_PREFIX) else { + return Err(Error::NotFound); + }; + Self::_restore_birthday_calendar(&self.db, principal, id).await } #[instrument] From 5588137f73e685a80b09f5fd25853ff44b7a60c9 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:01:54 +0100 Subject: [PATCH 08/17] sqlx prepare --- ...492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896.json | 12 ++++++++++++ ...2019ac24f603c53125a1b2326f324c1e2d7b6c690adc.json | 12 ++++++++++++ ...1ab36ad9dbb1dec943798d795fcbc811f4c651fea02a.json | 12 ++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 .sqlx/query-6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896.json create mode 100644 .sqlx/query-83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc.json create mode 100644 .sqlx/query-cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a.json diff --git a/.sqlx/query-6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896.json b/.sqlx/query-6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896.json new file mode 100644 index 0000000..25390d9 --- /dev/null +++ b/.sqlx/query-6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE birthday_calendars SET deleted_at = NULL WHERE (principal, id) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896" +} diff --git a/.sqlx/query-83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc.json b/.sqlx/query-83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc.json new file mode 100644 index 0000000..e08aeb3 --- /dev/null +++ b/.sqlx/query-83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE birthday_calendars SET deleted_at = datetime() WHERE (principal, id) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc" +} diff --git a/.sqlx/query-cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a.json b/.sqlx/query-cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a.json new file mode 100644 index 0000000..442774a --- /dev/null +++ b/.sqlx/query-cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM birthday_calendars WHERE (principal, id) = (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a" +} From 873b40ad10a783b9d982059dc01bf47d35c3d112 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:05:55 +0100 Subject: [PATCH 09/17] stylesheet: Add flex-wrap to actions --- crates/frontend/public/assets/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/frontend/public/assets/style.css b/crates/frontend/public/assets/style.css index 2c1c2c1..6a21965 100644 --- a/crates/frontend/public/assets/style.css +++ b/crates/frontend/public/assets/style.css @@ -282,6 +282,7 @@ ul.collection-list { grid-area: actions; width: fit-content; display: flex; + flex-wrap: wrap; gap: 12px; } } From 96f221f721e36b1e27d4d69ea59b62b5d84ccd61 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:32:53 +0100 Subject: [PATCH 10/17] birthday_calendar: Refactor insert_birthday_calendar --- Cargo.lock | 1 + .../store_sqlite/src/addressbook_store/birthday_calendar.rs | 5 +++-- crates/store_sqlite/src/addressbook_store/mod.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d3e5a0..b834b0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3230,6 +3230,7 @@ dependencies = [ "rustical_store_sqlite", "rustical_xml", "serde", + "sha2", "thiserror 2.0.17", "tokio", "tower", diff --git a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs index 3c310c8..5043e14 100644 --- a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs +++ b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs @@ -118,15 +118,16 @@ impl SqliteAddressbookStore { #[instrument] pub async fn _insert_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( executor: E, - addressbook: Addressbook, + addressbook: &Addressbook, ) -> Result<(), rustical_store::Error> { let birthday_name = addressbook .displayname + .as_ref() .map(|name| format!("{name} birthdays")); let birthday_push_topic = { let mut hasher = Sha256::new(); hasher.update("birthdays"); - hasher.update(addressbook.push_topic); + hasher.update(&addressbook.push_topic); format!("{:x}", hasher.finalize()) }; diff --git a/crates/store_sqlite/src/addressbook_store/mod.rs b/crates/store_sqlite/src/addressbook_store/mod.rs index 56a9c90..8d755d6 100644 --- a/crates/store_sqlite/src/addressbook_store/mod.rs +++ b/crates/store_sqlite/src/addressbook_store/mod.rs @@ -467,7 +467,7 @@ impl AddressbookStore for SqliteAddressbookStore { .await .map_err(crate::Error::from)?; Self::_insert_addressbook(&mut *tx, &addressbook).await?; - Self::_insert_birthday_calendar(&mut *tx, addressbook).await?; + Self::_insert_birthday_calendar(&mut *tx, &addressbook).await?; tx.commit().await.map_err(crate::Error::from)?; Ok(()) } From a79e1901b805596de29319f03549a7d43bd4d993 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:48:36 +0100 Subject: [PATCH 11/17] test_propfind: Revert assert_eq order --- crates/caldav/src/calendar/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/caldav/src/calendar/tests.rs b/crates/caldav/src/calendar/tests.rs index cebcdc0..cb6c8c5 100644 --- a/crates/caldav/src/calendar/tests.rs +++ b/crates/caldav/src/calendar/tests.rs @@ -39,7 +39,7 @@ async fn test_propfind() { .unwrap() .trim() .replace("\r\n", "\n"); - similar_asserts::assert_eq!(output, expected_output); + similar_asserts::assert_eq!(expected_output, output); } } } From d5c1ddc590b6d7dea7b6660e3133b042fd506199 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:49:32 +0100 Subject: [PATCH 12/17] caldav: Update test_propfind regression test --- crates/caldav/src/calendar/test_files/propfind.outputs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/caldav/src/calendar/test_files/propfind.outputs b/crates/caldav/src/calendar/test_files/propfind.outputs index 9330cdf..9a28b63 100644 --- a/crates/caldav/src/calendar/test_files/propfind.outputs +++ b/crates/caldav/src/calendar/test_files/propfind.outputs @@ -211,6 +211,9 @@ END:VCALENDAR + + + From e39657eb297b021ad88a81040c02c830d889b607 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:48:11 +0100 Subject: [PATCH 13/17] PROPPATCH: Fix privileges --- crates/dav/src/resource/methods/proppatch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dav/src/resource/methods/proppatch.rs b/crates/dav/src/resource/methods/proppatch.rs index dc24821..cbeb8b0 100644 --- a/crates/dav/src/resource/methods/proppatch.rs +++ b/crates/dav/src/resource/methods/proppatch.rs @@ -88,7 +88,7 @@ pub async fn route_proppatch( .get_resource(path_components, false) .await?; let privileges = resource.get_user_privileges(principal)?; - if !privileges.has(&UserPrivilege::Write) { + if !privileges.has(&UserPrivilege::WriteProperties) { return Err(Error::Unauthorized.into()); } From e99b1d912353a9f580b7f1a0cb961e886e50f0bf Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:48:35 +0100 Subject: [PATCH 14/17] calendar resource: Remove prop write guards --- crates/caldav/src/calendar/resource.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/caldav/src/calendar/resource.rs b/crates/caldav/src/calendar/resource.rs index 03bbe04..65d5912 100644 --- a/crates/caldav/src/calendar/resource.rs +++ b/crates/caldav/src/calendar/resource.rs @@ -188,9 +188,6 @@ impl Resource for CalendarResource { } fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> { - if self.read_only { - return Err(rustical_dav::Error::PropReadOnly); - } match prop { CalendarPropWrapper::Calendar(prop) => match prop { CalendarProp::CalendarColor(color) => { @@ -263,9 +260,6 @@ impl Resource for CalendarResource { } fn remove_prop(&mut self, prop: &CalendarPropWrapperName) -> Result<(), rustical_dav::Error> { - if self.read_only { - return Err(rustical_dav::Error::PropReadOnly); - } match prop { CalendarPropWrapperName::Calendar(prop) => match prop { CalendarPropName::CalendarColor => { From af239e34bf474d8fde8a29190ca9469432385c31 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:49:09 +0100 Subject: [PATCH 15/17] birthday calendar store: Support manual birthday calendar creation --- .../addressbook_store/birthday_calendar.rs | 53 ++++++++++++++----- .../store_sqlite/src/addressbook_store/mod.rs | 3 +- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs index 5043e14..4ca06a5 100644 --- a/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs +++ b/crates/store_sqlite/src/addressbook_store/birthday_calendar.rs @@ -115,11 +115,8 @@ impl SqliteAddressbookStore { .map_err(crate::Error::from).map(|cals| cals.into_iter().map(BirthdayCalendarJoinRow::into).collect())?) } - #[instrument] - pub async fn _insert_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( - executor: E, - addressbook: &Addressbook, - ) -> Result<(), rustical_store::Error> { + #[must_use] + pub fn default_birthday_calendar(addressbook: Addressbook) -> Calendar { let birthday_name = addressbook .displayname .as_ref() @@ -130,14 +127,44 @@ impl SqliteAddressbookStore { hasher.update(&addressbook.push_topic); format!("{:x}", hasher.finalize()) }; + Calendar { + principal: addressbook.principal, + meta: CalendarMetadata { + displayname: birthday_name, + order: 0, + description: None, + color: None, + }, + id: format!("{}{}", Self::PREFIX, addressbook.id), + components: vec![CalendarObjectType::Event], + timezone_id: None, + deleted_at: None, + synctoken: Default::default(), + subscription_url: None, + push_topic: birthday_push_topic, + } + } + + #[instrument] + pub async fn _insert_birthday_calendar<'e, E: Executor<'e, Database = Sqlite>>( + executor: E, + calendar: &Calendar, + ) -> Result<(), rustical_store::Error> { + let id = calendar + .id + .strip_prefix(BIRTHDAYS_PREFIX) + .ok_or(Error::NotFound)?; sqlx::query!( - r#"INSERT INTO birthday_calendars (principal, id, displayname, push_topic) - VALUES (?, ?, ?, ?)"#, - addressbook.principal, - addressbook.id, - birthday_name, - birthday_push_topic, + r#"INSERT INTO birthday_calendars (principal, id, displayname, description, "order", color, push_topic) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, + calendar.principal, + id, + calendar.meta.displayname, + calendar.meta.description, + calendar.meta.order, + calendar.meta.color, + calendar.push_topic, ) .execute(executor) .await @@ -256,8 +283,8 @@ impl CalendarStore for SqliteAddressbookStore { } #[instrument] - async fn insert_calendar(&self, _calendar: Calendar) -> Result<(), Error> { - Err(Error::ReadOnly) + async fn insert_calendar(&self, calendar: Calendar) -> Result<(), Error> { + Self::_insert_birthday_calendar(&self.db, &calendar).await } #[instrument] diff --git a/crates/store_sqlite/src/addressbook_store/mod.rs b/crates/store_sqlite/src/addressbook_store/mod.rs index 8d755d6..04e5730 100644 --- a/crates/store_sqlite/src/addressbook_store/mod.rs +++ b/crates/store_sqlite/src/addressbook_store/mod.rs @@ -467,7 +467,8 @@ impl AddressbookStore for SqliteAddressbookStore { .await .map_err(crate::Error::from)?; Self::_insert_addressbook(&mut *tx, &addressbook).await?; - Self::_insert_birthday_calendar(&mut *tx, &addressbook).await?; + let birthday_cal = Self::default_birthday_calendar(addressbook); + Self::_insert_birthday_calendar(&mut *tx, &birthday_cal).await?; tx.commit().await.map_err(crate::Error::from)?; Ok(()) } From 57275a10b444ff273fbd8e1bdcb465a16f647fd3 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:50:02 +0100 Subject: [PATCH 16/17] Add birthday calendar creation to frontend --- .../lib/create-birthday-calendar-form.ts | 102 +++++++++++++++ crates/frontend/js-components/vite.config.ts | 1 + .../js/create-birthday-calendar-form.mjs | 122 ++++++++++++++++++ .../sections/addressbooks_section.html | 12 +- .../frontend/public/templates/pages/user.html | 1 + crates/frontend/src/lib.rs | 8 +- crates/frontend/src/routes/addressbooks.rs | 34 ++++- 7 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 crates/frontend/js-components/lib/create-birthday-calendar-form.ts create mode 100644 crates/frontend/public/assets/js/create-birthday-calendar-form.mjs diff --git a/crates/frontend/js-components/lib/create-birthday-calendar-form.ts b/crates/frontend/js-components/lib/create-birthday-calendar-form.ts new file mode 100644 index 0000000..a99ec9a --- /dev/null +++ b/crates/frontend/js-components/lib/create-birthday-calendar-form.ts @@ -0,0 +1,102 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Ref, createRef, ref } from 'lit/directives/ref.js'; +import { escapeXml } from "."; +import { getTimezones } from "./timezones.ts"; + +@customElement("create-birthday-calendar-form") +export class CreateCalendarForm extends LitElement { + protected override createRenderRoot() { + return this + } + + @property() + principal: string = '' + @property() + addr_id: string = '' + @property() + displayname: string = '' + @property() + description: string = '' + @property() + color: string = '' + + dialog: Ref = createRef() + form: Ref = createRef() + @property() + timezones: Array = [] + + override render() { + return html` + + +

Create calendar

+
+ +
+ +
+ +
+ + +
+
+ ` + } + + async submit(e: SubmitEvent) { + e.preventDefault() + if (!this.addr_id) { + alert("Empty id") + return + } + if (!this.displayname) { + alert("Empty displayname") + return + } + + let response = await fetch(`/caldav/principal/${this.principal}/_birthdays_${this.addr_id}`, { + method: 'MKCOL', + headers: { + 'Content-Type': 'application/xml' + }, + body: ` + + + + ${escapeXml(this.displayname)} + ${this.description ? `${escapeXml(this.description)}` : ''} + ${this.color ? `${escapeXml(this.color)}` : ''} + + + + + + + ` + }) + + if (response.status >= 400) { + alert(`Error ${response.status}: ${await response.text()}`) + return null + } + window.location.reload() + return null + } +} + +declare global { + interface HTMLElementTagNameMap { + 'create-calendar-form': CreateCalendarForm + } +} diff --git a/crates/frontend/js-components/vite.config.ts b/crates/frontend/js-components/vite.config.ts index 7480569..9397a06 100644 --- a/crates/frontend/js-components/vite.config.ts +++ b/crates/frontend/js-components/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ rollupOptions: { input: [ + "lib/create-birthday-calendar-form.ts", "lib/create-calendar-form.ts", "lib/edit-calendar-form.ts", "lib/import-calendar-form.ts", diff --git a/crates/frontend/public/assets/js/create-birthday-calendar-form.mjs b/crates/frontend/public/assets/js/create-birthday-calendar-form.mjs new file mode 100644 index 0000000..7978fc8 --- /dev/null +++ b/crates/frontend/public/assets/js/create-birthday-calendar-form.mjs @@ -0,0 +1,122 @@ +import { i, x } from "./lit-DkXrt_Iv.mjs"; +import { n as n$1, t } from "./property-B8WoKf1Y.mjs"; +import { e, n } from "./ref-BwbQvJBB.mjs"; +import { e as escapeXml } from "./index-_IB1wMbZ.mjs"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __decorateClass = (decorators, target, key, kind) => { + var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; + for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--) + if (decorator = decorators[i2]) + result = (kind ? decorator(target, key, result) : decorator(result)) || result; + if (kind && result) __defProp(target, key, result); + return result; +}; +let CreateCalendarForm = class extends i { + constructor() { + super(...arguments); + this.principal = ""; + this.addr_id = ""; + this.displayname = ""; + this.description = ""; + this.color = ""; + this.dialog = e(); + this.form = e(); + this.timezones = []; + } + createRenderRoot() { + return this; + } + render() { + return x` + + +

Create calendar

+
+ +
+ +
+ +
+ + +
+
+ `; + } + async submit(e2) { + e2.preventDefault(); + if (!this.addr_id) { + alert("Empty id"); + return; + } + if (!this.displayname) { + alert("Empty displayname"); + return; + } + let response = await fetch(`/caldav/principal/${this.principal}/_birthdays_${this.addr_id}`, { + method: "MKCOL", + headers: { + "Content-Type": "application/xml" + }, + body: ` + + + + ${escapeXml(this.displayname)} + ${this.description ? `${escapeXml(this.description)}` : ""} + ${this.color ? `${escapeXml(this.color)}` : ""} + + + + + + + ` + }); + if (response.status >= 400) { + alert(`Error ${response.status}: ${await response.text()}`); + return null; + } + window.location.reload(); + return null; + } +}; +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "principal", 2); +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "addr_id", 2); +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "displayname", 2); +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "description", 2); +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "color", 2); +__decorateClass([ + n$1() +], CreateCalendarForm.prototype, "timezones", 2); +CreateCalendarForm = __decorateClass([ + t("create-birthday-calendar-form") +], CreateCalendarForm); +export { + CreateCalendarForm +}; diff --git a/crates/frontend/public/templates/components/sections/addressbooks_section.html b/crates/frontend/public/templates/components/sections/addressbooks_section.html index e699b0b..cf2291c 100644 --- a/crates/frontend/public/templates/components/sections/addressbooks_section.html +++ b/crates/frontend/public/templates/components/sections/addressbooks_section.html @@ -1,6 +1,6 @@

{{user.id }}'s Addressbooks

    - {% for (meta, addressbook) in addressbooks %} + {% for (meta, birthday_cal, addressbook) in addressbooks %}
  • @@ -24,9 +24,17 @@ > + {% if !birthday_cal.is_some() %} + + {% endif %}
@@ -37,7 +45,7 @@ {%if !deleted_addressbooks.is_empty() %}

Deleted Addressbooks