+442
backup/handle_bookmark_calendars_old.rs
+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
+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, ¶ms.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", ¶ms.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
+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
+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
+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
+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
+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
+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, ¤t_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
+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(¤t_handle.did, ¶ms.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
+
¤t_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(¤t_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
+
¤t_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
+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
+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
+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
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
+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
+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
+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
+2
src/storage/mod.rs
+226
static/bookmark-calendars.js
+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
+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
+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
+4
templates/bookmark_success.en-us.partial.html
+4
templates/bookmark_success.fr-ca.partial.html
+4
templates/bookmark_success.fr-ca.partial.html
+89
templates/bookmark_timeline.en-us.partial.html
+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
+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
+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>