diff --git a/Cargo.lock b/Cargo.lock index aa917d1..4d5b3d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -469,6 +469,7 @@ dependencies = [ name = "tiempo" version = "0.1.0" dependencies = [ + "ansi_term 0.12.1", "chrono", "clap", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 17225c2..4030624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ toml = "0.5" itertools = "0.10" textwrap = "0.14" terminal_size = "0.1" +ansi_term = "0.12" [dev-dependencies] pretty_assertions = "0.7.2" diff --git a/src/commands/display.rs b/src/commands/display.rs index f6dd352..33910be 100644 --- a/src/commands/display.rs +++ b/src/commands/display.rs @@ -3,16 +3,44 @@ use std::io::Write; use std::str::FromStr; use clap::ArgMatches; -use chrono::{DateTime, Utc, Local, TimeZone}; +use chrono::{DateTime, Utc, Local, TimeZone, LocalResult}; use terminal_size::{Width, terminal_size}; +use ansi_term::Color::Yellow; use crate::error; -use crate::database::Database; +use crate::database::{Database, DBVersion}; use crate::formatters::Formatter; use crate::config::Config; +use crate::models::Entry; use super::Command; +fn local_to_utc(t: DateTime) -> error::Result> { + let local_time = match Local.from_local_datetime(&t.naive_utc()) { + LocalResult::None => return Err(error::Error::NoneLocalTime(t.naive_utc())), + LocalResult::Single(t) => t, + LocalResult::Ambiguous(t1, t2) => return Err(error::Error::AmbiguousLocalTime { + orig: t.naive_utc(), + t1, t2, + }), + }; + + Ok(Utc.from_utc_datetime(&local_time.naive_utc())) +} + +fn local_to_utc_vec(entries: Vec) -> error::Result> { + entries + .into_iter() + .map(|e| { + Ok(Entry { + start: local_to_utc(e.start)?, + end: e.end.map(|t| local_to_utc(t)).transpose()?, + ..e + }) + }) + .collect() +} + fn term_width() -> usize { if let Some((Width(w), _)) = terminal_size() { w as usize @@ -64,8 +92,7 @@ impl<'a> TryFrom<&'a ArgMatches<'a>> for Args { } } -pub struct DisplayCommand { -} +pub struct DisplayCommand { } impl<'a> Command<'a> for DisplayCommand { type Args = Args; @@ -76,7 +103,7 @@ impl<'a> Command<'a> for DisplayCommand { O: Write, E: Write, { - let sheets_to_display = match args.sheet { + let entries = match args.sheet { Some(Sheet::All) => db.entries_all_visible()?, Some(Sheet::Full) => db.entries_full()?, Some(Sheet::Sheet(name)) => db.entries_by_sheet(&name)?, @@ -87,11 +114,25 @@ impl<'a> Command<'a> for DisplayCommand { } }; + let entries = if let DBVersion::Timetrap = db.version()? { + // this indicates that times in the database are specified in the + // local time and need to be converted to utc before displaying + writeln!( + err, + "{} You are using the old timetrap format, it is advised that \ + you update your database using t migrate", + Yellow.bold().paint("[WARNING]"), + )?; + + local_to_utc_vec(entries)? + } else { + entries + }; + args.format.print_formatted( - sheets_to_display, + entries, out, Utc::now(), - Local.offset_from_utc_datetime(&Utc::now().naive_utc()), args.ids, term_width(), ) @@ -119,13 +160,13 @@ mod tests { Day Start End Duration Notes Tue Jun 29, 2021 06:26:49 - 07:26:52 1:00:03 lets do some rust 1:00:03 - --------------------------------------------------------------------- + ------------------------------------------------------------------- Total 1:00:03 ")); assert_eq!( - PrettyString(&String::from_utf8_lossy(&err)), - PrettyString("[WARNING] You are using the old timetrap format, it is advised that you update your database using t migrate") + String::from_utf8_lossy(&err), + format!("{} You are using the old timetrap format, it is advised that you update your database using t migrate\n", Yellow.bold().paint("[WARNING]")), ); } @@ -134,4 +175,9 @@ mod tests { assert!(false, "start with a newly created database"); assert!(false, "correctly display times in local timezone"); } + + #[test] + fn query_by_local_time_unless_timezone_specified() { + assert!(false, "when using --start and --end times are assumed to be given in the local time unless they parse to a timezone aware time"); + } } diff --git a/src/database.rs b/src/database.rs index 5386b98..880c74e 100644 --- a/src/database.rs +++ b/src/database.rs @@ -6,6 +6,11 @@ use chrono::{DateTime, Utc}; use crate::error; use crate::models::{Entry, Meta}; +pub enum DBVersion { + Timetrap, + Version(u16), +} + pub trait Database { /// This is used to create tables and insert rows fn execute(&mut self, query: &str, params: &[&dyn ToSql]) -> error::Result<()>; @@ -34,10 +39,25 @@ pub trait Database { // Meta queries fn current_sheet(&self) -> error::Result> { - let results = self.meta_query("select * from meta where key=?1", &[&"current_sheet"])?; + let results = self.meta_query("select * from meta where key='current_sheet'", &[])?; Ok(results.into_iter().next().map(|m| m.value)) } + + fn version(&self) -> error::Result { + let results = self.meta_query("select * from meta where key='database_version'", &[])?; + + if let Some(v) = results.into_iter().next().map(|m| m.value) { + Ok(DBVersion::Version(v.parse().map_err(|e| { + error::Error::CorruptedData(format!( + "Found value '{}' for key 'database_version' in meta table, which is not a valid integer", + v + )) + })?)) + } else { + Ok(DBVersion::Timetrap) + } + } } pub struct SqliteDatabase { diff --git a/src/error.rs b/src/error.rs index f6fdf49..e44dd47 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,7 @@ use std::result; use std::path::PathBuf; use thiserror::Error; +use chrono::{NaiveDateTime, DateTime, Local}; #[derive(Debug, Error)] pub enum Error { @@ -30,7 +31,20 @@ pub enum Error { DateTimeParseError(#[from] chrono::format::ParseError), #[error("IOError: {0}")] - IOError(#[from] std::io::Error) + IOError(#[from] std::io::Error), + + #[error("Corrupted data found in the database: {0}")] + CorruptedData(String), + + #[error("Trying to parse {0} as a time in your timezone led to no results")] + NoneLocalTime(NaiveDateTime), + + #[error("Trying to parse {orig} as a time in your timezone led to the ambiguous results {t1} and {t2}")] + AmbiguousLocalTime { + orig: NaiveDateTime, + t1: DateTime, + t2: DateTime, + } } pub type Result = result::Result; diff --git a/src/formatters.rs b/src/formatters.rs index cbf6e39..e53b885 100644 --- a/src/formatters.rs +++ b/src/formatters.rs @@ -5,8 +5,8 @@ use std::borrow::Cow; use serde::{Serialize, Deserialize}; use itertools::Itertools; use chrono::{ - DateTime, Utc, Offset, TimeZone, Duration, NaiveTime, Timelike, - NaiveDateTime, + DateTime, Utc, TimeZone, Duration, NaiveTime, Timelike, NaiveDateTime, + Local, }; use crate::error; @@ -70,9 +70,9 @@ impl Formatter { /// 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, offset: O, ids: bool, term_width: usize) -> error::Result<()> { + 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, offset, ids, term_width)?, + Formatter::Text => self.print_formatted_text(entries, out, now, ids, term_width)?, Formatter::Custom(name) => { } } @@ -82,14 +82,9 @@ impl Formatter { /// 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, offset: O, ids: bool, term_width: usize) -> error::Result<()> { + 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()); - // Build a timezone based on the offset given to this function. This - // will later be used to properly group the entries by date in the local - // timezone. - let fixed_offset = offset.fix(); - for (key, group) in grouped_entries.into_iter() { writeln!(out, "Timesheet: {}", key)?; @@ -97,18 +92,18 @@ impl Formatter { // A vector of lines to be printed, with all the components let mut lines = Vec::new(); - let entries_by_date = group.group_by(|e| fixed_offset.from_utc_datetime(&e.start.naive_utc()).date()); + 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(fixed_offset.from_utc_datetime(&entry.start.naive_utc()).time()); + let start = format_start(Local.from_utc_datetime(&entry.start.naive_utc()).time()); let end = entry.end.map(|t| { format_end( - fixed_offset.from_utc_datetime(&entry.start.naive_utc()).naive_local(), - fixed_offset.from_utc_datetime(&t.naive_utc()).naive_local() + 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; @@ -231,6 +226,7 @@ mod tests { #[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", @@ -245,6 +241,7 @@ mod tests { #[test] fn test_text_output() { + std::env::set_var("TZ", "UTC"); let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ @@ -255,9 +252,8 @@ mod tests { ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); - let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, false, 100).unwrap(); + 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 @@ -274,6 +270,7 @@ mod tests { #[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![ @@ -281,9 +278,8 @@ mod tests { ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); - let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, false, 100).unwrap(); + 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 @@ -306,7 +302,7 @@ mod tests { let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, false, 100).unwrap(); + 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 @@ -333,7 +329,7 @@ mod tests { let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, true, 100).unwrap(); + 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 @@ -365,7 +361,7 @@ mod tests { let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, false, 100).unwrap(); + 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 @@ -400,7 +396,7 @@ mod tests { let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, true, 100).unwrap(); + 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 @@ -435,7 +431,7 @@ mod tests { let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; - formatter.print_formatted(entries, &mut output, now, offset, false, 100).unwrap(); + 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