diff --git a/src/commands.rs b/src/commands.rs index 5e245cc..f6de1b7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -21,6 +21,7 @@ pub mod resume; pub mod backend; pub mod kill; pub mod now; +pub mod edit; pub trait Command<'a> { type Args: TryFrom<&'a ArgMatches<'a>>; diff --git a/src/commands/edit.rs b/src/commands/edit.rs new file mode 100644 index 0000000..4a9eab3 --- /dev/null +++ b/src/commands/edit.rs @@ -0,0 +1,331 @@ +use std::convert::TryFrom; +use std::io::Write; + +use clap::ArgMatches; +use chrono::{DateTime, Utc}; + +use crate::database::{Database, DBVersion}; +use crate::error::{Error, Result}; +use crate::commands::Command; +use crate::config::Config; +use crate::timeparse::parse_time; +use crate::old::{time_or_warning, warn_if_needed}; +use crate::formatters::text; + +#[derive(Default)] +pub struct Args { + id: Option, + start: Option>, + end: Option>, + append: bool, + r#move: Option, + note: Option, +} + +impl<'a> TryFrom<&'a ArgMatches<'a>> for Args { + type Error = Error; + + fn try_from(matches: &'a ArgMatches) -> Result { + Ok(Args { + id: matches.value_of("id").map(|i| i.parse().unwrap()), + start: matches.value_of("start").map(|s| parse_time(s)).transpose()?, + end: matches.value_of("end").map(|s| parse_time(s)).transpose()?, + append: matches.is_present("append"), + r#move: matches.value_of("move").map(|s| s.into()), + note: matches.value_of("note").map(|s| s.into()), + }) + } +} + +pub struct EditCommand {} + +impl<'a> Command<'a> for EditCommand { + type Args = Args; + + fn handle(args: Args, db: &mut D, out: &mut O, err: &mut E, config: &Config, now: DateTime) -> Result<()> + where + D: Database, + O: Write, + E: Write, + { + let current_sheet = db.current_sheet()?.unwrap_or_else(|| "default".to_owned()); + let needs_warning = db.version()? == DBVersion::Timetrap; + + let entry = if let Some(id) = args.id { + if let Some(entry) = db.entry_by_id(id)? { + entry + } else { + writeln!(out, "Entry with id \"{}\" does not exist. Perhaps it was deleted", id)?; + + return Ok(()); + } + } else if let Some(entry) = db.last_entry_of_sheet(¤t_sheet)? { + entry + } else { + writeln!(out, "No entries to edit in sheet \"{}\".", current_sheet)?; + + return Ok(()); + }; + + let note = if let Some(new_note) = args.note { + if args.append { + Some(entry.note.unwrap_or_else(|| "".to_owned()) + &config.append_notes_delimiter + &new_note) + } else { + Some(new_note) + } + } else { + entry.note + }; + + db.entry_update( + entry.id, + args.start.unwrap_or(entry.start), + args.end.or(entry.end), + note, + &args.r#move.unwrap_or(entry.sheet), + )?; + + let updated_entry = db.entry_by_id(entry.id)?.unwrap(); + + text::print_formatted( + vec![updated_entry], + out, + now, + true, + )?; + + warn_if_needed(err, needs_warning)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use chrono::{Duration, TimeZone}; + + use crate::database::SqliteDatabase; + use crate::test_utils::Ps; + + use super::*; + + #[test] + fn edit_last_note() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = Args { + note: Some("new note".into()), + ..Default::default() + }; + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + db.entry_insert(an_hour_ago, Some(now), Some("should be left intact".into()), "sheet1").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + let entry2 = db.entry_by_id(2).unwrap().unwrap(); + + assert_eq!(entry2.note, Some("should be left intact".into())); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: default + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:29:00 - 15:29:00 1:00:00 new note + 1:00:00 +-------------------------------------------------------------- + Total 1:00:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn edit_with_id() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = Args { + id: Some(2), + note: Some("new note".into()), + ..Default::default() + }; + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + db.entry_insert(an_hour_ago, Some(now), Some("should be left intact".into()), "sheet1").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: sheet1 + ID Day Start End Duration Notes + 2 Tue Aug 03, 2021 14:29:00 - 15:29:00 1:00:00 new note + 1:00:00 +-------------------------------------------------------------- + Total 1:00:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn edit_start() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let args = Args { + start: Some(now - Duration::minutes(30)), + ..Default::default() + }; + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: default + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:59:00 - 15:29:00 0:30:00 a note + 0:30:00 +------------------------------------------------------------ + Total 0:30:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn edit_end() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let args = Args { + end: Some(now - Duration::minutes(30)), + ..Default::default() + }; + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: default + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:29:00 - 14:59:00 0:30:00 a note + 0:30:00 +------------------------------------------------------------ + Total 0:30:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn edit_append() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = Args { + note: Some("new note".into()), + append: true, + ..Default::default() + }; + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: default + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:29:00 - 15:29:00 1:00:00 a note new note + 1:00:00 +--------------------------------------------------------------------- + Total 1:00:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn edit_move() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let args = Args { + r#move: Some("new sheet".to_owned()), + ..Default::default() + }; + let an_hour_ago = now - Duration::hours(1); + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &Default::default(), now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: new sheet + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:29:00 - 15:29:00 1:00:00 a note + 1:00:00 +------------------------------------------------------------ + Total 1:00:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn warn_old() { + assert!(false); + } + + #[test] + fn non_default_delimiter() { + let mut db = SqliteDatabase::from_memory().unwrap(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let args = Args { + note: Some("new note".into()), + append: true, + ..Default::default() + }; + let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); + let an_hour_ago = now - Duration::hours(1); + let config = Config { + append_notes_delimiter: ";".to_owned(), + ..Default::default() + }; + + db.init().unwrap(); + + db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); + + EditCommand::handle(args, &mut db, &mut out, &mut err, &config, now).unwrap(); + + assert_eq!(Ps(&String::from_utf8_lossy(&out)), Ps("Timesheet: default + ID Day Start End Duration Notes + 1 Tue Aug 03, 2021 14:29:00 - 15:29:00 1:00:00 a note;new note + 1:00:00 +--------------------------------------------------------------------- + Total 1:00:00 +")); + assert_eq!(Ps(&String::from_utf8_lossy(&err)), Ps("")); + } + + #[test] + fn without_arguments_call_editor() { + assert!(false); + } +} diff --git a/src/database.rs b/src/database.rs index 93b734f..57fdb3c 100644 --- a/src/database.rs +++ b/src/database.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use crate::error::{Error, Result}; use crate::models::{Entry, Meta}; +#[derive(PartialEq)] pub enum DBVersion { Timetrap, Version(u16), @@ -173,6 +174,10 @@ pub trait Database { Ok(self.entry_query("select * from entries where end is not null and sheet=?1 order by end desc limit 1", &[&sheet])?.into_iter().next()) } + fn last_entry_of_sheet(&self, sheet: &str) -> Result> { + Ok(self.entry_query("select * from entries where sheet=?1 order by id desc limit 1", &[&sheet])?.into_iter().next()) + } + fn delete_entry_by_id(&mut self, id: u64) -> Result<()> { self.execute("delete from entries where id=?1", &[&id]) } diff --git a/src/main.rs b/src/main.rs index f941457..432e82c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use tiempo::commands::{ today::TodayCommand, yesterday::YesterdayCommand, week::WeekCommand, month::MonthCommand, list::ListCommand, out::OutCommand, resume::ResumeCommand, backend::BackendCommand, kill::KillCommand, - now::NowCommand, + now::NowCommand, edit::EditCommand, }; fn error_trap(matches: ArgMatches) -> error::Result<()> { @@ -44,6 +44,7 @@ fn error_trap(matches: ArgMatches) -> error::Result<()> { ("list", Some(matches)) => ListCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), ("kill", Some(matches)) => KillCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), ("now", Some(matches)) => NowCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), + ("edit", Some(matches)) => EditCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), (cmd, _) => Err(error::Error::UnimplementedCommand(cmd.into())), } @@ -292,6 +293,32 @@ fn main() { .about("Show all running entries") ) + .subcommand(SubCommand::with_name("edit") + .visible_alias("e") + .about("Edit an entry") + .arg(id_arg.clone().help("Edit entry with this ID instead of the last one in the current sheet")) + .arg(start_arg.clone().help("Set this as the start time")) + .arg(end_arg.clone().help("Set this as the end time")) + .arg( + Arg::with_name("append") + .long("append").short("z") + .help("Append to the current note instead of replacing it. The delimiter between appended notes is configurable (see configure)") + ) + .arg( + Arg::with_name("move") + .short("m").long("move") + .takes_value(true) + .value_name("SHEET") + .help("Move entry to another sheet") + ) + .arg( + Arg::with_name("note") + .takes_value(true) + .value_name("NOTE") + .help("The note text. It will replace the previous one unless --append is given") + ) + ) + .get_matches(); if let Err(e) = error_trap(matches) { diff --git a/src/old.rs b/src/old.rs index ef8554b..a724bee 100644 --- a/src/old.rs +++ b/src/old.rs @@ -61,6 +61,8 @@ pub fn entries_or_warning(entries: Vec, db: &D) -> Result<(V } } +/// Wrapper around utc_to_local that also returns a flag in case a warning is +/// needed pub fn time_or_warning(time: DateTime, db: &D) -> Result<(DateTime, bool)> { if let DBVersion::Timetrap = db.version()? { Ok((utc_to_local(time), true))