453 lines
15 KiB
Rust
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, ¶ms.start, ¶ms.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"
|
|
)))
|
|
}
|
|
}
|