use std::str::FromStr; use std::io::Write; use std::borrow::Cow; use serde::{Serialize, Deserialize}; use itertools::Itertools; use chrono::{ DateTime, Utc, TimeZone, Duration, NaiveTime, Timelike, NaiveDateTime, Local, }; use crate::error; use crate::models::Entry; fn format_duration(dur: Duration) -> String { format!("{}:{:02}:{:02}", dur.num_hours(), dur.num_minutes() % 60, dur.num_seconds() % 60) } fn format_start(t: NaiveTime) -> String { format!("{:02}:{:02}:{:02} -", t.hour(), t.minute(), t.second()) } fn lpad(s: &str, len: usize) -> String { let padding = " ".repeat(len.saturating_sub(s.len())); padding + s } fn rpad(s: &str, len: usize) -> String { let padding = " ".repeat(len.saturating_sub(s.len())); s.to_string() + &padding } fn format_end(start: NaiveDateTime, end: NaiveDateTime) -> String { let extra_days = (end - start).num_days(); let d = if extra_days > 0 { format!("+{}d", extra_days) } else { "".into() }; format!( "{:02}:{:02}:{:02}{}", end.hour(), end.minute() % 60, end.second() % 60, d ) } fn constrained_lines(text: &str, width: usize) -> Vec> { textwrap::wrap(text, width) } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Formatter { Text, Custom(String), } impl Default for Formatter { fn default() -> Formatter { Formatter::Text } } impl Formatter { /// Prints the given entries to the specified output device. /// /// the current time is given as the `now` argument and the offset from UTC /// to the local timezone is given in `offset` to prevent this function from /// using a secondary effect to retrieve the time and conver dates. This /// also makes it easier to test. pub fn print_formatted(&self, entries: Vec, out: &mut W, now: DateTime, ids: bool, term_width: usize) -> error::Result<()> { match &self { Formatter::Text => self.print_formatted_text(entries, out, now, ids, term_width)?, Formatter::Custom(name) => { } } Ok(()) } /// Print in the default text format. Assume entries are sorted by sheet and /// then by start fn print_formatted_text(&self, entries: Vec, out: &mut W, now: DateTime, ids: bool, term_width: usize) -> error::Result<()> { let grouped_entries = entries.into_iter().group_by(|e| e.sheet.to_string()); for (key, group) in grouped_entries.into_iter() { writeln!(out, "Timesheet: {}", key)?; // End Duration Notes")?; // A vector of lines to be printed, with all the components let mut lines = Vec::new(); let entries_by_date = group.group_by(|e| Local.from_utc_datetime(&e.start.naive_utc()).date()); let mut total = Duration::seconds(0); for (date, entries) in entries_by_date.into_iter() { let mut daily = Duration::seconds(0); for (i, entry) in entries.into_iter().enumerate() { let start = format_start(Local.from_utc_datetime(&entry.start.naive_utc()).time()); let end = entry.end.map(|t| { format_end( Local.from_utc_datetime(&entry.start.naive_utc()).naive_local(), Local.from_utc_datetime(&t.naive_utc()).naive_local() ) }).unwrap_or(" ".into()); let duration = entry.end.unwrap_or(now) - entry.start; daily = daily + duration; let duration = format_duration(duration); let id = if ids { entry.id.to_string() } else { "".into() }; if i == 0 { let date = date.format("%a %b %d, %Y").to_string(); lines.push([id, date, start, end, duration, entry.note]); } else { lines.push([id, "".into(), start, end, duration, entry.note]); } } total = total + daily; lines.push(["".into(), "".into(), "".into(), "".into(), format_duration(daily), "".into()]); } // compute some column widths before printing // When array_map is stabilized this can be shortened let lengths = lines .iter() .map(|[i, d, s, e, du, n]| [i.len(), d.len(), s.len(), e.len(), du.len(), n.len()]) .reduce(|[a, b, c, d, e, f], [g, h, i, j, k, l]| { [a.max(g), b.max(h), c.max(i), d.max(j), e.max(k), f.max(l)] }).unwrap(); writeln!(out, "{} {} {} {} {} {}", if ids { lpad("ID", 3.max(lengths[0])) } else { lpad(" ", 3.max(lengths[0])) }, rpad("Day", 18), rpad("Start", 10), rpad("End", 10.max(lengths[3])), rpad("Duration", 8.max(lengths[4])), "Notes", )?; let mut max_note_length = 0; for [id, date, start, end, duration, note] in lines { let first_line = format!( "{} {} {} {} {}", lpad(&id, 3.max(lengths[0])), rpad(&date, 18), rpad(&start, 10), rpad(&end, 10.max(lengths[3])), lpad(&duration, 8.max(lengths[4])), ); let space_left = term_width.saturating_sub(first_line.len() + 1).max(40); let note_lines = constrained_lines(¬e, space_left); for (i, note_line) in note_lines.into_iter().enumerate() { if i == 0 { if note_line.len() != 0 { writeln!(out, "{} {}", first_line, note_line)?; } else { writeln!(out, "{}", first_line)?; } } else { writeln!(out, "{} {}", " ".repeat(first_line.len()), note_line)?; } if note_line.len() > max_note_length { max_note_length = note_line.len(); } } } writeln!(out, "{} {}-{}-{}-{}-{}", lpad(" ", 3.max(lengths[0])), "-".repeat(18), "-".repeat(10), "-".repeat(10.max(lengths[3])), "-".repeat(8.max(lengths[4])), "-".repeat(4.max(max_note_length)), )?; writeln!(out, "{} {} {} {} {}", lpad(" ", 3.max(lengths[0])), rpad("Total", 18), rpad("", 10), rpad("", 10.max(lengths[3])), lpad(&format_duration(total), 8.max(lengths[4])), )?; } Ok(()) } } impl FromStr for Formatter { type Err = error::Error; fn from_str(s: &str) -> error::Result { let lower = s.to_lowercase(); Ok(match &*lower { "text" => Formatter::Text, custom_format => Formatter::Custom(custom_format.into()), }) } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use chrono::TimeZone; use crate::test_utils::PrettyString; use super::*; const LONG_NOTE: &'static str = "chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be."; #[test] fn test_constrained_lines_long_text() { std::env::set_var("TZ", "UTC"); assert_eq!(constrained_lines(LONG_NOTE, 46), vec![ "chatting with bob about upcoming task,", "district sharing of images, how the user", "settings currently works etc. Discussing the", "fingerprinting / cache busting issue with", "CKEDITOR, suggesting perhaps looking into", "forking the rubygem and seeing if we can work", "in our own changes, however hard that might", "be.", ]); } #[test] fn test_text_output() { std::env::set_var("TZ", "UTC"); let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(18, 0, 0))), Entry::new_sample(3, Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0))), Entry::new_sample(4, Utc.ymd(2008, 10, 5).and_hms(18, 0, 0), None), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); formatter.print_formatted(entries, &mut output, now, false, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 1 16:00:00 - 18:00:00 2:00:00 entry 2 4:00:00 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 entry 3 18:00:00 - 2:00:00 entry 4 4:00:00 --------------------------------------------------------- Total 8:00:00 ")); } #[test] fn test_text_output_with_millis() { std::env::set_var("TZ", "UTC"); let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms_milli(12, 0, 0, 432), Some(Utc.ymd(2008, 10, 3).and_hms_milli(14, 0, 0, 312))), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); formatter.print_formatted(entries, &mut output, now, false, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Fri Oct 03, 2008 12:00:00 - 14:00:00 1:59:59 entry 1 1:59:59 --------------------------------------------------------- Total 1:59:59 ")); } #[test] fn test_text_output_long_duration() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 1).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, false, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Wed Oct 01, 2008 12:00:00 - 14:00:00+2d 50:00:00 entry 1 50:00:00 Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 2 2:00:00 ---------------------------------------------------------- Total 52:00:00 ")); } #[test] fn test_text_output_with_ids() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(18, 0, 0))), Entry::new_sample(3, Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0))), Entry::new_sample(4, Utc.ymd(2008, 10, 5).and_hms(18, 0, 0), None), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, true, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default ID Day Start End Duration Notes 1 Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 1 2 16:00:00 - 18:00:00 2:00:00 entry 2 4:00:00 3 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 entry 3 4 18:00:00 - 2:00:00 entry 4 4:00:00 --------------------------------------------------------- Total 8:00:00 ")); } #[test] fn test_text_output_long_note_default_with() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry { id: 1, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: LONG_NOTE.into(), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, false, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be. 2:00:00 ----------------------------------------------------------------------------------------------- Total 2:00:00 ")); } #[test] fn test_text_output_long_note_with_ids() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry { id: 60000, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: LONG_NOTE.into(), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, true, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default ID Day Start End Duration Notes 60000 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be. 2:00:00 ---------------------------------------------------------------------------------------------- Total 2:00:00 ")); } #[test] fn test_text_output_note_with_line_breaks() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry { id: 1, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: "first line\nand a second line".into(), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, false, 100).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 first line and a second line 2:00:00 ------------------------------------------------------------------- Total 2:00:00 ")); } }