forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use std::collections::HashSet;
2
3use ammonia::Builder;
4use anyhow::Result;
5use chrono::{DateTime, Datelike, Timelike};
6use chrono_tz::Tz;
7use cityhasher::HashMap;
8use serde::{Deserialize, Serialize};
9use unic_langid::LanguageIdentifier;
10
11use crate::http::errors::EventViewError;
12
13use crate::{
14 atproto::{
15 lexicon::{
16 community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID,
17 events::smokesignal::calendar::event::NSID as SmokeSignalEventNSID,
18 },
19 uri::parse_aturi,
20 },
21 http::utils::truncate_text,
22 storage::{
23 errors::StorageError,
24 event::{
25 count_event_rsvps, extract_event_details, get_event_rsvp_counts,
26 model::{Event, EventWithRole},
27 },
28 handle::{handles_by_did, model::Handle},
29 StoragePool,
30 },
31};
32
33#[derive(Serialize, Deserialize, Debug, Clone)]
34pub struct EventView {
35 pub site_url: String,
36 pub aturi: String,
37 pub cid: String,
38 pub repository: String,
39 pub collection: String,
40
41 pub organizer_did: String,
42 pub organizer_display_name: String,
43
44 pub starts_at_machine: Option<String>,
45 pub starts_at_human: Option<String>,
46 pub ends_at_machine: Option<String>,
47 pub ends_at_human: Option<String>,
48
49 pub name: String,
50 pub description: Option<String>,
51 pub description_short: Option<String>,
52
53 pub count_going: u32,
54 pub count_notgoing: u32,
55 pub count_interested: u32,
56
57 pub mode: Option<String>,
58 pub status: Option<String>,
59 pub address_display: Option<String>,
60 pub links: Vec<(String, Option<String>)>, // (uri, name)
61}
62
63/// Format a datetime according to the specified locale
64///
65/// This function provides locale-aware datetime formatting. Currently uses
66/// English and French formats, but can be extended for additional locales.
67fn format_datetime_for_locale(dt: &DateTime<Tz>, locale: Option<&LanguageIdentifier>) -> String {
68 match locale.map(|l| l.language.as_str()) {
69 Some("fr") => {
70 // French format: "2 juin 2025 14:30 UTC"
71 let month_fr = get_french_month_name(dt.month());
72 format!("{} {} {} {}:{:02} {}",
73 dt.day(),
74 month_fr,
75 dt.year(),
76 dt.hour(),
77 dt.minute(),
78 dt.format("%Z") // Use %Z for timezone abbreviation
79 )
80 }
81 _ => {
82 // Default English format: "2 June 2025 2:30 pm UTC"
83 dt.format("%e %B %Y %I:%M %P %Z").to_string()
84 }
85 }
86}
87
88/// Get French month name for the given month number (1-12)
89fn get_french_month_name(month: u32) -> &'static str {
90 match month {
91 1 => "janvier",
92 2 => "février",
93 3 => "mars",
94 4 => "avril",
95 5 => "mai",
96 6 => "juin",
97 7 => "juillet",
98 8 => "août",
99 9 => "septembre",
100 10 => "octobre",
101 11 => "novembre",
102 12 => "décembre",
103 _ => "janvier", // fallback
104 }
105}
106
107impl TryFrom<(Option<&Handle>, Option<&Handle>, &Event)> for EventView {
108 type Error = anyhow::Error;
109
110 fn try_from(
111 (viewer, organizer, event): (Option<&Handle>, Option<&Handle>, &Event),
112 ) -> Result<Self, Self::Error> {
113 // Time zones are used to display date/time values from the perspective
114 // of the viewer. The timezone is selected with this priority:
115 // 1. If the viewer is a logged in user, use their time zone
116 // 2. If the event has a starts at, use the time zone associated with it (not possible with current model)
117 // 3. If the event has a ends at, use the time zone associated with it (not possible with current model)
118 // 4. If the event organizer is known and has a time zone set
119 // 5. UTC
120
121 let tz = match (viewer, organizer) {
122 (Some(handle), _) => handle.tz.parse::<Tz>().ok(),
123 (_, Some(handle)) => handle.tz.parse::<Tz>().ok(),
124 _ => None,
125 }
126 .unwrap_or(Tz::UTC);
127
128 let (repository, collection, rkey) = parse_aturi(event.aturi.as_str())?;
129
130 // We now support both community and smokesignal event formats
131 if collection != LexiconCommunityEventNSID && collection != SmokeSignalEventNSID {
132 return Err(EventViewError::InvalidCollection(collection).into());
133 }
134
135 let organizer_did = repository.clone();
136 let organizer_display_name = organizer
137 .map(|value| value.handle.clone())
138 .unwrap_or_else(|| organizer_did.clone());
139
140 // Extract event details using our new helper
141 let details = extract_event_details(event);
142
143 // Clean the name and description
144 let event_name = Builder::new()
145 .tags(HashSet::new())
146 .clean(&details.name)
147 .to_string();
148
149 let event_description = Some(
150 Builder::new()
151 .tags(HashSet::new())
152 .clean(&details.description)
153 .to_string(),
154 );
155
156 // Simplify mode and status strings
157 let mode = details.mode.as_deref().map(|mode_str| {
158 if mode_str.contains("inperson") {
159 "inperson".to_string()
160 } else if mode_str.contains("virtual") {
161 "virtual".to_string()
162 } else if mode_str.contains("hybrid") {
163 "hybrid".to_string()
164 } else {
165 mode_str.to_string()
166 }
167 });
168
169 let status = details.status.as_deref().map(|status_str| {
170 if status_str.contains("planned") {
171 "planned".to_string()
172 } else if status_str.contains("scheduled") {
173 "scheduled".to_string()
174 } else if status_str.contains("rescheduled") {
175 "rescheduled".to_string()
176 } else if status_str.contains("cancelled") {
177 "cancelled".to_string()
178 } else if status_str.contains("postponed") {
179 "postponed".to_string()
180 } else {
181 status_str.to_string()
182 }
183 });
184
185 let name = Some(event_name);
186 let description = event_description;
187 let starts_at = details.starts_at;
188 let ends_at = details.ends_at;
189
190 let name = name.ok_or(EventViewError::MissingEventName)?;
191
192 let description_short = description
193 .as_ref()
194 .map(|value| truncate_text(value, 200, Some("...".to_string())).to_string());
195
196 let starts_at_human = starts_at.as_ref().map(|value| {
197 let dt_with_tz = value.with_timezone(&tz);
198 format_datetime_for_locale(&dt_with_tz, None)
199 });
200 let starts_at_machine = starts_at
201 .as_ref()
202 .map(|value| value.with_timezone(&tz).to_rfc3339());
203
204 let ends_at_human = ends_at.as_ref().map(|value| {
205 let dt_with_tz = value.with_timezone(&tz);
206 format_datetime_for_locale(&dt_with_tz, None)
207 });
208 let ends_at_machine = ends_at
209 .as_ref()
210 .map(|value| value.with_timezone(&tz).to_rfc3339());
211
212 let site_url = if event.lexicon == LexiconCommunityEventNSID {
213 format!("/{}/{}", repository, rkey)
214 } else {
215 format!("/{}/{}?collection={}", repository, rkey, event.lexicon)
216 };
217
218 // Format address if an Address location is found
219 let address_display = details.locations.iter()
220 .filter_map(|loc| {
221 if let crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Address(address) = loc {
222 Some(crate::storage::event::format_address(address))
223 } else {
224 None
225 }
226 })
227 .next(); // Take the first address found
228
229 // Extract links from EventLink objects
230 let links = details.uris.iter()
231 .map(|uri| {
232 match uri {
233 crate::atproto::lexicon::community::lexicon::calendar::event::EventLink::Current { uri, name } => {
234 (uri.clone(), name.clone())
235 }
236 }
237 })
238 .collect::<Vec<_>>();
239
240 Ok(EventView {
241 site_url,
242 aturi: event.aturi.clone(),
243 cid: event.cid.clone(),
244 repository,
245 collection,
246 organizer_did,
247 organizer_display_name,
248 starts_at_machine,
249 starts_at_human,
250 ends_at_machine,
251 ends_at_human,
252 name,
253 description,
254 description_short,
255 count_going: 0,
256 count_notgoing: 0,
257 count_interested: 0,
258 mode,
259 status,
260 address_display,
261 links,
262 })
263 }
264}
265
266impl EventView {
267 /// Create an EventView with locale-aware datetime formatting
268 pub fn try_from_with_locale(
269 tuple: (Option<&Handle>, Option<&Handle>, &Event),
270 locale: Option<&LanguageIdentifier>,
271 ) -> Result<Self, anyhow::Error> {
272 let (viewer, organizer, event) = tuple;
273
274 // Use the same logic as the original try_from, but with locale-aware formatting
275 let tz = match (viewer, organizer) {
276 (Some(handle), _) => handle.tz.parse::<Tz>().ok(),
277 (_, Some(handle)) => handle.tz.parse::<Tz>().ok(),
278 _ => None,
279 }
280 .unwrap_or(Tz::UTC);
281
282 let (repository, collection, rkey) = parse_aturi(event.aturi.as_str())?;
283
284 // We now support both community and smokesignal event formats
285 if collection != LexiconCommunityEventNSID && collection != SmokeSignalEventNSID {
286 return Err(EventViewError::InvalidCollection(collection).into());
287 }
288
289 let organizer_did = repository.clone();
290 let organizer_display_name = organizer
291 .map(|value| value.handle.clone())
292 .unwrap_or_else(|| organizer_did.clone());
293
294 // Extract event details using our new helper
295 let details = extract_event_details(event);
296
297 // Clean the name and description
298 let event_name = Builder::new()
299 .tags(HashSet::new())
300 .clean(&details.name)
301 .to_string();
302
303 let event_description = Some(
304 Builder::new()
305 .tags(HashSet::new())
306 .clean(&details.description)
307 .to_string(),
308 );
309
310 // Simplify mode and status strings
311 let mode = details.mode.as_deref().map(|mode_str| {
312 if mode_str.contains("inperson") {
313 "inperson".to_string()
314 } else if mode_str.contains("virtual") {
315 "virtual".to_string()
316 } else if mode_str.contains("hybrid") {
317 "hybrid".to_string()
318 } else {
319 mode_str.to_string()
320 }
321 });
322
323 let status = details.status.as_deref().map(|status_str| {
324 if status_str.contains("planned") {
325 "planned".to_string()
326 } else if status_str.contains("scheduled") {
327 "scheduled".to_string()
328 } else if status_str.contains("rescheduled") {
329 "rescheduled".to_string()
330 } else if status_str.contains("cancelled") {
331 "cancelled".to_string()
332 } else if status_str.contains("postponed") {
333 "postponed".to_string()
334 } else {
335 status_str.to_string()
336 }
337 });
338
339 let name = Some(event_name);
340 let description = event_description;
341 let starts_at = details.starts_at;
342 let ends_at = details.ends_at;
343
344 let name = name.ok_or(EventViewError::MissingEventName)?;
345
346 let description_short = description
347 .as_ref()
348 .map(|value| truncate_text(value, 200, Some("...".to_string())).to_string());
349
350 // Use locale-aware formatting for human-readable dates
351 let starts_at_human = starts_at.as_ref().map(|value| {
352 let dt_with_tz = value.with_timezone(&tz);
353 format_datetime_for_locale(&dt_with_tz, locale)
354 });
355 let starts_at_machine = starts_at
356 .as_ref()
357 .map(|value| value.with_timezone(&tz).to_rfc3339());
358
359 let ends_at_human = ends_at.as_ref().map(|value| {
360 let dt_with_tz = value.with_timezone(&tz);
361 format_datetime_for_locale(&dt_with_tz, locale)
362 });
363 let ends_at_machine = ends_at
364 .as_ref()
365 .map(|value| value.with_timezone(&tz).to_rfc3339());
366
367 let site_url = if event.lexicon == LexiconCommunityEventNSID {
368 format!("/{}/{}", repository, rkey)
369 } else {
370 format!("/{}/{}?collection={}", repository, rkey, event.lexicon)
371 };
372
373 // Format address if an Address location is found
374 let address_display = details.locations.iter()
375 .filter_map(|loc| {
376 if let crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Address(address) = loc {
377 Some(crate::storage::event::format_address(address))
378 } else {
379 None
380 }
381 })
382 .next(); // Take the first address found
383
384 // Extract links from EventLink objects
385 let links = details.uris.iter()
386 .map(|uri| {
387 match uri {
388 crate::atproto::lexicon::community::lexicon::calendar::event::EventLink::Current { uri, name } => {
389 (uri.clone(), name.clone())
390 }
391 }
392 })
393 .collect::<Vec<_>>();
394
395 Ok(EventView {
396 site_url,
397 aturi: event.aturi.clone(),
398 cid: event.cid.clone(),
399 repository,
400 collection,
401 organizer_did,
402 organizer_display_name,
403 starts_at_machine,
404 starts_at_human,
405 ends_at_machine,
406 ends_at_human,
407 name,
408 description,
409 description_short,
410 count_going: 0,
411 count_notgoing: 0,
412 count_interested: 0,
413 mode,
414 status,
415 address_display,
416 links,
417 })
418 }
419}
420
421pub async fn hydrate_event_organizers(
422 pool: &StoragePool,
423 events: &[EventWithRole],
424) -> Result<HashMap<std::string::String, Handle>> {
425 if events.is_empty() {
426 return Ok(HashMap::default());
427 }
428 let event_creator_dids = events
429 .iter()
430 .map(|event| event.event.did.clone())
431 .collect::<Vec<_>>();
432 handles_by_did(pool, event_creator_dids)
433 .await
434 .map_err(|err| err.into())
435}
436
437pub async fn hydrate_event_rsvp_counts(
438 pool: &StoragePool,
439 events: &mut [EventView],
440) -> Result<(), anyhow::Error> {
441 if events.is_empty() {
442 return Ok(());
443 }
444 let aturis = events.iter().map(|e| e.aturi.clone()).collect::<Vec<_>>();
445 let res = get_event_rsvp_counts(pool, aturis).await;
446
447 match res {
448 Ok(counts) => {
449 for event in events.iter_mut() {
450 let key_going = (event.aturi.clone(), "going".to_string());
451 let key_interested = (event.aturi.clone(), "interested".to_string());
452 let key_notgoing = (event.aturi.clone(), "notgoing".to_string());
453
454 event.count_going = counts.get(&key_going).cloned().unwrap_or(0) as u32;
455 event.count_interested = counts.get(&key_interested).cloned().unwrap_or(0) as u32;
456 event.count_notgoing = counts.get(&key_notgoing).cloned().unwrap_or(0) as u32;
457 }
458 Ok(())
459 }
460 Err(StorageError::CannotBeginDatabaseTransaction(_)) => {
461 // Fall back to individual counts if the batched query fails
462 for event in events.iter_mut() {
463 event.count_going = count_event_rsvps(pool, &event.aturi, "going")
464 .await
465 .unwrap_or_default();
466 event.count_interested = count_event_rsvps(pool, &event.aturi, "interested")
467 .await
468 .unwrap_or_default();
469 event.count_notgoing = count_event_rsvps(pool, &event.aturi, "notgoing")
470 .await
471 .unwrap_or_default();
472 }
473 Ok(())
474 }
475 Err(e) => Err(EventViewError::FailedToHydrateRsvpCounts(e.to_string()).into()),
476 }
477}