use anyhow::{Result, bail};
pub fn tsv_to_deck(text: &str) -> Result<String> {
let mut out = String::new();
let mut cards = 0usize;
for raw in text.lines() {
let line = raw.trim_end_matches('\r'); if line.trim().is_empty() || line.starts_with('#') {
continue; }
let mut fields = line.split('\t');
let front = clean_field(fields.next().unwrap_or(""));
let back = clean_field(fields.next().unwrap_or(""));
if front.trim().is_empty() || back.trim().is_empty() {
continue;
}
let front_line = front.split_whitespace().collect::<Vec<_>>().join(" ");
out.push_str("# ");
out.push_str(&front_line);
out.push('\n');
for bl in back.lines() {
let bl = bl.trim();
if bl.is_empty() {
continue;
}
out.push('\t');
out.push_str(&escape_leading_markup(bl));
out.push('\n');
}
out.push('\n');
cards += 1;
}
if cards == 0 {
bail!("no cards found — expected tab-separated `front<TAB>back` lines");
}
Ok(out)
}
fn clean_field(field: &str) -> String {
let mut s = field.trim().to_string();
for br in ["<br>", "<br/>", "<br />", "<BR>", "<BR/>", "<BR />"] {
s = s.replace(br, "\n");
}
s = s
.replace(" ", " ")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&");
s
}
fn escape_leading_markup(line: &str) -> String {
if line.starts_with('%') || line.starts_with('!') {
format!("\\{line}")
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_str;
#[test]
fn one_row_becomes_one_card() {
let deck = tsv_to_deck("bonjour\thello\n").unwrap();
assert_eq!("# bonjour\n\thello\n\n", deck);
}
#[test]
fn skips_headers_blanks_and_half_rows() {
let tsv = "#separator:tab\n#html:true\n\nbonjour\thello\nlonely\nmerci\tthanks\n";
let deck = tsv_to_deck(tsv).unwrap();
let cards = parse_str("fr.txt", &deck).unwrap();
assert_eq!(2, cards.len());
}
#[test]
fn br_tags_split_the_back_into_lines() {
let deck = tsv_to_deck("q\tone<br>two<br/>three\n").unwrap();
assert_eq!("# q\n\tone\n\ttwo\n\tthree\n\n", deck);
}
#[test]
fn decodes_common_entities() {
let deck = tsv_to_deck("a & b\tx < y\n").unwrap();
assert_eq!("# a & b\n\tx < y\n\n", deck);
}
#[test]
fn escapes_a_back_line_that_starts_with_a_directive_char() {
let deck = tsv_to_deck("q\t% literal percent\n").unwrap();
assert_eq!("# q\n\t\\% literal percent\n\n", deck);
let cards = parse_str("d.txt", &deck).unwrap();
assert_eq!(vec!["% literal percent".to_string()], cards[0].back);
}
#[test]
fn output_parses_back_into_the_original_cards() {
let deck = tsv_to_deck("bonjour\thello\nmerci\tthank you\n").unwrap();
let cards = parse_str("fr.txt", &deck).unwrap();
assert_eq!(2, cards.len());
assert_eq!("bonjour", cards[0].front);
assert_eq!(vec!["hello".to_string()], cards[0].back);
assert_eq!("merci", cards[1].front);
}
#[test]
fn empty_or_headers_only_is_an_error() {
assert!(tsv_to_deck("").is_err());
assert!(tsv_to_deck("#separator:tab\n#html:true\n").is_err());
}
}