Skip to main content

renox_core/grid/
export.rs

1//! A grid's exports: every row its filters match, in the user's columns, as
2//! CSV, an Excel workbook (the `xlsx` feature) or a page to print.
3
4use 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
12/// The most rows one export holds.
13pub const MAX_EXPORT_ROWS: u64 = 100_000;
14
15/// The links of the export menu: the page's filters and sort, plus `export`.
16pub(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    /// Answers the export menu's links (`?export=csv|xlsx|print`): `None`
38    /// when the request isn't an export, so the handler goes on to draw the
39    /// page.
40    ///
41    /// ```
42    /// # use renox::prelude::*;
43    /// use renox::grid::{Column, Grid, GridRequest};
44    /// # #[derive(Model, serde::Serialize, Default)] struct Order { id: i64, number: String }
45    /// fn orders() -> Grid {
46    ///     Grid::new("orders").column(Column::text("number", "Order")).exports()
47    /// }
48    ///
49    /// async fn index(request: GridRequest) -> Result<Response> {
50    ///     if let Some(file) = orders().export(Order::query(), &request).await? {
51    ///         return Ok(file);
52    ///     }
53    ///     let page = orders().page(Order::query(), &request).await?;
54    ///     Ok(view("orders/index.html", context! { orders => page }).into_response())
55    /// }
56    /// ```
57    ///
58    /// Custom columns are left out, and so are the columns the user hid on
59    /// wide screens. Up to [`MAX_EXPORT_ROWS`] rows.
60    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    /// Every row `query` matches as a file, outside the grid's page: an
88    /// "Export" action on a record's page or in a menu (the grid's own
89    /// export menu uses [`Grid::export`]). The grid's columns that show by
90    /// default on wide screens, in their order; the request's filters,
91    /// sort and the user's column choices don't apply (sort the query
92    /// yourself). Up to [`MAX_EXPORT_ROWS`] rows.
93    ///
94    /// ```
95    /// # use renox::prelude::*;
96    /// use renox::grid::{Column, ExportFormat, Grid, GridRequest};
97    /// # #[derive(Model, serde::Serialize, Default)] struct Movement { id: i64, product_id: i64, quantity: i64 }
98    /// fn ledger() -> Grid {
99    ///     Grid::new("ledger").column(Column::number("quantity", "Quantity"))
100    /// }
101    ///
102    /// // GET /products/{id}/ledger.csv
103    /// async fn export_ledger(Path(id): Path<i64>, request: GridRequest) -> Result<Response> {
104    ///     let movements = Movement::where_eq("product_id", id).order_by("id");
105    ///     ledger().export_as(movements, ExportFormat::Csv, &request).await
106    /// }
107    /// ```
108    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    /// Every row of `query` (up to [`MAX_EXPORT_ROWS`]) with the related
124    /// values the grid's columns show.
125    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/// The file an export makes. `Xlsx` needs renox's `xlsx` feature (without
193/// it the export answers 400). It reads from a route or query string as
194/// `csv`, `xlsx` or `print`.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
196#[serde(rename_all = "lowercase")]
197#[non_exhaustive]
198pub enum ExportFormat {
199    /// CSV with a byte order mark, so Excel reads it as UTF-8.
200    Csv,
201    /// An Excel workbook (the `xlsx` feature).
202    Xlsx,
203    /// A page to print (or save as PDF from the browser).
204    Print,
205}
206
207impl ExportFormat {
208    /// `csv`, `xlsx` or `print`.
209    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    /// Whether this build can make it (`Xlsx` needs the `xlsx` feature).
219    pub fn available(self) -> bool {
220        self != Self::Xlsx || cfg!(feature = "xlsx")
221    }
222}
223
224/// An Excel workbook with several sheets, each one a grid's export of a
225/// query (needs renox's `xlsx` feature; without it [`Workbook::sheet`]
226/// answers 400, like the single-sheet export). Sheets appear in the order
227/// they are added; each uses the grid's columns that show by default on
228/// wide screens, with the same headings, number formats and frozen columns
229/// as [`Grid::export_as`].
230///
231/// ```
232/// # use renox::prelude::*;
233/// use renox::grid::{Column, Grid, GridRequest, Workbook};
234/// # #[derive(Model, serde::Serialize, Default)] struct Sale { id: i64, total: i64 }
235/// # #[derive(Model, serde::Serialize, Default)] struct Refund { id: i64, amount: i64 }
236/// // GET /reports/summary.xlsx
237/// async fn summary(request: GridRequest) -> Result<Response> {
238///     let sales = Grid::new("sales").column(Column::money("total", "Total"));
239///     let refunds = Grid::new("refunds").column(Column::money("amount", "Amount"));
240///     Workbook::new("summary")
241///         .sheet("Sales", &sales, Sale::query().order_by("id"), &request).await?
242///         .sheet("Refunds", &refunds, Refund::query().order_by("id"), &request).await?
243///         .into_response()
244/// }
245/// ```
246#[non_exhaustive]
247pub struct Workbook {
248    name: String,
249    #[cfg(feature = "xlsx")]
250    book: rust_xlsxwriter::Workbook,
251}
252
253impl Workbook {
254    /// A workbook whose file is `{name}-{date}.xlsx`.
255    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    /// Adds a sheet called `title` (at most 31 characters, none of
264    /// `[]:*?/\`, unique in the workbook) with every row `query`
265    /// matches, up to [`MAX_EXPORT_ROWS`]. The request's filters and the
266    /// user's column choices don't apply (as in [`Grid::export_as`]).
267    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    /// The `.xlsx` download of the sheets added so far.
307    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
330/// The exported rows with their columns.
331struct Table<'a> {
332    columns: &'a [(&'a Column, Option<Pin>)],
333    rows: &'a [Map<String, Value>],
334    request: &'a GridRequest,
335}
336
337impl Table<'_> {
338    /// A number as the grid shows it, with its decimals: money is divided
339    /// from the smallest unit into whole units of `APP_CURRENCY`.
340    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    /// One heading per column: its groups and its label, `Amounts / Total`.
362    fn heading(column: &Column) -> String {
363        let mut parts = column.under.clone();
364        parts.push(column.label.clone());
365        parts.join(" / ")
366    }
367
368    /// A value as text, the way the grid shows it (without number
369    /// separators, so spreadsheets read numbers).
370    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    /// A value for people to read: numbers with the locale's separators.
403    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    /// A moment in `APP_TIMEZONE`: `2026-03-05 14:30`.
419    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    /// RFC 4180 CSV with a BOM (so Excel reads UTF-8). Text that a
426    /// spreadsheet would run as a formula (`=`, `+`, `-`, `@` first) gets a
427    /// `'` in front.
428    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/// Writes a table into a worksheet: headings, rows, frozen panes.
488#[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    // The headings as on screen: groups span their columns, leaves span
500    // down to the last heading row.
501    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    // Headings and the frozen-left columns stay put, as in the grid.
599    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}