i18n+filtering fork - fluent-templates v2
at main 477 lines 17 kB view raw
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}