diff --git a/Cargo.toml b/Cargo.toml index 70799e6..12e5ce7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,12 @@ edition = "2021" [dependencies] anyhow = "1.0.98" +chrono = {version = "0.4.41", features = ["serde"]} colored = "3.0.0" clap = { version = "4.5.38", features = ["derive"] } directories = "6.0.0" rusqlite = { version = "0.35.0", features = ["chrono", "bundled"] } langtime = "0.2.1" +serde = { version = "1.0.217", features = ["derive"] } +tabled = "0.19.0" \ No newline at end of file diff --git a/src/commands/current.rs b/src/commands/current.rs new file mode 100644 index 0000000..ab4687b --- /dev/null +++ b/src/commands/current.rs @@ -0,0 +1,40 @@ +use anyhow::Result; + +use crate::State; +use crate::database::running_entries; +use crate::style::{style_string, Styles}; +use crate::utils::{time_from_now,format_duration}; + +pub fn current_task(state: &State) -> Result<()> { + let mut entries = running_entries(&state.database)?; + + entries.sort_unstable(); + + println!( + "{}{}", + style_string("Currently on sheet: ", Styles::Title), + style_string(&state.current_sheet, Styles::Primary) + ); + + if entries.is_empty() { + println!( + "{}", + style_string("There is no active task.", Styles::Message) + ); + + return Ok(()); + } + + // There will always be only one active entry for each sheet + println!("{}", style_string("Active tasks:", Styles::Title)); + for entry in &entries { + println!( + "{}: {} ({})", + style_string(&entry.sheet, Styles::Primary), + style_string(&entry.name, Styles::Secondary), + format_duration(&time_from_now(&entry.start)) + ); + } + + Ok(()) +} diff --git a/src/commands/in_cmd.rs b/src/commands/in_cmd.rs new file mode 100644 index 0000000..e78f9e5 --- /dev/null +++ b/src/commands/in_cmd.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; + +use crate::database::{running_entry, write_entry}; +use crate::style::{style_string, Styles}; +use crate::Entry; +use crate::State; + +pub fn start_task(task: &str, at: Option>, state: &State) -> Result<()> { + let start = at.unwrap_or(Local::now()); + let cur_task = running_entry(&state.database, &state.current_sheet)?; + + if cur_task.is_some() { + println!( + "{} {}", + style_string("Already checked into sheet:", Styles::Message), + cur_task.unwrap().sheet + ); + return Ok(()); + } + + let entry = Entry::start(task, &state.current_sheet, start); + + write_entry(&entry, &state.database)?; + + println!( + "{} {}", + style_string("Checked into sheet:", Styles::Message), + entry.sheet + ); + + Ok(()) +} diff --git a/src/commands/lists.rs b/src/commands/lists.rs new file mode 100644 index 0000000..8616b7b --- /dev/null +++ b/src/commands/lists.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use chrono::{Duration, Local}; + +use tabled::builder::Builder; +use tabled::settings::object::Rows; +use tabled::settings::themes::Colorization; +use tabled::settings::{Color, Style}; + +use crate::database::{get_all_sheets, get_sheet_entries}; +use crate::style::{style_string, Styles}; +use crate::utils::{format_duration, time_from_now}; +use crate::State; + + +pub fn list_sheets(state: &State) -> Result<()> { + let mut sheets = get_all_sheets(&state.database)?; + + if sheets.is_empty() { + sheets.push(state.current_sheet.to_string()); + } + + let mut builder = Builder::new(); + + println!("{}", style_string("Timesheets:", Styles::Title)); + + builder.push_record(vec!["Name", "Running", "Today", "Total time"]); + + for sheet in sheets { + let entries = get_sheet_entries(&sheet, &state.database)?; + + let running = entries.iter().find(|e| e.end.is_none()); + let running_time = match running { + Some(e) => time_from_now(&e.start), + None => Duration::seconds(0), + }; + + let today_total: Duration = entries + .iter() + .filter(|e| e.start.date_naive() == Local::now().date_naive()) + .map(|e| e.end.unwrap_or(Local::now()) - e.start) + .sum(); + + let total: Duration = entries + .iter() + .map(|e| e.end.unwrap_or(Local::now()) - e.start) + .sum(); + + let s = if sheet == state.current_sheet { + format!("{}{}", "*", sheet) + } else if sheet == state.last_sheet { + format!("{}{}", "-", sheet) + } else { + sheet + }; + + builder.push_record(vec![ + s, + format_duration(&running_time), + format_duration(&today_total), + format_duration(&total), + ]); + } + + let mut table = builder.build(); + table.with(Style::empty()); + table.with(Colorization::exact([Color::BOLD], Rows::first())); + + println!("{}", table); + + Ok(()) +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..9071a65 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,9 @@ +mod current; +mod in_cmd; +mod out; +mod lists; + +pub use current::current_task; +pub use in_cmd::start_task; +pub use out::stop_task; +pub use lists::list_sheets; \ No newline at end of file diff --git a/src/commands/out.rs b/src/commands/out.rs new file mode 100644 index 0000000..2b91410 --- /dev/null +++ b/src/commands/out.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use chrono::{DateTime, Local}; + + +use crate::database::{running_entry, write_entry}; +use crate::style::{style_string, Styles}; +use crate::State; + + +pub fn stop_task(at: Option>, state: &mut State) -> Result<()> { + let end = at.unwrap_or(Local::now()); + + let cur = running_entry(&state.database, &state.current_sheet)?; + + match cur { + None => println!( + "{}", + style_string("There is no active task.", Styles::Message) + ), + Some(mut e) => { + if e.start > end { + println!( + "{}", + style_string("Cannot stop a task before it started", Styles::Message) + ); + return Ok(()) + } + e.stop(end); + write_entry(&e,&state.database)?; + + println!( + "{} {}", + style_string("Stopped the task", Styles::Message), + e.sheet + ); + } + }; + Ok(()) +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 9ff66be..b5669ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result }; +use anyhow::{anyhow, Result}; use directories::ProjectDirs; pub struct Config { @@ -8,14 +8,14 @@ pub struct Config { impl Config { pub fn build() -> Result { - let proj_dirs = ProjectDirs::from("de","schacht-analyse","timetracker") + let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") .ok_or(anyhow!("Couldn't get project directories"))?; - + let data_dir = proj_dirs.data_dir(); let mut db_file = data_dir.to_path_buf(); - + db_file.push("database.db"); - + if let Some(db_file_str) = db_file.to_str() { return Ok(Config { database_file: db_file_str.to_string(), @@ -24,4 +24,4 @@ impl Config { } Err(anyhow!("Couldn't get database file")) } -} \ No newline at end of file +} diff --git a/src/database.rs b/src/database.rs index 3d6eb58..c8d211a 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,47 +1,198 @@ -use std::path::PathBuf; -use anyhow::{anyhow, Context}; use anyhow::Result; +use anyhow::{anyhow, Context}; +use rusqlite::{named_params, Connection}; +use std::path::PathBuf; use std::str::FromStr; -use rusqlite::Connection; use crate::config::Config; +use crate::entry::Entry; +use crate::utils::str_to_datetime; pub fn connect_to_db(config: &Config) -> Result { if let Ok(conn) = Connection::open(&config.database_file) { return Ok(conn); } - Err(anyhow!( - "Cannot create connection to database" - )) + Err(anyhow!("Cannot create connection to database")) } pub fn create_tables(db: &Connection) -> Result<()> { let query = " - CREATE TABLE IF NOT EXISTS entries ( + CREATE TABLE IF NOT EXISTS entries ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, note VARCHAR(255) NOT NULL, start TIMESTAMP NOT NULL, - end TIMESTAMP NOT NULL, + end TIMESTAMP, sheet VARCHAR(255) NOT NULL ) "; db.execute(query, ()) .context("Failed to create entries table")?; - + Ok(()) } pub fn ensure_db_exists(config: &Config) -> Result<()> { let mut db_path = PathBuf::from_str(&config.database_file).unwrap(); db_path.pop(); - - if!db_path.exists() { + + if !db_path.exists() { std::fs::create_dir_all(&db_path).context(format!( "Cannot create the data folder for Timetracker. The expected path was {:?}", &db_path ))?; - } Ok(()) +} +pub fn running_entries(db: &Connection) -> Result> { + let query = "SELECT id, note, start, end, sheet FROM entries WHERE end IS NULL;"; + + let mut stmt = db.prepare(query)?; + let entries = stmt.query_map([], |row| { + let end = row + .get::>(3)? + .map(|t| str_to_datetime(&t).unwrap()); + + let start = str_to_datetime(&row.get::(2)?).unwrap(); + + Ok(Entry { + id: row.get(0)?, + name: row.get(1)?, + start, + end, + sheet: row.get(4)?, + }) + })?; + + let entries_vec: Vec> = entries.collect(); + if entries_vec.iter().any(|e| e.is_err()) { + return Err(anyhow!("Cannot read entries from database")); + } + Ok(entries_vec.into_iter().map(|e| e.unwrap()).collect()) +} +pub fn running_entry(db: &Connection, sheet: &str) -> Result> { + let query = "SELECT id, note,start,end,sheet FROM entries WHERE end IS NULL AND sheet = ?"; + + let mut stmt = db.prepare(query)?; + let mut entries = stmt.query_map([sheet], |row| { + let end = row + .get::>(3)? + .map(|t| str_to_datetime(&t).unwrap()); + + let start = str_to_datetime(&row.get::(2)?).unwrap(); + + Ok(Entry { + id: row.get(0)?, + name: row.get(1)?, + start, + end, + sheet: row.get(4)?, + }) + })?; + let running_entry = entries.next(); + running_entry + .transpose() + .context(format!("Failed to run entry")) +} + +pub fn write_entry(entry: &Entry, db: &Connection) -> Result<()> { + match entry.id { + Some(_) => update_entry(entry, db)?, + None => create_entry(entry, db)?, + }; + + Ok(()) +} + +fn update_entry(entry: &Entry, db: &Connection) -> Result<()> { + let query = " + UPDATE entries SET + note = :note, + start = :start, + end = :end, + sheet = :sheet + WHERE + id = :id + "; + + let mut stmt = db.prepare(query)?; + stmt.execute(named_params! { + ":note": entry.name, + ":start": entry.start, + ":end": entry.end, + ":sheet": entry.sheet, + ":id": entry.id + })?; + + Ok(()) +} + +fn create_entry(entry: &Entry, db: &Connection) -> Result<()> { + let query = " + INSERT INTO entries (note, start, end, sheet) VALUES ( + :note, :start, :end, :sheet + ) + "; + + let mut stmt = db.prepare(query)?; + + stmt.execute(named_params! { + ":note": entry.name, + ":start": entry.start, + ":end": entry.end, + ":sheet": entry.sheet + }).context("Failed to create entry")?; + + Ok(()) +} +pub fn get_all_entries(db: &Connection) -> Result> { + let query = " + SELECT id, note, start, end, sheet FROM entries; + "; + + let mut stmt = db.prepare(query)?; + let entries = stmt.query_map([], |row| { + let end = row + .get::>(3)? + .map(|t| str_to_datetime(&t).unwrap()); + + let start = str_to_datetime(&row.get::(2)?).unwrap(); + + Ok(Entry { + id: row.get(0)?, + name: row.get(1)?, + start, + end, + sheet: row.get(4)?, + }) + })?; + + let mut entries_vec = Vec::new(); + + for sheet in entries { + entries_vec.push(sheet?); + } + + Ok(entries_vec) +} + +pub fn get_sheet_entries(sheet: &str, db: &Connection) -> Result> { + let entries = get_all_entries(db)?; + Ok(entries + .iter() + .filter(|e| e.sheet == sheet) + .cloned() + .collect()) +} + +pub fn get_all_sheets(db: &Connection) -> Result> { + let query = "SELECT DISTINCT sheet FROM entries;"; + let mut stmt = db.prepare(query)?; + let entries = stmt.query_map([], |row| row.get::(0))?; + + let mut sheets = Vec::new(); + for sheet in entries { + sheets.push(sheet?); + } + Ok(sheets) } \ No newline at end of file diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..714f3a7 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Duration, Local}; +use serde::Serialize; + +#[derive(Eq, PartialOrd, PartialEq ,Ord, Clone)] +pub struct Entry { + pub id: Option, + pub start: DateTime, + pub end: Option>, + pub name: String, + pub sheet: String, +} + +impl Entry { + pub fn start(name: &str, sheet: &str, start: DateTime) -> Self { + Entry { + id: None, + start, + end: None, + name: name.to_string(), + sheet: sheet.to_string(), + } + } + + pub fn stop(&mut self, end: DateTime) { + self.end = Some(end); + } + + pub fn get_duration(&self) -> Duration { + let end = self.end.unwrap_or(Local::now()); + end - self.start + } +} diff --git a/src/main.rs b/src/main.rs index 55e2f05..3233993 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,25 @@ -mod style; +mod commands; mod config; mod database; +mod entry; mod state; +mod style; +mod utils; -use database::{ensure_db_exists, connect_to_db}; -use config::Config; -use anyhow::{Result, Context}; -use clap::{ArgAction, Args, Parser, Subcommand}; use crate::database::create_tables; use crate::style::{style_string, Styles}; -pub use state::State; +use anyhow::{Context, Result}; +use clap::{ArgAction, Args, Parser, Subcommand}; +use commands::*; +use config::Config; +use database::{connect_to_db, ensure_db_exists}; +use directories::ProjectDirs; +pub use entry::Entry; use langtime::parse; +pub use state::State; #[derive(Parser, Debug)] -#[command(author,version, about, infer_subcommands = true)] +#[command(author, version, about, infer_subcommands = true)] struct Cli { #[command(subcommand)] command: SubCommands, @@ -36,9 +42,9 @@ enum SubCommands { }, /// Display the current timesheet Display { - /// Show an JSON + /// Show an JSON #[arg(long)] - json:bool, + json: bool, /// Show the Task IDs #[arg(short, long)] ids: bool, @@ -50,10 +56,14 @@ enum SubCommands { end: Option, /// Just filter by whole days, do not take into account the time #[arg(short, long)] - filter_by_date:bool, + filter_by_date: bool, /// The timesehet to display, or the current one - sheet:Option, - } + sheet: Option, + }, + /// List available timesheet + List, + /// Shows the active task for the current sheet + Current, } fn main() { @@ -63,7 +73,6 @@ fn main() { } } - fn cli() -> Result<()> { let config = Config::build().context("Failed to build configuration")?; setup(&config).context("Programmdatanbank konnte nicht erstellt werden")?; @@ -72,6 +81,35 @@ fn cli() -> Result<()> { let cli = Cli::parse(); + match &cli.command { + SubCommands::In { task, at } => { + let target_time = at.as_ref().map(|at| parse(at)).transpose()?; + + let task = task.as_ref(); + let default_task = "".to_string(); + let task = task.unwrap_or(&default_task); + + start_task(task, target_time, &state).context("Could not start task")?; + } + SubCommands::Out { at } => { + let target_time = at.as_ref().map(|at| parse(at)).transpose()?; + stop_task(target_time, &mut state).context("Could not stop task")?; + } + SubCommands::Display { + json, + ids, + start, + end, + filter_by_date, + sheet, + } => {} + SubCommands::List => { + list_sheets(&state).context("Could not list sheets")?; + } + SubCommands::Current => { + current_task(&state).context("could not get current task")?; + } + } Ok(()) } @@ -81,4 +119,4 @@ fn setup(config: &Config) -> Result<()> { let db = connect_to_db(config)?; create_tables(&db)?; Ok(()) -} \ No newline at end of file +} diff --git a/src/state.rs b/src/state.rs index 173e17b..68e4c4b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,6 @@ -use rusqlite::Connection; -use anyhow::{anyhow, Context, Result }; +use anyhow::{anyhow, Context, Result}; use directories::ProjectDirs; +use rusqlite::Connection; use std::fs; use crate::config::Config; @@ -17,19 +17,19 @@ impl State { let db = connect_to_db(config)?; let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") .ok_or(anyhow!("Could not determine project directories"))?; - + let mut state = State { - current_sheet : "default".to_string(), + current_sheet: "default".to_string(), last_sheet: "default".to_string(), database: db, }; - - let data_dir = proj_dirs.data_local_dir(); + + let data_dir = proj_dirs.data_dir(); let mut data_file = data_dir.to_path_buf(); data_file.push("data.txt"); - + let content_res = fs::read_to_string(&data_file); - + match content_res { Ok(content) => { let mut lines = content.lines(); @@ -48,19 +48,19 @@ impl State { } Ok(state) } - + fn update_file(&self) -> Result<()> { let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") .ok_or(anyhow!("Could not determine project directories"))?; - - let data_dir = proj_dirs.data_local_dir(); + + let data_dir = proj_dirs.data_dir(); let mut data_file = data_dir.to_path_buf(); data_file.push("data.txt"); - - fs::write(&data_file, - format!("{}\n{}",self.current_sheet,self.last_sheet),)?; + + fs::write( + &data_file, + format!("{}\n{}", self.current_sheet, self.last_sheet), + )?; Ok(()) - } - -} \ No newline at end of file +} diff --git a/src/style.rs b/src/style.rs index 8431e22..622de68 100644 --- a/src/style.rs +++ b/src/style.rs @@ -16,4 +16,4 @@ pub fn style_string(label: &str, style: Styles) -> ColoredString { Styles::Primary => label.green().bold(), Styles::Secondary => label.cyan(), } -} \ No newline at end of file +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..c8fea7e --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use chrono::{DateTime, Local,Duration, LocalResult, NaiveDateTime, TimeZone}; + +pub fn str_to_datetime(s: &str) -> Result> { + let no_tz = s.replace("+00:00", ""); + let dt = NaiveDateTime::parse_from_str(&no_tz, "%Y-%m-%d %H:%M:%S%.f")?; + Ok(Local.from_utc_datetime(&dt)) +} + +pub fn time_from_now(dt: &DateTime) -> Duration { + let now = Local::now(); + + now - dt +} + +pub fn format_duration(d: &Duration) -> String { + let duration_in_seconds = d.num_seconds(); + let hours = duration_in_seconds / 60 / 60; + let minutes = (duration_in_seconds % 3600) / 60; + let seconds = duration_in_seconds % 60; + + format!("{}:{:0>2}:{:0>2}", hours, minutes, seconds) +} \ No newline at end of file