Forms: CSV import keeps values that span lines (#4)

* Forms: CSV import keeps values that span lines

A quoted CSV value may hold line breaks, and export_data writes multiline text fields that
way. The importer split the file into lines before reading quotes, so such a value was cut
at its first line break and every later column was lost (an export, then an import, of a form
with a multiline field silently dropped data). CSV records now end only at unquoted line
breaks; tab-delimited text is read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Forms: build CSV records without unwrap (never-panic rule)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
storybored379
2026-10-04 13:24:11 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 storybored379 Brandon Thomas
parent 3908a03aa6
commit 096b515719
2 changed files with 44 additions and 24 deletions
+29 -24
View File
@@ -843,34 +843,39 @@ fn import_table(doc: &mut Document, text: &str) -> Result<Report, DataError> {
(name, vec![c.text().unwrap_or("").to_string()])
})
.collect()
} else {
} else if t.lines().next().is_some_and(|l| l.contains('\t')) {
let mut lines = t.lines();
let (Some(head), Some(row)) = (lines.next(), lines.next()) else { return Err(DataError::UnknownFormat) };
let split = |l: &str| -> Vec<String> {
if l.contains('\t') {
l.split('\t').map(str::to_string).collect()
} else {
// CSV with quotes.
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut chars = l.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => out.push(std::mem::take(&mut cur)),
c => cur.push(c),
}
head.split('\t').map(str::to_string).zip(row.split('\t')).map(|(k, v)| (k, vec![v.to_string()])).collect()
} else {
// CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks.
let mut records: Vec<Vec<String>> = Vec::new();
let mut record = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut chars = t.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
out.push(cur);
out
'"' => quoted = !quoted,
',' if !quoted => record.push(std::mem::take(&mut cur)),
'\r' if !quoted && chars.peek() == Some(&'\n') => {}
'\n' if !quoted => {
record.push(std::mem::take(&mut cur));
records.push(std::mem::take(&mut record));
}
c => cur.push(c),
}
};
split(head).into_iter().zip(split(row)).map(|(k, v)| (k, vec![v])).collect()
}
record.push(cur);
records.push(record);
// Blank lines (such as the one a trailing line break leaves) are not records.
let mut records = records.into_iter().filter(|r| !matches!(r.as_slice(), [only] if only.is_empty()));
let (Some(head), Some(row)) = (records.next(), records.next()) else { return Err(DataError::UnknownFormat) };
head.into_iter().zip(row).map(|(k, v)| (k, vec![v])).collect()
};
apply_values(doc, &values, &mut report);
Ok(report)
+15
View File
@@ -152,6 +152,21 @@ fn form_data_as_xml_csv_and_text() {
assert_eq!(values(&dst)[0].1, ["Lovelace, Ada"]);
}
#[test]
fn csv_values_may_span_lines() {
// A quoted CSV value may hold line breaks (export_data writes multiline text that way): the
// record goes on to the closing quote, so later columns still import.
let mut dst = blank();
let r = import(&mut dst, b"Name,Agree\r\n\"Ada \"\"the\"\"\r\nCountess\",Yes\r\n").unwrap();
assert_eq!(r.fields, 2, "{r:?}");
assert!(values(&dst)[0].1[0].starts_with("Ada \"the\"") && values(&dst)[0].1[0].ends_with("Countess"), "{:?}", values(&dst));
assert_eq!(values(&dst)[1].1, ["Yes"]);
// A header without a record is still not a data file.
assert!(import(&mut blank(), b"Name,Agree\r\n").is_err());
// An unterminated quote runs to the end of the file and is read leniently, as before.
assert!(import(&mut blank(), b"Name,Agree\r\n\"Ada, Yes\r\n").is_ok());
}
#[test]
fn bad_data_is_refused_with_a_reason() {
let mut dst = blank();