commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,452 @@
|
||||
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.1//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"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user