Files
RustBox/src/routes/calendar.rs
T
Mateusz Gruszczyński 93762fe9ef
RustBox CI / rustbox (push) Waiting to run
v0.0.3
2026-10-30 00:12:09 +01:00

453 lines
15 KiB
Rust

use std::sync::Arc;
use axum::extract::{Path, Query};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::auth::session::SessionState;
use crate::calendar::ics;
use crate::config::AppConfig;
use crate::db;
use crate::db::calendar::{CreateEvent, UpdateCalendarSettings, UpdateEvent};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// Query / request types
// ---------------------------------------------------------------------------
/// Query parameters for `GET /api/calendar/events`.
#[derive(Deserialize)]
pub struct ListEventsParams {
/// Start of the time range (ISO 8601).
pub start: String,
/// End of the time range (ISO 8601).
pub end: String,
}
/// JSON body for `POST /api/calendar/meeting-templates`.
#[derive(Deserialize)]
pub struct CreateMeetingTemplateRequest {
pub name: String,
pub url_template: String,
#[serde(default)]
pub icon: String,
}
/// Response for ICS import.
#[derive(Serialize)]
pub struct ImportIcsResponse {
pub created: usize,
pub skipped: usize,
pub events: Vec<db::calendar::CalendarEvent>,
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
fn ics_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "")
.replace(',', "\\,")
.replace(';', "\\;")
}
fn ics_datetime(value: &str) -> String {
let mut digits = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_digit() || ch == 'T' {
digits.push(ch);
}
}
if let Some(dot_pos) = digits.find('.') {
digits.truncate(dot_pos);
}
if digits.len() >= 15 {
digits.truncate(15);
}
if value.ends_with('Z') && !digits.ends_with('Z') {
digits.push('Z');
}
digits
}
fn ics_date(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_digit())
.take(8)
.collect()
}
fn append_ics_event(out: &mut String, event: &db::calendar::CalendarEvent) {
out.push_str("BEGIN:VEVENT\r\n");
let uid = event.source_uid.as_deref().unwrap_or(&event.id);
out.push_str(&format!("UID:{}\r\n", ics_escape(uid)));
out.push_str(&format!("SUMMARY:{}\r\n", ics_escape(&event.title)));
if !event.description.is_empty() {
out.push_str(&format!(
"DESCRIPTION:{}\r\n",
ics_escape(&event.description)
));
}
if !event.location.is_empty() {
out.push_str(&format!("LOCATION:{}\r\n", ics_escape(&event.location)));
}
if event.all_day {
out.push_str(&format!(
"DTSTART;VALUE=DATE:{}\r\n",
ics_date(&event.start_time)
));
out.push_str(&format!(
"DTEND;VALUE=DATE:{}\r\n",
ics_date(&event.end_time)
));
} else {
out.push_str(&format!("DTSTART:{}\r\n", ics_datetime(&event.start_time)));
out.push_str(&format!("DTEND:{}\r\n", ics_datetime(&event.end_time)));
}
if let Some(rule) = event
.recurrence_rule
.as_deref()
.filter(|value| !value.trim().is_empty())
{
out.push_str(&format!("RRULE:{}\r\n", rule.trim()));
}
if !event.organizer.trim().is_empty() {
out.push_str(&format!(
"ORGANIZER:mailto:{}\r\n",
ics_escape(event.organizer.trim())
));
}
if let Ok(attendees) = serde_json::from_str::<Vec<String>>(&event.attendees) {
for attendee in attendees
.into_iter()
.filter(|value| !value.trim().is_empty())
{
out.push_str(&format!(
"ATTENDEE:mailto:{}\r\n",
ics_escape(attendee.trim())
));
}
}
if let Some(url) = event
.meeting_url
.as_deref()
.filter(|value| !value.trim().is_empty())
{
out.push_str(&format!("URL:{}\r\n", ics_escape(url.trim())));
}
if let Some(color) = event
.color
.as_deref()
.filter(|value| !value.trim().is_empty())
{
out.push_str(&format!("COLOR:{}\r\n", ics_escape(color.trim())));
}
if !event.status.trim().is_empty() {
out.push_str(&format!(
"STATUS:{}\r\n",
event.status.trim().to_ascii_uppercase()
));
}
if let Some(minutes) = event.reminder_minutes.filter(|value| *value >= 0) {
out.push_str("BEGIN:VALARM\r\nACTION:DISPLAY\r\n");
out.push_str(&format!("TRIGGER:-PT{}M\r\n", minutes));
out.push_str("DESCRIPTION:Reminder\r\nEND:VALARM\r\n");
}
out.push_str("END:VEVENT\r\n");
}
/// `GET /api/calendar/events?start=...&end=...`
///
/// List events within a time range.
pub async fn list_events(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Query(params): Query<ListEventsParams>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let events = db::calendar::list_events(&conn, &params.start, &params.end)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(serde_json::json!({ "events": events })).into_response())
}
/// `GET /api/calendar/events/export-ics`
///
/// Export all locally known calendar events as a standard iCalendar file.
pub async fn export_ics(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let events = db::calendar::list_all_events(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut body = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//RustBox//Calendar 0.0.3//EN\r\nCALSCALE:GREGORIAN\r\n",
);
for event in &events {
append_ics_event(&mut body, event);
}
body.push_str("END:VCALENDAR\r\n");
let mut response = (StatusCode::OK, body).into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/calendar; charset=utf-8"),
);
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_static("attachment; filename=rustbox-calendar.ics"),
);
Ok(response)
}
/// `GET /api/calendar/events/{id}`
///
/// Get a single event by ID.
pub async fn get_event(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let event = db::calendar::get_event(&conn, &id)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
match event {
Some(e) => Ok(Json(e).into_response()),
None => Err(AppError::NotFound(format!("Event '{id}' not found"))),
}
}
/// `POST /api/calendar/events`
///
/// Create a new calendar event.
pub async fn create_event(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(body): Json<CreateEvent>,
) -> Result<Response, AppError> {
if body.title.trim().is_empty() {
return Err(AppError::BadRequest("Title is required".to_string()));
}
if body.start_time.trim().is_empty() {
return Err(AppError::BadRequest("Start time is required".to_string()));
}
if body.end_time.trim().is_empty() {
return Err(AppError::BadRequest("End time is required".to_string()));
}
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let event = db::calendar::create_event(&conn, &body)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok((axum::http::StatusCode::CREATED, Json(event)).into_response())
}
/// `PUT /api/calendar/events/{id}`
///
/// Update an existing calendar event.
pub async fn update_event(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<String>,
Json(body): Json<UpdateEvent>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
match db::calendar::update_event(&conn, &id, &body)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?
{
Some(event) => Ok(Json(event).into_response()),
None => Err(AppError::NotFound(format!("Event '{id}' not found"))),
}
}
/// `DELETE /api/calendar/events/{id}`
///
/// Delete a calendar event.
pub async fn delete_event(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let deleted = db::calendar::delete_event(&conn, &id)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if deleted {
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
} else {
Err(AppError::NotFound(format!("Event '{id}' not found")))
}
}
/// `POST /api/calendar/events/import-ics`
///
/// Import events from ICS text. Deduplicates by source_uid.
pub async fn import_ics(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
body: String,
) -> Result<Response, AppError> {
if body.trim().is_empty() {
return Err(AppError::BadRequest("Empty ICS body".to_string()));
}
let parsed = ics::parse_ics(&body)
.map_err(|e| AppError::BadRequest(format!("Failed to parse ICS: {e}")))?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let mut created_events = Vec::new();
let mut skipped = 0usize;
for event_data in parsed {
// Deduplicate by source_uid
if let Some(ref uid) = event_data.source_uid
&& let Ok(Some(_)) = db::calendar::find_event_by_source_uid(&conn, uid)
{
skipped += 1;
continue;
}
match db::calendar::create_event(&conn, &event_data) {
Ok(event) => created_events.push(event),
Err(e) => {
tracing::warn!("Failed to create imported event: {e}");
skipped += 1;
}
}
}
Ok(Json(ImportIcsResponse {
created: created_events.len(),
skipped,
events: created_events,
})
.into_response())
}
// ---------------------------------------------------------------------------
// Settings handlers
// ---------------------------------------------------------------------------
/// `GET /api/calendar/settings`
///
/// Get calendar settings.
pub async fn get_calendar_settings(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let settings = db::calendar::get_calendar_settings(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(settings).into_response())
}
/// `PUT /api/calendar/settings`
///
/// Update calendar settings.
pub async fn update_calendar_settings(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(body): Json<UpdateCalendarSettings>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let settings = db::calendar::update_calendar_settings(&conn, &body)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(settings).into_response())
}
// ---------------------------------------------------------------------------
// Meeting template handlers
// ---------------------------------------------------------------------------
/// `GET /api/calendar/meeting-templates`
///
/// List all meeting templates.
pub async fn list_meeting_templates(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let templates = db::calendar::list_meeting_templates(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(serde_json::json!({ "templates": templates })).into_response())
}
/// `POST /api/calendar/meeting-templates`
///
/// Create a new meeting template.
pub async fn create_meeting_template(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Json(body): Json<CreateMeetingTemplateRequest>,
) -> Result<Response, AppError> {
if body.name.trim().is_empty() {
return Err(AppError::BadRequest("Name is required".to_string()));
}
if body.url_template.trim().is_empty() {
return Err(AppError::BadRequest("URL template is required".to_string()));
}
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let template =
db::calendar::create_meeting_template(&conn, &body.name, &body.url_template, &body.icon)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok((axum::http::StatusCode::CREATED, Json(template)).into_response())
}
/// `DELETE /api/calendar/meeting-templates/{id}`
///
/// Delete a meeting template.
pub async fn delete_meeting_template(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
let deleted = db::calendar::delete_meeting_template(&conn, id)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if deleted {
Ok(Json(serde_json::json!({ "status": "deleted" })).into_response())
} else {
Err(AppError::NotFound(format!(
"Meeting template '{id}' not found"
)))
}
}