diff --git a/Cargo.lock b/Cargo.lock index 099b561..189d470 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2796,6 +2796,7 @@ dependencies = [ "mime_guess", "rand 0.9.1", "rust-embed", + "rustical_ical", "rustical_oidc", "rustical_store", "serde", diff --git a/crates/frontend/Cargo.toml b/crates/frontend/Cargo.toml index 9ad9f54..4690a31 100644 --- a/crates/frontend/Cargo.toml +++ b/crates/frontend/Cargo.toml @@ -17,6 +17,7 @@ serde.workspace = true thiserror.workspace = true tokio.workspace = true rustical_store.workspace = true +rustical_ical.workspace = true rust-embed.workspace = true futures-core.workspace = true hex.workspace = true diff --git a/crates/frontend/public/assets/style.css b/crates/frontend/public/assets/style.css index 7297cd1..2b33a7d 100644 --- a/crates/frontend/public/assets/style.css +++ b/crates/frontend/public/assets/style.css @@ -159,14 +159,14 @@ table { display: grid; min-height: 80px; grid-template-areas: - ". color-chip" - "title color-chip" - "description color-chip" - "subscription-url color-chip" - "restore color-chip" - ". color-chip"; + ". . color-chip" + "title comps color-chip" + "description . color-chip" + "subscription-url . color-chip" + "restore . color-chip" + ". . color-chip"; grid-template-rows: 12px auto auto auto 12px; - grid-template-columns: auto 80px; + grid-template-columns: min-content auto 80px; color: inherit; text-decoration: none; padding-left: 12px; @@ -180,14 +180,31 @@ table { .title { font-weight: bold; grid-area: title; + margin-right: 12px; + white-space: nowrap; + } + + .comps { + grid-area: comps; + + span { + margin: 0 2px; + background: var(--primary-color); + color: var(--text-on-primary-color); + font-size: .8em; + padding: 3px 8px; + border-radius: 12px; + } } .description { grid-area: description; + white-space: nowrap; } .subscription-url { grid-area: subscription-url; + white-space: nowrap; } .color-chip { diff --git a/crates/frontend/public/templates/pages/user.html b/crates/frontend/public/templates/pages/user.html index 1416d53..c8ff8a2 100644 --- a/crates/frontend/public/templates/pages/user.html +++ b/crates/frontend/public/templates/pages/user.html @@ -64,6 +64,11 @@
  • {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} +
    + {% for comp in calendar.components %} + {{ comp }} + {% endfor %} +
    {% if let Some(description) = calendar.description %}{{ description }}{% endif %} @@ -85,6 +90,11 @@
  • {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} +
    + {% for comp in calendar.components %} + {{ comp }} + {% endfor %} +
    {% if let Some(description) = calendar.description %}{{ description }}{% endif %} @@ -97,6 +107,49 @@ {% endfor %} {% endif %} + +

    Create calendar

    +
    + +
    + +
    + +
    + +
    + +
    + + + +
    + +
    diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 963b1f1..4378b89 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -34,7 +34,7 @@ use crate::{ routes::{ addressbook::{route_addressbook, route_addressbook_restore}, app_token::{route_delete_app_token, route_post_app_token}, - calendar::{route_calendar, route_calendar_restore}, + calendar::{route_calendar, route_calendar_restore, route_create_calendar}, login::{route_get_login, route_post_login, route_post_logout}, user::{route_get_home, route_root, route_user_named}, }, @@ -66,6 +66,7 @@ pub fn frontend_router< post(route_delete_app_token::), ) // Calendar + .route("/user/{user}/calendar", post(route_create_calendar::)) .route( "/user/{user}/calendar/{calendar}", get(route_calendar::), diff --git a/crates/frontend/src/routes/calendar.rs b/crates/frontend/src/routes/calendar.rs index b17afc5..db596e7 100644 --- a/crates/frontend/src/routes/calendar.rs +++ b/crates/frontend/src/routes/calendar.rs @@ -3,14 +3,16 @@ use std::sync::Arc; use askama::Template; use askama_web::WebTemplate; use axum::{ - Extension, + Extension, Form, extract::Path, response::{IntoResponse, Redirect, Response}, }; use axum_extra::TypedHeader; use headers::Referer; use http::StatusCode; +use rustical_ical::CalendarObjectType; use rustical_store::{Calendar, CalendarStore, auth::User}; +use serde::{Deserialize, Deserializer}; #[derive(Template, WebTemplate)] #[template(path = "pages/calendar.html")] @@ -32,6 +34,82 @@ pub async fn route_calendar( .into_response()) } +fn empty_to_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let val: Option = Deserialize::deserialize(deserializer)?; + Ok(val.filter(|val| !val.is_empty())) +} + +#[derive(Deserialize, Clone)] +pub struct PutCalendarForm { + id: String, + #[serde(deserialize_with = "empty_to_none")] + displayname: Option, + #[serde(deserialize_with = "empty_to_none")] + description: Option, + #[serde(deserialize_with = "empty_to_none")] + color: Option, + #[serde(deserialize_with = "empty_to_none")] + subscription_url: Option, + comp_event: Option, + comp_todo: Option, + comp_journal: Option, +} + +pub async fn route_create_calendar( + Path(owner): Path, + Extension(store): Extension>, + user: User, + Form(PutCalendarForm { + id, + displayname, + description, + color, + subscription_url, + comp_event, + comp_todo, + comp_journal, + }): Form, +) -> Result { + if !user.is_principal(&owner) { + return Ok(StatusCode::UNAUTHORIZED.into_response()); + } + + assert!(!id.is_empty()); + + let mut comps = vec![]; + if comp_event.is_some() { + comps.push(CalendarObjectType::Event); + } + if comp_todo.is_some() { + comps.push(CalendarObjectType::Todo); + } + if comp_journal.is_some() { + comps.push(CalendarObjectType::Journal); + } + + let cal = Calendar { + id: id.to_owned(), + displayname, + description, + color, + subscription_url, + principal: user.id, + components: comps, + order: 0, + timezone_id: None, + timezone: None, + synctoken: 0, + deleted_at: None, + push_topic: uuid::Uuid::new_v4().to_string(), + }; + + store.insert_calendar(cal).await?; + Ok(Redirect::to(&id).into_response()) +} + pub async fn route_calendar_restore( Path((owner, cal_id)): Path<(String, String)>, Extension(store): Extension>, diff --git a/crates/ical/src/icalendar/object.rs b/crates/ical/src/icalendar/object.rs index 8b02b4d..b9b679b 100644 --- a/crates/ical/src/icalendar/object.rs +++ b/crates/ical/src/icalendar/object.rs @@ -3,6 +3,7 @@ use crate::CalDateTime; use crate::Error; use chrono::DateTime; use chrono::Utc; +use derive_more::Display; use ical::{ generator::{Emitter, IcalCalendar}, parser::{Component, ical::component::IcalTimeZone}, @@ -11,7 +12,7 @@ use serde::Serialize; use sha2::{Digest, Sha256}; use std::{collections::HashMap, io::BufReader}; -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)] // specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6 pub enum CalendarObjectType { Event = 0, diff --git a/crates/store_sqlite/src/error.rs b/crates/store_sqlite/src/error.rs index 121bf0c..3c55fd4 100644 --- a/crates/store_sqlite/src/error.rs +++ b/crates/store_sqlite/src/error.rs @@ -1,3 +1,5 @@ +use tracing::warn; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -16,6 +18,7 @@ impl From for Error { sqlx::Error::RowNotFound => Error::StoreError(rustical_store::Error::NotFound), sqlx::Error::Database(err) => { if err.is_unique_violation() { + warn!("{err:?}"); Error::StoreError(rustical_store::Error::AlreadyExists) } else { Error::SqlxError(sqlx::Error::Database(err))