alle Funktionen implementiert

This commit is contained in:
Husky
2025-05-18 12:03:48 +02:00
parent eccd27ff6a
commit 08fbe3a780
12 changed files with 577 additions and 14 deletions

View File

@@ -14,4 +14,5 @@ directories = "6.0.0"
rusqlite = { version = "0.35.0", features = ["chrono", "bundled"] } rusqlite = { version = "0.35.0", features = ["chrono", "bundled"] }
langtime = "0.2.1" langtime = "0.2.1"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.217", features = ["derive"] }
tabled = "0.19.0" tabled = "0.19.0"
serde_json = "1.0.140"

205
src/commands/display.rs Normal file
View File

@@ -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<DateTime<Local>>,
end: Option<DateTime<Local>>,
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<Entry>, 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<Entry>) -> Result<()> {
println!("{}", to_string_pretty(entries)?);
Ok(())
}

View File

@@ -1,7 +1,11 @@
use anyhow::Result; 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::style::{style_string, Styles};
use crate::State;
pub fn edit_task( pub fn edit_task(
id: &Option<usize>, id: &Option<usize>,
@@ -28,8 +32,33 @@ pub fn edit_task(
} }
let mut entry = entry.unwrap_or_else(|| running_entry.unwrap()); 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(()) Ok(())
} }

99
src/commands/kill.rs Normal file
View File

@@ -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"
}

View File

@@ -3,9 +3,19 @@ mod in_cmd;
mod out; mod out;
mod lists; mod lists;
mod edit; mod edit;
mod display;
mod sheet;
mod kill;
mod month;
pub use current::current_task; pub use current::current_task;
pub use in_cmd::start_task; pub use in_cmd::start_task;
pub use out::stop_task; pub use out::stop_task;
pub use lists::list_sheets; pub use lists::list_sheets;
pub use edit::edit_task; 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;

20
src/commands/month.rs Normal file
View File

@@ -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)
}

46
src/commands/sheet.rs Normal file
View File

@@ -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(())
}

View File

@@ -104,7 +104,7 @@ pub fn write_entry(entry: &Entry, db: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn update_entry(entry: &Entry, db: &Connection) -> Result<()> { pub fn update_entry(entry: &Entry, db: &Connection) -> Result<()> {
let query = " let query = "
UPDATE entries SET UPDATE entries SET
note = :note, note = :note,
@@ -218,4 +218,33 @@ pub fn get_entry_by_id(id: &usize, db: &Connection) -> Result<Option<Entry>> {
let entry = entries.next(); let entry = entries.next();
entry.transpose().context(format!("Failed to read entry")) 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(())
} }

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Duration, Local}; use chrono::{DateTime, Duration, Local};
use serde::Serialize; use serde::Serialize;
#[derive(Eq, PartialOrd, PartialEq ,Ord, Clone)] #[derive(Eq, PartialOrd, PartialEq ,Ord, Clone, Serialize)]
pub struct Entry { pub struct Entry {
pub id: Option<usize>, pub id: Option<usize>,
pub start: DateTime<Local>, pub start: DateTime<Local>,

View File

@@ -9,11 +9,10 @@ mod utils;
use crate::database::create_tables; use crate::database::create_tables;
use crate::style::{style_string, Styles}; use crate::style::{style_string, Styles};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{ArgAction, Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use commands::*; use commands::*;
use config::Config; use config::Config;
use database::{connect_to_db, ensure_db_exists}; use database::{connect_to_db, ensure_db_exists};
use directories::ProjectDirs;
pub use entry::Entry; pub use entry::Entry;
use langtime::parse; use langtime::parse;
pub use state::State; pub use state::State;
@@ -60,6 +59,20 @@ enum SubCommands {
/// The timesehet to display, or the current one /// The timesehet to display, or the current one
sheet: Option<String>, sheet: Option<String>,
}, },
/// 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<String>,
/// The timesheet to display, or the current one
sheet: Option<String>,
},
/// Change timesheet /// Change timesheet
Sheet { Sheet {
name: String, name: String,
@@ -87,6 +100,21 @@ enum SubCommands {
}, },
/// Shows the active task for the current sheet /// Shows the active task for the current sheet
Current, 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<usize>,
/// The name of the timesheet to remove
sheet: Option<String>,
} }
fn main() { fn main() {
@@ -125,8 +153,29 @@ fn cli() -> Result<()> {
end, end,
filter_by_date, filter_by_date,
sheet, 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 => { SubCommands::List => {
list_sheets(&state).context("Could not list sheets")?; 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")?; 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")?;
}
}
} }

View File

@@ -48,6 +48,19 @@ impl State {
} }
Ok(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<()> { fn update_file(&self) -> Result<()> {
let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker")

View File

@@ -1,5 +1,5 @@
use anyhow::Result; 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<DateTime<Local>> { pub fn str_to_datetime(s: &str) -> Result<DateTime<Local>> {
let no_tz = s.replace("+00:00", ""); let no_tz = s.replace("+00:00", "");
@@ -20,4 +20,59 @@ pub fn format_duration(d: &Duration) -> String {
let seconds = duration_in_seconds % 60; let seconds = duration_in_seconds % 60;
format!("{}:{:0>2}:{:0>2}", hours, minutes, seconds) format!("{}:{:0>2}:{:0>2}", hours, minutes, seconds)
}
pub fn day_begin(dt: DateTime<Local>) -> DateTime<Local> {
Local
.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0)
.unwrap()
}
pub fn day_end(dt: DateTime<Local>) -> DateTime<Local> {
Local
.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59)
.unwrap()
}
pub fn is_same_day(dt1: &DateTime<Local>, dt2: &DateTime<Local>) -> bool {
dt1.year() == dt2.year() && dt1.month() == dt2.month() && dt1.day() == dt2.day()
}
pub fn get_month_boundaries(month: &str) -> Result<(DateTime<Local>, DateTime<Local>)> {
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<DateTime<Local>> {
let year = month_str.split('-').next().unwrap().parse::<i32>().unwrap();
let month = month_str.split('-').nth(1).unwrap().parse::<u32>().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<Local>) -> Result<DateTime<Local>> {
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")),
}
} }