diff --git a/Cargo.toml b/Cargo.toml index f3cef41..3e0d5bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,5 @@ 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 +tabled = "0.19.0" +serde_json = "1.0.140" \ No newline at end of file diff --git a/src/commands/display.rs b/src/commands/display.rs new file mode 100644 index 0000000..d6f5ecd --- /dev/null +++ b/src/commands/display.rs @@ -0,0 +1,205 @@ +use anyhow::Result; +use chrono::{DateTime, Duration, Local}; +use serde_json::to_string_pretty; +use tabled::builder::Builder; +use tabled::settings::object::Rows; +use tabled::settings::themes::Colorization; +use tabled::settings::{Border, Color, Padding, Style}; + + +use crate::database::get_sheet_entries; +use crate::style::{style_string, Styles}; +use crate::State; +use crate::Entry; +use crate::utils::{day_begin, day_end, format_duration, is_same_day}; + + +pub struct ReadableOptions { + pub show_ids: bool, + pub show_timesheet: bool, + pub show_partial_sum: bool, + pub show_total: bool, + pub show_headings: bool, + //pub padding: usize, +} + +impl ReadableOptions { + pub fn new() -> Self { + Self { + show_ids: false, + show_timesheet: false, + show_partial_sum : false, + show_total : false, + show_headings : false, + // padding: 0, + } + } + + pub fn complete() -> Self { + Self { + show_ids: true, + show_timesheet: true, + show_partial_sum: true, + show_total: true, + show_headings: true, + // padding: 0, + } + } +} + +pub fn display_tasks( + print_json: &bool, + sheet: Option<&String>, + start: Option>, + end: Option>, + filter_by_date: &bool, + ids: &bool, + state: &State, +) -> Result<()> { + // Getting the data + let sheet = sheet.unwrap_or(&state.current_sheet); + let mut entries = get_sheet_entries(sheet, &state.database)?; + + if entries.is_empty() { + println!( + "{} {}", + style_string("No sheet found with name:", Styles::Message), + sheet + ); + return Ok(()); + } + + // Sorting + entries.sort_by(|a, b| a.start.cmp(&b.start)); + + // Filtering + let mut start = start; + let mut end = end; + + if *filter_by_date { + start = start.map(day_begin); + end = end.map(day_end); + } + + entries.retain(|e| { + if start.is_some() && e.start < start.unwrap() { + return false; + } + + if end.is_some() && e.start > end.unwrap() { + return false; + } + + true + }); + + // Displaying + match print_json { + true => print_all_tasks_json(&entries)?, + false => { + let mut options = ReadableOptions::complete(); + options.show_ids = *ids; + + print_all_tasks_readable(sheet, &entries, &options); + } + }; + + Ok(()) +} + +pub fn print_all_tasks_readable(sheet: &str, entries: &Vec, options: &ReadableOptions) { + if options.show_timesheet { + println!("{} {}", style_string("Timesheet:", Styles::Title), sheet); + } + + let mut builder = Builder::new(); + + if options.show_headings { + let h_id = match options.show_ids { + true => "ID", + false => "", + }; + + let headings = vec![h_id, "Date", "Start", "End", "Duration", "Task"]; + builder.push_record(headings); + } + + let mut prev_date = None; + let mut day_sum = Duration::zero(); + for entry in entries { + let mut print_date = true; + let mut print_partial = false; + let is_same = prev_date.is_some() && is_same_day(prev_date.unwrap(), &entry.start); + + if is_same { + print_date = false; + } else if prev_date.is_some() { + print_partial = true; + } + + prev_date = Some(&entry.start); + + if print_partial && options.show_partial_sum { + builder.push_record(vec!["", "", "", "", &format_duration(&day_sum)]); + day_sum = Duration::zero(); + } + + day_sum = day_sum + entry.get_duration(); + + let id = match options.show_ids { + true => entry.id.unwrap().to_string(), + false => "".to_string(), + }; + + let date = match print_date { + true => entry.start.format("%a %b %d, %Y").to_string(), + false => "".to_string(), + }; + + let start = entry.start.format("%H:%M:%S").to_string(); + + let end = match entry.end { + Some(d) => d.format("%H:%M:%S").to_string(), + None => "".to_string(), + }; + + builder.push_record(vec![ + &id, + &date, + &start, + &end, + &format_duration(&entry.get_duration()), + &entry.name, + ]); + } + + if options.show_partial_sum { + builder.push_record(vec!["", "", "", "", &format_duration(&day_sum)]); + } + + let total = entries.iter().map(|e| e.get_duration()).sum(); + if options.show_total { + builder.push_record(vec!["Total", "", "", "", &format_duration(&total)]); + } + + let mut table = builder.build(); + table.with(Style::empty()); + table.with(Padding::new(2, 2, 0, 0)); + + if options.show_headings { + table.with(Colorization::exact([Color::BOLD], Rows::first())); + } + + if options.show_total { + table.with(Colorization::exact([Color::BOLD], Rows::last())); + table.modify(Rows::last(), Border::new().top('-')); + } + + println!("{}", table); +} + +pub fn print_all_tasks_json(entries: &Vec) -> Result<()> { + println!("{}", to_string_pretty(entries)?); + + Ok(()) +} \ No newline at end of file diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 0a3a20a..54d928d 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -1,7 +1,11 @@ use anyhow::Result; -use crate::database::{get_entry_by_id, running_entry}; -use crate::{Entry, State}; + +use langtime::parse; + +use crate::commands::display::{print_all_tasks_readable, ReadableOptions}; +use crate::database::{get_entry_by_id, running_entry, update_entry}; use crate::style::{style_string, Styles}; +use crate::State; pub fn edit_task( id: &Option, @@ -28,8 +32,33 @@ pub fn edit_task( } let mut entry = entry.unwrap_or_else(|| running_entry.unwrap()); - - - + + if let Some(start) = start { + entry.start = parse(start)?; + } + + if let Some(end) = end { + entry.end = Some(parse(end)?); + } + + if let Some(move_to) = move_to { + entry.sheet = move_to.to_string(); + } + + if let Some(notes) = notes { + entry.name = notes.to_string(); + } + + update_entry(&entry, &state.database)?; + + // Display output + println!("{}", style_string("Entry updated:", Styles::Message)); + + let mut options = ReadableOptions::new(); + options.show_headings = true; + options.show_ids = true; + + print_all_tasks_readable("", &vec![entry], &options); + Ok(()) } \ No newline at end of file diff --git a/src/commands/kill.rs b/src/commands/kill.rs new file mode 100644 index 0000000..2747c97 --- /dev/null +++ b/src/commands/kill.rs @@ -0,0 +1,99 @@ +use anyhow::Result; + +use crate::database::{ + get_all_sheets, get_entry_by_id, remove_entries_by_sheet, remove_entry_by_id, +}; +use crate::style::{style_string, Styles}; +use crate::State; + + +pub fn kill_task(id: &usize, state: &mut State) -> Result<()> { + let entry = get_entry_by_id(id, &state.database)?; + + // Guard for non-existent entries + if entry.is_none() { + println!( + "{} {}", + style_string("Entry not found. Id:", Styles::Message), + id + ); + return Ok(()); + } + + let entry = entry.unwrap(); + + if !confirm_action(&format!( + "Are you sure you want to remove entry {}?", + entry.name + )) { + return Ok(()); + } + + remove_entry_by_id(id, &state.database)?; + + println!("{} {}", style_string("Removed entry:", Styles::Message), id); + + Ok(()) +} + +pub fn kill_sheet(sheet: &str, state: &mut State) -> Result<()> { + let sheets = get_all_sheets(&state.database)?; + + // Guard for non-existent sheets + if !sheets.iter().any(|s| s == sheet) { + println!( + "{} {}", + style_string("Sheet not found:", Styles::Message), + sheet + ); + return Ok(()); + } + + if !confirm_action(&format!("Are you sure you want to remove sheet {}?", sheet)) { + return Ok(()); + } + + remove_entries_by_sheet(sheet, &state.database)?; + + // Check edge cases for sheets that are in use + if state.current_sheet == sheet { + move_to_last_sheet(state)?; + } else if state.last_sheet == sheet { + state.last_sheet = "default".to_string() + } + + println!( + "{} {}", + style_string("Removed sheet:", Styles::Message), + sheet + ); + + Ok(()) +} + +fn move_to_last_sheet(state: &mut State) -> Result<()> { + // If there is a last sheet, move to it, otherwise move + // to the first sheet available, or "default" if none exist. + let sheets = get_all_sheets(&state.database)?; + let last_sheet_exists = sheets.iter().any(|s| s == &state.last_sheet); + + match last_sheet_exists { + true => state.change_sheet(&state.last_sheet.clone())?, + false => { + match !sheets.is_empty() { + true => state.change_sheet(&sheets[0])?, + false => state.change_sheet("default")?, + }; + } + }; + + Ok(()) +} + + +fn confirm_action(msg: &str) -> bool { + println!("{} ", msg); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).unwrap(); + input.trim().to_lowercase() == "y" +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e773859..4484746 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,9 +3,19 @@ mod in_cmd; mod out; mod lists; mod edit; +mod display; +mod sheet; +mod kill; +mod month; pub use current::current_task; pub use in_cmd::start_task; pub use out::stop_task; pub use lists::list_sheets; -pub use edit::edit_task; \ No newline at end of file +pub use edit::edit_task; +pub use display::display_tasks; +pub use sheet::checkout_sheet; +pub use sheet::rename_sheet; +pub use kill::kill_sheet; +pub use kill::kill_task; +pub use month::display_month; \ No newline at end of file diff --git a/src/commands/month.rs b/src/commands/month.rs new file mode 100644 index 0000000..b593c24 --- /dev/null +++ b/src/commands/month.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use chrono::Local; + +use crate::commands::display_tasks; +use crate::utils::get_month_boundaries; +use crate::State; + +pub fn display_month( + json: &bool, + ids: &bool, + month: Option<&String>, + sheet: Option<&String>, + state: &mut State, +) -> Result<()> { + let now = Local::now().format("%Y-%m").to_string(); + let month = month.unwrap_or(&now); + let (start, end) = get_month_boundaries(month)?; + + display_tasks(json, sheet, Some(start), Some(end), &true, ids, state) +} \ No newline at end of file diff --git a/src/commands/sheet.rs b/src/commands/sheet.rs new file mode 100644 index 0000000..525cb6d --- /dev/null +++ b/src/commands/sheet.rs @@ -0,0 +1,46 @@ +use anyhow::Result; + +use crate:: { + database::update_sheet_name, + style::{style_string, Styles}, + State, +}; + +pub fn checkout_sheet(name: &str, state: &mut State) -> Result<()> { + if state.current_sheet == name { + println!( + "{} {}", + style_string("Already on sheet:", Styles::Message), + name + ); + return Ok(()); + } + + let name = if name == "-" { + state.last_sheet.clone() + } else { + name.to_string() + }; + + state.change_sheet(&name)?; + + println!( + "{} {}", + style_string("Switched to sheet:", Styles::Message), + name + ); + Ok(()) +} + +pub fn rename_sheet(name: &str, new_name: &str, state: &mut State) -> Result<()> { + update_sheet_name(name, new_name, &state.database)?; + if state.current_sheet == name { + state.update_sheet_name(new_name)?; + } + + println!( + "{}", + style_string("Sheet renamed successfully", Styles::Message) + ); + Ok(()) +} \ No newline at end of file diff --git a/src/database.rs b/src/database.rs index ff71988..76bdeec 100644 --- a/src/database.rs +++ b/src/database.rs @@ -104,7 +104,7 @@ pub fn write_entry(entry: &Entry, db: &Connection) -> Result<()> { Ok(()) } -fn update_entry(entry: &Entry, db: &Connection) -> Result<()> { +pub fn update_entry(entry: &Entry, db: &Connection) -> Result<()> { let query = " UPDATE entries SET note = :note, @@ -218,4 +218,33 @@ pub fn get_entry_by_id(id: &usize, db: &Connection) -> Result> { let entry = entries.next(); entry.transpose().context(format!("Failed to read entry")) +} + +pub fn update_sheet_name(old_name: &str, new_name: &str, db: &Connection) -> Result<()> { + let query = "UPDATE entries SET sheet = ? WHERE sheet = ?"; + let mut stmt = db.prepare(query)?; + stmt.execute([new_name, &old_name])?; + Ok(()) +} + +pub fn remove_entry_by_id(id: &usize, db: &Connection) -> Result<()> { + let query = " + DELETE FROM entries WHERE id = ?; + "; + + let mut stmt = db.prepare(query)?; + stmt.execute([id])?; + + Ok(()) +} + +pub fn remove_entries_by_sheet(sheet: &str, db: &Connection) -> Result<()> { + let query = " + DELETE FROM entries WHERE sheet = ?; + "; + + let mut stmt = db.prepare(query)?; + stmt.execute([sheet])?; + + Ok(()) } \ No newline at end of file diff --git a/src/entry.rs b/src/entry.rs index 714f3a7..4b58438 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Duration, Local}; use serde::Serialize; -#[derive(Eq, PartialOrd, PartialEq ,Ord, Clone)] +#[derive(Eq, PartialOrd, PartialEq ,Ord, Clone, Serialize)] pub struct Entry { pub id: Option, pub start: DateTime, diff --git a/src/main.rs b/src/main.rs index 9a99a9f..768ef2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,11 +9,10 @@ mod utils; use crate::database::create_tables; use crate::style::{style_string, Styles}; use anyhow::{Context, Result}; -use clap::{ArgAction, Args, Parser, Subcommand}; +use clap::{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; @@ -60,6 +59,20 @@ enum SubCommands { /// The timesehet to display, or the current one sheet: Option, }, + /// Like 'Display' but for a specific month, or the current one + Month { + /// Show a JSON representation instead of a human-readable one + #[arg(long)] + json: bool, + /// Show the tasks IDs + #[arg(short, long)] + ids: bool, + /// The specific month to show. The format is yyyy-mm (e.g. "2024-03") + #[arg(short, long)] + month: Option, + /// The timesheet to display, or the current one + sheet: Option, + }, /// Change timesheet Sheet { name: String, @@ -87,6 +100,21 @@ enum SubCommands { }, /// Shows the active task for the current sheet Current, + /// Removes a task or a whole timesheet + Kill { + #[command(flatten)] + kill_args: KillArgs, + }, +} + +#[derive(Args, Debug)] +#[group(required = true, multiple = false)] +struct KillArgs { + /// The ID of the task to remove + #[arg(long)] + id: Option, + /// The name of the timesheet to remove + sheet: Option, } fn main() { @@ -125,8 +153,29 @@ fn cli() -> Result<()> { end, filter_by_date, sheet, - } => {} - SubCommands::Sheet {name, rename} => {} + } => { + display_tasks( + json, + sheet.as_ref(), + start.as_ref().map(|s| parse(s)).transpose()?, + end.as_ref().map(|e| parse(e)).transpose()?, + filter_by_date, + ids, + &state, + ).context("Could not display tasks")?; + } + SubCommands::Month { + json, + ids, + month, + sheet, + } => { + display_month(json,ids, month.as_ref(), sheet.as_ref(), &mut state).context("Could not display month")?; + } + SubCommands::Sheet {name, rename} => match rename { + None => checkout_sheet(name, &mut state).context("Could not checkout sheet")?, + Some(new_name) => rename_sheet(name, new_name, &mut state).context("Could not rename sheet")?, + } SubCommands::List => { list_sheets(&state).context("Could not list sheets")?; } @@ -142,6 +191,13 @@ fn cli() -> Result<()> { } => { edit_task(id,start,end,move_to,notes, &mut state).context("Could not edit task")?; } + SubCommands::Kill {kill_args, } => { + if let Some(id) = &kill_args.id { + kill_task(id, &mut state).context("Could not kill task")?; + } else if let Some(sheet) = &kill_args.sheet { + kill_sheet(sheet, &mut state).context("Could not delete the timesheet")?; + } + } } diff --git a/src/state.rs b/src/state.rs index 68e4c4b..8c4a652 100644 --- a/src/state.rs +++ b/src/state.rs @@ -48,6 +48,19 @@ impl State { } Ok(state) } + + pub fn change_sheet(&mut self, sheet: &str) -> Result<()> { + self.last_sheet = self.current_sheet.clone(); + self.current_sheet = sheet.to_string(); + self.update_file()?; + Ok(()) + } + + pub fn update_sheet_name(&mut self, sheet: &str) -> Result<()> { + self.current_sheet = sheet.to_string(); + self.update_file()?; + Ok(()) + } fn update_file(&self) -> Result<()> { let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") diff --git a/src/utils.rs b/src/utils.rs index c8fea7e..92403b9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Local,Duration, LocalResult, NaiveDateTime, TimeZone}; +use chrono::{DateTime, Datelike, Duration, Local,LocalResult, NaiveDateTime, TimeZone}; pub fn str_to_datetime(s: &str) -> Result> { let no_tz = s.replace("+00:00", ""); @@ -20,4 +20,59 @@ pub fn format_duration(d: &Duration) -> String { let seconds = duration_in_seconds % 60; format!("{}:{:0>2}:{:0>2}", hours, minutes, seconds) +} + +pub fn day_begin(dt: DateTime) -> DateTime { + Local + .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0) + .unwrap() +} + +pub fn day_end(dt: DateTime) -> DateTime { + Local + .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59) + .unwrap() +} + +pub fn is_same_day(dt1: &DateTime, dt2: &DateTime) -> bool { + dt1.year() == dt2.year() && dt1.month() == dt2.month() && dt1.day() == dt2.day() +} + +pub fn get_month_boundaries(month: &str) -> Result<(DateTime, DateTime)> { + let start = get_month_from_string(month)?; + let end = get_last_day_of_month(start)?; + + Ok((start, end)) +} + +// The month is written as 2024-01 +pub fn get_month_from_string(month_str: &str) -> Result> { + let year = month_str.split('-').next().unwrap().parse::().unwrap(); + let month = month_str.split('-').nth(1).unwrap().parse::().unwrap(); + + let res = Local.with_ymd_and_hms(year, month, 1, 0, 0, 0); + + match res { + chrono::LocalResult::None => Err(anyhow::anyhow!("Invalid month")), + chrono::LocalResult::Single(dt) => Ok(dt), + chrono::LocalResult::Ambiguous(_, _) => Err(anyhow::anyhow!("Ambiguous month")), + } +} + +pub fn get_last_day_of_month(dt: DateTime) -> Result> { + let mut month = dt.month() + 1; + let mut year = dt.year(); + + if month > 12 { + month = 1; + year += 1; + } + + let res = Local.with_ymd_and_hms(year, month, 1, 0, 0, 0); + + match res { + LocalResult::None => Err(anyhow::anyhow!("Invalid month")), + LocalResult::Single(dt) => Ok(dt - Duration::days(1)), + LocalResult::Ambiguous(_, _) => Err(anyhow::anyhow!("Ambiguous month")), + } } \ No newline at end of file