diff --git a/proto/anki/import_export.proto b/proto/anki/import_export.proto index 793338163..186a7ee61 100644 --- a/proto/anki/import_export.proto +++ b/proto/anki/import_export.proto @@ -111,7 +111,7 @@ message ImportCsvRequest { int64 notetype_id = 3; repeated CsvColumn columns = 4; uint32 delimiter = 5; - bool allow_html = 6; + bool is_html = 6; } message CsvMetadataRequest { @@ -125,5 +125,5 @@ message CsvMetadata { repeated string columns = 3; int64 deck_id = 4; int64 notetype_id = 5; - optional bool html = 6; + optional bool is_html = 6; } diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 3835266af..ea2cc049c 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -416,7 +416,7 @@ class Collection(DeprecatedNamesMixin): notetype_id: NotetypeId, columns: list[CsvColumn], delimiter: int, - allow_html: bool, + is_html: bool, ) -> ImportLogWithChanges: return self._backend.import_csv( path=path, @@ -424,7 +424,7 @@ class Collection(DeprecatedNamesMixin): notetype_id=notetype_id, delimiter=delimiter, columns=columns, - allow_html=allow_html, + is_html=is_html, ) def import_json(self, json: str) -> ImportLogWithChanges: diff --git a/qt/aqt/import_export/import_dialog.py b/qt/aqt/import_export/import_dialog.py index c4f18bdc6..fa59fa00b 100644 --- a/qt/aqt/import_export/import_dialog.py +++ b/qt/aqt/import_export/import_dialog.py @@ -100,7 +100,7 @@ class ImportDialog(QDialog): self.column_map = ColumnMap(self.columns, self.model) self._render_mapping() self._set_delimiter_button_text() - self.frm.allowHTML.setChecked(self.html) + self.frm.allowHTML.setChecked(self.is_html) self.frm.importMode.setCurrentIndex(self.mw.pm.profile.get("importMode", 1)) self.frm.tagModified.setText(self.tags) self.frm.tagModified.setCol(self.mw.col) @@ -119,10 +119,10 @@ class ImportDialog(QDialog): else: self.model = self.mw.col.models.current() self.notetype_id = self.model["id"] - if self.options.html is None: - self.html = self.mw.pm.profile.get("allowHTML", True) + if self.options.is_html is None: + self.is_html = self.mw.pm.profile.get("allowHTML", True) else: - self.html = self.options.html + self.is_html = self.options.is_html def _setup_choosers(self) -> None: import aqt.deckchooser @@ -214,7 +214,7 @@ class ImportDialog(QDialog): notetype_id=self.model["id"], delimiter=self.delimiter, columns=self.column_map.csv_columns(), - allow_html=self.frm.allowHTML.isChecked(), + is_html=self.frm.allowHTML.isChecked(), ), ).with_backend_progress(import_progress_update).success( show_import_log diff --git a/rslib/src/backend/import_export.rs b/rslib/src/backend/import_export.rs index 86c13e8ef..fbef8b473 100644 --- a/rslib/src/backend/import_export.rs +++ b/rslib/src/backend/import_export.rs @@ -94,7 +94,7 @@ impl ImportExportService for Backend { input.notetype_id.into(), input.columns.into_iter().map(Into::into).collect(), try_into_byte(input.delimiter)?, - //input.allow_html, + input.is_html, ) }) .map(Into::into) diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs index 3f70552cf..40fcc559d 100644 --- a/rslib/src/import_export/text/csv/import.rs +++ b/rslib/src/import_export/text/csv/import.rs @@ -22,12 +22,12 @@ impl Collection { notetype_id: NotetypeId, columns: Vec, delimiter: u8, - //allow_html: bool, + is_html: bool, ) -> Result> { let notetype = self.get_notetype(notetype_id)?.ok_or(AnkiError::NotFound)?; let fields_len = notetype.fields.len(); let file = File::open(path)?; - let notes = deserialize_csv(file, &columns, fields_len, delimiter)?; + let notes = deserialize_csv(file, &columns, fields_len, delimiter, is_html)?; ForeignData { // TODO: refactor to allow passing ids directly @@ -45,6 +45,7 @@ fn deserialize_csv( columns: &[Column], fields_len: usize, delimiter: u8, + is_html: bool, ) -> Result> { remove_tags_line_from_reader(&mut reader)?; let mut csv_reader = csv::ReaderBuilder::new() @@ -54,7 +55,7 @@ fn deserialize_csv( .delimiter(delimiter) .trim(csv::Trim::All) .from_reader(reader); - deserialize_csv_reader(&mut csv_reader, columns, fields_len) + deserialize_csv_reader(&mut csv_reader, columns, fields_len, is_html) } /// If the reader's first line starts with "tags:", which is allowed for historic @@ -78,35 +79,45 @@ fn deserialize_csv_reader( reader: &mut csv::Reader, columns: &[Column], fields_len: usize, + is_html: bool, ) -> Result> { reader .records() .into_iter() .map(|res| { - res.map(|record| ForeignNote::from_record(record, columns, fields_len)) + res.map(|record| ForeignNote::from_record(record, columns, fields_len, is_html)) .map_err(Into::into) }) .collect() } impl ForeignNote { - fn from_record(record: csv::StringRecord, columns: &[Column], fields_len: usize) -> Self { + fn from_record( + record: csv::StringRecord, + columns: &[Column], + fields_len: usize, + is_html: bool, + ) -> Self { let mut note = Self { fields: vec!["".to_string(); fields_len], ..Default::default() }; for (&column, value) in columns.iter().zip(record.iter()) { - note.add_column_value(column, value); + note.add_column_value(column, value, is_html); } note } - fn add_column_value(&mut self, column: Column, value: &str) { + fn add_column_value(&mut self, column: Column, value: &str, is_html: bool) { match column { Column::Ignore => (), Column::Field(idx) => { if let Some(field) = self.fields.get_mut(idx) { - field.push_str(value) + *field = if is_html { + value.to_string() + } else { + htmlescape::encode_minimal(value) + }; } } Column::Tags => self.tags.extend(value.split(' ').map(ToString::to_string)), @@ -128,15 +139,11 @@ mod test { &$options.columns, $options.fields_len, $options.delimiter, + $options.is_html, ) .unwrap(); - assert_eq!(notes.len(), $expected.len()); - for (note, fields) in notes.iter().zip($expected.iter()) { - assert_eq!(note.fields.len(), fields.len()); - for (note_field, field) in note.fields.iter().zip(fields.iter()) { - assert_eq!(note_field, field); - } - } + let fields: Vec<_> = notes.into_iter().map(|note| note.fields).collect(); + assert_eq!(fields, $expected); }; } @@ -144,6 +151,7 @@ mod test { columns: Vec, fields_len: usize, delimiter: u8, + is_html: bool, } impl CsvOptions { @@ -152,6 +160,7 @@ mod test { columns: vec![Column::Field(0), Column::Field(1)], fields_len: 2, delimiter: b',', + is_html: false, } } @@ -174,6 +183,12 @@ mod test { self.delimiter = delimiter; self } + + #[allow(clippy::wrong_self_convention)] + fn is_html(mut self, is_html: bool) -> Self { + self.is_html = is_html; + self + } } #[test] @@ -210,4 +225,11 @@ mod test { let options = CsvOptions::new(); assert_imported_fields!(options, "#foo\nfront,back\n#bar\n", &[&["front", "back"]]); } + + #[test] + fn should_escape_html_entities_if_csv_is_html() { + assert_imported_fields!(CsvOptions::new(), "
\n", &[&["<hr>", ""]]); + let with_html = CsvOptions::new().is_html(true); + assert_imported_fields!(with_html, "
\n", &[&["
", ""]]); + } } diff --git a/rslib/src/import_export/text/csv/metadata.rs b/rslib/src/import_export/text/csv/metadata.rs index 1bc951a4f..30f04386b 100644 --- a/rslib/src/import_export/text/csv/metadata.rs +++ b/rslib/src/import_export/text/csv/metadata.rs @@ -90,7 +90,7 @@ impl Collection { metadata.notetype_id = nt.id.0; } } - "html" => metadata.html = value.to_lowercase().parse::().ok(), + "is_html" => metadata.is_html = value.to_lowercase().parse::().ok(), _ => (), } @@ -245,9 +245,9 @@ mod test { #[test] fn should_detect_valid_html_toggle() { let mut col = open_test_collection(); - assert_eq!(metadata!(col, "#html:true\n").html, Some(true)); - assert_eq!(metadata!(col, "#html:FALSE\n").html, Some(false)); - assert_eq!(metadata!(col, "#html:maybe\n").html, None); + assert_eq!(metadata!(col, "#is_html:true\n").is_html, Some(true)); + assert_eq!(metadata!(col, "#is_html:FALSE\n").is_html, Some(false)); + assert_eq!(metadata!(col, "#is_html:maybe\n").is_html, None); } #[test]