i18n+filtering fork - fluent-templates v2
at main 23 kB view raw
1use std::collections::BTreeMap; 2use std::collections::HashMap; 3 4use anyhow::Result; 5use axum::extract::Query; 6use axum::extract::State; 7use axum::response::IntoResponse; 8use axum_extra::extract::Cached; 9use axum_extra::extract::Form; 10use axum_htmx::HxBoosted; 11use axum_htmx::HxRequest; 12use chrono::Utc; 13use http::Method; 14use http::StatusCode; 15use minijinja::context as template_context; 16use serde::Deserialize; 17 18use crate::atproto::auth::SimpleOAuthSessionProvider; 19use crate::atproto::client::CreateRecordRequest; 20use crate::atproto::client::OAuthPdsClient; 21use crate::atproto::lexicon::community::lexicon::calendar::event::Event; 22use crate::atproto::lexicon::community::lexicon::calendar::event::EventLink; 23use crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation; 24use crate::atproto::lexicon::community::lexicon::calendar::event::Mode; 25use crate::atproto::lexicon::community::lexicon::calendar::event::Status; 26use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 27use crate::atproto::lexicon::community::lexicon::location::Address; 28use crate::create_renderer; 29use crate::http::context::WebContext; 30use crate::http::errors::CommonError; 31use crate::http::errors::CreateEventError; 32use crate::http::errors::WebError; 33use crate::http::event_form::BuildEventContentState; 34use crate::http::event_form::BuildEventForm; 35use crate::http::event_form::BuildLinkForm; 36use crate::http::event_form::BuildStartsForm; 37use crate::http::middleware_auth::Auth; 38use crate::http::middleware_i18n::Language; 39use crate::http::timezones::supported_timezones; 40use crate::http::utils::url_from_aturi; 41use crate::storage::event::event_insert; 42 43use super::cache_countries::cached_countries; 44use super::event_form::BuildLocationForm; 45 46pub async fn handle_create_event( 47 method: Method, 48 State(web_context): State<WebContext>, 49 Language(language): Language, 50 Cached(auth): Cached<Auth>, 51 HxRequest(hx_request): HxRequest, 52 HxBoosted(hx_boosted): HxBoosted, 53 Form(mut build_event_form): Form<BuildEventForm>, 54) -> Result<impl IntoResponse, WebError> { 55 let current_handle = auth.require(&web_context.config.destination_key, "/event")?; 56 57 // Create the template renderer with enhanced context 58 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), hx_boosted, hx_request); 59 60 let canonical_url = format!("https://{}/event", web_context.config.external_base); 61 let is_development = cfg!(debug_assertions); 62 63 let default_context = template_context! { 64 is_development, 65 create_event => true, 66 submit_url => format!("/event"), 67 }; 68 69 let (default_tz, timezones) = supported_timezones(auth.0.as_ref()); 70 71 if build_event_form.build_state.is_none() { 72 build_event_form.build_state = Some(BuildEventContentState::default()); 73 } 74 75 let mut starts_form = BuildStartsForm::from(build_event_form.clone()); 76 if starts_form.build_state.is_none() { 77 starts_form.build_state = Some(BuildEventContentState::default()); 78 } 79 80 if starts_form.tz.is_none() { 81 starts_form.tz = Some(default_tz.to_string()); 82 } 83 84 let mut location_form = BuildLocationForm::from(build_event_form.clone()); 85 if location_form.build_state.is_none() { 86 location_form.build_state = Some(BuildEventContentState::default()); 87 } 88 89 let mut link_form = BuildLinkForm::from(build_event_form.clone()); 90 if link_form.build_state.is_none() { 91 link_form.build_state = Some(BuildEventContentState::default()); 92 } 93 94 if method == Method::GET { 95 #[cfg(debug_assertions)] 96 { 97 build_event_form.name = Some("My awesome event".to_string()); 98 build_event_form.description = Some("A really great event.".to_string()); 99 } 100 101 // Set default values for required fields 102 if build_event_form.status.is_none() { 103 build_event_form.status = Some("scheduled".to_string()); 104 } 105 106 if build_event_form.mode.is_none() { 107 build_event_form.mode = Some("inperson".to_string()); 108 } 109 110 build_event_form.build_state = Some(BuildEventContentState::Selecting); 111 starts_form.build_state = Some(BuildEventContentState::Selected); 112 113 // Set default start time to 6:00 PM, 6 hours from now 114 let now = Utc::now(); // + chrono::Duration::hours(6); 115 116 // Parse default timezone string to a Tz object 117 let parsed_tz = default_tz 118 .parse::<chrono_tz::Tz>() 119 .unwrap_or(chrono_tz::UTC); 120 121 // Get the date in the target timezone 122 let local_date = now.with_timezone(&parsed_tz).date_naive(); 123 124 // Create a datetime at 6:00 PM on that date 125 if let Some(naive_dt) = local_date.and_hms_opt(18, 0, 0) { 126 // Convert to timezone-aware datetime 127 let local_dt = naive_dt.and_local_timezone(parsed_tz).single().unwrap(); 128 let utc_dt = local_dt.with_timezone(&Utc); 129 130 // Format the date and time as expected by the form 131 starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string()); 132 starts_form.starts_time = Some(local_dt.format("%H:%M").to_string()); 133 starts_form.starts_at = Some(utc_dt.to_string()); 134 starts_form.starts_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 135 136 build_event_form.starts_at = starts_form.starts_at.clone(); 137 } 138 139 return Ok(renderer.render_template( 140 "create_event", 141 template_context! { 142 is_development, 143 create_event => true, 144 submit_url => format!("/event"), 145 build_event_form, 146 starts_form, 147 location_form, 148 link_form, 149 timezones, 150 }, 151 Some(&current_handle), 152 &canonical_url, 153 )); 154 } 155 156 match build_event_form.build_state { 157 Some(BuildEventContentState::Reset) => { 158 build_event_form.build_state = Some(BuildEventContentState::Selecting); 159 build_event_form.name = None; 160 build_event_form.name_error = None; 161 build_event_form.description = None; 162 build_event_form.description_error = None; 163 build_event_form.status = Some("planned".to_string()); 164 build_event_form.status_error = None; 165 build_event_form.starts_at = None; 166 build_event_form.starts_at_error = None; 167 build_event_form.ends_at = None; 168 build_event_form.ends_at_error = None; 169 build_event_form.mode = Some("inperson".to_string()); 170 build_event_form.mode_error = None; 171 } 172 Some(BuildEventContentState::Selected) => { 173 let found_errors = 174 build_event_form.validate(&web_context.i18n_context.locales, &language); 175 if found_errors { 176 build_event_form.build_state = Some(BuildEventContentState::Selecting); 177 } else { 178 build_event_form.build_state = Some(BuildEventContentState::Selected); 179 } 180 181 if !found_errors { 182 // 1. Compose an event record 183 184 let now = Utc::now(); 185 186 let starts_at = build_event_form 187 .starts_at 188 .as_ref() 189 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok()); 190 let ends_at = build_event_form 191 .ends_at 192 .as_ref() 193 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok()); 194 195 let mode = build_event_form 196 .mode 197 .as_ref() 198 .and_then(|v| match v.as_str() { 199 "inperson" => Some(Mode::InPerson), 200 "virtual" => Some(Mode::Virtual), 201 "hybrid" => Some(Mode::Hybrid), 202 _ => None, 203 }); 204 205 let status = build_event_form 206 .status 207 .as_ref() 208 .and_then(|v| match v.as_str() { 209 "planned" => Some(Status::Planned), 210 "scheduled" => Some(Status::Scheduled), 211 "cancelled" => Some(Status::Cancelled), 212 "postponed" => Some(Status::Postponed), 213 "rescheduled" => Some(Status::Rescheduled), 214 _ => None, 215 }); 216 217 // Ensure we have auth data for the API call 218 let auth_data = auth.1.ok_or(CommonError::NotAuthorized)?; 219 let client_auth: SimpleOAuthSessionProvider = 220 SimpleOAuthSessionProvider::try_from(auth_data)?; 221 222 let client = OAuthPdsClient { 223 http_client: &web_context.http_client, 224 pds: &current_handle.pds, 225 }; 226 227 let locations = match &build_event_form.location_country { 228 Some(country) => vec![EventLocation::Address(Address::Current { 229 country: country.clone(), 230 postal_code: build_event_form.location_postal_code.clone(), 231 region: build_event_form.location_region.clone(), 232 locality: build_event_form.location_locality.clone(), 233 street: build_event_form.location_street.clone(), 234 name: build_event_form.location_name.clone(), 235 })], 236 None => vec![], 237 }; 238 239 // Process link if provided 240 let links = match &build_event_form.link_value { 241 Some(uri) => vec![EventLink::Current { 242 uri: uri.clone(), 243 name: build_event_form.link_name.clone(), 244 }], 245 None => vec![], 246 }; 247 248 let the_record = Event::Current { 249 name: build_event_form 250 .name 251 .clone() 252 .ok_or(CreateEventError::NameNotSet)?, 253 description: build_event_form 254 .description 255 .clone() 256 .ok_or(CreateEventError::DescriptionNotSet)?, 257 created_at: now, 258 starts_at, 259 ends_at, 260 mode, 261 status, 262 locations, 263 uris: links, 264 extra: HashMap::default(), 265 }; 266 267 let event_record = CreateRecordRequest { 268 repo: current_handle.did.clone(), 269 collection: NSID.to_string(), 270 validate: false, 271 record_key: None, 272 record: the_record.clone(), 273 swap_commit: None, 274 }; 275 276 let create_record_result = client.create_record(&client_auth, event_record).await; 277 278 if let Err(err) = create_record_result { 279 return Ok(renderer.render_error(err, default_context)); 280 } 281 282 // create_record_result is guaranteed to be Ok since we checked for Err above 283 let create_record_result = create_record_result?; 284 285 let event_insert_result = event_insert( 286 &web_context.pool, 287 &create_record_result.uri, 288 &create_record_result.cid, 289 &current_handle.did, 290 NSID, 291 &the_record, 292 ) 293 .await; 294 295 if let Err(err) = event_insert_result { 296 return Ok(renderer.render_error(err, default_context)); 297 } 298 299 let event_url = 300 url_from_aturi(&web_context.config.external_base, &create_record_result.uri)?; 301 302 return Ok(renderer.render_template( 303 "create_event", 304 template_context! { 305 is_development, 306 create_event => true, 307 submit_url => format!("/event"), 308 build_event_form, 309 starts_form, 310 location_form, 311 link_form, 312 operation_completed => true, 313 event_url, 314 }, 315 Some(&current_handle), 316 &canonical_url, 317 )); 318 } 319 } 320 _ => {} 321 } 322 323 Ok(renderer.render_template( 324 "create_event", 325 template_context! { 326 is_development, 327 create_event => true, 328 submit_url => format!("/event"), 329 build_event_form, 330 starts_form, 331 timezones, 332 location_form, 333 link_form, 334 }, 335 Some(&current_handle), 336 &canonical_url, 337 )) 338} 339 340pub async fn handle_starts_at_builder( 341 method: Method, 342 State(web_context): State<WebContext>, 343 Language(language): Language, 344 Cached(auth): Cached<Auth>, 345 HxRequest(hx_request): HxRequest, 346 Form(mut starts_form): Form<BuildStartsForm>, 347) -> Result<impl IntoResponse, WebError> { 348 if !hx_request { 349 return Ok(StatusCode::BAD_REQUEST.into_response()); 350 } 351 352 if auth.require_flat().is_err() { 353 return Ok(StatusCode::BAD_REQUEST.into_response()); 354 } 355 356 let (default_tz, timezones) = supported_timezones(auth.0.as_ref()); 357 358 let is_development = cfg!(debug_assertions); 359 360 // Create the template renderer for HTMX content replacement 361 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false); 362 363 let canonical_url = format!("https://{}/event", web_context.config.external_base); 364 365 if starts_form.build_state.is_none() { 366 starts_form.build_state = Some(BuildEventContentState::default()); 367 } 368 if starts_form.tz.is_none() { 369 starts_form.tz = Some(default_tz.to_string()); 370 } 371 372 if method == Method::GET { 373 return Ok(renderer.render_template( 374 "create_event.starts_form", 375 template_context! { 376 starts_form, 377 is_development, 378 timezones, 379 }, 380 None, 381 &canonical_url, 382 )); 383 } 384 385 if starts_form 386 .build_state 387 .as_ref() 388 .is_some_and(|value| value == &BuildEventContentState::Reset) 389 { 390 starts_form.tz = Some(default_tz.to_string()); 391 starts_form.tz_error = None; 392 starts_form.starts_at = None; 393 starts_form.starts_time = None; 394 starts_form.starts_date = None; 395 starts_form.ends_at = None; 396 starts_form.ends_time = None; 397 starts_form.ends_date = None; 398 starts_form.include_ends = None; 399 } 400 401 if starts_form 402 .build_state 403 .as_ref() 404 .is_some_and(|value| value == &BuildEventContentState::Selected) 405 { 406 let found_errors = starts_form.validate(&web_context.i18n_context.locales, &language); 407 if found_errors { 408 starts_form.build_state = Some(BuildEventContentState::Selecting); 409 } else { 410 starts_form.build_state = Some(BuildEventContentState::Selected); 411 412 if starts_form.ends_display.is_none() { 413 starts_form.ends_display = Some("--".to_string()); 414 } 415 } 416 } 417 418 Ok(renderer.render_template( 419 "create_event.starts_form", 420 template_context! { 421 starts_form, 422 is_development, 423 timezones, 424 }, 425 None, 426 &canonical_url, 427 )) 428} 429 430pub async fn handle_location_at_builder( 431 method: Method, 432 State(web_context): State<WebContext>, 433 Language(language): Language, 434 Cached(auth): Cached<Auth>, 435 HxRequest(hx_request): HxRequest, 436 Form(mut location_form): Form<BuildLocationForm>, 437) -> Result<impl IntoResponse, WebError> { 438 if !hx_request { 439 return Ok(StatusCode::BAD_REQUEST.into_response()); 440 } 441 442 if auth.require_flat().is_err() { 443 return Ok(StatusCode::BAD_REQUEST.into_response()); 444 } 445 446 let is_development = cfg!(debug_assertions); 447 448 // Create the template renderer for HTMX content replacement 449 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false); 450 451 let canonical_url = format!("https://{}/event", web_context.config.external_base); 452 453 if location_form.build_state.is_none() { 454 location_form.build_state = Some(BuildEventContentState::default()); 455 } 456 457 if method == Method::GET { 458 return Ok(renderer.render_template( 459 "create_event.location_form", 460 template_context! { 461 location_form, 462 is_development 463 }, 464 None, 465 &canonical_url, 466 )); 467 } 468 469 if location_form 470 .build_state 471 .as_ref() 472 .is_some_and(|value| value == &BuildEventContentState::Reset) 473 { 474 location_form.location_country = None; 475 location_form.location_country_error = None; 476 location_form.location_name = None; 477 location_form.location_name_error = None; 478 } 479 480 if location_form 481 .build_state 482 .as_ref() 483 .is_some_and(|value| value == &BuildEventContentState::Selected) 484 { 485 let found_errors = location_form.validate(&web_context.i18n_context.locales, &language); 486 if found_errors { 487 location_form.build_state = Some(BuildEventContentState::Selecting); 488 } else { 489 location_form.build_state = Some(BuildEventContentState::Selected); 490 } 491 } 492 493 Ok(renderer.render_template( 494 "create_event.location_form", 495 template_context! { 496 location_form, 497 is_development, 498 }, 499 None, 500 &canonical_url, 501 )) 502} 503 504pub async fn handle_link_at_builder( 505 method: Method, 506 State(web_context): State<WebContext>, 507 Language(language): Language, 508 Cached(auth): Cached<Auth>, 509 HxRequest(hx_request): HxRequest, 510 Form(mut link_form): Form<BuildLinkForm>, 511) -> Result<impl IntoResponse, WebError> { 512 if !hx_request { 513 return Ok(StatusCode::BAD_REQUEST.into_response()); 514 } 515 516 if auth.require_flat().is_err() { 517 return Ok(StatusCode::BAD_REQUEST.into_response()); 518 } 519 520 let is_development = cfg!(debug_assertions); 521 522 // Create the template renderer for HTMX content replacement 523 let renderer = create_renderer!(web_context.clone(), Language(language.clone()), false, false); 524 525 let canonical_url = format!("https://{}/event", web_context.config.external_base); 526 527 if link_form.build_state.is_none() { 528 link_form.build_state = Some(BuildEventContentState::default()); 529 } 530 531 if method == Method::GET { 532 return Ok(renderer.render_template( 533 "create_event.link_form", 534 template_context! { 535 link_form, 536 is_development 537 }, 538 None, 539 &canonical_url, 540 )); 541 } 542 543 if link_form 544 .build_state 545 .as_ref() 546 .is_some_and(|value| value == &BuildEventContentState::Reset) 547 { 548 link_form.link_name = None; 549 link_form.link_name_error = None; 550 link_form.link_value = None; 551 link_form.link_value_error = None; 552 } 553 554 if link_form 555 .build_state 556 .as_ref() 557 .is_some_and(|value| value == &BuildEventContentState::Selected) 558 { 559 let found_errors = link_form.validate(&web_context.i18n_context.locales, &language); 560 if found_errors { 561 link_form.build_state = Some(BuildEventContentState::Selecting); 562 } else { 563 link_form.build_state = Some(BuildEventContentState::Selected); 564 } 565 } 566 567 Ok(renderer.render_template( 568 "create_event.link_form", 569 template_context! { 570 link_form, 571 is_development, 572 }, 573 None, 574 &canonical_url, 575 )) 576} 577 578#[derive(Deserialize, Debug, Clone)] 579pub struct LocationDataListHint { 580 pub location_country: Option<String>, 581} 582 583pub async fn handle_location_datalist( 584 State(web_context): State<WebContext>, 585 Language(language): Language, 586 HxRequest(hx_request): HxRequest, 587 Query(location_country_hint): Query<LocationDataListHint>, 588) -> Result<impl IntoResponse, WebError> { 589 if !hx_request { 590 return Ok(StatusCode::BAD_REQUEST.into_response()); 591 } 592 593 // Create the template renderer for HTMX content replacement 594 let renderer = create_renderer!(web_context.clone(), Language(language), false, false); 595 596 let canonical_url = format!("https://{}/event", web_context.config.external_base); 597 598 let all_countries = cached_countries()?; 599 600 let locations = if let Some(value) = location_country_hint.location_country { 601 prefixed((**all_countries).clone(), &value) 602 .iter() 603 .take(30) 604 .map(|(k, v)| (v.clone(), k.clone())) 605 .collect::<Vec<(String, String)>>() 606 } else { 607 all_countries 608 .iter() 609 .take(30) 610 .map(|(k, v)| (v.clone(), k.clone())) 611 .collect::<Vec<(String, String)>>() 612 }; 613 614 Ok(renderer.render_template( 615 "create_event.countries_datalist", 616 template_context! { 617 locations, 618 }, 619 None, 620 &canonical_url, 621 )) 622} 623 624// Nick: The next two methods were adapted from https://www.thecodedmessage.com/posts/prefix-ranges/ which has no license. Thank you. 625 626fn upper_bound_from_prefix(prefix: &str) -> Option<String> { 627 for i in (0..prefix.len()).rev() { 628 if let Some(last_char_str) = prefix.get(i..) { 629 let rest_of_prefix = { 630 debug_assert!(prefix.is_char_boundary(i)); 631 &prefix[0..i] 632 }; 633 634 let last_char = last_char_str 635 .chars() 636 .next() 637 .expect("last_char_str will contain at least one char"); 638 let Some(last_char_incr) = (last_char..=char::MAX).nth(1) else { 639 // Last character is highest possible code point. 640 // Go to second-to-last character instead. 641 continue; 642 }; 643 644 let new_string = format!("{rest_of_prefix}{last_char_incr}"); 645 646 return Some(new_string); 647 } 648 } 649 650 None 651} 652 653pub fn prefixed(mut set: BTreeMap<String, String>, prefix: &str) -> BTreeMap<String, String> { 654 let mut set = set.split_off(prefix); 655 656 if let Some(not_in_prefix) = upper_bound_from_prefix(prefix) { 657 set.split_off(&not_in_prefix); 658 } 659 660 set 661}