diff --git a/Cargo.lock b/Cargo.lock index c30f4cb..b8a4ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1189,6 +1189,24 @@ dependencies = [ "shellexpand", ] +[[package]] +name = "timetracker-dump" +version = "0.3.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "colored", + "config", + "dirs 4.0.0", + "env_logger", + "log", + "serde", + "serde_derive", + "timetracker-core", + "timetracker-print-lib", +] + [[package]] name = "timetracker-print" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 79d8ab0..e080801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ authors = ["David Cattermole"] members = [ "configure-bin", "core", + "dump-bin", "print-bin", "print-lib", "recorder-bin", diff --git a/dump-bin/.gitignore b/dump-bin/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/dump-bin/.gitignore @@ -0,0 +1 @@ +/target diff --git a/dump-bin/Cargo.toml b/dump-bin/Cargo.toml new file mode 100644 index 0000000..295467b --- /dev/null +++ b/dump-bin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "timetracker-dump" +description = "Dumps Timetracker data to stdout or a file." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +anyhow = "1.0.70" +chrono = "0.4.19" +clap = { version = "3.2.23", features = ["std", "derive"], default-features = false } +colored = { version = "2.0.0", default-features = true } +config = { version = "0.13.3", features = ["toml"], default-features = false } +dirs = "4.0.0" +env_logger = "0.10.0" +log = "0.4.17" +serde = "1.0.159" +serde_derive = "1.0.159" + +[dependencies.timetracker-core] +path = "../core" + +[dependencies.timetracker-print-lib] +path = "../print-lib" diff --git a/dump-bin/README.md b/dump-bin/README.md new file mode 100644 index 0000000..0be2448 --- /dev/null +++ b/dump-bin/README.md @@ -0,0 +1,14 @@ +# Dump (binary) + +This directory contains the Rust crate for the main Dump program. + +Dump will gather data from the (database) storage then write that data +to stdout or to a file. + +## Configuration + +To be written. + +## How Dump Works + +To be written. diff --git a/dump-bin/src/main.rs b/dump-bin/src/main.rs new file mode 100644 index 0000000..dd84200 --- /dev/null +++ b/dump-bin/src/main.rs @@ -0,0 +1,144 @@ +use crate::settings::CommandArguments; +use crate::settings::DumpAppSettings; +use anyhow::bail; +use anyhow::Result; +use clap::Parser; +use log::debug; +use std::time::SystemTime; +use timetracker_core::filesystem::get_database_file_path; +use timetracker_core::settings::RECORD_INTERVAL_SECONDS; +use timetracker_core::storage::Storage; +use timetracker_print_lib::datetime::DateTimeLocalPair; +use timetracker_print_lib::print::get_relative_week_start_end; + +mod settings; + +// CSV Spec: Each record is located on a separate line, +// delimited by a line break (CRLF). +static LINE_END: &str = "\r\n"; + +fn convert_to_csv_string_value(entry_var_name: &Option) -> String { + match &entry_var_name { + Some(value) => value.to_string(), + None => "".to_string(), + } +} + +fn generate_csv_formated_lines( + storage: &mut Storage, + lines: &mut Vec, + week_datetime_pair: DateTimeLocalPair, +) -> Result<()> { + let (week_start_datetime, week_end_datetime) = week_datetime_pair; + + let week_start_of_time = week_start_datetime.timestamp() as u64; + let week_end_of_time = week_end_datetime.timestamp() as u64; + let week_entries = storage.read_entries(week_start_of_time, week_end_of_time)?; + + for week_entry in week_entries { + let line = format!( + concat!( + "{utc_time_seconds},{duration_seconds},", + "{status:?},{executable},", + "{var1_name},{var1_value},", + "{var2_name},{var2_value},", + "{var3_name},{var3_value},", + "{var4_name},{var4_value},", + "{var5_name},{var5_value}" + ), + utc_time_seconds = week_entry.utc_time_seconds, + duration_seconds = week_entry.duration_seconds, + status = week_entry.status, + executable = convert_to_csv_string_value(&week_entry.vars.executable), + var1_name = convert_to_csv_string_value(&week_entry.vars.var1_name), + var1_value = convert_to_csv_string_value(&week_entry.vars.var1_value), + var2_name = convert_to_csv_string_value(&week_entry.vars.var2_name), + var2_value = convert_to_csv_string_value(&week_entry.vars.var2_value), + var3_name = convert_to_csv_string_value(&week_entry.vars.var3_name), + var3_value = convert_to_csv_string_value(&week_entry.vars.var3_value), + var4_name = convert_to_csv_string_value(&week_entry.vars.var4_name), + var4_value = convert_to_csv_string_value(&week_entry.vars.var4_value), + var5_name = convert_to_csv_string_value(&week_entry.vars.var5_name), + var5_value = convert_to_csv_string_value(&week_entry.vars.var5_value), + ); + lines.push(line); + } + Ok(()) +} + +fn dump_database( + args: &CommandArguments, + settings: &DumpAppSettings, + output_lines: &mut Vec, +) -> Result<()> { + let database_file_path = get_database_file_path( + &settings.core.database_dir, + &settings.core.database_file_name, + ); + + let mut storage = Storage::open_as_read_only( + &database_file_path.expect("Database file path should be valid"), + RECORD_INTERVAL_SECONDS, + )?; + + let relative_week = if args.last_week { + -1 + } else { + args.relative_week + }; + + // 'relative_week' is added to the week number to find. A value of + // '-1' will get the previous week, a value of '0' will get the + // current week, and a value of '1' will get the next week (which + // shouldn't really give any results, so it's probably pointless). + let week_datetime_pair = get_relative_week_start_end(relative_week)?; + + generate_csv_formated_lines(&mut storage, output_lines, week_datetime_pair) +} + +fn main() -> Result<()> { + let env = env_logger::Env::default() + .filter_or("TIMETRACKER_LOG", "warn") + .write_style("TIMETRACKER_LOG_STYLE"); + env_logger::init_from_env(env); + + let args = CommandArguments::parse(); + + let settings = DumpAppSettings::new(&args); + if settings.is_err() { + bail!("Settings are invalid: {:?}", settings); + } + let settings = settings?; + debug!("Settings validated: {:#?}", settings); + + let now = SystemTime::now(); + + let mut lines = Vec::new(); + dump_database(&args, &settings, &mut lines)?; + + if !lines.is_empty() { + // The CSV File Format header is described here: + // https://www.rfc-editor.org/rfc/rfc4180#section-2 + print!( + "{}{}", + concat!( + "utc_time_seconds,duration_seconds,", + "status,executable,", + "var1_name,var1_value,", + "var2_name,var2_value,", + "var3_name,var3_value,", + "var4_name,var4_value,", + "var5_name,var5_value", + ), + LINE_END + ); + for line in &lines { + print!("{}{}", line, LINE_END); + } + } + + let duration = now.elapsed()?.as_secs_f32(); + debug!("Time taken: {:.2} seconds", duration); + + Ok(()) +} diff --git a/dump-bin/src/settings.rs b/dump-bin/src/settings.rs new file mode 100644 index 0000000..c685be5 --- /dev/null +++ b/dump-bin/src/settings.rs @@ -0,0 +1,53 @@ +use clap::Parser; +use config::ConfigError; +use serde_derive::Deserialize; +use timetracker_core::settings::new_core_settings; +use timetracker_core::settings::new_print_settings; +use timetracker_core::settings::validate_core_settings; +use timetracker_core::settings::CoreSettings; +use timetracker_core::settings::PrintSettings; + +#[derive(Parser, Debug)] +#[clap(author = "David Cattermole, Copyright 2023", version, about)] +pub struct CommandArguments { + /// Return the last week's results, shortcut for + /// '--relative-week=-1'. + #[clap(long, value_parser, default_value_t = false)] + pub last_week: bool, + + /// Relative week number. '0' is the current week, '-1' is the + /// previous week, etc. + #[clap(short = 'w', long, value_parser, default_value_t = 0)] + pub relative_week: i32, + + /// Override the directory to search for the database file. + #[clap(long, value_parser)] + pub database_dir: Option, + + /// Override the name of the database file to open. + #[clap(long, value_parser)] + pub database_file_name: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct DumpAppSettings { + pub core: CoreSettings, + pub print: PrintSettings, +} + +impl DumpAppSettings { + pub fn new(arguments: &CommandArguments) -> Result { + let builder = new_core_settings( + arguments.database_dir.clone(), + arguments.database_file_name.clone(), + false, + )?; + let builder = new_print_settings(builder)?; + + let settings: Self = builder.build()?.try_deserialize()?; + validate_core_settings(&settings.core).unwrap(); + + Ok(settings) + } +}