1use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6use serde_json::{Map, Value, json};
7
8use super::{Column, Grid, GridPrefs, GridRequest, Kind, Pin, header_rows, load_prefs};
9use crate::db::{Model, Query};
10use crate::{Error, Result};
11
12pub const MAX_EXPORT_ROWS: u64 = 100_000;
14
15pub(super) fn urls(path: &str, query: &[(String, String)], per_page: &str, export: &str) -> Value {
17 let link = |format: &str| {
18 let mut pairs: Vec<(&str, &str)> = query
19 .iter()
20 .filter(|(k, _)| k != per_page && k != export)
21 .map(|(k, v)| (k.as_str(), v.as_str()))
22 .collect();
23 pairs.push((export, format));
24 format!(
25 "{path}?{}",
26 serde_html_form::to_string(&pairs).unwrap_or_default()
27 )
28 };
29 json!({
30 "csv": link("csv"),
31 "xlsx": cfg!(feature = "xlsx").then(|| link("xlsx")),
32 "print": link("print"),
33 })
34}
35
36impl Grid {
37 pub async fn export<M: Model + Serialize>(
61 &self,
62 query: Query<M>,
63 request: &GridRequest,
64 ) -> Result<Option<Response>> {
65 let Some(format) = request.param(&self.name("export")) else {
66 return Ok(None);
67 };
68 let Some(format) = ExportFormat::parse(format) else {
69 return Err(Error::BadRequest(format!(
70 "`{format}` isn't an export (csv, xlsx, print)"
71 )));
72 };
73 let prefs = load_prefs(request, &self.id).await;
74 let wide = prefs
75 .wide
76 .clone()
77 .unwrap_or_else(|| self.default_visible(false));
78 let columns: Vec<(&Column, Option<Pin>)> = self
79 .ordered(&prefs)
80 .into_iter()
81 .filter(|(c, _)| c.kind != Kind::Custom && wide.contains(&c.key))
82 .collect();
83 let query = self.filter(query, request);
84 self.file(&columns, query, format, request).await.map(Some)
85 }
86
87 pub async fn export_as<M: Model + Serialize>(
109 &self,
110 query: Query<M>,
111 format: ExportFormat,
112 request: &GridRequest,
113 ) -> Result<Response> {
114 let shown = self.default_visible(false);
115 let columns: Vec<(&Column, Option<Pin>)> = self
116 .ordered(&GridPrefs::default())
117 .into_iter()
118 .filter(|(c, _)| c.kind != Kind::Custom && shown.contains(&c.key))
119 .collect();
120 self.file(&columns, query, format, request).await
121 }
122
123 async fn export_rows<M: Model + Serialize>(
126 &self,
127 query: Query<M>,
128 request: &GridRequest,
129 ) -> Result<Vec<Map<String, Value>>> {
130 let items = query.limit(MAX_EXPORT_ROWS).get(&request.db).await?;
131 let related = self.related_values(&request.db, &items).await?;
132 Ok(items
133 .iter()
134 .zip(related)
135 .map(|(item, related)| {
136 let mut row = match serde_json::to_value(item) {
137 Ok(Value::Object(map)) => map,
138 _ => Map::new(),
139 };
140 row.extend(related);
141 row
142 })
143 .collect())
144 }
145
146 async fn file<M: Model + Serialize>(
147 &self,
148 columns: &[(&Column, Option<Pin>)],
149 query: Query<M>,
150 format: ExportFormat,
151 request: &GridRequest,
152 ) -> Result<Response> {
153 let rows = self.export_rows(query, request).await?;
154 let today = crate::db::now().format("%Y-%m-%d").to_string();
155 let name = format!("{}-{today}", self.id);
156 let table = Table {
157 columns,
158 rows: &rows,
159 request,
160 };
161 Ok(match format {
162 ExportFormat::Csv => crate::Download::bytes(
163 format!("{name}.csv"),
164 "text/csv; charset=utf-8",
165 table.csv(),
166 )
167 .into_response(),
168 ExportFormat::Xlsx => xlsx(&table, &name)?,
169 ExportFormat::Print => crate::view(
170 "renox/grid_print.html",
171 minijinja::context! {
172 title => self.title.clone().unwrap_or_else(|| self.id.clone()),
173 header => header_rows(columns),
174 columns => columns.iter().map(|(c, _)| json!({
175 "key": c.key,
176 "label": c.label,
177 "numeric": matches!(c.kind, Kind::Number | Kind::Money),
178 })).collect::<Vec<_>>(),
179 rows => rows.iter().map(|row| columns.iter().map(|(c, _)| table.display(c, row.get(&c.key))).collect::<Vec<_>>()).collect::<Vec<_>>(),
180 total => rows.len(),
181 printed => crate::db::now().to_rfc3339(),
182 back => format!("{}?{}", request.path, serde_html_form::to_string(
183 request.params.iter().filter(|(k, _)| *k != self.name("export")).collect::<Vec<_>>()
184 ).unwrap_or_default()),
185 },
186 )
187 .into_response(),
188 })
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
196#[serde(rename_all = "lowercase")]
197#[non_exhaustive]
198pub enum ExportFormat {
199 Csv,
201 Xlsx,
203 Print,
205}
206
207impl ExportFormat {
208 pub fn parse(text: &str) -> Option<Self> {
210 match text {
211 "csv" => Some(Self::Csv),
212 "xlsx" => Some(Self::Xlsx),
213 "print" => Some(Self::Print),
214 _ => None,
215 }
216 }
217
218 pub fn available(self) -> bool {
220 self != Self::Xlsx || cfg!(feature = "xlsx")
221 }
222}
223
224#[non_exhaustive]
247pub struct Workbook {
248 name: String,
249 #[cfg(feature = "xlsx")]
250 book: rust_xlsxwriter::Workbook,
251}
252
253impl Workbook {
254 pub fn new(name: impl Into<String>) -> Self {
256 Self {
257 name: name.into(),
258 #[cfg(feature = "xlsx")]
259 book: rust_xlsxwriter::Workbook::new(),
260 }
261 }
262
263 pub async fn sheet<M: Model + Serialize>(
268 mut self,
269 title: &str,
270 grid: &Grid,
271 query: Query<M>,
272 request: &GridRequest,
273 ) -> Result<Self> {
274 let shown = grid.default_visible(false);
275 let columns: Vec<(&Column, Option<Pin>)> = grid
276 .ordered(&GridPrefs::default())
277 .into_iter()
278 .filter(|(c, _)| c.kind != Kind::Custom && shown.contains(&c.key))
279 .collect();
280 let rows = grid.export_rows(query, request).await?;
281 let table = Table {
282 columns: &columns,
283 rows: &rows,
284 request,
285 };
286 self.add(title, &table)?;
287 Ok(self)
288 }
289
290 #[cfg(feature = "xlsx")]
291 fn add(&mut self, title: &str, table: &Table<'_>) -> Result<()> {
292 let sheet = self.book.add_worksheet();
293 sheet
294 .set_name(title)
295 .map_err(|e| Error::BadRequest(format!("`{title}` can't name a sheet: {e}")))?;
296 write_sheet(sheet, table)
297 }
298
299 #[cfg(not(feature = "xlsx"))]
300 fn add(&mut self, _: &str, _: &Table<'_>) -> Result<()> {
301 Err(Error::BadRequest(
302 "Excel exports need renox's `xlsx` feature".into(),
303 ))
304 }
305
306 pub fn into_response(self) -> Result<Response> {
308 let today = crate::db::now().format("%Y-%m-%d").to_string();
309 let name = format!("{}-{today}", self.name);
310 self.finish(&name)
311 }
312
313 #[cfg(feature = "xlsx")]
314 fn finish(mut self, name: &str) -> Result<Response> {
315 let bytes = self
316 .book
317 .save_to_buffer()
318 .map_err(|e| Error::Internal(anyhow::anyhow!(e)))?;
319 Ok(xlsx_download(name, bytes))
320 }
321
322 #[cfg(not(feature = "xlsx"))]
323 fn finish(self, _: &str) -> Result<Response> {
324 Err(Error::BadRequest(
325 "Excel exports need renox's `xlsx` feature".into(),
326 ))
327 }
328}
329
330struct Table<'a> {
332 columns: &'a [(&'a Column, Option<Pin>)],
333 rows: &'a [Map<String, Value>],
334 request: &'a GridRequest,
335}
336
337impl Table<'_> {
338 fn figure(&self, column: &Column, n: f64) -> (f64, Option<u32>) {
341 match column.kind {
342 Kind::Money => {
343 let decimals = self.request.money_decimals;
344 (
345 n / 10f64.powi(decimals as i32),
346 Some(column.decimals.map_or(decimals, u32::from)),
347 )
348 }
349 _ => (n, column.decimals.map(u32::from)),
350 }
351 }
352
353 fn yes_no(&self, yes: bool) -> String {
354 let key = if yes { "ui.grid.yes" } else { "ui.grid.no" };
355 match &self.request.lang {
356 Some(lang) => lang.t(key, &[]),
357 None => (if yes { "Yes" } else { "No" }).to_owned(),
358 }
359 }
360
361 fn heading(column: &Column) -> String {
363 let mut parts = column.under.clone();
364 parts.push(column.label.clone());
365 parts.join(" / ")
366 }
367
368 fn text(&self, column: &Column, value: Option<&Value>) -> String {
371 let label = |v: &str| {
372 column
373 .options
374 .iter()
375 .find(|(o, _)| o == v)
376 .map_or_else(|| v.to_owned(), |(_, l)| l.clone())
377 };
378 match value {
379 None | Some(Value::Null) => String::new(),
380 Some(Value::Bool(b)) => self.yes_no(*b),
381 Some(Value::Number(n)) if column.kind == Kind::Bool => {
382 self.yes_no(n.as_i64() != Some(0))
383 }
384 Some(Value::Number(n)) => match n.as_f64().map(|f| self.figure(column, f)) {
385 Some((f, Some(d))) => format!("{f:.*}", d as usize),
386 _ => n.to_string(),
387 },
388 Some(Value::String(s)) => match column.kind {
389 Kind::Select => label(s),
390 Kind::DateTime => self.moment(s).unwrap_or_else(|| s.clone()),
391 _ => s.clone(),
392 },
393 Some(Value::Array(items)) => items
394 .iter()
395 .map(|v| v.as_str().map_or_else(|| v.to_string(), label))
396 .collect::<Vec<_>>()
397 .join(", "),
398 Some(other) => other.to_string(),
399 }
400 }
401
402 fn display(&self, column: &Column, value: Option<&Value>) -> String {
404 match (column.kind, value.and_then(Value::as_f64)) {
405 (Kind::Number | Kind::Money, Some(n)) if column.kind != Kind::Bool => {
406 let locale = self
407 .request
408 .lang
409 .as_ref()
410 .map_or("en", |l| l.locale.as_str());
411 let (n, decimals) = self.figure(column, n);
412 crate::view_filters::format_number(n, decimals.unwrap_or(0), locale)
413 }
414 _ => self.text(column, value),
415 }
416 }
417
418 fn moment(&self, text: &str) -> Option<String> {
420 let at = chrono::DateTime::parse_from_rfc3339(text).ok()?;
421 let local = self.request.zone.local(at.timestamp());
422 Some(local.format("%Y-%m-%d %H:%M").to_string())
423 }
424
425 fn csv(&self) -> Vec<u8> {
429 let mut out = String::from('\u{feff}');
430 let line = |cells: Vec<String>| -> String {
431 cells
432 .into_iter()
433 .map(|cell| {
434 if cell.contains([',', '"', '\n', '\r']) {
435 format!("\"{}\"", cell.replace('"', "\"\""))
436 } else {
437 cell
438 }
439 })
440 .collect::<Vec<_>>()
441 .join(",")
442 + "\r\n"
443 };
444 out.push_str(&line(
445 self.columns.iter().map(|(c, _)| Self::heading(c)).collect(),
446 ));
447 for row in self.rows {
448 out.push_str(&line(
449 self.columns
450 .iter()
451 .map(|(c, _)| {
452 let text = self.text(c, row.get(&c.key));
453 let numeric = matches!(c.kind, Kind::Number | Kind::Money);
454 if !numeric && text.starts_with(['=', '+', '-', '@', '\t', '\r']) {
455 format!("'{text}")
456 } else {
457 text
458 }
459 })
460 .collect(),
461 ));
462 }
463 out.into_bytes()
464 }
465}
466
467#[cfg(feature = "xlsx")]
468fn xlsx(table: &Table<'_>, name: &str) -> Result<Response> {
469 let mut book = rust_xlsxwriter::Workbook::new();
470 write_sheet(book.add_worksheet(), table)?;
471 let bytes = book
472 .save_to_buffer()
473 .map_err(|e| Error::Internal(anyhow::anyhow!(e)))?;
474 Ok(xlsx_download(name, bytes))
475}
476
477#[cfg(feature = "xlsx")]
478fn xlsx_download(name: &str, bytes: Vec<u8>) -> Response {
479 crate::Download::bytes(
480 format!("{name}.xlsx"),
481 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
482 bytes,
483 )
484 .into_response()
485}
486
487#[cfg(feature = "xlsx")]
489fn write_sheet(sheet: &mut rust_xlsxwriter::Worksheet, table: &Table<'_>) -> Result<()> {
490 use rust_xlsxwriter::{Format, FormatAlign, FormatBorder};
491
492 let fail = |e: rust_xlsxwriter::XlsxError| Error::Internal(anyhow::anyhow!(e));
493 let head = Format::new()
494 .set_bold()
495 .set_align(FormatAlign::Center)
496 .set_align(FormatAlign::VerticalCenter)
497 .set_border(FormatBorder::Thin)
498 .set_background_color("#F2F2F7");
499 let header = header_rows(table.columns);
502 let depth = header.len() as u32;
503 let mut column_of: std::collections::HashMap<String, u16> = Default::default();
504 for (i, (c, _)) in table.columns.iter().enumerate() {
505 column_of.insert(c.key.clone(), i as u16);
506 }
507 for (r, cells) in header.iter().enumerate() {
508 for cell in cells {
509 let keys: Vec<u16> = cell["keys"]
510 .as_array()
511 .into_iter()
512 .flatten()
513 .filter_map(|k| k.as_str().and_then(|k| column_of.get(k).copied()))
514 .collect();
515 let (Some(&first), Some(&last)) = (keys.iter().min(), keys.iter().max()) else {
516 continue;
517 };
518 let label = match cell["key"].as_str() {
519 Some(key) => table
520 .columns
521 .iter()
522 .find(|(c, _)| c.key == key)
523 .map(|(c, _)| c.label.clone())
524 .unwrap_or_default(),
525 None => cell["label"].as_str().unwrap_or_default().to_owned(),
526 };
527 let row = r as u32;
528 let bottom = row + cell["rowspan"].as_u64().unwrap_or(1) as u32 - 1;
529 if first == last && row == bottom {
530 sheet
531 .write_string_with_format(row, first, &label, &head)
532 .map_err(fail)?;
533 } else {
534 sheet
535 .merge_range(row, first, bottom, last, &label, &head)
536 .map_err(fail)?;
537 }
538 }
539 }
540 let decimals = table.request.money_decimals as usize;
541 let money = Format::new().set_num_format(if decimals == 0 {
542 "#,##0".to_owned()
543 } else {
544 format!("#,##0.{}", "0".repeat(decimals))
545 });
546 let date = Format::new().set_num_format("yyyy-mm-dd");
547 let moment = Format::new().set_num_format("yyyy-mm-dd hh:mm");
548 for (i, row) in table.rows.iter().enumerate() {
549 let r = depth + i as u32;
550 for (c, (column, _)) in table.columns.iter().enumerate() {
551 let c = c as u16;
552 let value = row.get(&column.key);
553 match (column.kind, value) {
554 (_, None | Some(Value::Null)) => {}
555 (Kind::Number | Kind::Money, Some(Value::Number(n))) => {
556 let n = n.as_f64().unwrap_or_default();
557 match (column.kind, column.decimals) {
558 (Kind::Money, _) => {
559 let (n, _) = table.figure(column, n);
560 sheet.write_number_with_format(r, c, n, &money)
561 }
562 (_, Some(d)) if d > 0 => sheet.write_number_with_format(
563 r,
564 c,
565 n,
566 &Format::new()
567 .set_num_format(format!("#,##0.{}", "0".repeat(usize::from(d)))),
568 ),
569 _ => sheet.write_number(r, c, n),
570 }
571 .map_err(fail)?;
572 }
573 (Kind::Date, Some(Value::String(s))) => {
574 match s.parse::<chrono::NaiveDate>() {
575 Ok(d) => sheet.write_datetime_with_format(r, c, d, &date),
576 Err(_) => sheet.write_string(r, c, s),
577 }
578 .map_err(fail)?;
579 }
580 (Kind::DateTime, Some(Value::String(s))) => {
581 match chrono::DateTime::parse_from_rfc3339(s) {
582 Ok(at) => {
583 let local = table.request.zone.local(at.timestamp());
584 sheet.write_datetime_with_format(r, c, local, &moment)
585 }
586 Err(_) => sheet.write_string(r, c, s),
587 }
588 .map_err(fail)?;
589 }
590 _ => {
591 sheet
592 .write_string(r, c, table.text(column, value))
593 .map_err(fail)?;
594 }
595 }
596 }
597 }
598 let frozen = table
600 .columns
601 .iter()
602 .take_while(|(_, pin)| *pin == Some(Pin::Left))
603 .count() as u16;
604 sheet.set_freeze_panes(depth, frozen).map_err(fail)?;
605 sheet.autofit();
606 Ok(())
607}
608
609#[cfg(not(feature = "xlsx"))]
610fn xlsx(_: &Table<'_>, _: &str) -> Result<Response> {
611 Err(Error::BadRequest(
612 "Excel exports need renox's `xlsx` feature".into(),
613 ))
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn export_links_keep_the_filters() {
622 let urls = urls(
623 "/orders",
624 &[
625 ("q.name".into(), "iced coffee".into()),
626 ("per_page".into(), "25".into()),
627 ],
628 "per_page",
629 "export",
630 );
631 assert_eq!(urls["csv"], "/orders?q.name=iced+coffee&export=csv");
632 assert_eq!(urls["print"], "/orders?q.name=iced+coffee&export=print");
633 assert_eq!(urls["xlsx"].is_string(), cfg!(feature = "xlsx"));
634 }
635}