···11+use axum::{
22+ extract::{Path, Query, State},
33+ http::StatusCode,
44+ response::{Html, IntoResponse},
55+ Json,
66+};
77+use serde::{Deserialize, Serialize};
88+use std::sync::Arc;
99+use tracing::{debug, error, info, warn};
1010+1111+use crate::http::{AppContext, HttpError, get_session_or_redirect};
1212+use crate::i18n::I18nService;
1313+use crate::storage::bookmark_calendars;
1414+use crate::storage::bookmark_calendars::BookmarkCalendar;
1515+1616+#[derive(Deserialize)]
1717+pub struct CreateBookmarkCalendarParams {
1818+ name: String,
1919+ description: Option<String>,
2020+ tags: Vec<String>,
2121+ tag_operator: Option<String>, // 'AND' or 'OR'
2222+ is_public: Option<bool>,
2323+}
2424+2525+#[derive(Deserialize)]
2626+pub struct UpdateBookmarkCalendarParams {
2727+ name: String,
2828+ description: Option<String>,
2929+ tags: Vec<String>,
3030+ tag_operator: Option<String>,
3131+ is_public: Option<bool>,
3232+}
3333+3434+#[derive(Deserialize)]
3535+pub struct AddEventToCalendarParams {
3636+ event_aturi: String,
3737+ tags: Vec<String>,
3838+}
3939+4040+#[derive(Deserialize)]
4141+pub struct CalendarListParams {
4242+ limit: Option<i32>,
4343+ offset: Option<i32>,
4444+}
4545+4646+#[derive(Serialize)]
4747+pub struct CalendarResponse {
4848+ success: bool,
4949+ message: String,
5050+ calendar_id: Option<String>,
5151+}
5252+5353+/// Handle creating a new bookmark calendar
5454+pub async fn handle_create_bookmark_calendar(
5555+ State(ctx): State<Arc<AppContext>>,
5656+ Json(payload): Json<CreateBookmarkCalendarParams>,
5757+) -> Result<Html<String>, HttpError> {
5858+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
5959+6060+ // Validate input
6161+ if payload.name.trim().is_empty() {
6262+ return Err(HttpError::BadRequest("Calendar name is required".to_string()));
6363+ }
6464+6565+ if payload.name.len() > 256 {
6666+ return Err(HttpError::BadRequest("Calendar name too long (maximum 256 characters)".to_string()));
6767+ }
6868+6969+ if payload.tags.is_empty() {
7070+ return Err(HttpError::BadRequest("At least one tag is required".to_string()));
7171+ }
7272+7373+ if payload.tags.len() > 10 {
7474+ return Err(HttpError::BadRequest("Maximum 10 tags allowed per calendar".to_string()));
7575+ }
7676+7777+ for tag in &payload.tags {
7878+ if tag.trim().is_empty() {
7979+ return Err(HttpError::BadRequest("Empty tags are not allowed".to_string()));
8080+ }
8181+ if tag.len() > 50 {
8282+ return Err(HttpError::BadRequest("Tag too long (maximum 50 characters)".to_string()));
8383+ }
8484+ }
8585+8686+ let calendar_id = bookmark_calendars::generate_calendar_id();
8787+ let tag_operator = payload.tag_operator.unwrap_or_else(|| "OR".to_string());
8888+8989+ if !["AND", "OR"].contains(&tag_operator.as_str()) {
9090+ return Err(HttpError::BadRequest("Tag operator must be 'AND' or 'OR'".to_string()));
9191+ }
9292+9393+ let calendar = BookmarkCalendar {
9494+ id: 0, // Will be set by database
9595+ calendar_id: calendar_id.clone(),
9696+ did: session.did.clone(),
9797+ name: payload.name.trim().to_string(),
9898+ description: payload.description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty()),
9999+ tags: payload.tags.iter().map(|t| t.trim().to_string()).collect(),
100100+ tag_operator,
101101+ is_public: payload.is_public.unwrap_or(false),
102102+ event_count: 0,
103103+ created_at: chrono::Utc::now(),
104104+ updated_at: chrono::Utc::now(),
105105+ };
106106+107107+ match bookmark_calendars::insert(&ctx.storage, &calendar).await {
108108+ Ok(created_calendar) => {
109109+ info!("Successfully created calendar {} for user {}", calendar_id, session.did);
110110+111111+ let i18n = I18nService::new(&session.language);
112112+ let message = i18n.t("calendar-created", &[("count", &created_calendar.tags.len().to_string())]);
113113+114114+ let mut template_context = axum_template::TemplateContext::new();
115115+ template_context.insert("calendar", &created_calendar);
116116+ template_context.insert("message", &message);
117117+118118+ let html = ctx.templates
119119+ .render("bookmark_calendar_item", &template_context)
120120+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
121121+122122+ Ok(Html(html))
123123+ }
124124+ Err(e) => {
125125+ error!("Failed to create calendar for user {}: {}", session.did, e);
126126+ Err(HttpError::InternalServerError("Failed to create calendar".to_string()))
127127+ }
128128+ }
129129+}
130130+131131+/// Handle viewing a specific bookmark calendar
132132+pub async fn handle_view_bookmark_calendar(
133133+ State(ctx): State<Arc<AppContext>>,
134134+ Path(calendar_id): Path<String>,
135135+ Query(params): Query<ViewCalendarParams>,
136136+) -> Result<Html<String>, HttpError> {
137137+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
138138+139139+ let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? {
140140+ Some(calendar) => calendar,
141141+ None => return Err(HttpError::NotFound("Calendar not found".to_string())),
142142+ };
143143+144144+ // Check if user has access to this calendar
145145+ if calendar.did != session.did && !calendar.is_public {
146146+ return Err(HttpError::Forbidden("Access denied".to_string()));
147147+ }
148148+149149+ let view_mode = params.view.as_deref().unwrap_or("timeline");
150150+ let limit = params.limit.unwrap_or(20);
151151+ let offset = params.offset.unwrap_or(0);
152152+153153+ // Get events for this calendar using its tag filtering rules
154154+ let bookmark_service = crate::services::event_bookmarks::EventBookmarkService::new(
155155+ ctx.storage.clone(),
156156+ ctx.atproto_client.clone(),
157157+ );
158158+159159+ match bookmark_service
160160+ .get_bookmarked_events(
161161+ &calendar.did,
162162+ Some(&calendar.tags),
163163+ None,
164164+ limit,
165165+ offset,
166166+ false,
167167+ )
168168+ .await
169169+ {
170170+ Ok(paginated_events) => {
171171+ let i18n = I18nService::new(&session.language);
172172+173173+ let template_name = match view_mode {
174174+ "calendar" => "bookmark_calendar_grid",
175175+ _ => "bookmark_calendar_timeline",
176176+ };
177177+178178+ let mut template_context = axum_template::TemplateContext::new();
179179+ template_context.insert("calendar", &calendar);
180180+ template_context.insert("events", &paginated_events.events);
181181+ template_context.insert("total_count", &paginated_events.total_count);
182182+ template_context.insert("has_more", &paginated_events.has_more);
183183+ template_context.insert("current_offset", &offset);
184184+ template_context.insert("view_mode", view_mode);
185185+ template_context.insert("is_owner", &(calendar.did == session.did));
186186+187187+ let html = ctx.templates
188188+ .render(template_name, &template_context)
189189+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
190190+191191+ Ok(Html(html))
192192+ }
193193+ Err(e) => {
194194+ error!("Failed to get calendar events for calendar {}: {}", calendar_id, e);
195195+ Err(HttpError::InternalServerError("Failed to load calendar".to_string()))
196196+ }
197197+ }
198198+}
199199+200200+/// Handle deleting a bookmark calendar
201201+pub async fn handle_delete_bookmark_calendar(
202202+ State(ctx): State<Arc<AppContext>>,
203203+ Path(calendar_id): Path<String>,
204204+) -> Result<Html<String>, HttpError> {
205205+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
206206+207207+ let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? {
208208+ Some(calendar) => calendar,
209209+ None => return Err(HttpError::NotFound("Calendar not found".to_string())),
210210+ };
211211+212212+ // Check if user owns this calendar
213213+ if calendar.did != session.did {
214214+ return Err(HttpError::Forbidden("You can only delete your own calendars".to_string()));
215215+ }
216216+217217+ match bookmark_calendars::delete(&ctx.storage, &calendar_id, &session.did).await {
218218+ Ok(()) => {
219219+ info!("Successfully deleted calendar {} for user {}", calendar_id, session.did);
220220+221221+ // Return empty HTML for HTMX to remove the element
222222+ Ok(Html(String::new()))
223223+ }
224224+ Err(e) => {
225225+ error!("Failed to delete calendar {}: {}", calendar_id, e);
226226+ Err(HttpError::InternalServerError("Failed to delete calendar".to_string()))
227227+ }
228228+ }
229229+}
230230+231231+/// Handle listing bookmark calendars (user's own and public)
232232+pub async fn handle_bookmark_calendars_index(
233233+ State(ctx): State<Arc<AppContext>>,
234234+ Query(params): Query<CalendarListParams>,
235235+) -> Result<Html<String>, HttpError> {
236236+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
237237+238238+ let limit = params.limit.unwrap_or(20);
239239+ let offset = params.offset.unwrap_or(0);
240240+241241+ // Get user's own calendars
242242+ let user_calendars = bookmark_calendars::get_by_user(&ctx.storage, &session.did, true).await?;
243243+244244+ // Get public calendars
245245+ let (public_calendars, total_public) = bookmark_calendars::get_public_paginated(&ctx.storage, limit, offset).await?;
246246+247247+ let i18n = I18nService::new(&session.language);
248248+249249+ let mut template_context = axum_template::TemplateContext::new();
250250+ template_context.insert("user_calendars", &user_calendars);
251251+ template_context.insert("public_calendars", &public_calendars);
252252+ template_context.insert("total_public", &total_public);
253253+ template_context.insert("current_offset", &offset);
254254+ template_context.insert("has_more_public", &((offset + limit) < total_public as i32));
255255+256256+ let html = ctx.templates
257257+ .render("bookmark_calendars_index", &template_context)
258258+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
259259+260260+ Ok(Html(html))
261261+}
262262+263263+/// Handle updating a bookmark calendar
264264+pub async fn handle_update_bookmark_calendar(
265265+ State(ctx): State<Arc<AppContext>>,
266266+ Path(calendar_id): Path<String>,
267267+ Json(payload): Json<UpdateBookmarkCalendarParams>,
268268+) -> Result<Html<String>, HttpError> {
269269+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
270270+271271+ let mut calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? {
272272+ Some(calendar) => calendar,
273273+ None => return Err(HttpError::NotFound("Calendar not found".to_string())),
274274+ };
275275+276276+ // Check if user owns this calendar
277277+ if calendar.did != session.did {
278278+ return Err(HttpError::Forbidden("You can only update your own calendars".to_string()));
279279+ }
280280+281281+ // Validate input (same as create)
282282+ if payload.name.trim().is_empty() {
283283+ return Err(HttpError::BadRequest("Calendar name is required".to_string()));
284284+ }
285285+286286+ if payload.name.len() > 256 {
287287+ return Err(HttpError::BadRequest("Calendar name too long (maximum 256 characters)".to_string()));
288288+ }
289289+290290+ if payload.tags.is_empty() {
291291+ return Err(HttpError::BadRequest("At least one tag is required".to_string()));
292292+ }
293293+294294+ if payload.tags.len() > 10 {
295295+ return Err(HttpError::BadRequest("Maximum 10 tags allowed per calendar".to_string()));
296296+ }
297297+298298+ for tag in &payload.tags {
299299+ if tag.trim().is_empty() {
300300+ return Err(HttpError::BadRequest("Empty tags are not allowed".to_string()));
301301+ }
302302+ if tag.len() > 50 {
303303+ return Err(HttpError::BadRequest("Tag too long (maximum 50 characters)".to_string()));
304304+ }
305305+ }
306306+307307+ let tag_operator = payload.tag_operator.unwrap_or_else(|| "OR".to_string());
308308+ if !["AND", "OR"].contains(&tag_operator.as_str()) {
309309+ return Err(HttpError::BadRequest("Tag operator must be 'AND' or 'OR'".to_string()));
310310+ }
311311+312312+ // Update calendar fields
313313+ calendar.name = payload.name.trim().to_string();
314314+ calendar.description = payload.description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty());
315315+ calendar.tags = payload.tags.iter().map(|t| t.trim().to_string()).collect();
316316+ calendar.tag_operator = tag_operator;
317317+ calendar.is_public = payload.is_public.unwrap_or(calendar.is_public);
318318+319319+ match bookmark_calendars::update(&ctx.storage, &calendar).await {
320320+ Ok(()) => {
321321+ info!("Successfully updated calendar {} for user {}", calendar_id, session.did);
322322+323323+ let i18n = I18nService::new(&session.language);
324324+ let message = i18n.t("calendar-updated", &[("count", &calendar.tags.len().to_string())]);
325325+326326+ let mut template_context = axum_template::TemplateContext::new();
327327+ template_context.insert("calendar", &calendar);
328328+ template_context.insert("message", &message);
329329+330330+ let html = ctx.templates
331331+ .render("bookmark_calendar_item", &template_context)
332332+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
333333+334334+ Ok(Html(html))
335335+ }
336336+ Err(e) => {
337337+ error!("Failed to update calendar {}: {}", calendar_id, e);
338338+ Err(HttpError::InternalServerError("Failed to update calendar".to_string()))
339339+ }
340340+ }
341341+}
342342+343343+/// Handle iCal export of a specific calendar
344344+pub async fn handle_export_calendar_ical(
345345+ State(ctx): State<Arc<AppContext>>,
346346+ Path(calendar_id): Path<String>,
347347+) -> Result<impl IntoResponse, HttpError> {
348348+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
349349+350350+ let calendar = match bookmark_calendars::get_by_calendar_id(&ctx.storage, &calendar_id).await? {
351351+ Some(calendar) => calendar,
352352+ None => return Err(HttpError::NotFound("Calendar not found".to_string())),
353353+ };
354354+355355+ // Check if user has access to this calendar
356356+ if calendar.did != session.did && !calendar.is_public {
357357+ return Err(HttpError::Forbidden("Access denied".to_string()));
358358+ }
359359+360360+ let bookmark_service = crate::services::event_bookmarks::EventBookmarkService::new(
361361+ ctx.storage.clone(),
362362+ ctx.atproto_client.clone(),
363363+ );
364364+365365+ match bookmark_service
366366+ .get_bookmarked_events(&calendar.did, Some(&calendar.tags), None, 1000, 0, false)
367367+ .await
368368+ {
369369+ Ok(paginated_events) => {
370370+ let ical_content = generate_ical_content(&paginated_events.events, &calendar)?;
371371+372372+ let filename = format!("{}.ics", calendar.name.replace(' ', "_"));
373373+ let headers = [
374374+ ("Content-Type", "text/calendar; charset=utf-8"),
375375+ ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
376376+ ];
377377+378378+ Ok((StatusCode::OK, headers, ical_content))
379379+ }
380380+ Err(e) => {
381381+ error!("Failed to export calendar {}: {}", calendar_id, e);
382382+ Err(HttpError::InternalServerError("Failed to export calendar".to_string()))
383383+ }
384384+ }
385385+}
386386+387387+#[derive(Deserialize)]
388388+pub struct ViewCalendarParams {
389389+ view: Option<String>, // 'timeline' or 'calendar'
390390+ limit: Option<i32>,
391391+ offset: Option<i32>,
392392+}
393393+394394+/// Generate iCal content for a specific calendar
395395+fn generate_ical_content(
396396+ events: &[crate::storage::event_bookmarks::BookmarkedEvent],
397397+ calendar: &BookmarkCalendar,
398398+) -> Result<String, HttpError> {
399399+ let mut ical = String::new();
400400+401401+ ical.push_str("BEGIN:VCALENDAR\r\n");
402402+ ical.push_str("VERSION:2.0\r\n");
403403+ ical.push_str("PRODID:-//smokesignal//bookmark-calendar//EN\r\n");
404404+ ical.push_str("CALSCALE:GREGORIAN\r\n");
405405+ ical.push_str(&format!("X-WR-CALNAME:{}\r\n", calendar.name));
406406+407407+ if let Some(description) = &calendar.description {
408408+ ical.push_str(&format!("X-WR-CALDESC:{}\r\n", description));
409409+ }
410410+411411+ for bookmarked_event in events {
412412+ // Parse event record to extract event details
413413+ if let Ok(event_data) = serde_json::from_value::<serde_json::Value>(bookmarked_event.event.record.clone()) {
414414+ ical.push_str("BEGIN:VEVENT\r\n");
415415+416416+ // Generate unique ID
417417+ let uid = format!("{}@smokesignal.events", bookmarked_event.event.aturi);
418418+ ical.push_str(&format!("UID:{}\r\n", uid));
419419+420420+ // Add event details
421421+ ical.push_str(&format!("SUMMARY:{}\r\n", bookmarked_event.event.name));
422422+423423+ if let Some(description) = event_data.get("description").and_then(|d| d.as_str()) {
424424+ ical.push_str(&format!("DESCRIPTION:{}\r\n", description));
425425+ }
426426+427427+ // Add calendar tags as categories
428428+ let tags = calendar.tags.join(",");
429429+ ical.push_str(&format!("CATEGORIES:{}\r\n", tags));
430430+431431+ // Add timestamps
432432+ let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
433433+ ical.push_str(&format!("DTSTAMP:{}\r\n", dtstamp));
434434+435435+ ical.push_str("END:VEVENT\r\n");
436436+ }
437437+ }
438438+439439+ ical.push_str("END:VCALENDAR\r\n");
440440+441441+ Ok(ical)
442442+}
+360
backup/handle_bookmark_events_old.rs
···11+use anyhow::Result;
22+use axum::{
33+ extract::{Path, Query, State},
44+ http::StatusCode,
55+ response::{Html, IntoResponse},
66+};
77+use chrono::{DateTime, Utc, Datelike, Timelike};
88+use minijinja::context as template_context;
99+use serde::{Deserialize, Serialize};
1010+use std::sync::Arc;
1111+use tracing::{debug, error, info};
1212+1313+use crate::create_renderer;
1414+use crate::http::context::{UserRequestContext, WebContext};
1515+use crate::http::errors::WebError;
1616+use crate::services::event_bookmarks::EventBookmarkService;
1717+use crate::storage::event_bookmarks::PaginatedBookmarkedEvents;
1818+1919+#[derive(Deserialize)]
2020+pub struct BookmarkEventParams {
2121+ event_aturi: String,
2222+ tags: Option<String>, // Comma-separated tags
2323+}
2424+2525+#[derive(Deserialize)]
2626+pub struct CalendarViewParams {
2727+ tags: Option<String>,
2828+ start_date: Option<String>,
2929+ end_date: Option<String>,
3030+ view: Option<String>, // 'timeline' or 'calendar'
3131+ limit: Option<i32>,
3232+ offset: Option<i32>,
3333+}
3434+3535+#[derive(Serialize)]
3636+pub struct BookmarkEventResponse {
3737+ success: bool,
3838+ message: String,
3939+ bookmark_id: Option<i32>,
4040+}
4141+4242+/// Handle bookmarking an event
4343+pub async fn handle_bookmark_event(
4444+ Query(params): Query<BookmarkEventParams>,
4545+ user_request_context: UserRequestContext,
4646+) -> Result<impl IntoResponse, WebError> {
4747+ let Some(auth) = user_request_context.auth else {
4848+ return Ok((StatusCode::UNAUTHORIZED, "Authentication required".to_string()).into_response());
4949+ };
5050+5151+ let tags: Vec<String> = params
5252+ .tags
5353+ .unwrap_or_default()
5454+ .split(',')
5555+ .map(|s| s.trim().to_string())
5656+ .filter(|s| !s.is_empty())
5757+ .collect();
5858+5959+ // Validate tags
6060+ if tags.len() > 10 {
6161+ return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed".to_string()).into_response());
6262+ }
6363+6464+ for tag in &tags {
6565+ if tag.len() > 50 {
6666+ return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response());
6767+ }
6868+ }
6969+7070+ let bookmark_service = EventBookmarkService::new(
7171+ Arc::new(user_request_context.web_context.0.pool.clone()),
7272+ Arc::new(user_request_context.web_context.0.atrium_oauth_manager.clone()),
7373+ );
7474+7575+ match bookmark_service
7676+ .bookmark_event(&auth.did, ¶ms.event_aturi, tags)
7777+ .await
7878+ {
7979+ Ok(_bookmark) => {
8080+ info!("Successfully bookmarked event {} for user {}", params.event_aturi, auth.did);
8181+8282+ let renderer = create_renderer!(
8383+ user_request_context.web_context.clone(),
8484+ user_request_context.language.clone(),
8585+ user_request_context.hx_boosted,
8686+ user_request_context.hx_request
8787+ );
8888+8989+ let html = renderer.render("bookmark_success", template_context! {
9090+ event_aturi => params.event_aturi,
9191+ })?;
9292+9393+ Ok(Html(html).into_response())
9494+ }
9595+ Err(e) => {
9696+ error!("Failed to bookmark event {}: {}", params.event_aturi, e);
9797+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to bookmark event".to_string()).into_response())
9898+ }
9999+ }
100100+}
101101+102102+/// Handle viewing bookmark calendar (timeline or grid view)
103103+pub async fn handle_bookmark_calendar(
104104+ State(ctx): State<Arc<AppContext>>,
105105+ Query(params): Query<CalendarViewParams>,
106106+) -> Result<Html<String>, HttpError> {
107107+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
108108+109109+ let view_mode = params.view.as_deref().unwrap_or("timeline");
110110+ let limit = params.limit.unwrap_or(20);
111111+ let offset = params.offset.unwrap_or(0);
112112+113113+ let tags: Option<Vec<String>> = params.tags.map(|tag_str| {
114114+ tag_str
115115+ .split(',')
116116+ .map(|s| s.trim().to_string())
117117+ .filter(|s| !s.is_empty())
118118+ .collect()
119119+ });
120120+121121+ // Parse date range if provided
122122+ let date_range = match (params.start_date.as_ref(), params.end_date.as_ref()) {
123123+ (Some(start), Some(end)) => {
124124+ match (start.parse::<DateTime<Utc>>(), end.parse::<DateTime<Utc>>()) {
125125+ (Ok(start_dt), Ok(end_dt)) => Some((start_dt, end_dt)),
126126+ _ => {
127127+ warn!("Invalid date format in calendar view params");
128128+ None
129129+ }
130130+ }
131131+ }
132132+ _ => None,
133133+ };
134134+135135+ let bookmark_service = EventBookmarkService::new(
136136+ ctx.storage.clone(),
137137+ ctx.atproto_client.clone(),
138138+ );
139139+140140+ match bookmark_service
141141+ .get_bookmarked_events(
142142+ &session.did,
143143+ tags.as_deref(),
144144+ date_range,
145145+ limit,
146146+ offset,
147147+ false, // Don't force sync unless specifically requested
148148+ )
149149+ .await
150150+ {
151151+ Ok(paginated_events) => {
152152+ let i18n = I18nService::new(&session.language);
153153+154154+ let template_name = match view_mode {
155155+ "calendar" => "bookmark_calendar_grid",
156156+ _ => "bookmark_calendar_timeline",
157157+ };
158158+159159+ let mut template_context = axum_template::TemplateContext::new();
160160+ template_context.insert("events", &paginated_events.events);
161161+ template_context.insert("total_count", &paginated_events.total_count);
162162+ template_context.insert("has_more", &paginated_events.has_more);
163163+ template_context.insert("current_offset", &offset);
164164+ template_context.insert("view_mode", view_mode);
165165+ template_context.insert("filter_tags", ¶ms.tags.unwrap_or_default());
166166+167167+ let html = ctx.templates
168168+ .render(template_name, &template_context)
169169+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
170170+171171+ Ok(Html(html))
172172+ }
173173+ Err(e) => {
174174+ error!("Failed to get bookmarked events for user {}: {}", session.did, e);
175175+ Err(HttpError::InternalServerError("Failed to load bookmarks".to_string()))
176176+ }
177177+ }
178178+}
179179+180180+/// Handle removing a bookmark
181181+pub async fn handle_remove_bookmark(
182182+ State(ctx): State<Arc<AppContext>>,
183183+ Path(bookmark_aturi): Path<String>,
184184+) -> Result<Html<String>, HttpError> {
185185+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
186186+187187+ // Decode the bookmark AT-URI if URL-encoded
188188+ let bookmark_aturi = urlencoding::decode(&bookmark_aturi)
189189+ .map_err(|_| HttpError::BadRequest("Invalid bookmark URI".to_string()))?
190190+ .to_string();
191191+192192+ let bookmark_service = EventBookmarkService::new(
193193+ ctx.storage.clone(),
194194+ ctx.atproto_client.clone(),
195195+ );
196196+197197+ match bookmark_service
198198+ .remove_bookmark(&session.did, &bookmark_aturi)
199199+ .await
200200+ {
201201+ Ok(()) => {
202202+ info!("Successfully removed bookmark {} for user {}", bookmark_aturi, session.did);
203203+204204+ // Return empty HTML for HTMX to remove the element
205205+ Ok(Html(String::new()))
206206+ }
207207+ Err(e) => {
208208+ error!("Failed to remove bookmark {}: {}", bookmark_aturi, e);
209209+ Err(HttpError::InternalServerError("Failed to remove bookmark".to_string()))
210210+ }
211211+ }
212212+}
213213+214214+/// Handle calendar navigation (mini-calendar widget)
215215+pub async fn handle_calendar_navigation(
216216+ State(ctx): State<Arc<AppContext>>,
217217+ Query(params): Query<CalendarNavParams>,
218218+) -> Result<Html<String>, HttpError> {
219219+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
220220+221221+ let year = params.year.unwrap_or_else(|| Utc::now().year());
222222+ let month = params.month.unwrap_or_else(|| Utc::now().month() as i32);
223223+224224+ // Get events for the requested month to highlight dates
225225+ let bookmark_service = EventBookmarkService::new(
226226+ ctx.storage.clone(),
227227+ ctx.atproto_client.clone(),
228228+ );
229229+230230+ let start_of_month = chrono::NaiveDate::from_ymd_opt(year, month as u32, 1)
231231+ .unwrap()
232232+ .and_hms_opt(0, 0, 0)
233233+ .unwrap()
234234+ .and_utc();
235235+236236+ let end_of_month = if month == 12 {
237237+ chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
238238+ } else {
239239+ chrono::NaiveDate::from_ymd_opt(year, month as u32 + 1, 1)
240240+ }
241241+ .unwrap()
242242+ .and_hms_opt(0, 0, 0)
243243+ .unwrap()
244244+ .and_utc();
245245+246246+ let date_range = Some((start_of_month, end_of_month));
247247+248248+ match bookmark_service
249249+ .get_bookmarked_events(&session.did, None, date_range, 1000, 0, false)
250250+ .await
251251+ {
252252+ Ok(paginated_events) => {
253253+ let mut template_context = axum_template::TemplateContext::new();
254254+ template_context.insert("year", &year);
255255+ template_context.insert("month", &month);
256256+ template_context.insert("events", &paginated_events.events);
257257+258258+ let html = ctx.templates
259259+ .render("mini_calendar", &template_context)
260260+ .map_err(|e| HttpError::TemplateError(e.to_string()))?;
261261+262262+ Ok(Html(html))
263263+ }
264264+ Err(e) => {
265265+ error!("Failed to get calendar events for user {}: {}", session.did, e);
266266+ Err(HttpError::InternalServerError("Failed to load calendar".to_string()))
267267+ }
268268+ }
269269+}
270270+271271+/// Handle iCal export of bookmarked events
272272+pub async fn handle_bookmark_calendar_ical(
273273+ State(ctx): State<Arc<AppContext>>,
274274+ Query(params): Query<CalendarViewParams>,
275275+) -> Result<impl IntoResponse, HttpError> {
276276+ let session = get_session_or_redirect(&ctx, &axum::http::HeaderMap::new()).await?;
277277+278278+ let tags: Option<Vec<String>> = params.tags.map(|tag_str| {
279279+ tag_str
280280+ .split(',')
281281+ .map(|s| s.trim().to_string())
282282+ .filter(|s| !s.is_empty())
283283+ .collect()
284284+ });
285285+286286+ let bookmark_service = EventBookmarkService::new(
287287+ ctx.storage.clone(),
288288+ ctx.atproto_client.clone(),
289289+ );
290290+291291+ match bookmark_service
292292+ .get_bookmarked_events(&session.did, tags.as_deref(), None, 1000, 0, false)
293293+ .await
294294+ {
295295+ Ok(paginated_events) => {
296296+ let ical_content = generate_ical_content(&paginated_events.events)?;
297297+298298+ let headers = [
299299+ ("Content-Type", "text/calendar; charset=utf-8"),
300300+ ("Content-Disposition", "attachment; filename=\"bookmarks.ics\""),
301301+ ];
302302+303303+ Ok((StatusCode::OK, headers, ical_content))
304304+ }
305305+ Err(e) => {
306306+ error!("Failed to export bookmarks for user {}: {}", session.did, e);
307307+ Err(HttpError::InternalServerError("Failed to export calendar".to_string()))
308308+ }
309309+ }
310310+}
311311+312312+#[derive(Deserialize)]
313313+pub struct CalendarNavParams {
314314+ year: Option<i32>,
315315+ month: Option<i32>,
316316+}
317317+318318+/// Generate iCal content from bookmarked events
319319+fn generate_ical_content(events: &[BookmarkedEvent]) -> Result<String, HttpError> {
320320+ let mut ical = String::new();
321321+322322+ ical.push_str("BEGIN:VCALENDAR\r\n");
323323+ ical.push_str("VERSION:2.0\r\n");
324324+ ical.push_str("PRODID:-//smokesignal//bookmarks//EN\r\n");
325325+ ical.push_str("CALSCALE:GREGORIAN\r\n");
326326+327327+ for bookmarked_event in events {
328328+ // Parse event record to extract event details
329329+ if let Ok(event_data) = serde_json::from_value::<serde_json::Value>(bookmarked_event.event.record.clone()) {
330330+ ical.push_str("BEGIN:VEVENT\r\n");
331331+332332+ // Generate unique ID
333333+ let uid = format!("{}@smokesignal.events", bookmarked_event.event.aturi);
334334+ ical.push_str(&format!("UID:{}\r\n", uid));
335335+336336+ // Add event details
337337+ ical.push_str(&format!("SUMMARY:{}\r\n", bookmarked_event.event.name));
338338+339339+ if let Some(description) = event_data.get("description").and_then(|d| d.as_str()) {
340340+ ical.push_str(&format!("DESCRIPTION:{}\r\n", description));
341341+ }
342342+343343+ // Add bookmark tags as categories
344344+ let tags = bookmarked_event.bookmark.tags.join(",");
345345+ if !tags.is_empty() {
346346+ ical.push_str(&format!("CATEGORIES:{}\r\n", tags));
347347+ }
348348+349349+ // Add timestamps
350350+ let dtstamp = Utc::now().format("%Y%m%dT%H%M%SZ");
351351+ ical.push_str(&format!("DTSTAMP:{}\r\n", dtstamp));
352352+353353+ ical.push_str("END:VEVENT\r\n");
354354+ }
355355+ }
356356+357357+ ical.push_str("END:VCALENDAR\r\n");
358358+359359+ Ok(ical)
360360+}
+63
i18n/en-us/bookmarks.ftl
···11+# Event Bookmarks
22+bookmark-event = Bookmark Event
33+bookmark-calendar = Event Calendar
44+bookmarked-events = Bookmarked Events
55+timeline-view = Timeline
66+calendar-view = Calendar
77+filter-by-tags = Filter by Tags
88+calendar-navigation = Calendar
99+remove-bookmark = Remove Bookmark
1010+confirm-remove-bookmark = Are you sure you want to remove this bookmark?
1111+add-to-calendar = Add to Calendar
1212+create-new-calendar = Create New Calendar
1313+bookmark-success = Event bookmarked successfully!
1414+no-bookmarked-events = No bookmarked events found
1515+bookmark-tags = Tags (comma-separated)
1616+bookmark-calendar-name = Calendar Name
1717+bookmark-calendar-description = Description (optional)
1818+make-calendar-public = Make this calendar public
1919+bookmarked-on = Bookmarked on {$date}
2020+ends-at = Ends at {$time}
2121+2222+# Enhanced calendar management
2323+bookmark-calendars = Custom Calendars
2424+create-calendar = Create Calendar
2525+create-bookmark-calendar = Create Custom Calendar
2626+create-new-calendar = Create New Calendar
2727+calendar-name = Calendar Name
2828+calendar-name-placeholder = e.g. Summer Festivals, Work Events
2929+calendar-name-help = Choose a descriptive name for your calendar
3030+calendar-tags = Tags
3131+add-tag-placeholder = Add a tag and press Enter
3232+calendar-tags-help = Add tags to organize your calendar
3333+calendar-description-placeholder = What types of events will you collect?
3434+make-calendar-public = Make this calendar public
3535+public-calendar-help = Public calendars appear on your profile and can be discovered by others
3636+atproto-privacy-notice = Privacy Notice
3737+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.
3838+my-calendars = My Calendars
3939+public-calendars = Public Calendars
4040+no-public-calendars = No public calendars yet
4141+export-calendar = Export Calendar
4242+share-calendar = Share Calendar
4343+events = events
4444+created = Created on
4545+calendar-updated = Calendar updated successfully
4646+calendar-created = Calendar created successfully
4747+event-added-to-calendar = Event added to calendar
4848+4949+# Tag management
5050+calendar-tags-help = Add multiple tags to organize this calendar (press Enter or comma to add)
5151+tag-suggestions = Suggested tags
5252+calendar-created-success = Calendar created with {$count} tags
5353+calendar-updated-success = Calendar updated with {$count} tags
5454+tag-match-all = Show events with ALL tags
5555+tag-match-any = Show events with ANY tags
5656+tag-strategy = Tag matching
5757+tag-strategy-help = Choose how events are matched to this calendar
5858+tag-operator = Tag operator
5959+tag-operator-help = Combine tags with AND (all required) or OR (any required)
6060+error-creating-calendar = Error creating calendar
6161+error-max-tags = Maximum 10 tags allowed per calendar
6262+error-empty-tag = Empty tags are not allowed
6363+error-tag-too-long = Tag too long (maximum 50 characters)
+63
i18n/fr-ca/bookmarks.ftl
···11+# Signets d'événements
22+bookmark-event = Marquer l'événement
33+bookmark-calendar = Calendrier des événements
44+bookmarked-events = Événements marqués
55+timeline-view = Vue chronologique
66+calendar-view = Vue calendrier
77+filter-by-tags = Filtrer par étiquettes
88+calendar-navigation = Navigation
99+remove-bookmark = Retirer le marque-page
1010+confirm-remove-bookmark = Êtes-vous sûr·e de vouloir retirer ce marque-page?
1111+add-to-calendar = Ajouter au calendrier
1212+create-new-calendar = Créer un nouveau calendrier
1313+bookmark-success = Événement marqué avec succès!
1414+no-bookmarked-events = Aucun événement marqué trouvé
1515+bookmark-tags = Étiquettes (séparées par des virgules)
1616+bookmark-calendar-name = Nom du calendrier
1717+bookmark-calendar-description = Description (optionnelle)
1818+make-calendar-public = Rendre ce calendrier public
1919+bookmarked-on = Marqué le {$date}
2020+ends-at = Se termine à {$time}
2121+2222+# Gestion avancée des calendriers
2323+bookmark-calendars = Calendriers personnalisés
2424+create-calendar = Créer un calendrier
2525+create-bookmark-calendar = Créer un calendrier personnalisé
2626+create-new-calendar = Créer un nouveau calendrier
2727+calendar-name = Nom du calendrier
2828+calendar-name-placeholder = ex. Festivals d'été, Événements de travail
2929+calendar-name-help = Choisissez un nom descriptif pour votre calendrier
3030+calendar-tags = Étiquettes
3131+add-tag-placeholder = Ajouter une étiquette et appuyer sur Entrée
3232+calendar-tags-help = Ajoutez des étiquettes pour organiser votre calendrier
3333+calendar-description-placeholder = Quels types d'événements allez-vous collecter ?
3434+make-calendar-public = Rendre ce calendrier public
3535+public-calendar-help = Les calendriers publics apparaissent sur votre profil et peuvent être découverts par d'autres
3636+atproto-privacy-notice = Avis de confidentialité
3737+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.
3838+my-calendars = Mes calendriers
3939+public-calendars = Calendriers publics
4040+no-public-calendars = Pas encore de calendriers publics
4141+export-calendar = Exporter le calendrier
4242+share-calendar = Partager le calendrier
4343+events = événements
4444+created = Créé le
4545+calendar-updated = Calendrier mis à jour avec succès
4646+calendar-created = Calendrier créé avec succès
4747+event-added-to-calendar = Événement ajouté au calendrier
4848+4949+# Gestion des étiquettes
5050+calendar-tags-help = Ajoutez plusieurs étiquettes pour organiser ce calendrier (appuyez sur Entrée ou virgule pour ajouter)
5151+tag-suggestions = Étiquettes suggérées
5252+calendar-created-success = Calendrier créé avec {$count} étiquettes
5353+calendar-updated-success = Calendrier mis à jour avec {$count} étiquettes
5454+tag-match-all = Afficher les événements avec TOUTES les étiquettes
5555+tag-match-any = Afficher les événements avec N'IMPORTE QUELLE étiquette
5656+tag-strategy = Correspondance d'étiquettes
5757+tag-strategy-help = Choisissez comment les événements correspondent à ce calendrier
5858+tag-operator = Opérateur d'étiquettes
5959+tag-operator-help = Combinez les étiquettes avec ET (toutes requises) ou OU (n'importe laquelle requise)
6060+error-creating-calendar = Erreur lors de la création du calendrier
6161+error-max-tags = Maximum 10 étiquettes autorisées par calendrier
6262+error-empty-tag = Les étiquettes vides ne sont pas autorisées
6363+error-tag-too-long = Étiquette trop longue (maximum 50 caractères)
+46
migrations/20250618120000_bookmarks.sql
···11+-- Event bookmarks migration
22+-- This creates the core tables for the bookmark calendar feature
33+44+-- Event bookmarks table (local cache for performance)
55+CREATE TABLE event_bookmarks (
66+ id SERIAL PRIMARY KEY,
77+ did VARCHAR(512) NOT NULL,
88+ bookmark_aturi VARCHAR(1024) NOT NULL, -- ATproto bookmark record URI
99+ event_aturi VARCHAR(1024) NOT NULL, -- Event being bookmarked
1010+ tags TEXT[] NOT NULL DEFAULT '{}', -- Tags for organization
1111+ synced_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
1212+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
1313+ UNIQUE(did, event_aturi), -- Prevent duplicate event bookmarks
1414+ FOREIGN KEY (event_aturi) REFERENCES events(aturi) ON DELETE CASCADE
1515+);
1616+1717+-- Bookmark calendars table (user-created collections)
1818+CREATE TABLE bookmark_calendars (
1919+ id SERIAL PRIMARY KEY,
2020+ calendar_id VARCHAR(64) NOT NULL, -- Public identifier for sharing
2121+ did VARCHAR(512) NOT NULL,
2222+ name VARCHAR(256) NOT NULL,
2323+ description TEXT DEFAULT NULL,
2424+ tags TEXT[] NOT NULL, -- Tags that define this calendar
2525+ tag_operator VARCHAR(16) NOT NULL DEFAULT 'OR', -- 'AND' or 'OR' for tag matching
2626+ is_public BOOLEAN NOT NULL DEFAULT FALSE,
2727+ event_count INTEGER NOT NULL DEFAULT 0,
2828+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
2929+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
3030+ UNIQUE(calendar_id),
3131+ CHECK(array_length(tags, 1) > 0), -- Ensure at least one tag per calendar
3232+ CHECK(tag_operator IN ('AND', 'OR')),
3333+ CHECK(array_length(tags, 1) <= 10) -- Maximum 10 tags per calendar
3434+);
3535+3636+-- Indexes for event_bookmarks
3737+CREATE INDEX idx_event_bookmarks_did ON event_bookmarks(did);
3838+CREATE INDEX idx_event_bookmarks_event_aturi ON event_bookmarks(event_aturi);
3939+CREATE INDEX idx_event_bookmarks_tags ON event_bookmarks USING GIN(tags);
4040+CREATE INDEX idx_event_bookmarks_synced_at ON event_bookmarks(synced_at);
4141+4242+-- Indexes for bookmark_calendars
4343+CREATE INDEX idx_bookmark_calendars_did ON bookmark_calendars(did);
4444+CREATE INDEX idx_bookmark_calendars_public ON bookmark_calendars(is_public) WHERE is_public = true;
4545+CREATE INDEX idx_bookmark_calendars_tags ON bookmark_calendars USING GIN(tags);
4646+CREATE INDEX idx_bookmark_calendars_calendar_id ON bookmark_calendars(calendar_id);
···11+use chrono::{DateTime, Utc};
22+use serde::{Deserialize, Serialize};
33+44+pub const BOOKMARK_NSID: &str = "community.lexicon.bookmarks.bookmark";
55+pub const GET_BOOKMARKS_NSID: &str = "community.lexicon.bookmarks.getActorBookmarks";
66+77+#[derive(Debug, Serialize, Deserialize, Clone)]
88+pub struct Bookmark {
99+ pub subject: String, // Event AT-URI (e.g., at://did:plc:xyz/community.lexicon.calendar.event/abc123)
1010+ pub tags: Vec<String>, // Tags are required for organization
1111+ #[serde(rename = "createdAt")]
1212+ pub created_at: DateTime<Utc>,
1313+}
1414+1515+#[derive(Debug, Serialize, Deserialize)]
1616+pub struct GetActorBookmarksParams {
1717+ pub actor: String,
1818+ #[serde(skip_serializing_if = "Option::is_none")]
1919+ pub cursor: Option<String>,
2020+ #[serde(skip_serializing_if = "Option::is_none")]
2121+ pub limit: Option<u32>,
2222+}
2323+2424+#[derive(Debug, Serialize, Deserialize)]
2525+pub struct GetActorBookmarksResponse {
2626+ pub bookmarks: Vec<BookmarkRecord>, // Returns full bookmark records with AT-URIs
2727+ #[serde(skip_serializing_if = "Option::is_none")]
2828+ pub cursor: Option<String>,
2929+}
3030+3131+#[derive(Debug, Serialize, Deserialize)]
3232+pub struct BookmarkRecord {
3333+ pub uri: String, // AT-URI of the bookmark record itself
3434+ pub value: Bookmark, // The bookmark data
3535+ #[serde(rename = "indexedAt")]
3636+ pub indexed_at: DateTime<Utc>,
3737+}
3838+3939+#[derive(Debug, Serialize, Deserialize)]
4040+pub struct CreateBookmarkInput {
4141+ pub repo: String, // User's DID
4242+ pub collection: String, // Should be BOOKMARK_NSID
4343+ pub record: Bookmark, // The bookmark record to create
4444+}
4545+4646+#[derive(Debug, Serialize, Deserialize)]
4747+pub struct CreateBookmarkResponse {
4848+ pub uri: String, // AT-URI of the created bookmark record
4949+ pub cid: String, // Content identifier
5050+}
5151+5252+#[derive(Debug, Serialize, Deserialize)]
5353+pub struct DeleteBookmarkInput {
5454+ pub repo: String, // User's DID
5555+ pub collection: String, // Should be BOOKMARK_NSID
5656+ pub rkey: String, // Record key from the bookmark URI
5757+}
+4
src/atproto/lexicon/mod.rs
···11pub mod com_atproto_repo;
22+mod community_lexicon_bookmarks;
23mod community_lexicon_calendar_event;
34mod community_lexicon_calendar_rsvp;
45pub mod community_lexicon_location;
···15161617pub mod community {
1718 pub mod lexicon {
1919+ pub mod bookmarks {
2020+ pub use crate::atproto::lexicon::community_lexicon_bookmarks::*;
2121+ }
1822 pub mod calendar {
1923 pub mod event {
2024 pub use crate::atproto::lexicon::community_lexicon_calendar_event::*;
+310
src/http/handle_bookmark_calendars.rs
···11+use axum::{
22+ extract::{Path},
33+ response::{Html, IntoResponse},
44+ http::StatusCode,
55+ Json,
66+};
77+use axum_htmx::HxBoosted;
88+use minijinja::context as template_context;
99+use serde::Deserialize;
1010+use tracing::{error, info};
1111+1212+use crate::create_renderer;
1313+use crate::http::context::UserRequestContext;
1414+use crate::http::errors::WebError;
1515+use crate::storage::bookmark_calendars::{BookmarkCalendar, generate_calendar_id};
1616+1717+#[derive(Deserialize)]
1818+pub struct CreateBookmarkCalendarParams {
1919+ name: String,
2020+ description: Option<String>,
2121+ tags: Option<String>, // JSON array as string
2222+ tag_operator: Option<String>, // "AND" or "OR"
2323+ is_public: Option<bool>,
2424+}
2525+2626+#[derive(Deserialize)]
2727+pub struct UpdateBookmarkCalendarParams {
2828+ name: String,
2929+ description: Option<String>,
3030+ tags: Option<String>, // JSON array as string
3131+ tag_operator: Option<String>, // "AND" or "OR"
3232+ is_public: Option<bool>,
3333+}
3434+3535+/// Handle viewing the bookmark calendars index
3636+pub async fn handle_bookmark_calendars_index(
3737+ ctx: UserRequestContext,
3838+ HxBoosted(hx_boosted): HxBoosted,
3939+) -> Result<impl IntoResponse, WebError> {
4040+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
4141+4242+ // Create the template renderer with enhanced context
4343+ let language_clone = ctx.language.clone();
4444+ let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false);
4545+4646+ // For now, return empty calendars list
4747+ let template_context = template_context! {
4848+ calendars => Vec::<BookmarkCalendar>::new(),
4949+ user_did => current_handle.did,
5050+ };
5151+5252+ let html = renderer.render_template(
5353+ "bookmark_calendars_index",
5454+ template_context,
5555+ ctx.current_handle.as_ref(),
5656+ "/bookmark-calendars"
5757+ );
5858+ Ok(Html(html).into_response())
5959+}
6060+6161+/// Handle creating a new bookmark calendar
6262+pub async fn handle_create_bookmark_calendar(
6363+ ctx: UserRequestContext,
6464+ Json(payload): Json<CreateBookmarkCalendarParams>,
6565+) -> Result<impl IntoResponse, WebError> {
6666+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
6767+6868+ // Parse tags from JSON string
6969+ let tags: Vec<String> = if let Some(tags_str) = payload.tags {
7070+ serde_json::from_str(&tags_str).unwrap_or_default()
7171+ } else {
7272+ Vec::new()
7373+ };
7474+7575+ // Validate input
7676+ if payload.name.trim().is_empty() {
7777+ return Ok((StatusCode::BAD_REQUEST, "Calendar name is required".to_string()).into_response());
7878+ }
7979+8080+ if tags.len() > 10 {
8181+ return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed per calendar".to_string()).into_response());
8282+ }
8383+8484+ for tag in &tags {
8585+ if tag.len() > 50 {
8686+ return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response());
8787+ }
8888+ }
8989+9090+ let calendar = BookmarkCalendar {
9191+ id: 0, // Will be set by database
9292+ calendar_id: generate_calendar_id(),
9393+ did: current_handle.did.clone(),
9494+ name: payload.name.trim().to_string(),
9595+ description: payload.description,
9696+ tags,
9797+ tag_operator: payload.tag_operator.unwrap_or_else(|| "OR".to_string()),
9898+ is_public: payload.is_public.unwrap_or(false),
9999+ event_count: 0,
100100+ created_at: chrono::Utc::now(),
101101+ updated_at: chrono::Utc::now(),
102102+ };
103103+104104+ match crate::storage::bookmark_calendars::insert(&ctx.web_context.pool, &calendar).await {
105105+ Ok(created_calendar) => {
106106+ info!("Successfully created calendar {} for user {}", created_calendar.calendar_id, current_handle.did);
107107+108108+ let html = format!(
109109+ r#"<div class="notification is-success">
110110+ <p>Calendar "{}" created successfully!</p>
111111+ </div>"#,
112112+ created_calendar.name
113113+ );
114114+115115+ Ok(Html(html).into_response())
116116+ }
117117+ Err(e) => {
118118+ error!("Failed to create calendar: {}", e);
119119+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to create calendar".to_string()).into_response())
120120+ }
121121+ }
122122+}
123123+124124+/// Handle viewing a specific bookmark calendar
125125+pub async fn handle_view_bookmark_calendar(
126126+ ctx: UserRequestContext,
127127+ HxBoosted(hx_boosted): HxBoosted,
128128+ Path(calendar_id): Path<String>,
129129+) -> Result<impl IntoResponse, WebError> {
130130+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
131131+132132+ // Create the template renderer with enhanced context
133133+ let language_clone = ctx.language.clone();
134134+ let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false);
135135+136136+ match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await {
137137+ Ok(Some(calendar)) => {
138138+ // Check if user owns this calendar or if it's public
139139+ if calendar.did != current_handle.did && !calendar.is_public {
140140+ return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response());
141141+ }
142142+143143+ let template_context = template_context! {
144144+ calendar => calendar,
145145+ is_owner => calendar.did == current_handle.did,
146146+ events => Vec::<String>::new(), // TODO: Fetch events for this calendar
147147+ };
148148+149149+ let html = renderer.render_template(
150150+ "bookmark_calendar_view",
151151+ template_context,
152152+ ctx.current_handle.as_ref(),
153153+ &format!("/bookmark-calendars/{}", calendar_id)
154154+ );
155155+ Ok(Html(html).into_response())
156156+ }
157157+ Ok(None) => {
158158+ Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response())
159159+ }
160160+ Err(e) => {
161161+ error!("Failed to get calendar {}: {}", calendar_id, e);
162162+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response())
163163+ }
164164+ }
165165+}
166166+167167+/// Handle updating a bookmark calendar
168168+pub async fn handle_update_bookmark_calendar(
169169+ ctx: UserRequestContext,
170170+ Path(calendar_id): Path<String>,
171171+ Json(payload): Json<UpdateBookmarkCalendarParams>,
172172+) -> Result<impl IntoResponse, WebError> {
173173+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
174174+175175+ // Get existing calendar
176176+ let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await {
177177+ Ok(Some(calendar)) => calendar,
178178+ Ok(None) => {
179179+ return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response());
180180+ }
181181+ Err(e) => {
182182+ error!("Failed to get calendar {}: {}", calendar_id, e);
183183+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response());
184184+ }
185185+ };
186186+187187+ // Check ownership
188188+ if calendar.did != current_handle.did {
189189+ return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response());
190190+ }
191191+192192+ // Parse tags from JSON string
193193+ let tags: Vec<String> = if let Some(tags_str) = payload.tags {
194194+ serde_json::from_str(&tags_str).unwrap_or_default()
195195+ } else {
196196+ calendar.tags.clone()
197197+ };
198198+199199+ // Validate input
200200+ if payload.name.trim().is_empty() {
201201+ return Ok((StatusCode::BAD_REQUEST, "Calendar name is required".to_string()).into_response());
202202+ }
203203+204204+ if tags.len() > 10 {
205205+ return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed per calendar".to_string()).into_response());
206206+ }
207207+208208+ let mut updated_calendar = calendar;
209209+ updated_calendar.name = payload.name.trim().to_string();
210210+ updated_calendar.description = payload.description;
211211+ updated_calendar.tags = tags;
212212+ updated_calendar.tag_operator = payload.tag_operator.unwrap_or(updated_calendar.tag_operator);
213213+ updated_calendar.is_public = payload.is_public.unwrap_or(updated_calendar.is_public);
214214+ updated_calendar.updated_at = chrono::Utc::now();
215215+216216+ match crate::storage::bookmark_calendars::update(&ctx.web_context.pool, &updated_calendar).await {
217217+ Ok(()) => {
218218+ info!("Successfully updated calendar {} for user {}", calendar_id, current_handle.did);
219219+220220+ let html = format!(
221221+ r#"<div class="notification is-success">
222222+ <p>Calendar "{}" updated successfully!</p>
223223+ </div>"#,
224224+ updated_calendar.name
225225+ );
226226+227227+ Ok(Html(html).into_response())
228228+ }
229229+ Err(e) => {
230230+ error!("Failed to update calendar {}: {}", calendar_id, e);
231231+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to update calendar".to_string()).into_response())
232232+ }
233233+ }
234234+}
235235+236236+/// Handle deleting a bookmark calendar
237237+pub async fn handle_delete_bookmark_calendar(
238238+ ctx: UserRequestContext,
239239+ Path(calendar_id): Path<String>,
240240+) -> Result<impl IntoResponse, WebError> {
241241+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
242242+243243+ // Get existing calendar to check ownership
244244+ let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await {
245245+ Ok(Some(calendar)) => calendar,
246246+ Ok(None) => {
247247+ return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response());
248248+ }
249249+ Err(e) => {
250250+ error!("Failed to get calendar {}: {}", calendar_id, e);
251251+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response());
252252+ }
253253+ };
254254+255255+ // Check ownership
256256+ if calendar.did != current_handle.did {
257257+ return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response());
258258+ }
259259+260260+ match crate::storage::bookmark_calendars::delete(&ctx.web_context.pool, &calendar_id, ¤t_handle.did).await {
261261+ Ok(()) => {
262262+ info!("Successfully deleted calendar {} for user {}", calendar_id, current_handle.did);
263263+264264+ // Return empty HTML for HTMX to remove the element
265265+ Ok(Html(String::new()).into_response())
266266+ }
267267+ Err(e) => {
268268+ error!("Failed to delete calendar {}: {}", calendar_id, e);
269269+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to delete calendar".to_string()).into_response())
270270+ }
271271+ }
272272+}
273273+274274+/// Handle exporting a calendar as iCal
275275+pub async fn handle_export_calendar_ical(
276276+ ctx: UserRequestContext,
277277+ Path(calendar_id): Path<String>,
278278+) -> Result<impl IntoResponse, WebError> {
279279+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmark-calendars")?;
280280+281281+ // Get calendar
282282+ let calendar = match crate::storage::bookmark_calendars::get_by_calendar_id(&ctx.web_context.pool, &calendar_id).await {
283283+ Ok(Some(calendar)) => calendar,
284284+ Ok(None) => {
285285+ return Ok((StatusCode::NOT_FOUND, "Calendar not found".to_string()).into_response());
286286+ }
287287+ Err(e) => {
288288+ error!("Failed to get calendar {}: {}", calendar_id, e);
289289+ return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load calendar".to_string()).into_response());
290290+ }
291291+ };
292292+293293+ // Check access
294294+ if calendar.did != current_handle.did && !calendar.is_public {
295295+ return Ok((StatusCode::FORBIDDEN, "Access denied".to_string()).into_response());
296296+ }
297297+298298+ // Generate iCal content - simplified for now
299299+ let ical_content = format!(
300300+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//smokesignal//EN\r\nX-WR-CALNAME:{}\r\nEND:VCALENDAR\r\n",
301301+ calendar.name
302302+ );
303303+304304+ let headers = [
305305+ ("Content-Type", "text/calendar; charset=utf-8"),
306306+ ("Content-Disposition", &format!("attachment; filename=\"{}.ics\"", calendar.name.replace(' ', "_"))),
307307+ ];
308308+309309+ Ok((StatusCode::OK, headers, ical_content).into_response())
310310+}
+302
src/http/handle_bookmark_events.rs
···11+use axum::{
22+ extract::{Path, Query},
33+ response::{Html, IntoResponse},
44+ http::StatusCode,
55+};
66+use axum_htmx::HxBoosted;
77+use chrono::Datelike;
88+use minijinja::context as template_context;
99+use serde::{Deserialize, Serialize};
1010+use std::sync::Arc;
1111+use tracing::{error, info};
1212+1313+use crate::create_renderer;
1414+use crate::http::context::UserRequestContext;
1515+use crate::http::errors::WebError;
1616+use crate::services::event_bookmarks::EventBookmarkService;
1717+1818+#[derive(Deserialize)]
1919+pub struct BookmarkEventParams {
2020+ event_aturi: String,
2121+ tags: Option<String>, // Comma-separated tags
2222+}
2323+2424+#[derive(Deserialize)]
2525+pub struct CalendarViewParams {
2626+ tags: Option<String>,
2727+ #[allow(dead_code)]
2828+ start_date: Option<String>,
2929+ #[allow(dead_code)]
3030+ end_date: Option<String>,
3131+ view: Option<String>, // 'timeline' or 'calendar'
3232+ limit: Option<i32>,
3333+ offset: Option<i32>,
3434+}
3535+3636+#[derive(Serialize)]
3737+pub struct BookmarkEventResponse {
3838+ success: bool,
3939+ message: String,
4040+ bookmark_id: Option<i32>,
4141+}
4242+4343+/// Handle bookmarking an event
4444+pub async fn handle_bookmark_event(
4545+ ctx: UserRequestContext,
4646+ Query(params): Query<BookmarkEventParams>,
4747+) -> Result<impl IntoResponse, WebError> {
4848+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?;
4949+5050+ let tags: Vec<String> = params
5151+ .tags
5252+ .unwrap_or_default()
5353+ .split(',')
5454+ .map(|s| s.trim().to_string())
5555+ .filter(|s| !s.is_empty())
5656+ .collect();
5757+5858+ // Validate tags
5959+ if tags.len() > 10 {
6060+ return Ok((StatusCode::BAD_REQUEST, "Maximum 10 tags allowed".to_string()).into_response());
6161+ }
6262+6363+ for tag in &tags {
6464+ if tag.len() > 50 {
6565+ return Ok((StatusCode::BAD_REQUEST, "Tag too long (maximum 50 characters)".to_string()).into_response());
6666+ }
6767+ }
6868+6969+ let bookmark_service = EventBookmarkService::new(
7070+ Arc::new(ctx.web_context.pool.clone()),
7171+ Arc::new(ctx.web_context.atrium_oauth_manager.clone()),
7272+ );
7373+7474+ match bookmark_service
7575+ .bookmark_event(¤t_handle.did, ¶ms.event_aturi, tags)
7676+ .await
7777+ {
7878+ Ok(_bookmark) => {
7979+ info!("Successfully bookmarked event {} for user {}", params.event_aturi, current_handle.did);
8080+8181+ let html = r#"<div class="notification is-success">
8282+ <p>Event bookmarked successfully!</p>
8383+ </div>"#;
8484+8585+ Ok(Html(html.to_string()).into_response())
8686+ }
8787+ Err(e) => {
8888+ error!("Failed to bookmark event {}: {}", params.event_aturi, e);
8989+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to bookmark event".to_string()).into_response())
9090+ }
9191+ }
9292+}
9393+9494+/// Handle viewing the bookmark calendar timeline
9595+pub async fn handle_bookmark_calendar(
9696+ ctx: UserRequestContext,
9797+ HxBoosted(hx_boosted): HxBoosted,
9898+ Query(params): Query<CalendarViewParams>,
9999+) -> Result<impl IntoResponse, WebError> {
100100+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?;
101101+102102+ let view_mode = params.view.as_deref().unwrap_or("timeline");
103103+ let limit = params.limit.unwrap_or(20);
104104+ let offset = params.offset.unwrap_or(0);
105105+106106+ // Create the template renderer with enhanced context
107107+ let language_clone = ctx.language.clone();
108108+ let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false);
109109+110110+ let bookmark_service = EventBookmarkService::new(
111111+ Arc::new(ctx.web_context.pool.clone()),
112112+ Arc::new(ctx.web_context.atrium_oauth_manager.clone()),
113113+ );
114114+115115+ // Parse tags if provided
116116+ let tags: Option<Vec<String>> = params.tags.as_ref().map(|tag_str| {
117117+ tag_str
118118+ .split(',')
119119+ .map(|s| s.trim().to_string())
120120+ .filter(|s| !s.is_empty())
121121+ .collect()
122122+ });
123123+124124+ match bookmark_service
125125+ .get_bookmarked_events(
126126+ ¤t_handle.did,
127127+ tags.as_deref(),
128128+ None, // No date range for now
129129+ limit,
130130+ offset,
131131+ false,
132132+ )
133133+ .await
134134+ {
135135+ Ok(paginated_events) => {
136136+ let template_name = match view_mode {
137137+ "calendar" => "bookmark_calendar_grid",
138138+ _ => "bookmark_calendar_timeline",
139139+ };
140140+141141+ let template_context = template_context! {
142142+ events => paginated_events.events,
143143+ total_count => paginated_events.total_count,
144144+ has_more => paginated_events.has_more,
145145+ current_offset => offset,
146146+ view_mode => view_mode,
147147+ filter_tags => params.tags.unwrap_or_default(),
148148+ };
149149+150150+ let html = renderer.render_template(
151151+ template_name,
152152+ template_context,
153153+ ctx.current_handle.as_ref(),
154154+ "/bookmarks"
155155+ );
156156+ Ok(Html(html).into_response())
157157+ }
158158+ Err(e) => {
159159+ error!("Failed to get bookmarked events for user {}: {}", current_handle.did, e);
160160+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to load bookmarks".to_string()).into_response())
161161+ }
162162+ }
163163+}
164164+165165+/// Handle removing a bookmark
166166+pub async fn handle_remove_bookmark(
167167+ ctx: UserRequestContext,
168168+ Path(bookmark_aturi): Path<String>,
169169+) -> Result<impl IntoResponse, WebError> {
170170+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?;
171171+172172+ // Decode the bookmark AT-URI if URL-encoded
173173+ let bookmark_aturi = urlencoding::decode(&bookmark_aturi)
174174+ .map_err(|_| anyhow::anyhow!("Invalid bookmark URI"))?
175175+ .to_string();
176176+177177+ let bookmark_service = EventBookmarkService::new(
178178+ Arc::new(ctx.web_context.pool.clone()),
179179+ Arc::new(ctx.web_context.atrium_oauth_manager.clone()),
180180+ );
181181+182182+ match bookmark_service
183183+ .remove_bookmark(¤t_handle.did, &bookmark_aturi)
184184+ .await
185185+ {
186186+ Ok(()) => {
187187+ info!("Successfully removed bookmark {} for user {}", bookmark_aturi, current_handle.did);
188188+189189+ // Return empty HTML for HTMX to remove the element
190190+ Ok(Html(String::new()).into_response())
191191+ }
192192+ Err(e) => {
193193+ error!("Failed to remove bookmark {}: {}", bookmark_aturi, e);
194194+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to remove bookmark".to_string()).into_response())
195195+ }
196196+ }
197197+}
198198+199199+/// Handle calendar navigation (mini calendar component)
200200+pub async fn handle_calendar_navigation(
201201+ ctx: UserRequestContext,
202202+ Query(params): Query<CalendarNavParams>,
203203+) -> Result<impl IntoResponse, WebError> {
204204+ let _current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?;
205205+206206+ let year = params.year.unwrap_or_else(|| chrono::Utc::now().year());
207207+ let month = params.month.unwrap_or_else(|| chrono::Utc::now().month() as i32);
208208+209209+ // Create the template renderer with enhanced context
210210+ let language_clone = ctx.language.clone();
211211+ let renderer = create_renderer!(ctx.web_context.clone(), language_clone, false, false);
212212+213213+ let template_context = template_context! {
214214+ year => year,
215215+ month => month,
216216+ month_names => vec![
217217+ "January", "February", "March", "April", "May", "June",
218218+ "July", "August", "September", "October", "November", "December"
219219+ ],
220220+ events => Vec::<String>::new(), // TODO: Fetch events for this month
221221+ };
222222+223223+ let html = renderer.render_template(
224224+ "mini_calendar",
225225+ template_context,
226226+ ctx.current_handle.as_ref(),
227227+ "/bookmarks/calendar-nav"
228228+ );
229229+ Ok(Html(html).into_response())
230230+}
231231+232232+/// Handle exporting bookmarks as iCal
233233+pub async fn handle_bookmark_calendar_ical(
234234+ ctx: UserRequestContext,
235235+ Query(params): Query<CalendarViewParams>,
236236+) -> Result<impl IntoResponse, WebError> {
237237+ let current_handle = ctx.auth.require(&ctx.web_context.config.destination_key, "/bookmarks")?;
238238+239239+ let bookmark_service = EventBookmarkService::new(
240240+ Arc::new(ctx.web_context.pool.clone()),
241241+ Arc::new(ctx.web_context.atrium_oauth_manager.clone()),
242242+ );
243243+244244+ // Parse tags if provided
245245+ let tags: Option<Vec<String>> = params.tags.as_ref().map(|tag_str| {
246246+ tag_str
247247+ .split(',')
248248+ .map(|s| s.trim().to_string())
249249+ .filter(|s| !s.is_empty())
250250+ .collect()
251251+ });
252252+253253+ match bookmark_service
254254+ .get_bookmarked_events(
255255+ ¤t_handle.did,
256256+ tags.as_deref(),
257257+ None,
258258+ 1000, // Get all events for export
259259+ 0,
260260+ false,
261261+ )
262262+ .await
263263+ {
264264+ Ok(paginated_events) => {
265265+ // Generate iCal content
266266+ let ical_content = generate_ical_from_bookmarks(&paginated_events.events);
267267+268268+ let headers = [
269269+ ("Content-Type", "text/calendar; charset=utf-8"),
270270+ ("Content-Disposition", "attachment; filename=\"bookmarks.ics\""),
271271+ ];
272272+273273+ Ok((StatusCode::OK, headers, ical_content).into_response())
274274+ }
275275+ Err(e) => {
276276+ error!("Failed to export bookmarks for user {}: {}", current_handle.did, e);
277277+ Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to export calendar".to_string()).into_response())
278278+ }
279279+ }
280280+}
281281+282282+#[derive(Deserialize)]
283283+pub struct CalendarNavParams {
284284+ year: Option<i32>,
285285+ month: Option<i32>,
286286+}
287287+288288+/// Generate iCal content from bookmarked events
289289+fn generate_ical_from_bookmarks(bookmarks: &[crate::storage::event_bookmarks::EventBookmark]) -> String {
290290+ let mut ical = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//smokesignal//EN\r\n");
291291+292292+ for bookmark in bookmarks {
293293+ ical.push_str(&format!(
294294+ "BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:Bookmarked Event\r\nDTSTAMP:{}\r\nEND:VEVENT\r\n",
295295+ bookmark.id,
296296+ bookmark.created_at.format("%Y%m%dT%H%M%SZ")
297297+ ));
298298+ }
299299+300300+ ical.push_str("END:VCALENDAR\r\n");
301301+ ical
302302+}
+2
src/http/mod.rs
···103103pub mod handle_ical_event; // New iCal handler
104104pub mod handle_view_feed;
105105pub mod handle_view_rsvp;
106106+pub mod handle_bookmark_events;
107107+pub mod handle_bookmark_calendars;
106108pub mod location_edit_status;
107109pub mod macros;
108110pub mod middleware_auth;
···11+use anyhow::Result;
22+use chrono::{DateTime, Utc};
33+use std::sync::Arc;
44+use tracing::{debug, info};
55+use uuid::Uuid;
66+77+use crate::atproto::atrium_auth::AtriumOAuthManager;
88+use crate::storage::{event_bookmarks, StoragePool};
99+use crate::storage::event_bookmarks::{EventBookmark, PaginatedBookmarkedEvents};
1010+1111+pub struct EventBookmarkService {
1212+ storage: Arc<StoragePool>,
1313+ #[allow(dead_code)]
1414+ oauth_manager: Arc<AtriumOAuthManager>,
1515+}
1616+1717+impl EventBookmarkService {
1818+ pub fn new(storage: Arc<StoragePool>, oauth_manager: Arc<AtriumOAuthManager>) -> Self {
1919+ Self {
2020+ storage,
2121+ oauth_manager,
2222+ }
2323+ }
2424+2525+ /// Bookmark an event with tags
2626+ pub async fn bookmark_event(
2727+ &self,
2828+ did: &str,
2929+ event_aturi: &str,
3030+ tags: Vec<String>,
3131+ ) -> Result<EventBookmark> {
3232+ info!("Creating bookmark for event {} by user {}", event_aturi, did);
3333+3434+ // For now, create a fake bookmark AT-URI since we don't have full ATproto integration yet
3535+ let bookmark_aturi = format!("at://{}/community.lexicon.bookmarks.bookmark/{}", did, Uuid::new_v4());
3636+3737+ // Store locally for performance
3838+ let local_bookmark = event_bookmarks::insert(
3939+ &self.storage,
4040+ did,
4141+ &bookmark_aturi,
4242+ event_aturi,
4343+ &tags,
4444+ ).await?;
4545+4646+ info!("Successfully bookmarked event {} with AT-URI {}", event_aturi, bookmark_aturi);
4747+ Ok(local_bookmark)
4848+ }
4949+5050+ /// Remove a bookmark
5151+ pub async fn remove_bookmark(
5252+ &self,
5353+ did: &str,
5454+ bookmark_aturi: &str,
5555+ ) -> Result<()> {
5656+ info!("Removing bookmark {} for user {}", bookmark_aturi, did);
5757+5858+ // Remove from local cache
5959+ event_bookmarks::delete_by_bookmark_aturi(&self.storage, did, bookmark_aturi).await?;
6060+6161+ info!("Successfully removed bookmark {}", bookmark_aturi);
6262+ Ok(())
6363+ }
6464+6565+ /// Get bookmarked events with filtering and pagination
6666+ pub async fn get_bookmarked_events(
6767+ &self,
6868+ did: &str,
6969+ tags: Option<&[String]>,
7070+ date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
7171+ limit: i32,
7272+ offset: i32,
7373+ _force_sync: bool,
7474+ ) -> Result<PaginatedBookmarkedEvents> {
7575+ debug!("Getting bookmarked events for user {} with limit {} offset {}", did, limit, offset);
7676+7777+ let (bookmarks, total_count) = event_bookmarks::get_by_filters_paginated(
7878+ &self.storage,
7979+ did,
8080+ tags,
8181+ date_range,
8282+ limit,
8383+ offset,
8484+ ).await?;
8585+8686+ let has_more = (offset + limit) < total_count as i32;
8787+8888+ Ok(PaginatedBookmarkedEvents {
8989+ events: bookmarks,
9090+ total_count,
9191+ has_more,
9292+ })
9393+ }
9494+9595+ /// Check if a specific event is bookmarked by the user
9696+ pub async fn is_event_bookmarked(
9797+ &self,
9898+ did: &str,
9999+ event_aturi: &str,
100100+ ) -> Result<Option<EventBookmark>> {
101101+ event_bookmarks::get_bookmark_by_event(&self.storage, did, event_aturi)
102102+ .await
103103+ .map_err(Into::into)
104104+ }
105105+106106+ /// Get bookmarks summary statistics
107107+ pub async fn get_bookmark_stats(&self, did: &str) -> Result<BookmarkStats> {
108108+ let bookmarks = event_bookmarks::get_by_filters(&self.storage, did, None, None).await?;
109109+110110+ let total_count = bookmarks.len() as i32;
111111+ let mut tags = std::collections::HashSet::new();
112112+113113+ for bookmark in &bookmarks {
114114+ for tag in &bookmark.tags {
115115+ tags.insert(tag.clone());
116116+ }
117117+ }
118118+119119+ let unique_tags = tags.len() as i32;
120120+121121+ Ok(BookmarkStats {
122122+ total_bookmarks: total_count,
123123+ unique_tags,
124124+ last_synced: bookmarks
125125+ .iter()
126126+ .map(|b| b.synced_at)
127127+ .max(),
128128+ })
129129+ }
130130+}
131131+132132+#[derive(Debug, Clone)]
133133+pub struct BookmarkStats {
134134+ pub total_bookmarks: i32,
135135+ pub unique_tags: i32,
136136+ pub last_synced: Option<DateTime<Utc>>,
137137+}
+2
src/services/mod.rs
···22pub mod address_geocoding_strategies;
33pub mod venues;
44pub mod events;
55+pub mod event_bookmarks;
5667#[cfg(test)]
78mod nominatim_client_tests;
···1415 handle_venue_nearby, handle_venue_enrich, handle_venue_suggest
1516};
1617pub use events::{EventVenueIntegrationService, VenueIntegrationError};
1818+pub use event_bookmarks::{EventBookmarkService, BookmarkStats};
···7272//! }
7373//! ```
74747575+pub mod bookmark_calendars;
7576pub mod cache;
7677pub mod denylist;
7778pub mod errors;
7879pub mod event;
8080+pub mod event_bookmarks;
7981pub mod handle;
8082pub mod oauth;
8183pub mod types;