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]
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"

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;
pub struct Config {
@@ -8,14 +8,14 @@ pub struct Config {
impl 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"))?;
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"))
}
}
}

View File

@@ -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<Connection> {
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<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 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<String>,
/// 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<String>,
}
sheet: Option<String>,
},
/// 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(())
}
}

View File

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

View File

@@ -16,4 +16,4 @@ pub fn style_string(label: &str, style: Styles) -> ColoredString {
Styles::Primary => label.green().bold(),
Styles::Secondary => label.cyan(),
}
}
}

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