use std::io::{self, BufRead, Write}; use std::collections::HashMap; use std::cmp::Reverse; use chrono::{DateTime, Utc, Duration}; use crate::io::Streams; use crate::database::Database; use crate::error::Result; use crate::commands::Facts; use crate::models::Entry; use crate::tabulate::{Tabulate, Col, Align}; use crate::formatters::text::format_duration; use crate::old::entries_or_warning; fn read_line(mut r#in: I) -> io::Result { let mut pre_n = String::new(); r#in.read_line(&mut pre_n)?; Ok(pre_n) } pub fn ask(streams: &mut Streams, question: &str) -> io::Result where D: Database, I: BufRead, O: Write, E: Write, { write!(streams.out, "{} [y/N] ", question)?; streams.out.flush()?; Ok(read_line(&mut streams.r#in)?.to_lowercase().starts_with('y')) } enum Choice { Number(usize), Quit, CtrlD, Whatever, } fn to_choice(s: String) -> Choice { let s = s.trim(); if let Ok(n) = s.parse::() { if n == 0 { Choice::Whatever } else { Choice::Number(n) } } else if s.is_empty() { Choice::CtrlD } else if s.to_lowercase() == "q" { Choice::Quit } else { Choice::Whatever } } /// Offers the last N entries (configurable) to the user and waits for a choice. pub fn note_from_last_entries(streams: &mut Streams, facts: &Facts, current_sheet: &str) -> Result> where D: Database, I: BufRead, O: Write, E: Write, { let entries = streams.db.entries_by_sheet(current_sheet, None, None)?; let entries = entries_or_warning(entries, &streams.db)?.0; let mut uniques = HashMap::new(); struct GroupedEntry { note: String, last_start: DateTime, accumulated_time: Duration, } // From all possible entries belonging to this sheet keep only those with a // note let entries_with_notes = entries .into_iter() .filter_map(|e| e.note.map(|n| GroupedEntry { note: n, last_start: e.start, accumulated_time: e.end.unwrap_or(facts.now) - e.start, })); // iterate over the entries with a note and group them into `uniques` // accumulating their elapsed times and recording the last time it was // started for entry in entries_with_notes { let mut e = uniques.entry(entry.note.clone()).or_insert(GroupedEntry { accumulated_time: Duration::seconds(0), ..entry }); if entry.last_start > e.last_start { e.last_start = entry.last_start; } e.accumulated_time = e.accumulated_time + entry.accumulated_time; } // turn uniques into a vector and sort it by the time it was last started let mut uniques: Vec<_> = uniques.into_values().collect(); uniques.sort_unstable_by_key(|e| Reverse(e.last_start)); writeln!(streams.out, "Latest entries of sheet '{current_sheet}':\n")?; let formatter = timeago::Formatter::new(); // Create a table for nicer output let mut table = Tabulate::with_columns(vec![ Col::new().min_width(3).and_alignment(Align::Right), // option number Col::new(), // note Col::new().and_alignment(Align::Right), // acumulated time Col::new().min_width(13).and_alignment(Align::Right), // last started ]); table.feed(vec!["#", "Note", "Total time", "Last started"]); table.separator(' '); for (i, entry) in uniques.iter().take(facts.config.interactive_entries).enumerate().rev() { let i = i + 1; let ago = formatter.convert_chrono(entry.last_start, facts.now); table.feed(vec![ i.to_string(), entry.note.clone(), format_duration(entry.accumulated_time), ago, ]); } write!(streams.out, "{}", table.print(false))?; writeln!(streams.out, "\nenter number or q to cancel")?; loop { write!(streams.out, ">> ")?; streams.out.flush()?; let choice = to_choice(read_line(&mut streams.r#in)?); match choice { Choice::Number(i) => if let Some(e) = uniques.get(i - 1) { return Ok(Some(e.note.clone())); } else { writeln!(streams.out, "Not an option")?; } Choice::Quit => return Ok(None), Choice::CtrlD => { writeln!(streams.out)?; return Ok(None); } Choice::Whatever => writeln!(streams.out, "Not an option")?, } }; } pub fn confirm_deletion(streams: &mut Streams, entry: Entry, now: DateTime) -> Result<()> where D: Database, I: BufRead, O: Write, E: Write, { let id = entry.id; let note = entry.note.unwrap_or_else(|| "-empty note-".into()); let formatter = { let mut formatter = timeago::Formatter::new(); formatter.ago(""); formatter }; let duration = if let Some(end) = entry.end { let span = formatter.convert_chrono(entry.start, end); format!("finished with a timespan of {span}") } else { let span = formatter.convert_chrono(entry.start, now); format!("unfinished and running for {span}") }; if ask(streams, &format!("\ are you sure you want to delete entry {id} with note \"{note}\" {duration}?"))? { streams.db.delete_entry_by_id(entry.id)?; writeln!(streams.out, "Gone")?; } else { writeln!(streams.out, "Don't worry, it's still there")?; } Ok(()) } #[cfg(test)] mod tests { use chrono::Duration; use pretty_assertions::assert_str_eq; use crate::config::Config; use super::*; #[test] fn interactive_choice_of_tasks() { let mut streams = Streams::fake(b"1\n"); let facts = Facts::new(); let one_hour_ago = facts.now - Duration::hours(1); let two_hours_ago = facts.now - Duration::hours(2); // insert some entries to pick from streams.db.entry_insert(two_hours_ago, Some(one_hour_ago), Some("first task".into()), "default").unwrap(); streams.db.entry_insert(one_hour_ago, Some(facts.now), Some("second task".into()), "default").unwrap(); // call the command interactively assert_eq!(note_from_last_entries(&mut streams, &facts, "default").unwrap().unwrap(), "second task"); // check the output assert_str_eq!(&String::from_utf8_lossy(&streams.out), "Latest entries of sheet 'default': # Note Total time Last started 2 first task 1:00:00 2 hours ago 1 second task 1:00:00 1 hour ago enter number or q to cancel >> "); assert_str_eq!(&String::from_utf8_lossy(&streams.err), ""); } /// only the most recently started N items (from settings) are shown and /// they are ordered by start date descending #[test] fn list_is_limited_to_n() { let config = Config { interactive_entries: 4, ..Default::default() }; let mut streams = Streams::fake(b"1\n"); let facts = Facts::new().with_config(config); // insert some entries to pick from streams.db.entry_insert(facts.now - Duration::minutes(9), Some(facts.now - Duration::minutes(8)), Some("task 1".into()), "default").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(8), Some(facts.now - Duration::minutes(7)), Some("task 2".into()), "default").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(7), Some(facts.now - Duration::minutes(6)), Some("task 3".into()), "default").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(6), Some(facts.now - Duration::minutes(5)), Some("task 4".into()), "default").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(5), Some(facts.now - Duration::minutes(4)), Some("task 5".into()), "default").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(4), Some(facts.now - Duration::minutes(3)), Some("task 6".into()), "default").unwrap(); // call the command interactively assert_eq!(note_from_last_entries(&mut streams, &facts, "default").unwrap().unwrap(), "task 6"); // check the output assert_str_eq!(&String::from_utf8_lossy(&streams.out), "Latest entries of sheet 'default': # Note Total time Last started 4 task 3 0:01:00 7 minutes ago 3 task 4 0:01:00 6 minutes ago 2 task 5 0:01:00 5 minutes ago 1 task 6 0:01:00 4 minutes ago enter number or q to cancel >> "); assert_str_eq!(&String::from_utf8_lossy(&streams.err), ""); } }