1use serde_json::{Map, Value};
5use sofie_core::{Error, FieldError, Result, Shown};
6
7use crate::{Area, RequestContext};
8use sofie_db::{Input, Resolve};
9
10const FORMULA_STARTS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
12
13pub(crate) fn render(shown: Shown<'_>) -> Result<String> {
15 let Shown::List(items) = shown else {
16 return Err(Error::internal_message(
17 "a CSV route's loader answered no list of records: an export answers every row as a \
18 `Vec`, not a page such as `Paginated` or `Listed`",
19 ));
20 };
21 let mut csv = String::new();
22 let mut names: Vec<&str> = Vec::new();
23 for index in 0.. {
24 let Some(item) = items.item(index) else { break };
25 let Shown::Record(fields) = item else {
26 return Err(Error::internal_message(
27 "a CSV route's loader answered a list of something other than records",
28 ));
29 };
30 if index == 0 {
31 names = fields.names().collect();
32 push_row(&mut csv, names.iter().map(|name| (*name).to_owned()));
33 }
34 push_row(
35 &mut csv,
36 names
37 .iter()
38 .map(|name| fields.get(name).map(cell).unwrap_or_default()),
39 );
40 }
41 Ok(csv)
42}
43
44fn push_row(csv: &mut String, cells: impl Iterator<Item = String>) {
45 for (index, cell) in cells.enumerate() {
46 if index > 0 {
47 csv.push(',');
48 }
49 push_cell(csv, &cell);
50 }
51 csv.push_str("\r\n");
52}
53
54fn push_cell(csv: &mut String, cell: &str) {
55 let guarded = cell.starts_with(FORMULA_STARTS) && cell.parse::<f64>().is_err();
56 if !guarded && !cell.contains([',', '"', '\n', '\r']) {
57 csv.push_str(cell);
58 return;
59 }
60 csv.push('"');
61 if guarded {
62 csv.push('\'');
63 }
64 csv.push_str(&cell.replace('"', "\"\""));
65 csv.push('"');
66}
67
68fn cell(shown: Shown<'_>) -> String {
69 match shown {
70 Shown::Null => String::new(),
71 Shown::Bool(flag) => flag.to_string(),
72 Shown::Integer(number) => number.to_string(),
73 Shown::Float(number) => number.to_string(),
74 Shown::Number(number) => number.to_string(),
75 Shown::Text(text) => text.to_owned(),
76 Shown::Uuid(id) => id.to_string(),
77 Shown::Instant(instant) => instant.to_string(),
78 Shown::Formatted(value) => value.to_string(),
79 Shown::List(items) => (0..)
80 .map_while(|index| items.item(index))
81 .map(cell)
82 .collect::<Vec<_>>()
83 .join("; "),
84 Shown::Record(fields) => fields
85 .names()
86 .filter_map(|name| fields.get(name))
87 .map(cell)
88 .filter(|text| !text.is_empty())
89 .collect::<Vec<_>>()
90 .join(" "),
91 }
92}
93
94pub struct CsvRows<I>(pub Vec<I>);
97
98impl<A: Area, I: Input + Send + 'static> Resolve<RequestContext<A>> for CsvRows<I> {
99 fn resolve(context: &mut RequestContext<A>) -> impl Future<Output = Result<Self>> + Send {
100 let body = context.matched.body.take();
101 std::future::ready(
102 body.ok_or_else(crate::context::body_already_read)
103 .and_then(|body| {
104 let text = std::str::from_utf8(&body)
105 .map_err(|_| Error::BadRequest("the CSV is not UTF-8".to_owned()))?;
106 rows(text)
107 }),
108 )
109 }
110}
111
112fn rows<I: Input>(text: &str) -> Result<CsvRows<I>> {
113 let mut lines = parse(text)?.into_iter();
114 let header = lines
115 .next()
116 .ok_or_else(|| Error::BadRequest("the CSV has no header row".to_owned()))?;
117 let mut read = Vec::new();
118 let mut refused = Vec::new();
119 for (index, cells) in lines.enumerate() {
120 let row = index + 2;
121 if cells.len() != header.len() {
122 return Err(Error::BadRequest(format!(
123 "row {row} has {} cells; the header names {}",
124 cells.len(),
125 header.len()
126 )));
127 }
128 let fields: Map<String, Value> = header
129 .iter()
130 .cloned()
131 .zip(cells.into_iter().map(Value::String))
132 .collect();
133 match I::from_json(Value::Object(fields)) {
134 Ok(input) => read.push(input),
135 Err(Error::Invalid(errors)) => {
136 refused.extend(errors.into_iter().map(|error| FieldError {
137 field: format!("{row}.{}", error.field),
138 ..error
139 }));
140 }
141 Err(error) => return Err(error),
142 }
143 }
144 if refused.is_empty() {
145 Ok(CsvRows(read))
146 } else {
147 Err(Error::Invalid(refused))
148 }
149}
150
151fn parse(text: &str) -> Result<Vec<Vec<String>>> {
153 let mut rows = Vec::new();
154 let mut row = Vec::new();
155 let mut cell = String::new();
156 let mut quoted = false;
157 let mut characters = text.trim_start_matches('\u{feff}').chars().peekable();
158 while let Some(character) = characters.next() {
159 match (quoted, character) {
160 (true, '"') if characters.peek() == Some(&'"') => {
161 cell.push('"');
162 characters.next();
163 }
164 (true, '"') => quoted = false,
165 (false, '"') if cell.is_empty() => quoted = true,
166 (false, ',') => row.push(std::mem::take(&mut cell)),
167 (false, '\r') => {}
168 (false, '\n') => end_row(&mut rows, &mut row, &mut cell),
169 _ => cell.push(character),
170 }
171 }
172 if quoted {
173 return Err(Error::BadRequest(
174 "the CSV ends inside a quoted cell".to_owned(),
175 ));
176 }
177 end_row(&mut rows, &mut row, &mut cell);
178 Ok(rows)
179}
180
181fn end_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>, cell: &mut String) {
182 row.push(std::mem::take(cell));
183 let row = std::mem::take(row);
184 if row.len() > 1 || !row[0].is_empty() {
185 rows.push(row);
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use serde_json::json;
192
193 use super::*;
194
195 #[test]
196 fn writes_quoted_cells_and_defuses_formulas() {
197 let list = json!([
198 {"name": "Scarf, blue", "note": "said \"soft\"", "total": 12},
199 {"name": "=HYPERLINK(\"x\")", "note": null, "total": -3},
200 ]);
201
202 let csv = render(sofie_core::Show::show(&list)).unwrap();
203
204 assert_eq!(
205 csv,
206 "name,note,total\r\n\"Scarf, blue\",\"said \"\"soft\"\"\",12\r\n\
207 \"'=HYPERLINK(\"\"x\"\")\",,-3\r\n"
208 );
209 }
210
211 #[test]
212 fn a_page_of_a_list_is_refused_with_what_to_answer_instead() {
213 let page = json!({"items": [{"name": "Scarf"}], "page": 1, "size": 20, "has_next": false});
214
215 let error = render(sofie_core::Show::show(&page))
216 .unwrap_err()
217 .to_string();
218
219 assert!(error.contains("not a page"), "{error}");
220 }
221
222 #[test]
223 fn reads_quoted_cells_line_breaks_and_a_byte_order_mark() {
224 let rows =
225 parse("\u{feff}name,note\r\n\"a, b\",\"two\nlines\"\n\n\"say \"\"hi\"\"\",\n").unwrap();
226
227 assert_eq!(
228 rows,
229 [
230 vec!["name", "note"],
231 vec!["a, b", "two\nlines"],
232 vec!["say \"hi\"", ""],
233 ]
234 );
235 assert!(parse("name\n\"open").is_err());
236 }
237}