use doing_config::Config;
use doing_taskpaper::Entry;
use doing_template::renderer::RenderOptions;
use crate::{ExportPlugin, Plugin, PluginSettings};
const CSV_DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S %z";
pub struct CsvExport;
impl ExportPlugin for CsvExport {
fn render(&self, entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
let mut out = String::from("start,end,title,note,timer,section\n");
for entry in entries {
let start = entry.date().format(CSV_DATE_FORMAT).to_string();
let end = entry
.done_date()
.map(|d| d.format(CSV_DATE_FORMAT).to_string())
.unwrap_or_default();
let title = entry.full_title();
let note = if entry.note().is_empty() {
String::new()
} else {
entry.note().to_line(" ")
};
let timer = entry
.interval()
.map(|iv| iv.num_seconds().to_string())
.unwrap_or_default();
let section = entry.section();
out.push_str(&format!(
"{},{},{},{},{},{}\n",
csv_field(&start),
csv_field(&end),
csv_field(&title),
csv_field(¬e),
csv_field(&timer),
csv_field(section),
));
}
out
}
}
impl Plugin for CsvExport {
fn name(&self) -> &str {
"csv"
}
fn settings(&self) -> PluginSettings {
PluginSettings {
trigger: "csv".into(),
}
}
}
fn csv_field(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
#[cfg(test)]
mod test {
use doing_taskpaper::{Note, Tag, Tags};
use super::*;
use crate::test_helpers::{sample_date, sample_options};
mod csv_export_name {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_returns_csv() {
assert_eq!(CsvExport.name(), "csv");
}
}
mod csv_export_render {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_renders_header_row() {
let config = Config::default();
let options = sample_options();
let output = CsvExport.render(&[], &options, &config);
assert_eq!(output, "start,end,title,note,timer,section\n");
}
#[test]
fn it_renders_finished_entry_with_seconds_and_timezone() {
let config = Config::default();
let options = sample_options();
let entry = Entry::new(
sample_date(17, 14, 30),
"Working on project",
Tags::from_iter(vec![
Tag::new("coding", None::<String>),
Tag::new("done", Some("2024-03-17 15:00")),
]),
Note::from_text("A note"),
"Currently",
None::<String>,
);
let output = CsvExport.render(&[entry], &options, &config);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "start,end,title,note,timer,section");
assert!(lines[1].contains("2024-03-17 14:30:00"));
assert!(lines[1].contains("2024-03-17 15:00:00"));
assert!(lines[1].contains("Working on project"));
assert!(lines[1].contains("\"1800\""));
}
#[test]
fn it_quotes_empty_fields() {
let config = Config::default();
let options = sample_options();
let entry = Entry::new(
sample_date(17, 14, 30),
"In progress",
Tags::new(),
Note::new(),
"Currently",
None::<String>,
);
let output = CsvExport.render(&[entry], &options, &config);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[1].contains("\"\""));
}
#[test]
fn it_quotes_all_fields() {
let config = Config::default();
let options = sample_options();
let entry = Entry::new(
sample_date(17, 14, 30),
"Simple task",
Tags::new(),
Note::new(),
"Currently",
None::<String>,
);
let output = CsvExport.render(&[entry], &options, &config);
let lines: Vec<&str> = output.lines().collect();
let data_line = lines[1];
assert!(data_line.starts_with('"'));
assert!(data_line.contains("\"Simple task\""));
assert!(data_line.contains("\"Currently\""));
}
#[test]
fn it_escapes_commas_in_title() {
let config = Config::default();
let options = sample_options();
let entry = Entry::new(
sample_date(17, 14, 30),
"Task with, comma",
Tags::new(),
Note::new(),
"Currently",
None::<String>,
);
let output = CsvExport.render(&[entry], &options, &config);
assert!(output.contains("\"Task with, comma\""));
}
#[test]
fn it_renders_timer_as_seconds() {
let config = Config::default();
let options = sample_options();
let entry = Entry::new(
sample_date(17, 14, 0),
"Work",
Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:30"))]),
Note::new(),
"Currently",
None::<String>,
);
let output = CsvExport.render(&[entry], &options, &config);
assert!(output.contains("\"5400\""));
}
}
mod csv_export_settings {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_returns_csv_trigger() {
let settings = CsvExport.settings();
assert_eq!(settings.trigger, "csv");
}
}
mod csv_field {
use pretty_assertions::assert_eq;
use super::super::csv_field;
#[test]
fn it_doubles_embedded_quotes() {
assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn it_handles_value_containing_but_not_wrapped_in_quotes() {
assert_eq!(csv_field("a \"b\" c"), "\"a \"\"b\"\" c\"");
}
#[test]
fn it_quotes_empty_value() {
assert_eq!(csv_field(""), "\"\"");
}
#[test]
fn it_wraps_plain_value_in_quotes() {
assert_eq!(csv_field("hello"), "\"hello\"");
}
#[test]
fn it_wraps_value_with_comma_in_quotes() {
assert_eq!(csv_field("hello, world"), "\"hello, world\"");
}
#[test]
fn it_wraps_value_with_newline_in_quotes() {
assert_eq!(csv_field("line1\nline2"), "\"line1\nline2\"");
}
}
}