1use std::collections::HashMap;
62use std::fmt;
63
64use axum::response::{IntoResponse, Response};
65use futures_util::future::BoxFuture;
66use serde::de::DeserializeOwned;
67use serde_json::json;
68
69use crate::auth::User;
70use crate::db::Transaction;
71use crate::validation::extract::{Messages, Parsed, parse_pairs};
72use crate::validation::{Errors, FormContext, Validate, ValidationError, Validator};
73use crate::{AppState, Error, Lang, Result};
74
75pub const MAX_IMPORT_ROWS: usize = 10_000;
77
78type WriteRow<'a, T> =
80 Box<dyn for<'t> FnMut(&'t mut Transaction, T) -> BoxFuture<'t, Result> + Send + 'a>;
81
82type Record = (usize, Vec<(String, String)>);
84
85const LISTED_FAILURES: usize = 100;
87
88#[must_use = "an import does nothing until it is `run`"]
90pub struct Import {
91 data: Vec<u8>,
92 field: String,
93 delimiter: char,
94 headers: Option<Vec<String>>,
95 renames: Vec<(String, String)>,
96 max_rows: usize,
97 all_or_nothing: bool,
98 lang: Option<Lang>,
99 user: Option<User>,
100}
101
102impl Import {
103 pub fn csv(data: impl AsRef<[u8]>) -> Self {
106 Self {
107 data: data.as_ref().to_vec(),
108 field: "file".into(),
109 delimiter: ',',
110 headers: None,
111 renames: Vec::new(),
112 max_rows: MAX_IMPORT_ROWS,
113 all_or_nothing: false,
114 lang: None,
115 user: None,
116 }
117 }
118
119 pub fn delimiter(mut self, delimiter: char) -> Self {
122 self.delimiter = delimiter;
123 self
124 }
125
126 pub fn headers(mut self, names: &[&str]) -> Self {
128 self.headers = Some(names.iter().map(|n| (*n).to_owned()).collect());
129 self
130 }
131
132 pub fn rename(mut self, heading: &str, field: &str) -> Self {
137 self.renames.push((field_name(heading), field.to_owned()));
138 self
139 }
140
141 pub fn max_rows(mut self, rows: usize) -> Self {
144 self.max_rows = rows;
145 self
146 }
147
148 pub fn all_or_nothing(mut self) -> Self {
152 self.all_or_nothing = true;
153 self
154 }
155
156 pub fn lang(mut self, lang: &Lang) -> Self {
159 self.lang = Some(lang.clone());
160 self
161 }
162
163 pub fn user(mut self, user: &User) -> Self {
166 self.user = Some(user.clone());
167 self
168 }
169
170 pub fn field(mut self, name: &str) -> Self {
173 self.field = name.to_owned();
174 self
175 }
176
177 pub fn run<'a, T, F>(
186 self,
187 state: &'a AppState,
188 write: F,
189 ) -> impl Future<Output = Result<ImportReport>> + Send + 'a
190 where
191 T: DeserializeOwned + Validate + Send + Sync + 'a,
192 F: for<'t> FnMut(&'t mut Transaction, T) -> BoxFuture<'t, Result> + Send + 'a,
193 {
194 let lang = self
195 .lang
196 .clone()
197 .unwrap_or_else(|| state.lang(&state.config.locale));
198 let mut write: WriteRow<'a, T> = Box::new(write);
201 async move {
202 let records = self.records(&lang)?;
203 let mut report = ImportReport {
204 imported: 0,
205 failed: Vec::new(),
206 all_or_nothing: self.all_or_nothing,
207 lang: lang.clone(),
208 };
209 let messages = Messages {
210 texts: lang.texts(),
211 };
212 let mut valid = Vec::new();
213 for (row, pairs) in records {
214 match check::<T>(state, self.user.as_ref(), &messages, pairs).await? {
215 Ok(data) => valid.push((row, data)),
216 Err(errors) => report.failed.push(FailedRow { row, errors }),
217 }
218 }
219 if self.all_or_nothing && !report.failed.is_empty() {
220 return Ok(report);
221 }
222 let mut tx = state.db.begin().await?;
223 let mut imported = 0;
224 for (row, data) in valid {
225 let outcome = tx.savepoint(|tx| write(tx, data)).await;
226 match outcome {
227 Ok(()) => imported += 1,
228 Err(err) => {
229 report.failed.push(FailedRow {
230 row,
231 errors: row_errors(err, &lang)?,
232 });
233 if self.all_or_nothing {
234 return Ok(report);
236 }
237 }
238 }
239 }
240 tx.commit().await?;
241 report.imported = imported;
242 report.failed.sort_by_key(|f| f.row);
243 Ok(report)
244 }
245 }
246
247 fn records(&self, lang: &Lang) -> Result<Vec<Record>> {
250 let refuse = |key: &str, count: usize| -> Error {
251 let mut errors = Errors::new();
252 errors.add(
253 self.field.clone(),
254 lang.t(key, &[("count", &count as &dyn fmt::Display)]),
255 );
256 ValidationError::new(errors).into()
257 };
258 let Ok(text) = std::str::from_utf8(&self.data) else {
259 return Err(refuse("ui.import.not_utf8", 0));
260 };
261 let mut rows = parse_csv(text.trim_start_matches('\u{feff}'), self.delimiter);
262 let headers = match &self.headers {
264 Some(headers) => headers.clone(),
265 None if rows.is_empty() => return Err(refuse("ui.import.empty", 0)),
266 None => rows.remove(0).1,
267 };
268 let headers: Vec<String> = headers
269 .iter()
270 .map(|h| {
271 let key = field_name(h);
272 self.renames
273 .iter()
274 .find(|(from, _)| *from == key)
275 .map_or(key, |(_, to)| to.clone())
276 })
277 .collect();
278 let records: Vec<Record> = rows
279 .into_iter()
280 .filter(|(_, cells)| cells.iter().any(|c| !c.trim().is_empty()))
281 .map(|(index, cells)| {
282 let pairs = headers
283 .iter()
284 .zip(cells)
285 .filter(|(h, _)| !h.is_empty())
286 .map(|(h, c)| (h.clone(), c))
287 .collect();
288 (index + 1, pairs)
289 })
290 .collect();
291 if records.is_empty() {
292 return Err(refuse("ui.import.empty", 0));
293 }
294 if records.len() > self.max_rows {
295 return Err(refuse("ui.import.too_many", self.max_rows));
296 }
297 Ok(records)
298 }
299}
300
301fn field_name(heading: &str) -> String {
303 heading
304 .trim()
305 .to_lowercase()
306 .split([' ', '-'])
307 .filter(|part| !part.is_empty())
308 .collect::<Vec<_>>()
309 .join("_")
310}
311
312async fn check<T: DeserializeOwned + Validate + Send + Sync>(
314 state: &AppState,
315 user: Option<&User>,
316 messages: &Messages,
317 pairs: Vec<(String, String)>,
318) -> Result<std::result::Result<T, Errors>> {
319 let (parsed, _) = parse_pairs::<T>(pairs, &HashMap::new(), messages);
320 let (mut data, mut errors) = match parsed {
321 Parsed::Ok(data, errors) => (data, errors),
322 Parsed::Invalid(errors) => return Ok(Err(errors)),
323 };
324 data.prepare();
325 let rule_errors = Validator::rules_with_texts(&data, messages.texts.clone())
326 .finish_for(state, user)
327 .await?;
328 for (field, list) in rule_errors.iter() {
329 if !errors.has(field) {
330 for message in list {
331 errors.add(field, message.clone());
332 }
333 }
334 }
335 if errors.is_empty() {
336 let form = FormContext {
337 state,
338 user,
339 method: &axum::http::Method::POST,
340 path: "",
341 };
342 data.after(&form, &mut errors).await?;
343 }
344 Ok(if errors.is_empty() {
345 Ok(data)
346 } else {
347 Err(errors)
348 })
349}
350
351fn row_errors(err: Error, lang: &Lang) -> Result<Errors> {
353 let mut errors = Errors::new();
354 match err {
355 Error::Validation(invalid) => return Ok(invalid.errors),
356 Error::BadRequest(message) | Error::Status(_, message) => errors.add("row", message),
357 err if err.is_unique_violation() => errors.add("row", lang.t("ui.import.duplicate", &[])),
358 Error::Internal(err) => {
359 tracing::warn!(?err, "an imported row could not be saved");
360 errors.add("row", lang.t("ui.import.not_saved", &[]));
361 }
362 other => return Err(other),
364 }
365 Ok(errors)
366}
367
368#[non_exhaustive]
374pub struct ImportReport {
375 pub imported: usize,
377 pub failed: Vec<FailedRow>,
379 all_or_nothing: bool,
380 lang: Lang,
381}
382
383impl fmt::Debug for ImportReport {
384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385 f.debug_struct("ImportReport")
386 .field("imported", &self.imported)
387 .field("failed", &self.failed)
388 .field("all_or_nothing", &self.all_or_nothing)
389 .finish()
390 }
391}
392
393impl ImportReport {
394 pub fn is_clean(&self) -> bool {
396 self.failed.is_empty()
397 }
398
399 pub fn summary(&self) -> String {
402 let failed = self.failed.len() as i64;
403 if self.failed.is_empty() {
404 self.lang
405 .choice("ui.import.done", self.imported as i64, &[])
406 } else if self.imported == 0 {
407 self.lang.choice("ui.import.nothing", failed, &[])
408 } else {
409 self.lang.choice(
410 "ui.import.partial",
411 failed,
412 &[("imported", &self.imported as &dyn fmt::Display)],
413 )
414 }
415 }
416}
417
418impl IntoResponse for ImportReport {
419 fn into_response(self) -> Response {
420 let summary = self.summary();
421 if self.failed.is_empty() {
422 return (
423 crate::Toast::success(summary),
424 crate::HxRefresh,
425 String::new(),
426 )
427 .into_response();
428 }
429 let failed: Vec<_> = self
430 .failed
431 .iter()
432 .take(LISTED_FAILURES)
433 .map(|f| json!({ "row": f.row, "messages": f.messages() }))
434 .collect();
435 crate::view(
436 "renox/import_report.html",
437 minijinja::context! {
438 summary,
439 imported => self.imported,
440 failed,
441 more => self.failed.len().saturating_sub(LISTED_FAILURES),
442 },
443 )
444 .into_response()
445 }
446}
447
448#[derive(Debug, Clone)]
450#[non_exhaustive]
451pub struct FailedRow {
452 pub row: usize,
455 pub errors: Errors,
457}
458
459impl FailedRow {
460 pub fn messages(&self) -> Vec<String> {
462 self.errors
463 .iter()
464 .flat_map(|(_, list)| list.iter().cloned())
465 .collect()
466 }
467}
468
469pub fn template(filename: &str, columns: &[&str]) -> crate::Download {
479 let line = columns
480 .iter()
481 .map(|c| quote_cell(c))
482 .collect::<Vec<_>>()
483 .join(",");
484 crate::Download::bytes(
485 filename.to_owned(),
486 "text/csv; charset=utf-8",
487 format!("\u{feff}{line}\r\n"),
488 )
489}
490
491fn quote_cell(cell: &str) -> String {
492 if cell.contains([',', '"', '\n', '\r', ';']) {
493 format!("\"{}\"", cell.replace('"', "\"\""))
494 } else {
495 cell.to_owned()
496 }
497}
498
499fn parse_csv(text: &str, delimiter: char) -> Vec<(usize, Vec<String>)> {
503 let mut records = Vec::new();
504 let mut record: Vec<String> = Vec::new();
505 let mut cell = String::new();
506 let mut quoted = false;
507 let mut chars = text.chars().peekable();
508 let mut started = false;
509 while let Some(c) = chars.next() {
510 started = true;
511 if quoted {
512 match c {
513 '"' if chars.peek() == Some(&'"') => {
514 chars.next();
515 cell.push('"');
516 }
517 '"' => quoted = false,
518 c => cell.push(c),
519 }
520 continue;
521 }
522 match c {
523 '"' if cell.is_empty() => quoted = true,
524 c if c == delimiter => record.push(std::mem::take(&mut cell)),
525 '\r' if chars.peek() == Some(&'\n') => {}
526 '\n' | '\r' => {
527 record.push(std::mem::take(&mut cell));
528 records.push((records.len(), std::mem::take(&mut record)));
529 started = false;
530 }
531 c => cell.push(c),
532 }
533 }
534 if started {
535 record.push(cell);
536 records.push((records.len(), record));
537 }
538 records
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 fn cells(text: &str) -> Vec<Vec<String>> {
546 parse_csv(text, ',').into_iter().map(|(_, r)| r).collect()
547 }
548
549 #[test]
550 fn reads_rfc_4180() {
551 assert_eq!(
552 cells("a,b\r\n\"x, y\",\"say \"\"hi\"\"\"\n\"two\nlines\",\n"),
553 vec![
554 vec!["a".to_owned(), "b".into()],
555 vec!["x, y".into(), "say \"hi\"".into()],
556 vec!["two\nlines".into(), String::new()],
557 ]
558 );
559 assert_eq!(cells("a,b"), vec![vec!["a".to_owned(), "b".into()]]);
560 assert_eq!(parse_csv("a;b\n", ';')[0].1, vec!["a", "b"]);
561 assert!(cells("").is_empty());
562 }
563
564 #[test]
565 fn headings_name_fields() {
566 assert_eq!(field_name(" Unit price "), "unit_price");
567 assert_eq!(field_name("e-mail"), "e_mail");
568 assert_eq!(field_name("SKU"), "sku");
569 }
570
571 #[test]
572 fn templates_quote_their_cells() {
573 assert_eq!(quote_cell("name"), "name");
574 assert_eq!(quote_cell("a,b"), "\"a,b\"");
575 }
576
577 #[derive(serde::Deserialize)]
578 struct Row {
579 name: String,
580 qty: i64,
581 #[allow(dead_code)]
582 due: Option<chrono::NaiveDate>,
583 }
584
585 impl Validate for Row {
586 fn rules(&self, v: &mut Validator) {
587 v.field("name", &self.name).required();
588 v.field("qty", &self.qty).min(1);
589 }
590 }
591
592 async fn app() -> crate::testing::TestApp {
593 crate::testing::TestApp::new(crate::App::new()).await
594 }
595
596 #[tokio::test]
600 async fn rows_that_dont_read_are_reported_once() {
601 let app = app().await;
602 let csv = "name,qty,due\nTea,2,someday\nCoffee,lots,2026-01-01\nCake,3,\n";
603 let user = User::default();
604 let report = Import::csv(csv)
605 .user(&user)
606 .run(app.state(), |_tx, _row: Row| Box::pin(async { Ok(()) }))
607 .await
608 .unwrap();
609 assert_eq!(report.imported, 1);
610 let rows: Vec<_> = report.failed.iter().map(|f| f.row).collect();
611 assert_eq!(rows, [2, 3]);
612 assert!(report.failed[0].errors.has("due"));
613 let qty: Vec<_> = report.failed[1]
614 .errors
615 .iter()
616 .filter(|(field, _)| *field == "qty")
617 .flat_map(|(_, messages)| messages.iter())
618 .collect();
619 assert_eq!(qty, ["The qty must be a number."]);
620 let debug = format!("{report:?}");
621 assert!(
622 debug.starts_with("ImportReport { imported: 1, failed: [")
623 && debug.contains("all_or_nothing: false"),
624 "{debug}"
625 );
626 }
627
628 #[tokio::test]
632 async fn writes_that_fail_are_reported_by_kind() {
633 let app = app().await;
634 let csv = "name,qty\ninvalid,1\nbad,1\nboom,1\nfine,1\n";
635 let report = Import::csv(csv)
636 .run(app.state(), |_tx, row: Row| {
637 Box::pin(async move {
638 match row.name.as_str() {
639 "invalid" => {
640 let mut errors = Errors::new();
641 errors.add("name", "The name is taken.");
642 Err(ValidationError::new(errors).into())
643 }
644 "bad" => Err(Error::BadRequest("Unknown supplier.".into())),
645 "boom" => Err(Error::Internal(anyhow::anyhow!("disk full"))),
646 _ => Ok(()),
647 }
648 })
649 })
650 .await
651 .unwrap();
652 assert_eq!(report.imported, 1);
653 let first = |i: usize, field: &str| report.failed[i].errors.first(field).map(str::to_owned);
654 assert_eq!(first(0, "name").as_deref(), Some("The name is taken."));
655 assert_eq!(first(1, "row").as_deref(), Some("Unknown supplier."));
656 assert!(first(2, "row").is_some_and(|m| !m.contains("disk full")));
657
658 let stopped = Import::csv(csv)
659 .run(app.state(), |_tx, _row: Row| {
660 Box::pin(async { Err(Error::Forbidden) })
661 })
662 .await;
663 assert!(matches!(stopped, Err(Error::Forbidden)));
664 }
665
666 #[tokio::test]
669 async fn an_unreadable_file_is_an_error_on_its_field() {
670 let app = app().await;
671 let err = Import::csv([0xFF, 0xFE, 0x00])
672 .field("upload")
673 .run(app.state(), |_tx, _row: Row| Box::pin(async { Ok(()) }))
674 .await
675 .unwrap_err();
676 let Error::Validation(invalid) = err else {
677 panic!("a validation error");
678 };
679 assert!(invalid.errors.has("upload"));
680 }
681}