use super::{ BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, Variables, absolute_path, bad, json, read_file_head, str_param, variables_unlocked, write_file, }; use crate::cmd::place::text::windows_1252; /// Cap on a graph variable's CSV (MCP and files are untrusted). pub(super) const MAX_GRAPH_CSV: usize = 256 * 1024; /// Caps on a datasets CSV (paths and MCP payloads are untrusted). const MAX_CSV_BYTES: usize = 1_048_576; const MAX_CSV_ROWS: usize = 1_000; const MAX_CSV_COLS: usize = 256; /// Bytes of a datasets CSV from `path`, `data`, `dataBase64` or `csv`, and whether they came /// as text (`data` or `csv`). fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<(Vec, bool)> { let (bytes, text) = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64"), str_param(p, "csv")) { // One byte past the cap is enough to refuse the file below. (Some(path), ..) => (read_file_head(&absolute_path(path), MAX_CSV_BYTES + 1)?, false), (None, Some(text), ..) => (text.as_bytes().to_vec(), true), (None, None, Some(b64), _) => (vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, false), (None, None, None, Some(csv)) => (csv.as_bytes().to_vec(), true), _ => return Err(bad(cmd, "give `path`, `data`, `dataBase64` or `csv`")), }; if bytes.len() > MAX_CSV_BYTES { return Err(bad(cmd, format!("CSV is larger than {MAX_CSV_BYTES} bytes"))); } Ok((bytes, text)) } /// The text of a datasets CSV given as a file or as `dataBase64`: UTF-16 after a UTF-16 /// byte-order mark, else UTF-8, with or without a byte-order mark, else Windows-1252. A file /// that starts with a byte-order mark must be valid in its encoding, and a file without one must /// not hold NUL bytes (UTF-16 without a byte-order mark). fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result { let utf16 = |rest: &[u8], le: bool| { let (units, odd) = rest.as_chunks::<2>(); let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); let text = char::decode_utf16(units).collect::>(); text.ok().filter(|_| odd.is_empty()).ok_or_else(|| bad(cmd, "CSV is not UTF-16")) }; match bytes { [0xFF, 0xFE, rest @ ..] => return utf16(rest, true), [0xFE, 0xFF, rest @ ..] => return utf16(rest, false), _ if bytes.contains(&0) => return Err(bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark")), _ => {} } match std::str::from_utf8(bytes) { Ok(text) => Ok(text.to_string()), Err(_) if bytes.starts_with(b"\xEF\xBB\xBF") => Err(bad(cmd, "CSV is not UTF-8")), Err(_) => Ok(bytes.iter().map(|&b| windows_1252(b)).collect()), } } /// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. type CsvRow = (usize, Vec, Vec); /// Whether a datasets CSV separates its cells with tabs: its header row (the first line that /// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside /// quotes. fn tab_separated(text: &str) -> bool { // `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds // anything but white space yet. let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false); let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { match c { '"' if in_quotes => { if chars.peek() == Some(&'"') { chars.next(); blank = false; } else { in_quotes = false; } } '"' if start => in_quotes = true, c if in_quotes => blank &= c.is_whitespace(), ',' => return false, '\t' => { tab = true; start = true; continue; } '\r' => continue, '\n' if blank => { tab = false; start = true; continue; } '\n' => return tab, c => blank &= c.is_whitespace(), } start = false; } tab } /// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated /// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside), /// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted /// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and /// drops white space after the closing quote at the end of the cell. A line whose cells are all /// blank and unquoted is skipped; a quoted empty cell keeps its line as a row. fn parse_datasets_csv(text: &str, cmd: &str) -> Result> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); let sep = if tab_separated(text) { '\t' } else { ',' }; let (mut rows, mut row, mut quoted, mut cur) = (Vec::new(), Vec::::new(), Vec::::new(), String::new()); // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the // current row starts on. let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); let mut chars = text.chars().peekable(); loop { let c = chars.next(); match c { Some('"') if in_quotes => { if chars.peek() == Some(&'"') { cur.push('"'); chars.next(); } else { in_quotes = false; closed = Some(cur.len()); } } Some('"') if cur.is_empty() => in_quotes = true, Some(ch) if ch == sep && !in_quotes => { quoted.push(closed.is_some()); row.push(end_csv_cell(&mut cur, &mut closed)); } Some('\r') if !in_quotes => {} Some('\n') | None if !in_quotes => { quoted.push(closed.is_some()); row.push(end_csv_cell(&mut cur, &mut closed)); if row.len() > MAX_CSV_COLS { return Err(bad(cmd, format!("line {line}: more than {MAX_CSV_COLS} columns"))); } if row.iter().any(|c| !c.trim().is_empty()) || quoted.contains(&true) { rows.push((start, std::mem::take(&mut row), std::mem::take(&mut quoted))); // +1 for the header. if rows.len() > MAX_CSV_ROWS + 1 { return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); } } row.clear(); quoted.clear(); if c.is_none() { return Ok(rows); } line += 1; start = line; } None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), Some(c) => { line += usize::from(c == '\n'); cur.push(c); } } if row.len() > MAX_CSV_COLS { return Err(bad(cmd, format!("line {line}: more than {MAX_CSV_COLS} columns"))); } } } /// Take the cell read so far out of `cur`. Without a closing quote (`closed` is `None`) the cell /// is trimmed; with one, the text before it is kept as written and the text after it is /// trimmed at the end. fn end_csv_cell(cur: &mut String, closed: &mut Option) -> String { let cell = std::mem::take(cur); match closed.take() { Some(at) => match cell.split_at_checked(at) { Some((quoted, after)) => format!("{quoted}{}", after.trim_end()), None => cell, }, None => cell.trim().to_string(), } } fn csv_escape(s: &str) -> String { // An empty string is quoted so that it reads back as an empty value, not as a missing one. A // tab is quoted so that a header of one variable whose name holds a tab isn't read as // tab-separated. if s.is_empty() || s.contains([',', '"', '\n', '\r', '\t']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } } /// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive). An error names the /// file line `line`. fn parse_visible_cell(s: &str, var: &str, line: usize, cmd: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "true" | "1" | "yes" => Ok(true), "false" | "0" | "no" => Ok(false), _ => Err(bad(cmd, format!("line {line}: value for `{var}` must be true or false"))), } } /// The value of a cell on file line `line`. A quoted empty text cell is an empty text value; any /// other empty cell is no value. An error names the line and the reason. fn cell_to_value(kind: VariableKind, cell: &str, quoted: bool, var: &str, line: usize, cmd: &str) -> Result> { // A text cell is not trimmed here (the parser trims unquoted cells); image, graph and // visibility cells are trimmed. let t = if kind == VariableKind::Text { cell } else { cell.trim() }; if t.is_empty() { return Ok((quoted && kind == VariableKind::Text).then(|| DataValue::Text(String::new()))); } Ok(Some(match kind { VariableKind::Text => DataValue::Text(t.to_string()), VariableKind::Image => DataValue::Image(t.to_string()), VariableKind::Graph if t.len() <= MAX_GRAPH_CSV => DataValue::Graph(t.to_string()), VariableKind::Graph => return Err(bad(cmd, format!("line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes"))), VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, line, cmd)?), VariableKind::Unknown => return Err(bad(cmd, format!("line {line}: value for `{var}` must match its variable kind"))), })) } fn value_to_cell(v: &DataValue) -> String { match v { DataValue::Text(t) | DataValue::Image(t) | DataValue::Graph(t) => t.clone(), DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(), } } /// The header cell that [`datasets_to_csv`] writes for `v`, as [`header_variable`] reads it: `#` /// before a visibility variable's name, `@` before an image variable's name, and the name alone /// for a text or graph variable. fn export_header(v: &Variable) -> String { match v.kind { VariableKind::Visibility => format!("#{}", v.name), VariableKind::Image => format!("@{}", v.name), VariableKind::Text | VariableKind::Graph | VariableKind::Unknown => v.name.clone(), } } /// What [`dataset_import`] does with each variable's column of the CSV that [`datasets_to_csv`] /// writes, in a document without the variables: the first list names the variables whose header /// or values it refuses, so it doesn't import the file (a `%` header, a `#` header whose values /// aren't true or false, a header that names a variable an earlier column named), and the second /// the variables it reads as another variable (a text variable whose name starts with `#` or `@`, /// and a graph variable, which no header cell defines). fn kinds_a_new_document_loses(vars: &Variables) -> (Vec<&str>, Vec<&str>) { const C: &str = "dataset.import"; let none = BTreeMap::new(); let mut defined = Vec::new(); let (mut refused, mut other) = (Vec::new(), Vec::new()); let mut seen = BTreeSet::new(); for (i, v) in vars.variables.iter().enumerate() { let header = export_header(v); let cell = header.trim(); let read = if cell.is_empty() { None } else { header_variable(cell, i + 1, &none, &mut defined, C).ok() }; // The import refuses a header that names a variable an earlier column named. let Some((name, kind)) = read.filter(|(name, _)| seen.insert(name.clone())) else { refused.push(v.name.as_str()); continue; }; let cells_read = vars.datasets.iter().filter_map(|d| d.values.get(&v.name)).all(|val| { let cell = value_to_cell(val); cell_to_value(kind, &cell, cell.is_empty(), &name, 2, C).is_ok() }); if !cells_read { refused.push(v.name.as_str()); } else if name != v.name || kind != v.kind { other.push(v.name.as_str()); } } (refused, other) } /// Build the CSV of every dataset: a header of each variable's [`export_header`] in definition /// order, then one row per dataset. The dataset names are not written. Refused when a variable's /// header is another variable's name, which the import reads as that variable (a visibility /// variable `Show` next to a text variable `#Show`). A dataset with no value for any variable would /// be a blank line, which the import skips, so its row gets a quoted empty cell: in the first column /// whose variable isn't a text variable, where it reads back as no value, or else in the first /// column, where it reads back as an empty text. fn datasets_to_csv(vars: &Variables, cmd: &str) -> Result { let names: BTreeSet<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect(); let mut header = Vec::with_capacity(vars.variables.len()); for v in &vars.variables { let cell = export_header(v); if cell != v.name && names.contains(cell.as_str()) { let (name, other) = (&v.name, &cell); return Err(bad(cmd, format!("the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them"))); } header.push(csv_escape(&cell)); } let mut out = header.join(","); out.push('\n'); let keep = vars.variables.iter().position(|v| v.kind != VariableKind::Text).unwrap_or(0); for ds in &vars.datasets { let mut row: Vec = vars.variables.iter().map(|v| ds.values.get(&v.name).map(|val| csv_escape(&value_to_cell(val))).unwrap_or_default()).collect(); if row.iter().all(String::is_empty) && let Some(cell) = row.get_mut(keep) { *cell = csv_escape(""); } out.push_str(&row.join(",")); out.push('\n'); } Ok(out) } /// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names, /// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables /// that earlier columns define) names that variable. Otherwise `#` before the name makes it a /// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text /// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new /// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused. fn header_variable( cell: &str, column: usize, kinds: &BTreeMap, defined: &mut Vec, cmd: &str, ) -> Result<(String, VariableKind)> { let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind)); if let Some(kind) = known(cell, defined) { return Ok((cell.to_string(), kind)); } let (name, kind) = if let Some(rest) = cell.strip_prefix('#') { (rest.trim(), VariableKind::Visibility) } else if let Some(rest) = cell.strip_prefix('@') { (rest.trim(), VariableKind::Image) } else if cell.starts_with('%') { return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read"))); } else { (cell, VariableKind::Text) }; if name.is_empty() { return Err(bad(cmd, format!("column {column}: `{cell}` names no variable"))); } match known(name, defined) { Some(k) if k == kind => Ok((name.to_string(), kind)), Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))), None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))), None => { defined.push(Variable { name: name.to_string(), kind }); Ok((name.to_string(), kind)) } } } /// Import datasets from CSV. Every header cell names a variable, as [`header_variable`] reads it, /// and the variables the document doesn't define yet are defined, unbound. Two headers that name /// one variable are refused. The rows replace every dataset of the document and are named "Data /// Set 1", "Data Set 2" and so on, in file order. The document's variables and their bindings /// stay. One undo step, and none when the variables and datasets stay as they were. pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.import"; let (bytes, given_as_text) = datasets_csv_bytes(p, C)?; // `data` and `csv` are already text, NUL characters included (`dataset.export` writes them). let text = if given_as_text { String::from_utf8(bytes).map_err(|_| bad(C, "CSV is not UTF-8"))? } else { datasets_csv_text(&bytes, C)? }; let rows = parse_datasets_csv(&text, C)?; let Some((_, header, _)) = rows.first() else { return Err(bad(C, "CSV is empty")); }; let kinds: BTreeMap = s.doc()?.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); let mut defined: Vec = Vec::new(); for (i, h) in header.iter().enumerate() { let cell = h.trim(); if cell.is_empty() { return Err(bad(C, format!("empty header in column {}", i + 1))); } let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?; if let Some(&(first, ..)) = columns.iter().find(|(_, n, _)| *n == name) { let (first, column) = (first + 1, i + 1); return Err(bad(C, format!("columns {first} and {column} both name the variable `{name}`"))); } columns.push((i, name, kind)); } // Lock Variables refuses New Variable, so the import is refused when a header would define one. if !defined.is_empty() { variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; } if rows.len() == 1 { return Err(bad(C, "CSV has a header but no data rows")); } let mut parsed: Vec = Vec::new(); for (n, &(line, ref row, ref quoted)) in rows.iter().skip(1).enumerate() { let name = format!("Data Set {}", n + 1); let mut values = BTreeMap::new(); for &(col, ref var, kind) in &columns { let cell = row.get(col).map(String::as_str).unwrap_or(""); let was_quoted = quoted.get(col).copied().unwrap_or(false); if let Some(v) = cell_to_value(kind, cell, was_quoted, var, line, C)? { values.insert(var.clone(), v); } } parsed.push(DataSet { name, values }); } let names: Vec = parsed.iter().map(|d| d.name.clone()).collect(); let new_variables: Vec = defined.iter().map(|v| v.name.clone()).collect(); let deleted: Vec = s.doc()?.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); // The import is one undo step, and an import that leaves the variables and data sets as they // were adds none, so the active data set stays when nothing changes. super::edit_variables(s, "Import Data Sets", |vars| { if defined.is_empty() && vars.datasets == parsed { return Ok(()); } vars.variables.extend(defined); vars.datasets = parsed; vars.active_dataset = None; Ok(()) })?; Ok(json!({"imported": names.len(), "names": names, "defined": new_variables, "deletedDataSets": deleted})) } /// Export every dataset as CSV ([`datasets_to_csv`]: one column per variable, no dataset names). /// With `path`, write the file; else return /// `{csv}`. The reply's `warning` names the variables whose column makes a document without them /// refuse the file, or else the variables it doesn't read back with their kind /// ([`kinds_a_new_document_loses`]); the app shows it in its status bar. /// A document with more data sets or variables than [`dataset_import`] reads is refused. pub(super) fn dataset_export(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.export"; let st = s.doc()?; if st.doc.variables.variables.is_empty() { return Err(bad(C, "define a variable first")); } if st.doc.variables.datasets.len() > MAX_CSV_ROWS { return Err(bad(C, format!("more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS}"))); } if st.doc.variables.variables.len() > MAX_CSV_COLS { return Err(bad(C, format!("more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS}"))); } let csv = datasets_to_csv(&st.doc.variables, C)?; if csv.len() > MAX_CSV_BYTES { return Err(bad(C, format!("CSV would be larger than {MAX_CSV_BYTES} bytes"))); } let mut out = match str_param(p, "path") { Some(path) => { let path = absolute_path(path); write_file(&path, csv.as_bytes())?; json!({"path": path, "datasets": st.doc.variables.datasets.len()}) } None => json!({"csv": csv, "datasets": st.doc.variables.datasets.len()}), }; let (refused, lost) = kinds_a_new_document_loses(&st.doc.variables); let list = |vars: &[&str]| vars.iter().map(|n| format!("`{n}`")).collect::>().join(", "); if !refused.is_empty() { let names = list(&refused); out["warning"] = json!(format!("a new document refuses this file because of these variables: {names}")); } else if !lost.is_empty() { let names = list(&lost); out["warning"] = json!(format!("a new document won't read these variables back with their kind: {names}")); } Ok(out) }