Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t

First pass : bookmarks plan execution

GHCP first pass at implementing bookmarks
compiles but not fully tested.

kayrozen 38616e08 14beef89

+442
backup/handle_bookmark_calendars_old.rs
··· 1 + use axum::{ 2 + extract::{Path, Query, State}, 3 + http::StatusCode, 4 + response::{Html, IntoResponse}, 5 + Json, 6 + }; 7 + use serde::{Deserialize, Serialize}; 8 + use std::sync::Arc; 9 + use tracing::{debug, error, info, warn}; 10 + 11 + use crate::http::{AppContext, HttpError, get_session_or_redirect}; 12 + use crate::i18n::I18nService; 13 + use crate::storage::bookmark_calendars; 14 + use crate::storage::bookmark_calendars::BookmarkCalendar; 15 + 16 + #[derive(Deserialize)] 17 + pub struct CreateBookmarkCalendarParams { 18 + name: String, 19 + description: Option<String>, 20 + tags: Vec<String>, 21 + tag_operator: Option<String>, // 'AND' or 'OR' 22 + is_public: Option<bool>, 23 + } 24 + 25 + #[derive(Deserialize)] 26 + pub struct UpdateBookmarkCalendarParams { 27 + name: String, 28 + description: Option<String>, 29 + tags: Vec<String>, 30 + tag_operator: Option<String>, 31 + is_public: Option<bool>, 32 + } 33 + 34 + #[derive(Deserialize)] 35 + pub struct AddEventToCalendarParams { 36 + event_aturi: String, 37 + tags: Vec<String>, 38 + } 39 + 40 + #[derive(Deserialize)] 41 + pub struct CalendarListParams { 42 + limit: Option<i32>, 43 + offset: Option<i32>, 44 + } 45 + 46 + #[derive(Serialize)] 47 + pub struct CalendarResponse { 48 + success: bool, 49 + message: String, 50 + calendar_id: Option<String>, 51 + } 52 + 53 + /// Handle creating a new bookmark calendar 54 + pub async fn handle_create_bookmark_calendar( 55 + State(ctx): State<Arc<AppContext>>, 56 + Json(payload): Json<CreateBookmarkCalendarParams>, 57 + ) -> Result<Html<String>, HttpError> { 58 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 59 + 60 + // Validate input 61 + if payload.name.trim().is_empty() { 62 + return Err(HttpError::BadRequest("Calendar name is required".to_string())); 63 + } 64 + 65 + if payload.name.len() > 256 { 66 + return Err(HttpError::BadRequest("Calendar name too long (maximum 256 characters)".to_string())); 67 + } 68 + 69 + if payload.tags.is_empty() { 70 + return Err(HttpError::BadRequest("At least one tag is required".to_string())); 71 + } 72 + 73 + if payload.tags.len() > 10 { 74 + return Err(HttpError::BadRequest("Maximum 10 tags allowed per calendar".to_string())); 75 + } 76 + 77 + for tag in &payload.tags { 78 + if tag.trim().is_empty() { 79 + return Err(HttpError::BadRequest("Empty tags are not allowed".to_string())); 80 + } 81 + if tag.len() > 50 { 82 + return Err(HttpError::BadRequest("Tag too long (maximum 50 characters)".to_string())); 83 + } 84 + } 85 + 86 + let calendar_id = bookmark_calendars::generate_calendar_id(); 87 + let tag_operator = payload.tag_operator.unwrap_or_else(|| "OR".to_string()); 88 + 89 + if !["AND", "OR"].contains(&tag_operator.as_str()) { 90 + return Err(HttpError::BadRequest("Tag operator must be 'AND' or 'OR'".to_string())); 91 + } 92 + 93 + let calendar = BookmarkCalendar { 94 + id: 0, // Will be set by database 95 + calendar_id: calendar_id.clone(), 96 + did: session.did.clone(), 97 + name: payload.name.trim().to_string(), 98 + description: payload.description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty()), 99 + tags: payload.tags.iter().map(|t| t.trim().to_string()).collect(), 100 + tag_operator, 101 + is_public: payload.is_public.unwrap_or(false), 102 + event_count: 0, 103 + created_at: chrono::Utc::now(), 104 + updated_at: chrono::Utc::now(), 105 + }; 106 + 107 + match bookmark_calendars::insert(&ctx.storage, &calendar).await { 108 + Ok(created_calendar) => { 109 + info!("Successfully created calendar {} for user {}", calendar_id, session.did); 110 + 111 + let i18n = I18nService::new(&session.language); 112 + let message = i18n.t("calendar-created", &[("count", &created_calendar.tags.len().to_string())]); 113 + 114 + let mut template_context = axum_template::TemplateContext::new(); 115 + template_context.insert("calendar", &created_calendar); 116 + template_context.insert("message", &message); 117 + 118 + let html = ctx.templates 119 + .render("bookmark_calendar_item", &template_context) 120 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 121 + 122 + Ok(Html(html)) 123 + } 124 + Err(e) => { 125 + error!("Failed to create calendar for user {}: {}", session.did, e); 126 + Err(HttpError::InternalServerError("Failed to create calendar".to_string())) 127 + } 128 + } 129 + } 130 + 131 + /// Handle viewing a specific bookmark calendar 132 + pub async fn handle_view_bookmark_calendar( 133 + State(ctx): State<Arc<AppContext>>, 134 + Path(calendar_id): Path<String>, 135 + Query(params): Query<ViewCalendarParams>, 136 + ) -> Result<Html<String>, HttpError> { 137 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 138 + 139 + let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? { 140 + Some(calendar) => calendar, 141 + None => return Err(HttpError::NotFound("Calendar not found".to_string())), 142 + }; 143 + 144 + // Check if user has access to this calendar 145 + if calendar.did != session.did && !calendar.is_public { 146 + return Err(HttpError::Forbidden("Access denied".to_string())); 147 + } 148 + 149 + let view_mode = params.view.as_deref().unwrap_or("timeline"); 150 + let limit = params.limit.unwrap_or(20); 151 + let offset = params.offset.unwrap_or(0); 152 + 153 + // Get events for this calendar using its tag filtering rules 154 + let bookmark_service = crate::services::event_bookmarks::EventBookmarkService::new( 155 + ctx.storage.clone(), 156 + ctx.atproto_client.clone(), 157 + ); 158 + 159 + match bookmark_service 160 + .get_bookmarked_events( 161 + &calendar.did, 162 + Some(&calendar.tags), 163 + None, 164 + limit, 165 + offset, 166 + false, 167 + ) 168 + .await 169 + { 170 + Ok(paginated_events) => { 171 + let i18n = I18nService::new(&session.language); 172 + 173 + let template_name = match view_mode { 174 + "calendar" => "bookmark_calendar_grid", 175 + _ => "bookmark_calendar_timeline", 176 + }; 177 + 178 + let mut template_context = axum_template::TemplateContext::new(); 179 + template_context.insert("calendar", &calendar); 180 + template_context.insert("events", &paginated_events.events); 181 + template_context.insert("total_count", &paginated_events.total_count); 182 + template_context.insert("has_more", &paginated_events.has_more); 183 + template_context.insert("current_offset", &offset); 184 + template_context.insert("view_mode", view_mode); 185 + template_context.insert("is_owner", &(calendar.did == session.did)); 186 + 187 + let html = ctx.templates 188 + .render(template_name, &template_context) 189 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 190 + 191 + Ok(Html(html)) 192 + } 193 + Err(e) => { 194 + error!("Failed to get calendar events for calendar {}: {}", calendar_id, e); 195 + Err(HttpError::InternalServerError("Failed to load calendar".to_string())) 196 + } 197 + } 198 + } 199 + 200 + /// Handle deleting a bookmark calendar 201 + pub async fn handle_delete_bookmark_calendar( 202 + State(ctx): State<Arc<AppContext>>, 203 + Path(calendar_id): Path<String>, 204 + ) -> Result<Html<String>, HttpError> { 205 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 206 + 207 + let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? { 208 + Some(calendar) => calendar, 209 + None => return Err(HttpError::NotFound("Calendar not found".to_string())), 210 + }; 211 + 212 + // Check if user owns this calendar 213 + if calendar.did != session.did { 214 + return Err(HttpError::Forbidden("You can only delete your own calendars".to_string())); 215 + } 216 + 217 + match bookmark_calendars::delete(&ctx.storage, &calendar_id, &session.did).await { 218 + Ok(()) => { 219 + info!("Successfully deleted calendar {} for user {}", calendar_id, session.did); 220 + 221 + // Return empty HTML for HTMX to remove the element 222 + Ok(Html(String::new())) 223 + } 224 + Err(e) => { 225 + error!("Failed to delete calendar {}: {}", calendar_id, e); 226 + Err(HttpError::InternalServerError("Failed to delete calendar".to_string())) 227 + } 228 + } 229 + } 230 + 231 + /// Handle listing bookmark calendars (user's own and public) 232 + pub async fn handle_bookmark_calendars_index( 233 + State(ctx): State<Arc<AppContext>>, 234 + Query(params): Query<CalendarListParams>, 235 + ) -> Result<Html<String>, HttpError> { 236 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 237 + 238 + let limit = params.limit.unwrap_or(20); 239 + let offset = params.offset.unwrap_or(0); 240 + 241 + // Get user's own calendars 242 + let user_calendars = bookmark_calendars::get_by_user(&ctx.storage, &session.did, true).await?; 243 + 244 + // Get public calendars 245 + let (public_calendars, total_public) = bookmark_calendars::get_public_paginated(&ctx.storage, limit, offset).await?; 246 + 247 + let i18n = I18nService::new(&session.language); 248 + 249 + let mut template_context = axum_template::TemplateContext::new(); 250 + template_context.insert("user_calendars", &user_calendars); 251 + template_context.insert("public_calendars", &public_calendars); 252 + template_context.insert("total_public", &total_public); 253 + template_context.insert("current_offset", &offset); 254 + template_context.insert("has_more_public", &((offset + limit) < total_public as i32)); 255 + 256 + let html = ctx.templates 257 + .render("bookmark_calendars_index", &template_context) 258 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 259 + 260 + Ok(Html(html)) 261 + } 262 + 263 + /// Handle updating a bookmark calendar 264 + pub async fn handle_update_bookmark_calendar( 265 + State(ctx): State<Arc<AppContext>>, 266 + Path(calendar_id): Path<String>, 267 + Json(payload): Json<UpdateBookmarkCalendarParams>, 268 + ) -> Result<Html<String>, HttpError> { 269 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 270 + 271 + let mut calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? { 272 + Some(calendar) => calendar, 273 + None => return Err(HttpError::NotFound("Calendar not found".to_string())), 274 + }; 275 + 276 + // Check if user owns this calendar 277 + if calendar.did != session.did { 278 + return Err(HttpError::Forbidden("You can only update your own calendars".to_string())); 279 + } 280 + 281 + // Validate input (same as create) 282 + if payload.name.trim().is_empty() { 283 + return Err(HttpError::BadRequest("Calendar name is required".to_string())); 284 + } 285 + 286 + if payload.name.len() > 256 { 287 + return Err(HttpError::BadRequest("Calendar name too long (maximum 256 characters)".to_string())); 288 + } 289 + 290 + if payload.tags.is_empty() { 291 + return Err(HttpError::BadRequest("At least one tag is required".to_string())); 292 + } 293 + 294 + if payload.tags.len() > 10 { 295 + return Err(HttpError::BadRequest("Maximum 10 tags allowed per calendar".to_string())); 296 + } 297 + 298 + for tag in &payload.tags { 299 + if tag.trim().is_empty() { 300 + return Err(HttpError::BadRequest("Empty tags are not allowed".to_string())); 301 + } 302 + if tag.len() > 50 { 303 + return Err(HttpError::BadRequest("Tag too long (maximum 50 characters)".to_string())); 304 + } 305 + } 306 + 307 + let tag_operator = payload.tag_operator.unwrap_or_else(|| "OR".to_string()); 308 + if !["AND", "OR"].contains(&tag_operator.as_str()) { 309 + return Err(HttpError::BadRequest("Tag operator must be 'AND' or 'OR'".to_string())); 310 + } 311 + 312 + // Update calendar fields 313 + calendar.name = payload.name.trim().to_string(); 314 + calendar.description = payload.description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty()); 315 + calendar.tags = payload.tags.iter().map(|t| t.trim().to_string()).collect(); 316 + calendar.tag_operator = tag_operator; 317 + calendar.is_public = payload.is_public.unwrap_or(calendar.is_public); 318 + 319 + match bookmark_calendars::update(&ctx.storage, &calendar).await { 320 + Ok(()) => { 321 + info!("Successfully updated calendar {} for user {}", calendar_id, session.did); 322 + 323 + let i18n = I18nService::new(&session.language); 324 + let message = i18n.t("calendar-updated", &[("count", &calendar.tags.len().to_string())]); 325 + 326 + let mut template_context = axum_template::TemplateContext::new(); 327 + template_context.insert("calendar", &calendar); 328 + template_context.insert("message", &message); 329 + 330 + let html = ctx.templates 331 + .render("bookmark_calendar_item", &template_context) 332 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 333 + 334 + Ok(Html(html)) 335 + } 336 + Err(e) => { 337 + error!("Failed to update calendar {}: {}", calendar_id, e); 338 + Err(HttpError::InternalServerError("Failed to update calendar".to_string())) 339 + } 340 + } 341 + } 342 + 343 + /// Handle iCal export of a specific calendar 344 + pub async fn handle_export_calendar_ical( 345 + State(ctx): State<Arc<AppContext>>, 346 + Path(calendar_id): Path<String>, 347 + ) -> Result<impl IntoResponse, HttpError> { 348 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 349 + 350 + let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? { 351 + Some(calendar) => calendar, 352 + None => return Err(HttpError::NotFound("Calendar not found".to_string())), 353 + }; 354 + 355 + // Check if user has access to this calendar 356 + if calendar.did != session.did && !calendar.is_public { 357 + return Err(HttpError::Forbidden("Access denied".to_string())); 358 + } 359 + 360 + let bookmark_service = crate::services::event_bookmarks::EventBookmarkService::new( 361 + ctx.storage.clone(), 362 + ctx.atproto_client.clone(), 363 + ); 364 + 365 + match bookmark_service 366 + .get_bookmarked_events(&calendar.did, Some(&calendar.tags), None, 1000, 0, false) 367 + .await 368 + { 369 + Ok(paginated_events) => { 370 + let ical_content = generate_ical_content(&paginated_events.events, &calendar)?; 371 + 372 + let filename = format!("{}.ics", calendar.name.replace(' ', "_")); 373 + let headers = [ 374 + ("Content-Type", "text/calendar; charset=utf-8"), 375 + ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), 376 + ]; 377 + 378 + Ok((StatusCode::OK, headers, ical_content)) 379 + } 380 + Err(e) => { 381 + error!("Failed to export calendar {}: {}", calendar_id, e); 382 + Err(HttpError::InternalServerError("Failed to export calendar".to_string())) 383 + } 384 + } 385 + } 386 + 387 + #[derive(Deserialize)] 388 + pub struct ViewCalendarParams { 389 + view: Option<String>, // 'timeline' or 'calendar' 390 + limit: Option<i32>, 391 + offset: Option<i32>, 392 + } 393 + 394 + /// Generate iCal content for a specific calendar 395 + fn generate_ical_content( 396 + events: &[crate::storage::event_bookmarks::BookmarkedEvent], 397 + calendar: &BookmarkCalendar, 398 + ) -> Result<String, HttpError> { 399 + let mut ical = String::new(); 400 + 401 + ical.push_str("BEGIN:VCALENDAR\r\n"); 402 + ical.push_str("VERSION:2.0\r\n"); 403 + ical.push_str("PRODID:-//smokesignal//bookmark-calendar//EN\r\n"); 404 + ical.push_str("CALSCALE:GREGORIAN\r\n"); 405 + ical.push_str(&format!("X-WR-CALNAME:{}\r\n", calendar.name)); 406 + 407 + if let Some(description) = &calendar.description { 408 + ical.push_str(&format!("X-WR-CALDESC:{}\r\n", description)); 409 + } 410 + 411 + for bookmarked_event in events { 412 + // Parse event record to extract event details 413 + if let Ok(event_data) = serde_json::from_value::<serde_json::Value>(bookmarked_event.event.record.clone()) { 414 + ical.push_str("BEGIN:VEVENT\r\n"); 415 + 416 + // Generate unique ID 417 + let uid = format!("{}@smokesignal.events", bookmarked_event.event.aturi); 418 + ical.push_str(&format!("UID:{}\r\n", uid)); 419 + 420 + // Add event details 421 + ical.push_str(&format!("SUMMARY:{}\r\n", bookmarked_event.event.name)); 422 + 423 + if let Some(description) = event_data.get("description").and_then(|d| d.as_str()) { 424 + ical.push_str(&format!("DESCRIPTION:{}\r\n", description)); 425 + } 426 + 427 + // Add calendar tags as categories 428 + let tags = calendar.tags.join(","); 429 + ical.push_str(&format!("CATEGORIES:{}\r\n", tags)); 430 + 431 + // Add timestamps 432 + let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); 433 + ical.push_str(&format!("DTSTAMP:{}\r\n", dtstamp)); 434 + 435 + ical.push_str("END:VEVENT\r\n"); 436 + } 437 + } 438 + 439 + ical.push_str("END:VCALENDAR\r\n"); 440 + 441 + Ok(ical) 442 + }
+360
backup/handle_bookmark_events_old.rs
··· 1 + use anyhow::Result; 2 + use axum::{ 3 + extract::{Path, Query, State}, 4 + http::StatusCode, 5 + response::{Html, IntoResponse}, 6 + }; 7 + use chrono::{DateTime, Utc, Datelike, Timelike}; 8 + use minijinja::context as template_context; 9 + use serde::{Deserialize, Serialize}; 10 + use std::sync::Arc; 11 + use tracing::{debug, error, info}; 12 + 13 + use crate::create_renderer; 14 + use crate::http::context::{UserRequestContext, WebContext}; 15 + use crate::http::errors::WebError; 16 + use crate::services::event_bookmarks::EventBookmarkService; 17 + use crate::storage::event_bookmarks::PaginatedBookmarkedEvents; 18 + 19 + #[derive(Deserialize)] 20 + pub struct BookmarkEventParams { 21 + event_aturi: String, 22 + tags: Option<String>, // Comma-separated tags 23 + } 24 + 25 + #[derive(Deserialize)] 26 + pub struct CalendarViewParams { 27 + tags: Option<String>, 28 + start_date: Option<String>, 29 + end_date: Option<String>, 30 + view: Option<String>, // 'timeline' or 'calendar' 31 + limit: Option<i32>, 32 + offset: Option<i32>, 33 + } 34 + 35 + #[derive(Serialize)] 36 + pub struct BookmarkEventResponse { 37 + success: bool, 38 + message: String, 39 + bookmark_id: Option<i32>, 40 + } 41 + 42 + /// Handle bookmarking an event 43 + pub async fn handle_bookmark_event( 44 + Query(params): Query<BookmarkEventParams>, 45 + user_request_context: UserRequestContext, 46 + ) -> Result<impl IntoResponse, WebError> { 47 + let Some(auth) = user_request_context.auth else { 48 + return Ok((StatusCode::UNAUTHORIZED, "Authentication required".to_string()).into_response()); 49 + }; 50 + 51 + let tags: Vec<String> = params 52 + .tags 53 + .unwrap_or_default() 54 + .split(',') 55 + .map(|s| s.trim().to_string()) 56 + .filter(|s| !s.is_empty()) 57 + .collect(); 58 + 59 + // Validate tags 60 + if tags.len() > 10 { 61 + return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed".to_string()).into_response()); 62 + } 63 + 64 + for tag in &tags { 65 + if tag.len() > 50 { 66 + return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response()); 67 + } 68 + } 69 + 70 + let bookmark_service = EventBookmarkService::new( 71 + Arc::new(user_request_context.web_context.0.pool.clone()), 72 + Arc::new(user_request_context.web_context.0.atrium_oauth_manager.clone()), 73 + ); 74 + 75 + match bookmark_service 76 + .bookmark_event(&auth.did, &params.event_aturi, tags) 77 + .await 78 + { 79 + Ok(_bookmark) => { 80 + info!("Successfully bookmarked event {} for user {}", params.event_aturi, auth.did); 81 + 82 + let renderer = create_renderer!( 83 + user_request_context.web_context.clone(), 84 + user_request_context.language.clone(), 85 + user_request_context.hx_boosted, 86 + user_request_context.hx_request 87 + ); 88 + 89 + let html = renderer.render("bookmark_success", template_context! { 90 + event_aturi => params.event_aturi, 91 + })?; 92 + 93 + Ok(Html(html).into_response()) 94 + } 95 + Err(e) => { 96 + error!("Failed to bookmark event {}: {}", params.event_aturi, e); 97 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to bookmark event".to_string()).into_response()) 98 + } 99 + } 100 + } 101 + 102 + /// Handle viewing bookmark calendar (timeline or grid view) 103 + pub async fn handle_bookmark_calendar( 104 + State(ctx): State<Arc<AppContext>>, 105 + Query(params): Query<CalendarViewParams>, 106 + ) -> Result<Html<String>, HttpError> { 107 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 108 + 109 + let view_mode = params.view.as_deref().unwrap_or("timeline"); 110 + let limit = params.limit.unwrap_or(20); 111 + let offset = params.offset.unwrap_or(0); 112 + 113 + let tags: Option<Vec<String>> = params.tags.map(|tag_str| { 114 + tag_str 115 + .split(',') 116 + .map(|s| s.trim().to_string()) 117 + .filter(|s| !s.is_empty()) 118 + .collect() 119 + }); 120 + 121 + // Parse date range if provided 122 + let date_range = match (params.start_date.as_ref(), params.end_date.as_ref()) { 123 + (Some(start), Some(end)) => { 124 + match (start.parse::<DateTime<Utc>>(), end.parse::<DateTime<Utc>>()) { 125 + (Ok(start_dt), Ok(end_dt)) => Some((start_dt, end_dt)), 126 + _ => { 127 + warn!("Invalid date format in calendar view params"); 128 + None 129 + } 130 + } 131 + } 132 + _ => None, 133 + }; 134 + 135 + let bookmark_service = EventBookmarkService::new( 136 + ctx.storage.clone(), 137 + ctx.atproto_client.clone(), 138 + ); 139 + 140 + match bookmark_service 141 + .get_bookmarked_events( 142 + &session.did, 143 + tags.as_deref(), 144 + date_range, 145 + limit, 146 + offset, 147 + false, // Don't force sync unless specifically requested 148 + ) 149 + .await 150 + { 151 + Ok(paginated_events) => { 152 + let i18n = I18nService::new(&session.language); 153 + 154 + let template_name = match view_mode { 155 + "calendar" => "bookmark_calendar_grid", 156 + _ => "bookmark_calendar_timeline", 157 + }; 158 + 159 + let mut template_context = axum_template::TemplateContext::new(); 160 + template_context.insert("events", &paginated_events.events); 161 + template_context.insert("total_count", &paginated_events.total_count); 162 + template_context.insert("has_more", &paginated_events.has_more); 163 + template_context.insert("current_offset", &offset); 164 + template_context.insert("view_mode", view_mode); 165 + template_context.insert("filter_tags", &params.tags.unwrap_or_default()); 166 + 167 + let html = ctx.templates 168 + .render(template_name, &template_context) 169 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 170 + 171 + Ok(Html(html)) 172 + } 173 + Err(e) => { 174 + error!("Failed to get bookmarked events for user {}: {}", session.did, e); 175 + Err(HttpError::InternalServerError("Failed to load bookmarks".to_string())) 176 + } 177 + } 178 + } 179 + 180 + /// Handle removing a bookmark 181 + pub async fn handle_remove_bookmark( 182 + State(ctx): State<Arc<AppContext>>, 183 + Path(bookmark_aturi): Path<String>, 184 + ) -> Result<Html<String>, HttpError> { 185 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 186 + 187 + // Decode the bookmark AT-URI if URL-encoded 188 + let bookmark_aturi = urlencoding::decode(&bookmark_aturi) 189 + .map_err(|_| HttpError::BadRequest("Invalid bookmark URI".to_string()))? 190 + .to_string(); 191 + 192 + let bookmark_service = EventBookmarkService::new( 193 + ctx.storage.clone(), 194 + ctx.atproto_client.clone(), 195 + ); 196 + 197 + match bookmark_service 198 + .remove_bookmark(&session.did, &bookmark_aturi) 199 + .await 200 + { 201 + Ok(()) => { 202 + info!("Successfully removed bookmark {} for user {}", bookmark_aturi, session.did); 203 + 204 + // Return empty HTML for HTMX to remove the element 205 + Ok(Html(String::new())) 206 + } 207 + Err(e) => { 208 + error!("Failed to remove bookmark {}: {}", bookmark_aturi, e); 209 + Err(HttpError::InternalServerError("Failed to remove bookmark".to_string())) 210 + } 211 + } 212 + } 213 + 214 + /// Handle calendar navigation (mini-calendar widget) 215 + pub async fn handle_calendar_navigation( 216 + State(ctx): State<Arc<AppContext>>, 217 + Query(params): Query<CalendarNavParams>, 218 + ) -> Result<Html<String>, HttpError> { 219 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 220 + 221 + let year = params.year.unwrap_or_else(|| Utc::now().year()); 222 + let month = params.month.unwrap_or_else(|| Utc::now().month() as i32); 223 + 224 + // Get events for the requested month to highlight dates 225 + let bookmark_service = EventBookmarkService::new( 226 + ctx.storage.clone(), 227 + ctx.atproto_client.clone(), 228 + ); 229 + 230 + let start_of_month = chrono::NaiveDate::from_ymd_opt(year, month as u32, 1) 231 + .unwrap() 232 + .and_hms_opt(0, 0, 0) 233 + .unwrap() 234 + .and_utc(); 235 + 236 + let end_of_month = if month == 12 { 237 + chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) 238 + } else { 239 + chrono::NaiveDate::from_ymd_opt(year, month as u32 + 1, 1) 240 + } 241 + .unwrap() 242 + .and_hms_opt(0, 0, 0) 243 + .unwrap() 244 + .and_utc(); 245 + 246 + let date_range = Some((start_of_month, end_of_month)); 247 + 248 + match bookmark_service 249 + .get_bookmarked_events(&session.did, None, date_range, 1000, 0, false) 250 + .await 251 + { 252 + Ok(paginated_events) => { 253 + let mut template_context = axum_template::TemplateContext::new(); 254 + template_context.insert("year", &year); 255 + template_context.insert("month", &month); 256 + template_context.insert("events", &paginated_events.events); 257 + 258 + let html = ctx.templates 259 + .render("mini_calendar", &template_context) 260 + .map_err(|e| HttpError::TemplateError(e.to_string()))?; 261 + 262 + Ok(Html(html)) 263 + } 264 + Err(e) => { 265 + error!("Failed to get calendar events for user {}: {}", session.did, e); 266 + Err(HttpError::InternalServerError("Failed to load calendar".to_string())) 267 + } 268 + } 269 + } 270 + 271 + /// Handle iCal export of bookmarked events 272 + pub async fn handle_bookmark_calendar_ical( 273 + State(ctx): State<Arc<AppContext>>, 274 + Query(params): Query<CalendarViewParams>, 275 + ) -> Result<impl IntoResponse, HttpError> { 276 + let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?; 277 + 278 + let tags: Option<Vec<String>> = params.tags.map(|tag_str| { 279 + tag_str 280 + .split(',') 281 + .map(|s| s.trim().to_string()) 282 + .filter(|s| !s.is_empty()) 283 + .collect() 284 + }); 285 + 286 + let bookmark_service = EventBookmarkService::new( 287 + ctx.storage.clone(), 288 + ctx.atproto_client.clone(), 289 + ); 290 + 291 + match bookmark_service 292 + .get_bookmarked_events(&session.did, tags.as_deref(), None, 1000, 0, false) 293 + .await 294 + { 295 + Ok(paginated_events) => { 296 + let ical_content = generate_ical_content(&paginated_events.events)?; 297 + 298 + let headers = [ 299 + ("Content-Type", "text/calendar; charset=utf-8"), 300 + ("Content-Disposition", "attachment; filename=\"bookmarks.ics\""), 301 + ]; 302 + 303 + Ok((StatusCode::OK, headers, ical_content)) 304 + } 305 + Err(e) => { 306 + error!("Failed to export bookmarks for user {}: {}", session.did, e); 307 + Err(HttpError::InternalServerError("Failed to export calendar".to_string())) 308 + } 309 + } 310 + } 311 + 312 + #[derive(Deserialize)] 313 + pub struct CalendarNavParams { 314 + year: Option<i32>, 315 + month: Option<i32>, 316 + } 317 + 318 + /// Generate iCal content from bookmarked events 319 + fn generate_ical_content(events: &[BookmarkedEvent]) -> Result<String, HttpError> { 320 + let mut ical = String::new(); 321 + 322 + ical.push_str("BEGIN:VCALENDAR\r\n"); 323 + ical.push_str("VERSION:2.0\r\n"); 324 + ical.push_str("PRODID:-//smokesignal//bookmarks//EN\r\n"); 325 + ical.push_str("CALSCALE:GREGORIAN\r\n"); 326 + 327 + for bookmarked_event in events { 328 + // Parse event record to extract event details 329 + if let Ok(event_data) = serde_json::from_value::<serde_json::Value>(bookmarked_event.event.record.clone()) { 330 + ical.push_str("BEGIN:VEVENT\r\n"); 331 + 332 + // Generate unique ID 333 + let uid = format!("{}@smokesignal.events", bookmarked_event.event.aturi); 334 + ical.push_str(&format!("UID:{}\r\n", uid)); 335 + 336 + // Add event details 337 + ical.push_str(&format!("SUMMARY:{}\r\n", bookmarked_event.event.name)); 338 + 339 + if let Some(description) = event_data.get("description").and_then(|d| d.as_str()) { 340 + ical.push_str(&format!("DESCRIPTION:{}\r\n", description)); 341 + } 342 + 343 + // Add bookmark tags as categories 344 + let tags = bookmarked_event.bookmark.tags.join(","); 345 + if !tags.is_empty() { 346 + ical.push_str(&format!("CATEGORIES:{}\r\n", tags)); 347 + } 348 + 349 + // Add timestamps 350 + let dtstamp = Utc::now().format("%Y%m%dT%H%M%SZ"); 351 + ical.push_str(&format!("DTSTAMP:{}\r\n", dtstamp)); 352 + 353 + ical.push_str("END:VEVENT\r\n"); 354 + } 355 + } 356 + 357 + ical.push_str("END:VCALENDAR\r\n"); 358 + 359 + Ok(ical) 360 + }
+63
i18n/en-us/bookmarks.ftl
··· 1 + # Event Bookmarks 2 + bookmark-event = Bookmark Event 3 + bookmark-calendar = Event Calendar 4 + bookmarked-events = Bookmarked Events 5 + timeline-view = Timeline 6 + calendar-view = Calendar 7 + filter-by-tags = Filter by Tags 8 + calendar-navigation = Calendar 9 + remove-bookmark = Remove Bookmark 10 + confirm-remove-bookmark = Are you sure you want to remove this bookmark? 11 + add-to-calendar = Add to Calendar 12 + create-new-calendar = Create New Calendar 13 + bookmark-success = Event bookmarked successfully! 14 + no-bookmarked-events = No bookmarked events found 15 + bookmark-tags = Tags (comma-separated) 16 + bookmark-calendar-name = Calendar Name 17 + bookmark-calendar-description = Description (optional) 18 + make-calendar-public = Make this calendar public 19 + bookmarked-on = Bookmarked on {$date} 20 + ends-at = Ends at {$time} 21 + 22 + # Enhanced calendar management 23 + bookmark-calendars = Custom Calendars 24 + create-calendar = Create Calendar 25 + create-bookmark-calendar = Create Custom Calendar 26 + create-new-calendar = Create New Calendar 27 + calendar-name = Calendar Name 28 + calendar-name-placeholder = e.g. Summer Festivals, Work Events 29 + calendar-name-help = Choose a descriptive name for your calendar 30 + calendar-tags = Tags 31 + add-tag-placeholder = Add a tag and press Enter 32 + calendar-tags-help = Add tags to organize your calendar 33 + calendar-description-placeholder = What types of events will you collect? 34 + make-calendar-public = Make this calendar public 35 + public-calendar-help = Public calendars appear on your profile and can be discovered by others 36 + atproto-privacy-notice = Privacy Notice 37 + atproto-privacy-explanation = Your bookmarks and tags are always public on the ATproto network. This setting only controls whether this calendar appears on your smokesignal profile. 38 + my-calendars = My Calendars 39 + public-calendars = Public Calendars 40 + no-public-calendars = No public calendars yet 41 + export-calendar = Export Calendar 42 + share-calendar = Share Calendar 43 + events = events 44 + created = Created on 45 + calendar-updated = Calendar updated successfully 46 + calendar-created = Calendar created successfully 47 + event-added-to-calendar = Event added to calendar 48 + 49 + # Tag management 50 + calendar-tags-help = Add multiple tags to organize this calendar (press Enter or comma to add) 51 + tag-suggestions = Suggested tags 52 + calendar-created-success = Calendar created with {$count} tags 53 + calendar-updated-success = Calendar updated with {$count} tags 54 + tag-match-all = Show events with ALL tags 55 + tag-match-any = Show events with ANY tags 56 + tag-strategy = Tag matching 57 + tag-strategy-help = Choose how events are matched to this calendar 58 + tag-operator = Tag operator 59 + tag-operator-help = Combine tags with AND (all required) or OR (any required) 60 + error-creating-calendar = Error creating calendar 61 + error-max-tags = Maximum 10 tags allowed per calendar 62 + error-empty-tag = Empty tags are not allowed 63 + error-tag-too-long = Tag too long (maximum 50 characters)
+63
i18n/fr-ca/bookmarks.ftl
··· 1 + # Signets d'événements 2 + bookmark-event = Marquer l'événement 3 + bookmark-calendar = Calendrier des événements 4 + bookmarked-events = Événements marqués 5 + timeline-view = Vue chronologique 6 + calendar-view = Vue calendrier 7 + filter-by-tags = Filtrer par étiquettes 8 + calendar-navigation = Navigation 9 + remove-bookmark = Retirer le marque-page 10 + confirm-remove-bookmark = Êtes-vous sûr·e de vouloir retirer ce marque-page? 11 + add-to-calendar = Ajouter au calendrier 12 + create-new-calendar = Créer un nouveau calendrier 13 + bookmark-success = Événement marqué avec succès! 14 + no-bookmarked-events = Aucun événement marqué trouvé 15 + bookmark-tags = Étiquettes (séparées par des virgules) 16 + bookmark-calendar-name = Nom du calendrier 17 + bookmark-calendar-description = Description (optionnelle) 18 + make-calendar-public = Rendre ce calendrier public 19 + bookmarked-on = Marqué le {$date} 20 + ends-at = Se termine à {$time} 21 + 22 + # Gestion avancée des calendriers 23 + bookmark-calendars = Calendriers personnalisés 24 + create-calendar = Créer un calendrier 25 + create-bookmark-calendar = Créer un calendrier personnalisé 26 + create-new-calendar = Créer un nouveau calendrier 27 + calendar-name = Nom du calendrier 28 + calendar-name-placeholder = ex. Festivals d'été, Événements de travail 29 + calendar-name-help = Choisissez un nom descriptif pour votre calendrier 30 + calendar-tags = Étiquettes 31 + add-tag-placeholder = Ajouter une étiquette et appuyer sur Entrée 32 + calendar-tags-help = Ajoutez des étiquettes pour organiser votre calendrier 33 + calendar-description-placeholder = Quels types d'événements allez-vous collecter ? 34 + make-calendar-public = Rendre ce calendrier public 35 + public-calendar-help = Les calendriers publics apparaissent sur votre profil et peuvent être découverts par d'autres 36 + atproto-privacy-notice = Avis de confidentialité 37 + atproto-privacy-explanation = Vos signets et étiquettes sont toujours publics sur le réseau ATproto. Ce paramètre contrôle seulement si ce calendrier apparaît sur votre profil smokesignal. 38 + my-calendars = Mes calendriers 39 + public-calendars = Calendriers publics 40 + no-public-calendars = Pas encore de calendriers publics 41 + export-calendar = Exporter le calendrier 42 + share-calendar = Partager le calendrier 43 + events = événements 44 + created = Créé le 45 + calendar-updated = Calendrier mis à jour avec succès 46 + calendar-created = Calendrier créé avec succès 47 + event-added-to-calendar = Événement ajouté au calendrier 48 + 49 + # Gestion des étiquettes 50 + calendar-tags-help = Ajoutez plusieurs étiquettes pour organiser ce calendrier (appuyez sur Entrée ou virgule pour ajouter) 51 + tag-suggestions = Étiquettes suggérées 52 + calendar-created-success = Calendrier créé avec {$count} étiquettes 53 + calendar-updated-success = Calendrier mis à jour avec {$count} étiquettes 54 + tag-match-all = Afficher les événements avec TOUTES les étiquettes 55 + tag-match-any = Afficher les événements avec N'IMPORTE QUELLE étiquette 56 + tag-strategy = Correspondance d'étiquettes 57 + tag-strategy-help = Choisissez comment les événements correspondent à ce calendrier 58 + tag-operator = Opérateur d'étiquettes 59 + tag-operator-help = Combinez les étiquettes avec ET (toutes requises) ou OU (n'importe laquelle requise) 60 + error-creating-calendar = Erreur lors de la création du calendrier 61 + error-max-tags = Maximum 10 étiquettes autorisées par calendrier 62 + error-empty-tag = Les étiquettes vides ne sont pas autorisées 63 + error-tag-too-long = Étiquette trop longue (maximum 50 caractères)
+46
migrations/20250618120000_bookmarks.sql
··· 1 + -- Event bookmarks migration 2 + -- This creates the core tables for the bookmark calendar feature 3 + 4 + -- Event bookmarks table (local cache for performance) 5 + CREATE TABLE event_bookmarks ( 6 + id SERIAL PRIMARY KEY, 7 + did VARCHAR(512) NOT NULL, 8 + bookmark_aturi VARCHAR(1024) NOT NULL, -- ATproto bookmark record URI 9 + event_aturi VARCHAR(1024) NOT NULL, -- Event being bookmarked 10 + tags TEXT[] NOT NULL DEFAULT '{}', -- Tags for organization 11 + synced_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 12 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 13 + UNIQUE(did, event_aturi), -- Prevent duplicate event bookmarks 14 + FOREIGN KEY (event_aturi) REFERENCES events(aturi) ON DELETE CASCADE 15 + ); 16 + 17 + -- Bookmark calendars table (user-created collections) 18 + CREATE TABLE bookmark_calendars ( 19 + id SERIAL PRIMARY KEY, 20 + calendar_id VARCHAR(64) NOT NULL, -- Public identifier for sharing 21 + did VARCHAR(512) NOT NULL, 22 + name VARCHAR(256) NOT NULL, 23 + description TEXT DEFAULT NULL, 24 + tags TEXT[] NOT NULL, -- Tags that define this calendar 25 + tag_operator VARCHAR(16) NOT NULL DEFAULT 'OR', -- 'AND' or 'OR' for tag matching 26 + is_public BOOLEAN NOT NULL DEFAULT FALSE, 27 + event_count INTEGER NOT NULL DEFAULT 0, 28 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 29 + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 30 + UNIQUE(calendar_id), 31 + CHECK(array_length(tags, 1) > 0), -- Ensure at least one tag per calendar 32 + CHECK(tag_operator IN ('AND', 'OR')), 33 + CHECK(array_length(tags, 1) <= 10) -- Maximum 10 tags per calendar 34 + ); 35 + 36 + -- Indexes for event_bookmarks 37 + CREATE INDEX idx_event_bookmarks_did ON event_bookmarks(did); 38 + CREATE INDEX idx_event_bookmarks_event_aturi ON event_bookmarks(event_aturi); 39 + CREATE INDEX idx_event_bookmarks_tags ON event_bookmarks USING GIN(tags); 40 + CREATE INDEX idx_event_bookmarks_synced_at ON event_bookmarks(synced_at); 41 + 42 + -- Indexes for bookmark_calendars 43 + CREATE INDEX idx_bookmark_calendars_did ON bookmark_calendars(did); 44 + CREATE INDEX idx_bookmark_calendars_public ON bookmark_calendars(is_public) WHERE is_public = true; 45 + CREATE INDEX idx_bookmark_calendars_tags ON bookmark_calendars USING GIN(tags); 46 + CREATE INDEX idx_bookmark_calendars_calendar_id ON bookmark_calendars(calendar_id);
+57
src/atproto/lexicon/community_lexicon_bookmarks.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + pub const BOOKMARK_NSID: &str = "community.lexicon.bookmarks.bookmark"; 5 + pub const GET_BOOKMARKS_NSID: &str = "community.lexicon.bookmarks.getActorBookmarks"; 6 + 7 + #[derive(Debug, Serialize, Deserialize, Clone)] 8 + pub struct Bookmark { 9 + pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123) 10 + pub tags: Vec<String>, // Tags are required for organization 11 + #[serde(rename = "createdAt")] 12 + pub created_at: DateTime<Utc>, 13 + } 14 + 15 + #[derive(Debug, Serialize, Deserialize)] 16 + pub struct GetActorBookmarksParams { 17 + pub actor: String, 18 + #[serde(skip_serializing_if = "Option::is_none")] 19 + pub cursor: Option<String>, 20 + #[serde(skip_serializing_if = "Option::is_none")] 21 + pub limit: Option<u32>, 22 + } 23 + 24 + #[derive(Debug, Serialize, Deserialize)] 25 + pub struct GetActorBookmarksResponse { 26 + pub bookmarks: Vec<BookmarkRecord>, // Returns full bookmark records with AT-URIs 27 + #[serde(skip_serializing_if = "Option::is_none")] 28 + pub cursor: Option<String>, 29 + } 30 + 31 + #[derive(Debug, Serialize, Deserialize)] 32 + pub struct BookmarkRecord { 33 + pub uri: String, // AT-URI of the bookmark record itself 34 + pub value: Bookmark, // The bookmark data 35 + #[serde(rename = "indexedAt")] 36 + pub indexed_at: DateTime<Utc>, 37 + } 38 + 39 + #[derive(Debug, Serialize, Deserialize)] 40 + pub struct CreateBookmarkInput { 41 + pub repo: String, // User's DID 42 + pub collection: String, // Should be BOOKMARK_NSID 43 + pub record: Bookmark, // The bookmark record to create 44 + } 45 + 46 + #[derive(Debug, Serialize, Deserialize)] 47 + pub struct CreateBookmarkResponse { 48 + pub uri: String, // AT-URI of the created bookmark record 49 + pub cid: String, // Content identifier 50 + } 51 + 52 + #[derive(Debug, Serialize, Deserialize)] 53 + pub struct DeleteBookmarkInput { 54 + pub repo: String, // User's DID 55 + pub collection: String, // Should be BOOKMARK_NSID 56 + pub rkey: String, // Record key from the bookmark URI 57 + }
+4
src/atproto/lexicon/mod.rs
··· 1 1 pub mod com_atproto_repo; 2 + mod community_lexicon_bookmarks; 2 3 mod community_lexicon_calendar_event; 3 4 mod community_lexicon_calendar_rsvp; 4 5 pub mod community_lexicon_location; ··· 15 16 16 17 pub mod community { 17 18 pub mod lexicon { 19 + pub mod bookmarks { 20 + pub use crate::atproto::lexicon::community_lexicon_bookmarks::*; 21 + } 18 22 pub mod calendar { 19 23 pub mod event { 20 24 pub use crate::atproto::lexicon::community_lexicon_calendar_event::*;
+310
src/http/handle_bookmark_calendars.rs
··· 1 + use axum::{ 2 + extract::{Path}, 3 + response::{Html, IntoResponse}, 4 + http::StatusCode, 5 + Json, 6 + }; 7 + use axum_htmx::HxBoosted; 8 + use minijinja::context as template_context; 9 + use serde::Deserialize; 10 + use tracing::{error, info}; 11 + 12 + use crate::create_renderer; 13 + use crate::http::context::UserRequestContext; 14 + use crate::http::errors::WebError; 15 + use crate::storage::bookmark_calendars::{BookmarkCalendar, generate_calendar_id}; 16 + 17 + #[derive(Deserialize)] 18 + pub struct CreateBookmarkCalendarParams { 19 + name: String, 20 + description: Option<String>, 21 + tags: Option<String>, // JSON array as string 22 + tag_operator: Option<String>, // "AND" or "OR" 23 + is_public: Option<bool>, 24 + } 25 + 26 + #[derive(Deserialize)] 27 + pub struct UpdateBookmarkCalendarParams { 28 + name: String, 29 + description: Option<String>, 30 + tags: Option<String>, // JSON array as string 31 + tag_operator: Option<String>, // "AND" or "OR" 32 + is_public: Option<bool>, 33 + } 34 + 35 + /// Handle viewing the bookmark calendars index 36 + pub async fn handle_bookmark_calendars_index( 37 + ctx: UserRequestContext, 38 + HxBoosted(hx_boosted): HxBoosted, 39 + ) -> Result<impl IntoResponse, WebError> { 40 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 41 + 42 + // Create the template renderer with enhanced context 43 + let language_clone = ctx.language.clone(); 44 + let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false); 45 + 46 + // For now, return empty calendars list 47 + let template_context = template_context! { 48 + calendars => Vec::<BookmarkCalendar>::new(), 49 + user_did => current_handle.did, 50 + }; 51 + 52 + let html = renderer.render_template( 53 + "bookmark_calendars_index", 54 + template_context, 55 + ctx.current_handle.as_ref(), 56 + "/bookmark-calendars" 57 + ); 58 + Ok(Html(html).into_response()) 59 + } 60 + 61 + /// Handle creating a new bookmark calendar 62 + pub async fn handle_create_bookmark_calendar( 63 + ctx: UserRequestContext, 64 + Json(payload): Json<CreateBookmarkCalendarParams>, 65 + ) -> Result<impl IntoResponse, WebError> { 66 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 67 + 68 + // Parse tags from JSON string 69 + let tags: Vec<String> = if let Some(tags_str) = payload.tags { 70 + serde_json::from_str(&tags_str).unwrap_or_default() 71 + } else { 72 + Vec::new() 73 + }; 74 + 75 + // Validate input 76 + if payload.name.trim().is_empty() { 77 + return Ok((StatusCode::BAD_REQUEST, "Calendar name is required".to_string()).into_response()); 78 + } 79 + 80 + if tags.len() > 10 { 81 + return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed per calendar".to_string()).into_response()); 82 + } 83 + 84 + for tag in &tags { 85 + if tag.len() > 50 { 86 + return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response()); 87 + } 88 + } 89 + 90 + let calendar = BookmarkCalendar { 91 + id: 0, // Will be set by database 92 + calendar_id: generate_calendar_id(), 93 + did: current_handle.did.clone(), 94 + name: payload.name.trim().to_string(), 95 + description: payload.description, 96 + tags, 97 + tag_operator: payload.tag_operator.unwrap_or_else(|| "OR".to_string()), 98 + is_public: payload.is_public.unwrap_or(false), 99 + event_count: 0, 100 + created_at: chrono::Utc::now(), 101 + updated_at: chrono::Utc::now(), 102 + }; 103 + 104 + match crate::storage::bookmark_calendars::insert(&ctx.web_context.pool, &calendar).await { 105 + Ok(created_calendar) => { 106 + info!("Successfully created calendar {} for user {}", created_calendar.calendar_id, current_handle.did); 107 + 108 + let html = format!( 109 + r#"<div class="notification is-success"> 110 + <p>Calendar "{}" created successfully!</p> 111 + </div>"#, 112 + created_calendar.name 113 + ); 114 + 115 + Ok(Html(html).into_response()) 116 + } 117 + Err(e) => { 118 + error!("Failed to create calendar: {}", e); 119 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to create calendar".to_string()).into_response()) 120 + } 121 + } 122 + } 123 + 124 + /// Handle viewing a specific bookmark calendar 125 + pub async fn handle_view_bookmark_calendar( 126 + ctx: UserRequestContext, 127 + HxBoosted(hx_boosted): HxBoosted, 128 + Path(calendar_id): Path<String>, 129 + ) -> Result<impl IntoResponse, WebError> { 130 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 131 + 132 + // Create the template renderer with enhanced context 133 + let language_clone = ctx.language.clone(); 134 + let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false); 135 + 136 + match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await { 137 + Ok(Some(calendar)) => { 138 + // Check if user owns this calendar or if it's public 139 + if calendar.did != current_handle.did && !calendar.is_public { 140 + return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response()); 141 + } 142 + 143 + let template_context = template_context! { 144 + calendar => calendar, 145 + is_owner => calendar.did == current_handle.did, 146 + events => Vec::<String>::new(), // TODO: Fetch events for this calendar 147 + }; 148 + 149 + let html = renderer.render_template( 150 + "bookmark_calendar_view", 151 + template_context, 152 + ctx.current_handle.as_ref(), 153 + &format!("/bookmark-calendars/{}", calendar_id) 154 + ); 155 + Ok(Html(html).into_response()) 156 + } 157 + Ok(None) => { 158 + Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response()) 159 + } 160 + Err(e) => { 161 + error!("Failed to get calendar {}: {}", calendar_id, e); 162 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response()) 163 + } 164 + } 165 + } 166 + 167 + /// Handle updating a bookmark calendar 168 + pub async fn handle_update_bookmark_calendar( 169 + ctx: UserRequestContext, 170 + Path(calendar_id): Path<String>, 171 + Json(payload): Json<UpdateBookmarkCalendarParams>, 172 + ) -> Result<impl IntoResponse, WebError> { 173 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 174 + 175 + // Get existing calendar 176 + let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await { 177 + Ok(Some(calendar)) => calendar, 178 + Ok(None) => { 179 + return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response()); 180 + } 181 + Err(e) => { 182 + error!("Failed to get calendar {}: {}", calendar_id, e); 183 + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response()); 184 + } 185 + }; 186 + 187 + // Check ownership 188 + if calendar.did != current_handle.did { 189 + return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response()); 190 + } 191 + 192 + // Parse tags from JSON string 193 + let tags: Vec<String> = if let Some(tags_str) = payload.tags { 194 + serde_json::from_str(&tags_str).unwrap_or_default() 195 + } else { 196 + calendar.tags.clone() 197 + }; 198 + 199 + // Validate input 200 + if payload.name.trim().is_empty() { 201 + return Ok((StatusCode::BAD_REQUEST, "Calendar name is required".to_string()).into_response()); 202 + } 203 + 204 + if tags.len() > 10 { 205 + return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed per calendar".to_string()).into_response()); 206 + } 207 + 208 + let mut updated_calendar = calendar; 209 + updated_calendar.name = payload.name.trim().to_string(); 210 + updated_calendar.description = payload.description; 211 + updated_calendar.tags = tags; 212 + updated_calendar.tag_operator = payload.tag_operator.unwrap_or(updated_calendar.tag_operator); 213 + updated_calendar.is_public = payload.is_public.unwrap_or(updated_calendar.is_public); 214 + updated_calendar.updated_at = chrono::Utc::now(); 215 + 216 + match crate::storage::bookmark_calendars::update(&ctx.web_context.pool, &updated_calendar).await { 217 + Ok(()) => { 218 + info!("Successfully updated calendar {} for user {}", calendar_id, current_handle.did); 219 + 220 + let html = format!( 221 + r#"<div class="notification is-success"> 222 + <p>Calendar "{}" updated successfully!</p> 223 + </div>"#, 224 + updated_calendar.name 225 + ); 226 + 227 + Ok(Html(html).into_response()) 228 + } 229 + Err(e) => { 230 + error!("Failed to update calendar {}: {}", calendar_id, e); 231 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to update calendar".to_string()).into_response()) 232 + } 233 + } 234 + } 235 + 236 + /// Handle deleting a bookmark calendar 237 + pub async fn handle_delete_bookmark_calendar( 238 + ctx: UserRequestContext, 239 + Path(calendar_id): Path<String>, 240 + ) -> Result<impl IntoResponse, WebError> { 241 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 242 + 243 + // Get existing calendar to check ownership 244 + let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await { 245 + Ok(Some(calendar)) => calendar, 246 + Ok(None) => { 247 + return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response()); 248 + } 249 + Err(e) => { 250 + error!("Failed to get calendar {}: {}", calendar_id, e); 251 + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response()); 252 + } 253 + }; 254 + 255 + // Check ownership 256 + if calendar.did != current_handle.did { 257 + return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response()); 258 + } 259 + 260 + match crate::storage::bookmark_calendars::delete(&ctx.web_context.pool, &calendar_id, &current_handle.did).await { 261 + Ok(()) => { 262 + info!("Successfully deleted calendar {} for user {}", calendar_id, current_handle.did); 263 + 264 + // Return empty HTML for HTMX to remove the element 265 + Ok(Html(String::new()).into_response()) 266 + } 267 + Err(e) => { 268 + error!("Failed to delete calendar {}: {}", calendar_id, e); 269 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete calendar".to_string()).into_response()) 270 + } 271 + } 272 + } 273 + 274 + /// Handle exporting a calendar as iCal 275 + pub async fn handle_export_calendar_ical( 276 + ctx: UserRequestContext, 277 + Path(calendar_id): Path<String>, 278 + ) -> Result<impl IntoResponse, WebError> { 279 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?; 280 + 281 + // Get calendar 282 + let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await { 283 + Ok(Some(calendar)) => calendar, 284 + Ok(None) => { 285 + return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response()); 286 + } 287 + Err(e) => { 288 + error!("Failed to get calendar {}: {}", calendar_id, e); 289 + return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response()); 290 + } 291 + }; 292 + 293 + // Check access 294 + if calendar.did != current_handle.did && !calendar.is_public { 295 + return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response()); 296 + } 297 + 298 + // Generate iCal content - simplified for now 299 + let ical_content = format!( 300 + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//smokesignal//EN\r\nX-WR-CALNAME:{}\r\nEND:VCALENDAR\r\n", 301 + calendar.name 302 + ); 303 + 304 + let headers = [ 305 + ("Content-Type", "text/calendar; charset=utf-8"), 306 + ("Content-Disposition", &format!("attachment; filename=\"{}.ics\"", calendar.name.replace(' ', "_"))), 307 + ]; 308 + 309 + Ok((StatusCode::OK, headers, ical_content).into_response()) 310 + }
+302
src/http/handle_bookmark_events.rs
··· 1 + use axum::{ 2 + extract::{Path, Query}, 3 + response::{Html, IntoResponse}, 4 + http::StatusCode, 5 + }; 6 + use axum_htmx::HxBoosted; 7 + use chrono::Datelike; 8 + use minijinja::context as template_context; 9 + use serde::{Deserialize, Serialize}; 10 + use std::sync::Arc; 11 + use tracing::{error, info}; 12 + 13 + use crate::create_renderer; 14 + use crate::http::context::UserRequestContext; 15 + use crate::http::errors::WebError; 16 + use crate::services::event_bookmarks::EventBookmarkService; 17 + 18 + #[derive(Deserialize)] 19 + pub struct BookmarkEventParams { 20 + event_aturi: String, 21 + tags: Option<String>, // Comma-separated tags 22 + } 23 + 24 + #[derive(Deserialize)] 25 + pub struct CalendarViewParams { 26 + tags: Option<String>, 27 + #[allow(dead_code)] 28 + start_date: Option<String>, 29 + #[allow(dead_code)] 30 + end_date: Option<String>, 31 + view: Option<String>, // 'timeline' or 'calendar' 32 + limit: Option<i32>, 33 + offset: Option<i32>, 34 + } 35 + 36 + #[derive(Serialize)] 37 + pub struct BookmarkEventResponse { 38 + success: bool, 39 + message: String, 40 + bookmark_id: Option<i32>, 41 + } 42 + 43 + /// Handle bookmarking an event 44 + pub async fn handle_bookmark_event( 45 + ctx: UserRequestContext, 46 + Query(params): Query<BookmarkEventParams>, 47 + ) -> Result<impl IntoResponse, WebError> { 48 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 49 + 50 + let tags: Vec<String> = params 51 + .tags 52 + .unwrap_or_default() 53 + .split(',') 54 + .map(|s| s.trim().to_string()) 55 + .filter(|s| !s.is_empty()) 56 + .collect(); 57 + 58 + // Validate tags 59 + if tags.len() > 10 { 60 + return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed".to_string()).into_response()); 61 + } 62 + 63 + for tag in &tags { 64 + if tag.len() > 50 { 65 + return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response()); 66 + } 67 + } 68 + 69 + let bookmark_service = EventBookmarkService::new( 70 + Arc::new(ctx.web_context.pool.clone()), 71 + Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 72 + ); 73 + 74 + match bookmark_service 75 + .bookmark_event(&current_handle.did, &params.event_aturi, tags) 76 + .await 77 + { 78 + Ok(_bookmark) => { 79 + info!("Successfully bookmarked event {} for user {}", params.event_aturi, current_handle.did); 80 + 81 + let html = r#"<div class="notification is-success"> 82 + <p>Event bookmarked successfully!</p> 83 + </div>"#; 84 + 85 + Ok(Html(html.to_string()).into_response()) 86 + } 87 + Err(e) => { 88 + error!("Failed to bookmark event {}: {}", params.event_aturi, e); 89 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to bookmark event".to_string()).into_response()) 90 + } 91 + } 92 + } 93 + 94 + /// Handle viewing the bookmark calendar timeline 95 + pub async fn handle_bookmark_calendar( 96 + ctx: UserRequestContext, 97 + HxBoosted(hx_boosted): HxBoosted, 98 + Query(params): Query<CalendarViewParams>, 99 + ) -> Result<impl IntoResponse, WebError> { 100 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 101 + 102 + let view_mode = params.view.as_deref().unwrap_or("timeline"); 103 + let limit = params.limit.unwrap_or(20); 104 + let offset = params.offset.unwrap_or(0); 105 + 106 + // Create the template renderer with enhanced context 107 + let language_clone = ctx.language.clone(); 108 + let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false); 109 + 110 + let bookmark_service = EventBookmarkService::new( 111 + Arc::new(ctx.web_context.pool.clone()), 112 + Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 113 + ); 114 + 115 + // Parse tags if provided 116 + let tags: Option<Vec<String>> = params.tags.as_ref().map(|tag_str| { 117 + tag_str 118 + .split(',') 119 + .map(|s| s.trim().to_string()) 120 + .filter(|s| !s.is_empty()) 121 + .collect() 122 + }); 123 + 124 + match bookmark_service 125 + .get_bookmarked_events( 126 + &current_handle.did, 127 + tags.as_deref(), 128 + None, // No date range for now 129 + limit, 130 + offset, 131 + false, 132 + ) 133 + .await 134 + { 135 + Ok(paginated_events) => { 136 + let template_name = match view_mode { 137 + "calendar" => "bookmark_calendar_grid", 138 + _ => "bookmark_calendar_timeline", 139 + }; 140 + 141 + let template_context = template_context! { 142 + events => paginated_events.events, 143 + total_count => paginated_events.total_count, 144 + has_more => paginated_events.has_more, 145 + current_offset => offset, 146 + view_mode => view_mode, 147 + filter_tags => params.tags.unwrap_or_default(), 148 + }; 149 + 150 + let html = renderer.render_template( 151 + template_name, 152 + template_context, 153 + ctx.current_handle.as_ref(), 154 + "/bookmarks" 155 + ); 156 + Ok(Html(html).into_response()) 157 + } 158 + Err(e) => { 159 + error!("Failed to get bookmarked events for user {}: {}", current_handle.did, e); 160 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load bookmarks".to_string()).into_response()) 161 + } 162 + } 163 + } 164 + 165 + /// Handle removing a bookmark 166 + pub async fn handle_remove_bookmark( 167 + ctx: UserRequestContext, 168 + Path(bookmark_aturi): Path<String>, 169 + ) -> Result<impl IntoResponse, WebError> { 170 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 171 + 172 + // Decode the bookmark AT-URI if URL-encoded 173 + let bookmark_aturi = urlencoding::decode(&bookmark_aturi) 174 + .map_err(|_| anyhow::anyhow!("Invalid bookmark URI"))? 175 + .to_string(); 176 + 177 + let bookmark_service = EventBookmarkService::new( 178 + Arc::new(ctx.web_context.pool.clone()), 179 + Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 180 + ); 181 + 182 + match bookmark_service 183 + .remove_bookmark(&current_handle.did, &bookmark_aturi) 184 + .await 185 + { 186 + Ok(()) => { 187 + info!("Successfully removed bookmark {} for user {}", bookmark_aturi, current_handle.did); 188 + 189 + // Return empty HTML for HTMX to remove the element 190 + Ok(Html(String::new()).into_response()) 191 + } 192 + Err(e) => { 193 + error!("Failed to remove bookmark {}: {}", bookmark_aturi, e); 194 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to remove bookmark".to_string()).into_response()) 195 + } 196 + } 197 + } 198 + 199 + /// Handle calendar navigation (mini calendar component) 200 + pub async fn handle_calendar_navigation( 201 + ctx: UserRequestContext, 202 + Query(params): Query<CalendarNavParams>, 203 + ) -> Result<impl IntoResponse, WebError> { 204 + let _current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 205 + 206 + let year = params.year.unwrap_or_else(|| chrono::Utc::now().year()); 207 + let month = params.month.unwrap_or_else(|| chrono::Utc::now().month() as i32); 208 + 209 + // Create the template renderer with enhanced context 210 + let language_clone = ctx.language.clone(); 211 + let renderer = create_renderer!(ctx.web_context.clone(), language_clone, false, false); 212 + 213 + let template_context = template_context! { 214 + year => year, 215 + month => month, 216 + month_names => vec![ 217 + "January", "February", "March", "April", "May", "June", 218 + "July", "August", "September", "October", "November", "December" 219 + ], 220 + events => Vec::<String>::new(), // TODO: Fetch events for this month 221 + }; 222 + 223 + let html = renderer.render_template( 224 + "mini_calendar", 225 + template_context, 226 + ctx.current_handle.as_ref(), 227 + "/bookmarks/calendar-nav" 228 + ); 229 + Ok(Html(html).into_response()) 230 + } 231 + 232 + /// Handle exporting bookmarks as iCal 233 + pub async fn handle_bookmark_calendar_ical( 234 + ctx: UserRequestContext, 235 + Query(params): Query<CalendarViewParams>, 236 + ) -> Result<impl IntoResponse, WebError> { 237 + let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?; 238 + 239 + let bookmark_service = EventBookmarkService::new( 240 + Arc::new(ctx.web_context.pool.clone()), 241 + Arc::new(ctx.web_context.atrium_oauth_manager.clone()), 242 + ); 243 + 244 + // Parse tags if provided 245 + let tags: Option<Vec<String>> = params.tags.as_ref().map(|tag_str| { 246 + tag_str 247 + .split(',') 248 + .map(|s| s.trim().to_string()) 249 + .filter(|s| !s.is_empty()) 250 + .collect() 251 + }); 252 + 253 + match bookmark_service 254 + .get_bookmarked_events( 255 + &current_handle.did, 256 + tags.as_deref(), 257 + None, 258 + 1000, // Get all events for export 259 + 0, 260 + false, 261 + ) 262 + .await 263 + { 264 + Ok(paginated_events) => { 265 + // Generate iCal content 266 + let ical_content = generate_ical_from_bookmarks(&paginated_events.events); 267 + 268 + let headers = [ 269 + ("Content-Type", "text/calendar; charset=utf-8"), 270 + ("Content-Disposition", "attachment; filename=\"bookmarks.ics\""), 271 + ]; 272 + 273 + Ok((StatusCode::OK, headers, ical_content).into_response()) 274 + } 275 + Err(e) => { 276 + error!("Failed to export bookmarks for user {}: {}", current_handle.did, e); 277 + Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to export calendar".to_string()).into_response()) 278 + } 279 + } 280 + } 281 + 282 + #[derive(Deserialize)] 283 + pub struct CalendarNavParams { 284 + year: Option<i32>, 285 + month: Option<i32>, 286 + } 287 + 288 + /// Generate iCal content from bookmarked events 289 + fn generate_ical_from_bookmarks(bookmarks: &[crate::storage::event_bookmarks::EventBookmark]) -> String { 290 + let mut ical = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//smokesignal//EN\r\n"); 291 + 292 + for bookmark in bookmarks { 293 + ical.push_str(&format!( 294 + "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:Bookmarked Event\r\nDTSTAMP:{}\r\nEND:VEVENT\r\n", 295 + bookmark.id, 296 + bookmark.created_at.format("%Y%m%dT%H%M%SZ") 297 + )); 298 + } 299 + 300 + ical.push_str("END:VCALENDAR\r\n"); 301 + ical 302 + }
+2
src/http/mod.rs
··· 103 103 pub mod handle_ical_event; // New iCal handler 104 104 pub mod handle_view_feed; 105 105 pub mod handle_view_rsvp; 106 + pub mod handle_bookmark_events; 107 + pub mod handle_bookmark_calendars; 106 108 pub mod location_edit_status; 107 109 pub mod macros; 108 110 pub mod middleware_auth;
+23 -1
src/http/server.rs
··· 3 3 use axum::{ 4 4 http::HeaderValue, 5 5 middleware::{from_fn, from_fn_with_state}, 6 - routing::{get, post}, 6 + routing::{delete, get, post, put}, 7 7 Router, 8 8 }; 9 9 use axum_htmx::AutoVaryLayer; ··· 61 61 handle_ical_event::handle_ical_event, 62 62 handle_view_feed::handle_view_feed, 63 63 handle_view_rsvp::handle_view_rsvp, 64 + handle_bookmark_events::{ 65 + handle_bookmark_event, handle_bookmark_calendar, handle_remove_bookmark, 66 + handle_calendar_navigation, handle_bookmark_calendar_ical, 67 + }, 68 + handle_bookmark_calendars::{ 69 + handle_bookmark_calendars_index, handle_create_bookmark_calendar, 70 + handle_view_bookmark_calendar, handle_update_bookmark_calendar, 71 + handle_delete_bookmark_calendar, handle_export_calendar_ical, 72 + }, 64 73 middleware_filter, 65 74 middleware_timezone, 66 75 }; ··· 151 160 .route("/rsvp/{handle_slug}/{rsvp_rkey}", get(handle_view_rsvp)) 152 161 .route("/{handle_slug}/{event_rkey}/ical", get(handle_ical_event)) 153 162 .route("/{handle_slug}/{event_rkey}", get(handle_view_event)) 163 + // Bookmark event routes 164 + .route("/bookmarks", get(handle_bookmark_calendar)) 165 + .route("/bookmarks", post(handle_bookmark_event)) 166 + .route("/bookmarks/:bookmark_aturi", delete(handle_remove_bookmark)) 167 + .route("/bookmarks/calendar-nav", get(handle_calendar_navigation)) 168 + .route("/bookmarks.ics", get(handle_bookmark_calendar_ical)) 169 + // Bookmark calendar management routes 170 + .route("/bookmark-calendars", get(handle_bookmark_calendars_index)) 171 + .route("/bookmark-calendars", post(handle_create_bookmark_calendar)) 172 + .route("/bookmark-calendars/:calendar_id", get(handle_view_bookmark_calendar)) 173 + .route("/bookmark-calendars/:calendar_id", put(handle_update_bookmark_calendar)) 174 + .route("/bookmark-calendars/:calendar_id", delete(handle_delete_bookmark_calendar)) 175 + .route("/bookmark-calendars/:calendar_id.ics", get(handle_export_calendar_ical)) 154 176 .route("/favicon.ico", get(|| async { axum::response::Redirect::permanent("/static/favicon.ico") })) 155 177 .route("/{handle_slug}", get(handle_profile_view)) 156 178 .nest_service("/static", serve_dir.clone())
+137
src/services/event_bookmarks.rs
··· 1 + use anyhow::Result; 2 + use chrono::{DateTime, Utc}; 3 + use std::sync::Arc; 4 + use tracing::{debug, info}; 5 + use uuid::Uuid; 6 + 7 + use crate::atproto::atrium_auth::AtriumOAuthManager; 8 + use crate::storage::{event_bookmarks, StoragePool}; 9 + use crate::storage::event_bookmarks::{EventBookmark, PaginatedBookmarkedEvents}; 10 + 11 + pub struct EventBookmarkService { 12 + storage: Arc<StoragePool>, 13 + #[allow(dead_code)] 14 + oauth_manager: Arc<AtriumOAuthManager>, 15 + } 16 + 17 + impl EventBookmarkService { 18 + pub fn new(storage: Arc<StoragePool>, oauth_manager: Arc<AtriumOAuthManager>) -> Self { 19 + Self { 20 + storage, 21 + oauth_manager, 22 + } 23 + } 24 + 25 + /// Bookmark an event with tags 26 + pub async fn bookmark_event( 27 + &self, 28 + did: &str, 29 + event_aturi: &str, 30 + tags: Vec<String>, 31 + ) -> Result<EventBookmark> { 32 + info!("Creating bookmark for event {} by user {}", event_aturi, did); 33 + 34 + // For now, create a fake bookmark AT-URI since we don't have full ATproto integration yet 35 + let bookmark_aturi = format!("at://{}/community.lexicon.bookmarks.bookmark/{}", did, Uuid::new_v4()); 36 + 37 + // Store locally for performance 38 + let local_bookmark = event_bookmarks::insert( 39 + &self.storage, 40 + did, 41 + &bookmark_aturi, 42 + event_aturi, 43 + &tags, 44 + ).await?; 45 + 46 + info!("Successfully bookmarked event {} with AT-URI {}", event_aturi, bookmark_aturi); 47 + Ok(local_bookmark) 48 + } 49 + 50 + /// Remove a bookmark 51 + pub async fn remove_bookmark( 52 + &self, 53 + did: &str, 54 + bookmark_aturi: &str, 55 + ) -> Result<()> { 56 + info!("Removing bookmark {} for user {}", bookmark_aturi, did); 57 + 58 + // Remove from local cache 59 + event_bookmarks::delete_by_bookmark_aturi(&self.storage, did, bookmark_aturi).await?; 60 + 61 + info!("Successfully removed bookmark {}", bookmark_aturi); 62 + Ok(()) 63 + } 64 + 65 + /// Get bookmarked events with filtering and pagination 66 + pub async fn get_bookmarked_events( 67 + &self, 68 + did: &str, 69 + tags: Option<&[String]>, 70 + date_range: Option<(DateTime<Utc>, DateTime<Utc>)>, 71 + limit: i32, 72 + offset: i32, 73 + _force_sync: bool, 74 + ) -> Result<PaginatedBookmarkedEvents> { 75 + debug!("Getting bookmarked events for user {} with limit {} offset {}", did, limit, offset); 76 + 77 + let (bookmarks, total_count) = event_bookmarks::get_by_filters_paginated( 78 + &self.storage, 79 + did, 80 + tags, 81 + date_range, 82 + limit, 83 + offset, 84 + ).await?; 85 + 86 + let has_more = (offset + limit) < total_count as i32; 87 + 88 + Ok(PaginatedBookmarkedEvents { 89 + events: bookmarks, 90 + total_count, 91 + has_more, 92 + }) 93 + } 94 + 95 + /// Check if a specific event is bookmarked by the user 96 + pub async fn is_event_bookmarked( 97 + &self, 98 + did: &str, 99 + event_aturi: &str, 100 + ) -> Result<Option<EventBookmark>> { 101 + event_bookmarks::get_bookmark_by_event(&self.storage, did, event_aturi) 102 + .await 103 + .map_err(Into::into) 104 + } 105 + 106 + /// Get bookmarks summary statistics 107 + pub async fn get_bookmark_stats(&self, did: &str) -> Result<BookmarkStats> { 108 + let bookmarks = event_bookmarks::get_by_filters(&self.storage, did, None, None).await?; 109 + 110 + let total_count = bookmarks.len() as i32; 111 + let mut tags = std::collections::HashSet::new(); 112 + 113 + for bookmark in &bookmarks { 114 + for tag in &bookmark.tags { 115 + tags.insert(tag.clone()); 116 + } 117 + } 118 + 119 + let unique_tags = tags.len() as i32; 120 + 121 + Ok(BookmarkStats { 122 + total_bookmarks: total_count, 123 + unique_tags, 124 + last_synced: bookmarks 125 + .iter() 126 + .map(|b| b.synced_at) 127 + .max(), 128 + }) 129 + } 130 + } 131 + 132 + #[derive(Debug, Clone)] 133 + pub struct BookmarkStats { 134 + pub total_bookmarks: i32, 135 + pub unique_tags: i32, 136 + pub last_synced: Option<DateTime<Utc>>, 137 + }
+2
src/services/mod.rs
··· 2 2 pub mod address_geocoding_strategies; 3 3 pub mod venues; 4 4 pub mod events; 5 + pub mod event_bookmarks; 5 6 6 7 #[cfg(test)] 7 8 mod nominatim_client_tests; ··· 14 15 handle_venue_nearby, handle_venue_enrich, handle_venue_suggest 15 16 }; 16 17 pub use events::{EventVenueIntegrationService, VenueIntegrationError}; 18 + pub use event_bookmarks::{EventBookmarkService, BookmarkStats};
+181
src/storage/bookmark_calendars.rs
··· 1 + use super::{StoragePool, errors::StorageError}; 2 + use chrono::{DateTime, Utc}; 3 + use sqlx::Row; 4 + use serde::Serialize; 5 + 6 + #[derive(Debug, Clone, Serialize)] 7 + pub struct BookmarkCalendar { 8 + pub id: i32, 9 + pub calendar_id: String, 10 + pub did: String, 11 + pub name: String, 12 + pub description: Option<String>, 13 + pub tags: Vec<String>, 14 + pub tag_operator: String, 15 + pub is_public: bool, 16 + pub event_count: i32, 17 + pub created_at: DateTime<Utc>, 18 + pub updated_at: DateTime<Utc>, 19 + } 20 + 21 + #[derive(Debug, Clone, Serialize)] 22 + pub struct BookmarkCalendarStats { 23 + pub event_count: i32, 24 + pub earliest_event: Option<DateTime<Utc>>, 25 + pub latest_event: Option<DateTime<Utc>>, 26 + } 27 + 28 + pub async fn insert( 29 + pool: &StoragePool, 30 + calendar: &BookmarkCalendar, 31 + ) -> Result<BookmarkCalendar, StorageError> { 32 + let result = sqlx::query( 33 + r#" 34 + INSERT INTO bookmark_calendars (calendar_id, did, name, description, tags, tag_operator, is_public) 35 + VALUES ($1, $2, $3, $4, $5, $6, $7) 36 + RETURNING id, calendar_id, did, name, description, tags, tag_operator, is_public, event_count, created_at, updated_at 37 + "# 38 + ) 39 + .bind(&calendar.calendar_id) 40 + .bind(&calendar.did) 41 + .bind(&calendar.name) 42 + .bind(&calendar.description) 43 + .bind(&calendar.tags) 44 + .bind(&calendar.tag_operator) 45 + .bind(calendar.is_public) 46 + .fetch_one(pool) 47 + .await?; 48 + 49 + Ok(BookmarkCalendar { 50 + id: result.get("id"), 51 + calendar_id: result.get("calendar_id"), 52 + did: result.get("did"), 53 + name: result.get("name"), 54 + description: result.get("description"), 55 + tags: result.get::<Vec<String>, _>("tags"), 56 + tag_operator: result.get("tag_operator"), 57 + is_public: result.get("is_public"), 58 + event_count: result.get("event_count"), 59 + created_at: result.get("created_at"), 60 + updated_at: result.get("updated_at"), 61 + }) 62 + } 63 + 64 + pub async fn get_by_calendar_id( 65 + pool: &StoragePool, 66 + calendar_id: &str, 67 + ) -> Result<Option<BookmarkCalendar>, StorageError> { 68 + let result = sqlx::query( 69 + "SELECT id, calendar_id, did, name, description, tags, tag_operator, is_public, event_count, created_at, updated_at FROM bookmark_calendars WHERE calendar_id = $1" 70 + ) 71 + .bind(calendar_id) 72 + .fetch_optional(pool) 73 + .await?; 74 + 75 + if let Some(row) = result { 76 + Ok(Some(BookmarkCalendar { 77 + id: row.get("id"), 78 + calendar_id: row.get("calendar_id"), 79 + did: row.get("did"), 80 + name: row.get("name"), 81 + description: row.get("description"), 82 + tags: row.get::<Vec<String>, _>("tags"), 83 + tag_operator: row.get("tag_operator"), 84 + is_public: row.get("is_public"), 85 + event_count: row.get("event_count"), 86 + created_at: row.get("created_at"), 87 + updated_at: row.get("updated_at"), 88 + })) 89 + } else { 90 + Ok(None) 91 + } 92 + } 93 + 94 + // Helper function to generate a unique calendar_id 95 + pub fn generate_calendar_id() -> String { 96 + use rand::Rng; 97 + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 98 + let mut rng = rand::thread_rng(); 99 + (0..12) 100 + .map(|_| { 101 + let idx = rng.gen_range(0..CHARSET.len()); 102 + CHARSET[idx] as char 103 + }) 104 + .collect() 105 + } 106 + 107 + // Simplified implementation - we'll implement full functionality later 108 + pub async fn get_by_user( 109 + _pool: &StoragePool, 110 + _did: &str, 111 + _include_private: bool, 112 + ) -> Result<Vec<BookmarkCalendar>, StorageError> { 113 + // For now, return empty vector to allow compilation 114 + Ok(Vec::new()) 115 + } 116 + 117 + pub async fn get_public_paginated( 118 + _pool: &StoragePool, 119 + _limit: i32, 120 + _offset: i32, 121 + ) -> Result<(Vec<BookmarkCalendar>, i64), StorageError> { 122 + // For now, return empty results to allow compilation 123 + Ok((Vec::new(), 0)) 124 + } 125 + 126 + pub async fn update( 127 + pool: &StoragePool, 128 + calendar: &BookmarkCalendar, 129 + ) -> Result<(), StorageError> { 130 + sqlx::query( 131 + "UPDATE bookmark_calendars SET name = $1, description = $2, tags = $3, tag_operator = $4, is_public = $5, updated_at = NOW() WHERE calendar_id = $6 AND did = $7" 132 + ) 133 + .bind(&calendar.name) 134 + .bind(&calendar.description) 135 + .bind(&calendar.tags) 136 + .bind(&calendar.tag_operator) 137 + .bind(calendar.is_public) 138 + .bind(&calendar.calendar_id) 139 + .bind(&calendar.did) 140 + .execute(pool) 141 + .await?; 142 + Ok(()) 143 + } 144 + 145 + pub async fn delete( 146 + pool: &StoragePool, 147 + calendar_id: &str, 148 + did: &str, 149 + ) -> Result<(), StorageError> { 150 + sqlx::query("DELETE FROM bookmark_calendars WHERE calendar_id = $1 AND did = $2") 151 + .bind(calendar_id) 152 + .bind(did) 153 + .execute(pool) 154 + .await?; 155 + Ok(()) 156 + } 157 + 158 + pub async fn update_event_count( 159 + pool: &StoragePool, 160 + calendar_id: i32, 161 + increment: i32, 162 + ) -> Result<(), StorageError> { 163 + sqlx::query("UPDATE bookmark_calendars SET event_count = GREATEST(0, event_count + $1), updated_at = NOW() WHERE id = $2") 164 + .bind(increment) 165 + .bind(calendar_id) 166 + .execute(pool) 167 + .await?; 168 + Ok(()) 169 + } 170 + 171 + pub async fn get_calendar_stats( 172 + _pool: &StoragePool, 173 + _calendar_id: i32, 174 + ) -> Result<BookmarkCalendarStats, StorageError> { 175 + // Simplified implementation for now 176 + Ok(BookmarkCalendarStats { 177 + event_count: 0, 178 + earliest_event: None, 179 + latest_event: None, 180 + }) 181 + }
+6
src/storage/errors.rs
··· 134 134 OAuthModelError(#[from] OAuthModelError), 135 135 } 136 136 137 + impl From<sqlx::Error> for StorageError { 138 + fn from(err: sqlx::Error) -> Self { 139 + StorageError::UnableToExecuteQuery(err) 140 + } 141 + } 142 + 137 143 /// Represents errors that can occur during cache operations. 138 144 #[derive(Debug, Error)] 139 145 pub enum CacheError {
+247
src/storage/event_bookmarks.rs
··· 1 + use super::{StoragePool, errors::StorageError}; 2 + use chrono::{DateTime, Utc, Duration}; 3 + use crate::atproto::lexicon::community::lexicon::bookmarks::BookmarkRecord; 4 + use sqlx::Row; 5 + use serde::Serialize; 6 + 7 + #[derive(Debug, Clone, Serialize)] 8 + pub struct EventBookmark { 9 + pub id: i32, 10 + pub did: String, 11 + pub bookmark_aturi: String, 12 + pub event_aturi: String, 13 + pub tags: Vec<String>, 14 + pub synced_at: DateTime<Utc>, 15 + pub created_at: DateTime<Utc>, 16 + } 17 + 18 + #[derive(Debug, Clone, Serialize)] 19 + pub struct BookmarkedEvent { 20 + pub bookmark: EventBookmark, 21 + pub event: super::event::model::Event, 22 + } 23 + 24 + #[derive(Debug, Clone, Serialize)] 25 + pub struct PaginatedBookmarkedEvents { 26 + pub events: Vec<EventBookmark>, 27 + pub total_count: i64, 28 + pub has_more: bool, 29 + } 30 + 31 + pub async fn insert( 32 + pool: &StoragePool, 33 + did: &str, 34 + bookmark_aturi: &str, 35 + event_aturi: &str, 36 + tags: &[String], 37 + ) -> Result<EventBookmark, StorageError> { 38 + let result = sqlx::query( 39 + r#" 40 + INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags) 41 + VALUES ($1, $2, $3, $4) 42 + RETURNING id, did, bookmark_aturi, event_aturi, tags, synced_at, created_at 43 + "# 44 + ) 45 + .bind(did) 46 + .bind(bookmark_aturi) 47 + .bind(event_aturi) 48 + .bind(tags) 49 + .fetch_one(pool) 50 + .await?; 51 + 52 + Ok(EventBookmark { 53 + id: result.get("id"), 54 + did: result.get("did"), 55 + bookmark_aturi: result.get("bookmark_aturi"), 56 + event_aturi: result.get("event_aturi"), 57 + tags: result.get::<Vec<String>, _>("tags"), 58 + synced_at: result.get("synced_at"), 59 + created_at: result.get("created_at"), 60 + }) 61 + } 62 + 63 + pub async fn sync_from_atproto( 64 + pool: &StoragePool, 65 + did: &str, 66 + bookmark_records: &[BookmarkRecord], 67 + ) -> Result<(), StorageError> { 68 + let mut tx = pool.begin().await?; 69 + 70 + // Clear existing bookmarks for this user before syncing 71 + sqlx::query("DELETE FROM event_bookmarks WHERE did = $1") 72 + .bind(did) 73 + .execute(&mut *tx) 74 + .await?; 75 + 76 + // Insert all bookmarks from ATproto 77 + for record in bookmark_records { 78 + sqlx::query( 79 + r#" 80 + INSERT INTO event_bookmarks (did, bookmark_aturi, event_aturi, tags, synced_at) 81 + VALUES ($1, $2, $3, $4, NOW()) 82 + ON CONFLICT (did, event_aturi) DO UPDATE SET 83 + bookmark_aturi = EXCLUDED.bookmark_aturi, 84 + tags = EXCLUDED.tags, 85 + synced_at = NOW() 86 + "# 87 + ) 88 + .bind(did) 89 + .bind(&record.uri) 90 + .bind(&record.value.subject) 91 + .bind(&record.value.tags) 92 + .execute(&mut *tx) 93 + .await?; 94 + } 95 + 96 + tx.commit().await?; 97 + Ok(()) 98 + } 99 + 100 + pub async fn get_by_filters_paginated( 101 + pool: &StoragePool, 102 + did: &str, 103 + tags: Option<&[String]>, 104 + _date_range: Option<(DateTime<Utc>, DateTime<Utc>)>, 105 + limit: i32, 106 + offset: i32, 107 + ) -> Result<(Vec<EventBookmark>, i64), StorageError> { 108 + // For now, let's return just bookmarks without joining with events 109 + // This can be enhanced later with proper event hydration 110 + 111 + let mut where_clauses = vec!["did = $1".to_string()]; 112 + let mut param_count = 1; 113 + 114 + // Add tag filtering 115 + if let Some(tag_list) = tags { 116 + if !tag_list.is_empty() { 117 + param_count += 1; 118 + where_clauses.push(format!("tags && ${}", param_count)); 119 + } 120 + } 121 + 122 + let where_clause = where_clauses.join(" AND "); 123 + 124 + // Get total count 125 + let count_query = format!("SELECT COUNT(*) FROM event_bookmarks WHERE {}", where_clause); 126 + 127 + // Get paginated results 128 + let events_query = format!( 129 + "SELECT id, did, bookmark_aturi, event_aturi, tags, synced_at, created_at FROM event_bookmarks WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}", 130 + where_clause, 131 + param_count + 1, 132 + param_count + 2 133 + ); 134 + 135 + // Execute count query 136 + let mut count_query_builder = sqlx::query_scalar(&count_query); 137 + count_query_builder = count_query_builder.bind(did); 138 + 139 + if let Some(tag_list) = tags { 140 + if !tag_list.is_empty() { 141 + count_query_builder = count_query_builder.bind(tag_list); 142 + } 143 + } 144 + 145 + let total_count: i64 = count_query_builder.fetch_one(pool).await?; 146 + 147 + // Execute events query 148 + let mut events_query_builder = sqlx::query(&events_query); 149 + events_query_builder = events_query_builder.bind(did); 150 + 151 + if let Some(tag_list) = tags { 152 + if !tag_list.is_empty() { 153 + events_query_builder = events_query_builder.bind(tag_list); 154 + } 155 + } 156 + 157 + events_query_builder = events_query_builder.bind(limit); 158 + events_query_builder = events_query_builder.bind(offset); 159 + 160 + let rows = events_query_builder.fetch_all(pool).await?; 161 + 162 + let mut bookmarks = Vec::new(); 163 + for row in rows { 164 + let bookmark = EventBookmark { 165 + id: row.get("id"), 166 + did: row.get("did"), 167 + bookmark_aturi: row.get("bookmark_aturi"), 168 + event_aturi: row.get("event_aturi"), 169 + tags: row.get::<Vec<String>, _>("tags"), 170 + synced_at: row.get("synced_at"), 171 + created_at: row.get("created_at"), 172 + }; 173 + 174 + bookmarks.push(bookmark); 175 + } 176 + 177 + Ok((bookmarks, total_count)) 178 + } 179 + 180 + pub async fn get_by_filters( 181 + pool: &StoragePool, 182 + did: &str, 183 + tags: Option<&[String]>, 184 + date_range: Option<(DateTime<Utc>, DateTime<Utc>)>, 185 + ) -> Result<Vec<EventBookmark>, StorageError> { 186 + let (bookmarks, _) = get_by_filters_paginated(pool, did, tags, date_range, 1000, 0).await?; 187 + Ok(bookmarks) 188 + } 189 + 190 + pub async fn delete_by_bookmark_aturi( 191 + pool: &StoragePool, 192 + did: &str, 193 + bookmark_aturi: &str, 194 + ) -> Result<(), StorageError> { 195 + sqlx::query("DELETE FROM event_bookmarks WHERE did = $1 AND bookmark_aturi = $2") 196 + .bind(did) 197 + .bind(bookmark_aturi) 198 + .execute(pool) 199 + .await?; 200 + Ok(()) 201 + } 202 + 203 + pub async fn is_cache_stale( 204 + pool: &StoragePool, 205 + did: &str, 206 + max_age: Duration, 207 + ) -> Result<bool, StorageError> { 208 + let cutoff = Utc::now() - max_age; 209 + 210 + let result: i64 = sqlx::query_scalar( 211 + "SELECT COUNT(*) FROM event_bookmarks WHERE did = $1 AND synced_at > $2" 212 + ) 213 + .bind(did) 214 + .bind(cutoff) 215 + .fetch_one(pool) 216 + .await?; 217 + 218 + Ok(result == 0) 219 + } 220 + 221 + pub async fn get_bookmark_by_event( 222 + pool: &StoragePool, 223 + did: &str, 224 + event_aturi: &str, 225 + ) -> Result<Option<EventBookmark>, StorageError> { 226 + let result = sqlx::query( 227 + "SELECT id, did, bookmark_aturi, event_aturi, tags, synced_at, created_at FROM event_bookmarks WHERE did = $1 AND event_aturi = $2" 228 + ) 229 + .bind(did) 230 + .bind(event_aturi) 231 + .fetch_optional(pool) 232 + .await?; 233 + 234 + if let Some(row) = result { 235 + Ok(Some(EventBookmark { 236 + id: row.get("id"), 237 + did: row.get("did"), 238 + bookmark_aturi: row.get("bookmark_aturi"), 239 + event_aturi: row.get("event_aturi"), 240 + tags: row.get::<Vec<String>, _>("tags"), 241 + synced_at: row.get("synced_at"), 242 + created_at: row.get("created_at"), 243 + })) 244 + } else { 245 + Ok(None) 246 + } 247 + }
+2
src/storage/mod.rs
··· 72 72 //! } 73 73 //! ``` 74 74 75 + pub mod bookmark_calendars; 75 76 pub mod cache; 76 77 pub mod denylist; 77 78 pub mod errors; 78 79 pub mod event; 80 + pub mod event_bookmarks; 79 81 pub mod handle; 80 82 pub mod oauth; 81 83 pub mod types;
+226
static/bookmark-calendars.js
··· 1 + // Bookmark Calendar JavaScript 2 + let calendarTags = []; 3 + 4 + // Initialize page 5 + document.addEventListener('DOMContentLoaded', function() { 6 + // Initialize tag input handling 7 + const tagInput = document.getElementById('new-calendar-tag-input'); 8 + if (tagInput) { 9 + tagInput.addEventListener('keydown', handleTagInput); 10 + } 11 + }); 12 + 13 + // Handle tag input for calendar creation 14 + function handleTagInput(event) { 15 + if (event.key === 'Enter' || event.key === ',') { 16 + event.preventDefault(); 17 + addCalendarTag(); 18 + } 19 + } 20 + 21 + // Add a tag to the calendar 22 + function addCalendarTag() { 23 + const input = document.getElementById('new-calendar-tag-input'); 24 + if (!input) return; 25 + 26 + const tag = input.value.trim(); 27 + if (tag && !calendarTags.includes(tag)) { 28 + if (calendarTags.length >= 10) { 29 + alert('Maximum 10 tags allowed per calendar'); 30 + return; 31 + } 32 + 33 + if (tag.length > 50) { 34 + alert('Tag too long (maximum 50 characters)'); 35 + return; 36 + } 37 + 38 + calendarTags.push(tag); 39 + input.value = ''; 40 + renderCalendarTags(); 41 + updateTagOperatorVisibility(); 42 + } 43 + } 44 + 45 + // Remove a tag from the calendar 46 + function removeCalendarTag(tag) { 47 + calendarTags = calendarTags.filter(t => t !== tag); 48 + renderCalendarTags(); 49 + updateTagOperatorVisibility(); 50 + } 51 + 52 + // Render the selected tags 53 + function renderCalendarTags() { 54 + const container = document.getElementById('selected-calendar-tags'); 55 + if (!container) return; 56 + 57 + container.innerHTML = calendarTags.map(tag => ` 58 + <span class="tag is-primary"> 59 + ${tag} 60 + <button class="delete is-small" onclick="removeCalendarTag('${tag}')"></button> 61 + </span> 62 + `).join(''); 63 + } 64 + 65 + // Show/hide tag operator selection based on number of tags 66 + function updateTagOperatorVisibility() { 67 + const operatorField = document.getElementById('tag-operator-field'); 68 + if (!operatorField) return; 69 + 70 + if (calendarTags.length > 1) { 71 + operatorField.style.display = 'block'; 72 + } else { 73 + operatorField.style.display = 'none'; 74 + } 75 + } 76 + 77 + // Submit calendar form 78 + function submitCalendarForm() { 79 + const form = document.getElementById('create-calendar-form'); 80 + if (!form) return; 81 + 82 + const formData = new FormData(form); 83 + 84 + // Add tags to form data 85 + const tagsInput = document.createElement('input'); 86 + tagsInput.type = 'hidden'; 87 + tagsInput.name = 'tags'; 88 + tagsInput.value = JSON.stringify(calendarTags); 89 + form.appendChild(tagsInput); 90 + 91 + // Submit via HTMX 92 + htmx.trigger(form, 'submit'); 93 + } 94 + 95 + // Smart calendar suggestions based on existing tags 96 + function suggestCalendarTags(eventTags) { 97 + const suggestions = document.getElementById('tag-suggestions'); 98 + if (!suggestions || !eventTags) return; 99 + 100 + const suggestedTags = eventTags.filter(tag => !calendarTags.includes(tag)); 101 + 102 + suggestions.innerHTML = suggestedTags.map(tag => ` 103 + <button class="button is-small is-light" onclick="addSuggestedTag('${tag}')"> 104 + ${tag} 105 + </button> 106 + `).join(''); 107 + } 108 + 109 + // Add a suggested tag 110 + function addSuggestedTag(tag) { 111 + if (!calendarTags.includes(tag) && calendarTags.length < 10) { 112 + calendarTags.push(tag); 113 + renderCalendarTags(); 114 + updateTagOperatorVisibility(); 115 + 116 + // Update suggestions 117 + const allTags = Array.from(document.querySelectorAll('.tag')).map(el => el.textContent.trim()); 118 + suggestCalendarTags(allTags); 119 + } 120 + } 121 + 122 + // Filter timeline by tags 123 + function filterTimelineByTags() { 124 + const tagsInput = document.getElementById('filter-tags'); 125 + if (!tagsInput) return; 126 + 127 + const tags = tagsInput.value; 128 + const url = `/bookmarks?tags=${encodeURIComponent(tags)}`; 129 + 130 + htmx.ajax('GET', url, { 131 + target: '#timeline-content', 132 + swap: 'innerHTML' 133 + }); 134 + } 135 + 136 + // Handle bookmark form submission 137 + function bookmarkEvent(eventAturi) { 138 + const tagsInput = document.getElementById('bookmark-tags-input'); 139 + const tags = tagsInput ? tagsInput.value : ''; 140 + 141 + const url = `/bookmarks?event_aturi=${encodeURIComponent(eventAturi)}&tags=${encodeURIComponent(tags)}`; 142 + 143 + htmx.ajax('POST', url, { 144 + target: '#bookmark-feedback', 145 + swap: 'innerHTML' 146 + }); 147 + } 148 + 149 + // Calendar navigation 150 + function navigateCalendar(year, month) { 151 + const url = `/bookmarks/calendar-nav?year=${year}&month=${month}`; 152 + 153 + htmx.ajax('GET', url, { 154 + target: '#mini-calendar', 155 + swap: 'outerHTML' 156 + }); 157 + } 158 + 159 + // Toggle view mode 160 + function toggleViewMode(mode) { 161 + const url = `/bookmarks?view=${mode}`; 162 + 163 + htmx.ajax('GET', url, { 164 + target: '#timeline-content', 165 + swap: 'innerHTML' 166 + }); 167 + 168 + // Update button states 169 + document.querySelectorAll('.view-toggle').forEach(btn => { 170 + btn.classList.remove('is-primary'); 171 + }); 172 + document.querySelector(`.view-toggle[data-mode="${mode}"]`).classList.add('is-primary'); 173 + } 174 + 175 + // Export calendar 176 + function exportCalendar(calendarId) { 177 + const url = calendarId ? 178 + `/bookmark-calendars/${calendarId}.ics` : 179 + `/bookmarks.ics`; 180 + 181 + window.open(url, '_blank'); 182 + } 183 + 184 + // Modal management 185 + function openCalendarModal() { 186 + const modal = document.getElementById('create-calendar-modal'); 187 + if (modal) { 188 + modal.classList.add('is-active'); 189 + calendarTags = []; // Reset tags 190 + renderCalendarTags(); 191 + updateTagOperatorVisibility(); 192 + } 193 + } 194 + 195 + function closeCalendarModal() { 196 + const modal = document.getElementById('create-calendar-modal'); 197 + if (modal) { 198 + modal.classList.remove('is-active'); 199 + } 200 + } 201 + 202 + // Handle HTMX events 203 + document.addEventListener('htmx:afterSwap', function(event) { 204 + // Re-initialize any new tag inputs after HTMX swaps 205 + const tagInput = document.getElementById('new-calendar-tag-input'); 206 + if (tagInput) { 207 + tagInput.addEventListener('keydown', handleTagInput); 208 + } 209 + }); 210 + 211 + // Keyboard shortcuts 212 + document.addEventListener('keydown', function(event) { 213 + // Escape to close modals 214 + if (event.key === 'Escape') { 215 + closeCalendarModal(); 216 + } 217 + 218 + // Ctrl+B to bookmark current event (if on event page) 219 + if (event.ctrlKey && event.key === 'b') { 220 + const bookmarkBtn = document.getElementById('bookmark-event-btn'); 221 + if (bookmarkBtn) { 222 + event.preventDefault(); 223 + bookmarkBtn.click(); 224 + } 225 + } 226 + });
+94
templates/bookmark_calendar_timeline.en-us.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}{{ t("bookmark-calendar") }}{% endblock %} 4 + 5 + {% block content %} 6 + <div class="bookmark-calendar-container"> 7 + <div class="columns"> 8 + <div class="column is-3"> 9 + <!-- Mini Calendar Widget --> 10 + <div class="box"> 11 + <h4 class="title is-5">{{ t("calendar-navigation") }}</h4> 12 + <div id="mini-calendar" hx-get="/bookmarks/calendar-nav" hx-trigger="load"> 13 + <!-- Calendar will be loaded here --> 14 + </div> 15 + </div> 16 + 17 + <!-- Filter Controls --> 18 + <div class="box"> 19 + <h4 class="title is-5">{{ t("filter-by-tags") }}</h4> 20 + <div class="field"> 21 + <div class="control"> 22 + <input class="input" type="text" placeholder="{{ t('bookmark-tags') }}" 23 + id="filter-tags" value="{{ filter_tags }}"> 24 + </div> 25 + </div> 26 + <div class="field"> 27 + <div class="control"> 28 + <button class="button is-primary" 29 + hx-get="/bookmarks" 30 + hx-include="#filter-tags" 31 + hx-target="#timeline-content"> 32 + {{ t("filter-events") }} 33 + </button> 34 + </div> 35 + </div> 36 + </div> 37 + 38 + <!-- My Calendars --> 39 + <div class="box"> 40 + <h4 class="title is-5">{{ t("my-calendars") }}</h4> 41 + <div class="buttons"> 42 + <button class="button is-success is-small" 43 + hx-get="/bookmark-calendars/modal" 44 + hx-target="#modal-container"> 45 + {{ t("create-new-calendar") }} 46 + </button> 47 + </div> 48 + <div id="user-calendars" hx-get="/bookmark-calendars" hx-trigger="load"> 49 + <!-- User calendars will be loaded here --> 50 + </div> 51 + </div> 52 + </div> 53 + 54 + <div class="column is-9"> 55 + <!-- View Mode Toggle --> 56 + <div class="level"> 57 + <div class="level-left"> 58 + <div class="level-item"> 59 + <h2 class="title is-3">{{ t("bookmarked-events") }}</h2> 60 + </div> 61 + </div> 62 + <div class="level-right"> 63 + <div class="level-item"> 64 + <div class="buttons"> 65 + <button class="button {% if view_mode == 'timeline' %}is-primary{% endif %}" 66 + hx-get="/bookmarks?view=timeline" 67 + hx-target="#timeline-content"> 68 + {{ t("timeline-view") }} 69 + </button> 70 + <button class="button {% if view_mode == 'calendar' %}is-primary{% endif %}" 71 + hx-get="/bookmarks?view=calendar" 72 + hx-target="#timeline-content"> 73 + {{ t("calendar-view") }} 74 + </button> 75 + </div> 76 + </div> 77 + </div> 78 + </div> 79 + 80 + <!-- Timeline Content --> 81 + <div id="timeline-content"> 82 + {% include "bookmark_timeline.partial.html" %} 83 + </div> 84 + </div> 85 + </div> 86 + </div> 87 + 88 + <!-- Modal Container --> 89 + <div id="modal-container"></div> 90 + {% endblock %} 91 + 92 + {% block scripts %} 93 + <script src="/static/bookmark-calendars.js"></script> 94 + {% endblock %}
+94
templates/bookmark_calendar_timeline.fr-ca.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block title %}{{ t("bookmark-calendar") }}{% endblock %} 4 + 5 + {% block content %} 6 + <div class="bookmark-calendar-container"> 7 + <div class="columns"> 8 + <div class="column is-3"> 9 + <!-- Mini Calendar Widget --> 10 + <div class="box"> 11 + <h4 class="title is-5">{{ t("calendar-navigation") }}</h4> 12 + <div id="mini-calendar" hx-get="/bookmarks/calendar-nav" hx-trigger="load"> 13 + <!-- Le calendrier sera chargé ici --> 14 + </div> 15 + </div> 16 + 17 + <!-- Contrôles de filtrage --> 18 + <div class="box"> 19 + <h4 class="title is-5">{{ t("filter-by-tags") }}</h4> 20 + <div class="field"> 21 + <div class="control"> 22 + <input class="input" type="text" placeholder="{{ t('bookmark-tags') }}" 23 + id="filter-tags" value="{{ filter_tags }}"> 24 + </div> 25 + </div> 26 + <div class="field"> 27 + <div class="control"> 28 + <button class="button is-primary" 29 + hx-get="/bookmarks" 30 + hx-include="#filter-tags" 31 + hx-target="#timeline-content"> 32 + {{ t("filter-events") }} 33 + </button> 34 + </div> 35 + </div> 36 + </div> 37 + 38 + <!-- Mes calendriers --> 39 + <div class="box"> 40 + <h4 class="title is-5">{{ t("my-calendars") }}</h4> 41 + <div class="buttons"> 42 + <button class="button is-success is-small" 43 + hx-get="/bookmark-calendars/modal" 44 + hx-target="#modal-container"> 45 + {{ t("create-new-calendar") }} 46 + </button> 47 + </div> 48 + <div id="user-calendars" hx-get="/bookmark-calendars" hx-trigger="load"> 49 + <!-- Les calendriers utilisateur seront chargés ici --> 50 + </div> 51 + </div> 52 + </div> 53 + 54 + <div class="column is-9"> 55 + <!-- Basculement de mode d'affichage --> 56 + <div class="level"> 57 + <div class="level-left"> 58 + <div class="level-item"> 59 + <h2 class="title is-3">{{ t("bookmarked-events") }}</h2> 60 + </div> 61 + </div> 62 + <div class="level-right"> 63 + <div class="level-item"> 64 + <div class="buttons"> 65 + <button class="button {% if view_mode == 'timeline' %}is-primary{% endif %}" 66 + hx-get="/bookmarks?view=timeline" 67 + hx-target="#timeline-content"> 68 + {{ t("timeline-view") }} 69 + </button> 70 + <button class="button {% if view_mode == 'calendar' %}is-primary{% endif %}" 71 + hx-get="/bookmarks?view=calendar" 72 + hx-target="#timeline-content"> 73 + {{ t("calendar-view") }} 74 + </button> 75 + </div> 76 + </div> 77 + </div> 78 + </div> 79 + 80 + <!-- Contenu de la chronologie --> 81 + <div id="timeline-content"> 82 + {% include "bookmark_timeline.partial.html" %} 83 + </div> 84 + </div> 85 + </div> 86 + </div> 87 + 88 + <!-- Conteneur modal --> 89 + <div id="modal-container"></div> 90 + {% endblock %} 91 + 92 + {% block scripts %} 93 + <script src="/static/bookmark-calendars.js"></script> 94 + {% endblock %}
+4
templates/bookmark_success.en-us.partial.html
··· 1 + <div class="notification is-success"> 2 + <button class="delete"></button> 3 + <p>{{ t("bookmark-success") }}</p> 4 + </div>
+4
templates/bookmark_success.fr-ca.partial.html
··· 1 + <div class="notification is-success"> 2 + <button class="delete"></button> 3 + <p>{{ t("bookmark-success") }}</p> 4 + </div>
+89
templates/bookmark_timeline.en-us.partial.html
··· 1 + <div class="timeline"> 2 + {% if events %} 3 + {% for bookmarked_event in events %} 4 + <div class="timeline-item" id="bookmark-{{ bookmarked_event.bookmark.id }}"> 5 + <div class="box"> 6 + <div class="level"> 7 + <div class="level-left"> 8 + <div class="level-item"> 9 + <div> 10 + <h4 class="title is-5"> 11 + <a href="/{{ bookmarked_event.event.handle }}/{{ bookmarked_event.event.rkey }}"> 12 + {{ bookmarked_event.event.name }} 13 + </a> 14 + </h4> 15 + <p class="subtitle is-6"> 16 + {{ t("bookmarked-on", date=bookmarked_event.bookmark.created_at|date) }} 17 + </p> 18 + </div> 19 + </div> 20 + </div> 21 + <div class="level-right"> 22 + <div class="level-item"> 23 + <button class="button is-danger is-small" 24 + hx-delete="/bookmarks/{{ bookmarked_event.bookmark.bookmark_aturi|urlencode }}" 25 + hx-target="#bookmark-{{ bookmarked_event.bookmark.id }}" 26 + hx-swap="outerHTML" 27 + hx-confirm="{{ t('confirm-remove-bookmark') }}"> 28 + {{ t("remove-bookmark") }} 29 + </button> 30 + </div> 31 + </div> 32 + </div> 33 + 34 + <!-- Event details --> 35 + {% if bookmarked_event.event.start_time %} 36 + <p class="has-text-grey"> 37 + <i class="fas fa-clock"></i> 38 + {{ bookmarked_event.event.start_time|date }} 39 + {% if bookmarked_event.event.end_time %} 40 + - {{ t("ends-at", time=bookmarked_event.event.end_time|time) }} 41 + {% endif %} 42 + </p> 43 + {% endif %} 44 + 45 + {% if bookmarked_event.event.location %} 46 + <p class="has-text-grey"> 47 + <i class="fas fa-map-marker-alt"></i> 48 + {{ bookmarked_event.event.location }} 49 + </p> 50 + {% endif %} 51 + 52 + <!-- Tags --> 53 + {% if bookmarked_event.bookmark.tags %} 54 + <div class="tags"> 55 + {% for tag in bookmarked_event.bookmark.tags %} 56 + <span class="tag is-light">{{ tag }}</span> 57 + {% endfor %} 58 + </div> 59 + {% endif %} 60 + 61 + <!-- RSVP status if available --> 62 + {% if bookmarked_event.event.user_rsvp_status %} 63 + <div class="tags"> 64 + <span class="tag is-primary"> 65 + {{ t("rsvp-status-" + bookmarked_event.event.user_rsvp_status) }} 66 + </span> 67 + </div> 68 + {% endif %} 69 + </div> 70 + </div> 71 + {% endfor %} 72 + 73 + <!-- Pagination --> 74 + {% if has_more %} 75 + <div class="has-text-centered"> 76 + <button class="button is-primary" 77 + hx-get="/bookmarks?offset={{ current_offset + events|length }}&view={{ view_mode }}" 78 + hx-target="#timeline-content" 79 + hx-swap="beforeend"> 80 + {{ t("load-more") }} 81 + </button> 82 + </div> 83 + {% endif %} 84 + {% else %} 85 + <div class="notification is-info"> 86 + <p>{{ t("no-bookmarked-events") }}</p> 87 + </div> 88 + {% endif %} 89 + </div>
+89
templates/bookmark_timeline.fr-ca.partial.html
··· 1 + <div class="timeline"> 2 + {% if events %} 3 + {% for bookmarked_event in events %} 4 + <div class="timeline-item" id="bookmark-{{ bookmarked_event.bookmark.id }}"> 5 + <div class="box"> 6 + <div class="level"> 7 + <div class="level-left"> 8 + <div class="level-item"> 9 + <div> 10 + <h4 class="title is-5"> 11 + <a href="/{{ bookmarked_event.event.handle }}/{{ bookmarked_event.event.rkey }}"> 12 + {{ bookmarked_event.event.name }} 13 + </a> 14 + </h4> 15 + <p class="subtitle is-6"> 16 + {{ t("bookmarked-on", date=bookmarked_event.bookmark.created_at|date) }} 17 + </p> 18 + </div> 19 + </div> 20 + </div> 21 + <div class="level-right"> 22 + <div class="level-item"> 23 + <button class="button is-danger is-small" 24 + hx-delete="/bookmarks/{{ bookmarked_event.bookmark.bookmark_aturi|urlencode }}" 25 + hx-target="#bookmark-{{ bookmarked_event.bookmark.id }}" 26 + hx-swap="outerHTML" 27 + hx-confirm="{{ t('confirm-remove-bookmark') }}"> 28 + {{ t("remove-bookmark") }} 29 + </button> 30 + </div> 31 + </div> 32 + </div> 33 + 34 + <!-- Détails de l'événement --> 35 + {% if bookmarked_event.event.start_time %} 36 + <p class="has-text-grey"> 37 + <i class="fas fa-clock"></i> 38 + {{ bookmarked_event.event.start_time|date }} 39 + {% if bookmarked_event.event.end_time %} 40 + - {{ t("ends-at", time=bookmarked_event.event.end_time|time) }} 41 + {% endif %} 42 + </p> 43 + {% endif %} 44 + 45 + {% if bookmarked_event.event.location %} 46 + <p class="has-text-grey"> 47 + <i class="fas fa-map-marker-alt"></i> 48 + {{ bookmarked_event.event.location }} 49 + </p> 50 + {% endif %} 51 + 52 + <!-- Étiquettes --> 53 + {% if bookmarked_event.bookmark.tags %} 54 + <div class="tags"> 55 + {% for tag in bookmarked_event.bookmark.tags %} 56 + <span class="tag is-light">{{ tag }}</span> 57 + {% endfor %} 58 + </div> 59 + {% endif %} 60 + 61 + <!-- Statut RSVP si disponible --> 62 + {% if bookmarked_event.event.user_rsvp_status %} 63 + <div class="tags"> 64 + <span class="tag is-primary"> 65 + {{ t("rsvp-status-" + bookmarked_event.event.user_rsvp_status) }} 66 + </span> 67 + </div> 68 + {% endif %} 69 + </div> 70 + </div> 71 + {% endfor %} 72 + 73 + <!-- Pagination --> 74 + {% if has_more %} 75 + <div class="has-text-centered"> 76 + <button class="button is-primary" 77 + hx-get="/bookmarks?offset={{ current_offset + events|length }}&view={{ view_mode }}" 78 + hx-target="#timeline-content" 79 + hx-swap="beforeend"> 80 + {{ t("load-more") }} 81 + </button> 82 + </div> 83 + {% endif %} 84 + {% else %} 85 + <div class="notification is-info"> 86 + <p>{{ t("no-bookmarked-events") }}</p> 87 + </div> 88 + {% endif %} 89 + </div>
+71
templates/mini_calendar.html
··· 1 + <div class="calendar-mini"> 2 + <div class="calendar-nav"> 3 + <button class="button is-small" 4 + hx-get="/bookmarks/calendar-nav?year={{ year }}&month={{ month - 1 }}" 5 + hx-target="#mini-calendar"> 6 + <i class="fas fa-chevron-left"></i> 7 + </button> 8 + <span class="calendar-month-year"> 9 + {{ month_names[month - 1] }} {{ year }} 10 + </span> 11 + <button class="button is-small" 12 + hx-get="/bookmarks/calendar-nav?year={{ year }}&month={{ month + 1 }}" 13 + hx-target="#mini-calendar"> 14 + <i class="fas fa-chevron-right"></i> 15 + </button> 16 + </div> 17 + 18 + <table class="table is-narrow is-fullwidth"> 19 + <thead> 20 + <tr> 21 + <th>Mo</th> 22 + <th>Tu</th> 23 + <th>We</th> 24 + <th>Th</th> 25 + <th>Fr</th> 26 + <th>Sa</th> 27 + <th>Su</th> 28 + </tr> 29 + </thead> 30 + <tbody> 31 + {% set month_start = date(year, month, 1) %} 32 + {% set start_weekday = month_start.weekday() %} 33 + {% set days_in_month = month_start.days_in_month() %} 34 + 35 + {% set current_day = 1 %} 36 + {% set week_count = 0 %} 37 + 38 + {% for week in range(6) %} 39 + {% if current_day <= days_in_month %} 40 + <tr> 41 + {% for day_of_week in range(7) %} 42 + {% if week == 0 and day_of_week < start_weekday %} 43 + <td></td> 44 + {% elif current_day <= days_in_month %} 45 + {% set has_events = false %} 46 + {% for event in events %} 47 + {% set event_date = event.event.start_time|date('Y-m-d') %} 48 + {% set current_date = year ~ '-' ~ month|string|pad(2, '0') ~ '-' ~ current_day|string|pad(2, '0') %} 49 + {% if event_date == current_date %} 50 + {% set has_events = true %} 51 + {% endif %} 52 + {% endfor %} 53 + 54 + <td class="{% if has_events %}has-background-primary-light{% endif %}"> 55 + <button class="button is-small is-white {% if has_events %}has-text-primary{% endif %}" 56 + hx-get="/bookmarks?start_date={{ year }}-{{ month|string|pad(2, '0') }}-{{ current_day|string|pad(2, '0') }}&end_date={{ year }}-{{ month|string|pad(2, '0') }}-{{ current_day|string|pad(2, '0') }}" 57 + hx-target="#timeline-content"> 58 + {{ current_day }} 59 + </button> 60 + </td> 61 + {% set current_day = current_day + 1 %} 62 + {% else %} 63 + <td></td> 64 + {% endif %} 65 + {% endfor %} 66 + </tr> 67 + {% endif %} 68 + {% endfor %} 69 + </tbody> 70 + </table> 71 + </div>