Programm weitergeschrieben

This commit is contained in:
Damian Wessels
2025-05-14 15:15:32 +02:00
parent 3b120bb7d1
commit a6430a98f8
13 changed files with 489 additions and 50 deletions

View File

@@ -5,9 +5,12 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
chrono = {version = "0.4.41", features = ["serde"]}
colored = "3.0.0" colored = "3.0.0"
clap = { version = "4.5.38", features = ["derive"] } clap = { version = "4.5.38", features = ["derive"] }
directories = "6.0.0" 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"] }
tabled = "0.19.0"

40
src/commands/current.rs Normal file
View File

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

33
src/commands/in_cmd.rs Normal file
View File

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

71
src/commands/lists.rs Normal file
View File

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

9
src/commands/mod.rs Normal file
View File

@@ -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;

39
src/commands/out.rs Normal file
View File

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

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result }; use anyhow::{anyhow, Result};
use directories::ProjectDirs; use directories::ProjectDirs;
pub struct Config { pub struct Config {
@@ -8,7 +8,7 @@ pub struct Config {
impl Config { impl Config {
pub fn build() -> Result<Config> { pub fn build() -> Result<Config> {
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"))?; .ok_or(anyhow!("Couldn't get project directories"))?;
let data_dir = proj_dirs.data_dir(); let data_dir = proj_dirs.data_dir();

View File

@@ -1,28 +1,28 @@
use std::path::PathBuf;
use anyhow::{anyhow, Context};
use anyhow::Result; use anyhow::Result;
use anyhow::{anyhow, Context};
use rusqlite::{named_params, Connection};
use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use rusqlite::Connection;
use crate::config::Config; use crate::config::Config;
use crate::entry::Entry;
use crate::utils::str_to_datetime;
pub fn connect_to_db(config: &Config) -> Result<Connection> { pub fn connect_to_db(config: &Config) -> Result<Connection> {
if let Ok(conn) = Connection::open(&config.database_file) { if let Ok(conn) = Connection::open(&config.database_file) {
return Ok(conn); return Ok(conn);
} }
Err(anyhow!( Err(anyhow!("Cannot create connection to database"))
"Cannot create connection to database"
))
} }
pub fn create_tables(db: &Connection) -> Result<()> { pub fn create_tables(db: &Connection) -> Result<()> {
let query = " let query = "
CREATE TABLE IF NOT EXISTS entries ( CREATE TABLE IF NOT EXISTS entries (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
note VARCHAR(255) NOT NULL, note VARCHAR(255) NOT NULL,
start TIMESTAMP NOT NULL, start TIMESTAMP NOT NULL,
end TIMESTAMP NOT NULL, end TIMESTAMP,
sheet VARCHAR(255) NOT NULL sheet VARCHAR(255) NOT NULL
) )
"; ";
@@ -36,12 +36,163 @@ pub fn ensure_db_exists(config: &Config) -> Result<()> {
let mut db_path = PathBuf::from_str(&config.database_file).unwrap(); let mut db_path = PathBuf::from_str(&config.database_file).unwrap();
db_path.pop(); db_path.pop();
if!db_path.exists() { if !db_path.exists() {
std::fs::create_dir_all(&db_path).context(format!( std::fs::create_dir_all(&db_path).context(format!(
"Cannot create the data folder for Timetracker. The expected path was {:?}", "Cannot create the data folder for Timetracker. The expected path was {:?}",
&db_path &db_path
))?; ))?;
} }
Ok(()) Ok(())
} }
pub fn running_entries(db: &Connection) -> Result<Vec<Entry>> {
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::<usize, Option<String>>(3)?
.map(|t| str_to_datetime(&t).unwrap());
let start = str_to_datetime(&row.get::<usize, String>(2)?).unwrap();
Ok(Entry {
id: row.get(0)?,
name: row.get(1)?,
start,
end,
sheet: row.get(4)?,
})
})?;
let entries_vec: Vec<Result<Entry,_>> = 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<Option<Entry>> {
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::<usize, Option<String>>(3)?
.map(|t| str_to_datetime(&t).unwrap());
let start = str_to_datetime(&row.get::<usize, String>(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<Vec<Entry>> {
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::<usize, Option<String>>(3)?
.map(|t| str_to_datetime(&t).unwrap());
let start = str_to_datetime(&row.get::<usize, String>(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<Vec<Entry>> {
let entries = get_all_entries(db)?;
Ok(entries
.iter()
.filter(|e| e.sheet == sheet)
.cloned()
.collect())
}
pub fn get_all_sheets(db: &Connection) -> Result<Vec<String>> {
let query = "SELECT DISTINCT sheet FROM entries;";
let mut stmt = db.prepare(query)?;
let entries = stmt.query_map([], |row| row.get::<usize, String>(0))?;
let mut sheets = Vec::new();
for sheet in entries {
sheets.push(sheet?);
}
Ok(sheets)
}

32
src/entry.rs Normal file
View File

@@ -0,0 +1,32 @@
use chrono::{DateTime, Duration, Local};
use serde::Serialize;
#[derive(Eq, PartialOrd, PartialEq ,Ord, Clone)]
pub struct Entry {
pub id: Option<usize>,
pub start: DateTime<Local>,
pub end: Option<DateTime<Local>>,
pub name: String,
pub sheet: String,
}
impl Entry {
pub fn start(name: &str, sheet: &str, start: DateTime<Local>) -> Self {
Entry {
id: None,
start,
end: None,
name: name.to_string(),
sheet: sheet.to_string(),
}
}
pub fn stop(&mut self, end: DateTime<Local>) {
self.end = Some(end);
}
pub fn get_duration(&self) -> Duration {
let end = self.end.unwrap_or(Local::now());
end - self.start
}
}

View File

@@ -1,19 +1,25 @@
mod style; mod commands;
mod config; mod config;
mod database; mod database;
mod entry;
mod state; 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::database::create_tables;
use crate::style::{style_string, Styles}; 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; use langtime::parse;
pub use state::State;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author,version, about, infer_subcommands = true)] #[command(author, version, about, infer_subcommands = true)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: SubCommands, command: SubCommands,
@@ -38,7 +44,7 @@ enum SubCommands {
Display { Display {
/// Show an JSON /// Show an JSON
#[arg(long)] #[arg(long)]
json:bool, json: bool,
/// Show the Task IDs /// Show the Task IDs
#[arg(short, long)] #[arg(short, long)]
ids: bool, ids: bool,
@@ -50,10 +56,14 @@ enum SubCommands {
end: Option<String>, end: Option<String>,
/// Just filter by whole days, do not take into account the time /// Just filter by whole days, do not take into account the time
#[arg(short, long)] #[arg(short, long)]
filter_by_date:bool, filter_by_date: bool,
/// The timesehet to display, or the current one /// The timesehet to display, or the current one
sheet:Option<String>, sheet: Option<String>,
} },
/// List available timesheet
List,
/// Shows the active task for the current sheet
Current,
} }
fn main() { fn main() {
@@ -63,7 +73,6 @@ fn main() {
} }
} }
fn cli() -> Result<()> { fn cli() -> Result<()> {
let config = Config::build().context("Failed to build configuration")?; let config = Config::build().context("Failed to build configuration")?;
setup(&config).context("Programmdatanbank konnte nicht erstellt werden")?; setup(&config).context("Programmdatanbank konnte nicht erstellt werden")?;
@@ -72,6 +81,35 @@ fn cli() -> Result<()> {
let cli = Cli::parse(); 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(()) Ok(())
} }

View File

@@ -1,6 +1,6 @@
use rusqlite::Connection; use anyhow::{anyhow, Context, Result};
use anyhow::{anyhow, Context, Result };
use directories::ProjectDirs; use directories::ProjectDirs;
use rusqlite::Connection;
use std::fs; use std::fs;
use crate::config::Config; use crate::config::Config;
@@ -19,12 +19,12 @@ impl State {
.ok_or(anyhow!("Could not determine project directories"))?; .ok_or(anyhow!("Could not determine project directories"))?;
let mut state = State { let mut state = State {
current_sheet : "default".to_string(), current_sheet: "default".to_string(),
last_sheet: "default".to_string(), last_sheet: "default".to_string(),
database: db, 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(); let mut data_file = data_dir.to_path_buf();
data_file.push("data.txt"); data_file.push("data.txt");
@@ -53,14 +53,14 @@ impl State {
let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker") let proj_dirs = ProjectDirs::from("de", "schacht-analyse", "timetracker")
.ok_or(anyhow!("Could not determine project directories"))?; .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(); let mut data_file = data_dir.to_path_buf();
data_file.push("data.txt"); data_file.push("data.txt");
fs::write(&data_file, fs::write(
format!("{}\n{}",self.current_sheet,self.last_sheet),)?; &data_file,
format!("{}\n{}", self.current_sheet, self.last_sheet),
)?;
Ok(()) Ok(())
} }
} }

23
src/utils.rs Normal file
View File

@@ -0,0 +1,23 @@
use anyhow::Result;
use chrono::{DateTime, Local,Duration, LocalResult, NaiveDateTime, TimeZone};
pub fn str_to_datetime(s: &str) -> Result<DateTime<Local>> {
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<Local>) -> 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)
}