feat: add pdf gen capability

This commit is contained in:
alphafetus committed 2026-04-27 02:52:44 -04:00
1 parent 3cc67cb9e6
commit 8282ba4302
5 files changed
+370 -16

No files matched your search

+32
View File
@@ -7,6 +7,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys};
use crate::pages::api::students::{get_student_info_json, get_student_pdf};
fn get_important_dates() -> Vec<DateInfo> {
vec![
@@ -100,6 +101,36 @@ pub fn AdminUtilsPage() -> impl IntoView {
});
};
let on_click_json = move |_| {
spawn_local(async move {
let (file_name, file_bytes) = get_student_pdf("54d82438-c051-709d-30f7-c60e97f5112a".to_string())
.await
.unwrap_or_default();
let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32);
uint_arr.copy_from(&file_bytes);
let blob_parts = js_sys::Array::new();
blob_parts.push(&uint_arr);
let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap();
let blob_url = Url::create_object_url_with_blob(&blob).unwrap();
let window = window();
let document = window.document().unwrap();
let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap();
a.set_href(&blob_url);
a.set_download(&file_name);
document.body().unwrap().append_child(&a).unwrap();
a.click();
document.body().unwrap().remove_child(&a).unwrap();
Url::revoke_object_url(&blob_url).unwrap();
});
};
view! {
<div class="mx-auto px-6">
<div class="flex flex-col gap-4">
@@ -109,6 +140,7 @@ pub fn AdminUtilsPage() -> impl IntoView {
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
<ActionButton on:click=on_click_json>"Get student JSON"</ActionButton>
</div>
</div>
}.into_any()
+154
View File
@@ -2,6 +2,7 @@ use leptos::prelude::*;
use leptos::server_fn::codec::Json;
use std::collections::HashMap;
use std::io::Write;
use std::process::Stdio;
#[cfg(feature = "ssr")]
mod imports {
@@ -14,8 +15,12 @@ mod imports {
pub use leptos::logging::{debug_log, error};
pub use std::collections::HashMap;
pub use zip::write::SimpleFileOptions;
pub use leptos::serde_json;
pub use std::process::Command;
}
static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ");
#[server(input = Json)]
pub async fn put_student_data(
subject: String,
@@ -512,3 +517,152 @@ pub async fn provider_get_all_input_files(
get_all_input_files(form_name, input_name).await
}
#[server]
pub async fn get_student_pdf(
student_id: String,
) -> Result<(String, Vec<u8>), ServerFnError> {
use imports::*;
// NOTE: this API requires that the server has the typst-cli available ON PATH.
let student_info_str = get_student_info_json(student_id.clone()).await?;
let student_info = get_student_data(student_id, "DEMOGRAPHICS".to_string()).await?;
let first_name = student_info.get("first_name")
.map(|v| v.to_string())
.unwrap_or_default();
let last_name = student_info.get("last_name")
.map(|v| v.to_string())
.unwrap_or_default();
let typst_string = student_info_str
.replace("{", "(")
.replace("}", ")")
.replace("[", "(")
.replace("]", ")")
// Ensures trailing commas for lists of dictionaries
.replace("))", "),)");
debug_log!("New string: {}", typst_string);
let pdf_string = format!("#let student = {typst_string}\n{PDF_TEMPLATE}");
debug_log!("PDF string: {}", pdf_string);
let mut child = Command::new("typst")
.arg("compile")
.arg("-")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
{
let stdin = child.stdin.as_mut().unwrap();
stdin.write_all(pdf_string.as_bytes())?;
stdin.flush()?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).to_string();
error!("Failed to generate PDF: {}", msg);
Err(ServerFnError::new(format!("Failed to generate PDF: {msg}")))
} else {
let bytes = output.stdout;
let file_name = format!("{first_name}{last_name}_Application.pdf");
Ok((file_name, bytes))
}
}
#[server]
pub async fn get_student_info_json(
student_id: String,
) -> Result<String, ServerFnError> {
use imports::*;
let client = create_dynamo_client().await;
let response = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.key_condition_expression("HK = :hk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})?;
let work_exp = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(":sk", AttributeValue::S("WORKEXP".to_string()))
.key_condition_expression("HK = :hk AND SK = :sk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let items = output.items.unwrap_or_default();
debug_log!("Number of items: {}", items.len());
items.into_iter()
.next()
.unwrap_or_default()
.get("extracurricular")
.cloned()
.unwrap_or(AttributeValue::S("".to_string()))
})?;
let extracurricular = client
.query()
.table_name(MAIN_TABLE_NAME)
.expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}")))
.expression_attribute_values(":sk", AttributeValue::S("DEMOGRAPHICS".to_string()))
.key_condition_expression("HK = :hk AND SK = :sk")
.send()
.await
.map_err(|e| {
let msg = e.message().unwrap_or("Unknown error occurred");
error!("{}", msg);
ServerFnError::new(msg)
})
.map(|output| {
let items = output.items.unwrap_or_default();
debug_log!("Number of items: {}", items.len());
items.into_iter()
.next()
.unwrap_or_default()
.get("extracurricular")
.cloned()
.unwrap_or(AttributeValue::S("".to_string()))
})?;
let Some(items) = response.items else {
return Err(ServerFnError::new("Failed to find student information."));
};
// Join items together into a single object.
let mut data = items.into_iter()
.fold(HashMap::new(), |mut map, item| {
item.into_iter()
.for_each(|(k, v)| { map.insert(k, v); });
map
});
data.insert("work_experience".to_string(), work_exp);
data.insert("extracurricular".to_string(), extracurricular);
let json_value: serde_json::Value = serde_dynamo::from_item(data)?;
let json_string = serde_json::to_string(&json_value)?;
println!("{}", json_string);
Ok(json_string)
}
+40 -16
View File
@@ -2,7 +2,7 @@
use crate::components::{ActionButton, Banner, DataDisplay, Loading};
use crate::pages::UnauthenticatedPage;
use crate::pages::api::get_comparison_info;
use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data};
use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data, GetStudentPdf};
use base64::Engine;
use leptos::ev::{Event, MouseEvent};
use leptos::html::Dialog;
@@ -1120,7 +1120,10 @@ pub fn StudentInformationDialog(
// scholarship, meaning their essays and/or FAFSA by request.
let get_student_files = ServerAction::<GetStudentFiles>::new();
let get_buttons_disabled = get_student_files.pending();
let get_student_pdf = ServerAction::<GetStudentPdf>::new();
let get_buttons_disabled = Memo::new(move |_| {
get_student_files.pending().get() && get_student_pdf.pending().get()
});
let on_click_fafsa = move |_| {
get_student_files.dispatch(GetStudentFiles {
access_token: access_token.get().unwrap_or_default(),
@@ -1142,23 +1145,41 @@ pub fn StudentInformationDialog(
});
};
let on_click_pdf = move |_| {
debug_log!("Getting student PDF");
get_student_pdf.dispatch(GetStudentPdf {
student_id: student_id.get().unwrap_or_default(),
});
};
fn open_file(file_name: String, file: Vec<u8>) {
let base64 = base64::engine::general_purpose::STANDARD.encode(file);
let data_url = format!("data:application/zip;base64,{}", base64);
// Create a base64 link and open it.
let a: HtmlAnchorElement = document()
.create_element("a")
.expect("Failed to create anchor element")
.dyn_into()
.unwrap();
a.set_download(&*file_name);
a.set_href(&data_url);
document().body().unwrap().append_child(&a).unwrap();
a.click();
a.remove();
}
Effect::new(move || {
if let Some(Ok((file_name, file))) = get_student_files.value().get() {
let base64 = base64::engine::general_purpose::STANDARD.encode(file);
let data_url = format!("data:application/zip;base64,{}", base64);
// Create a base64 link and open it.
let a: HtmlAnchorElement = document()
.create_element("a")
.expect("Failed to create anchor element")
.dyn_into()
.unwrap();
a.set_download(&*file_name);
a.set_href(&data_url);
document().body().unwrap().append_child(&a).unwrap();
a.click();
a.remove();
open_file(file_name, file);
get_student_files.clear();
}
});
// Copy and paste is bad, but it's a little short on time.
Effect::new(move || {
if let Some(Ok((file_name, file))) = get_student_pdf.value().get() {
open_file(file_name, file);
get_student_files.clear();
}
});
@@ -1192,6 +1213,9 @@ pub fn StudentInformationDialog(
"Download Essay File(s)"
</ActionButton>
</Show>
<ActionButton on:click=on_click_pdf disabled=get_buttons_disabled>
"Download Student Application"
</ActionButton>
</div>
{move || Suspend::new(async move {
get_student_data(