Add basic version of the CLI

This commit is contained in:
Thomas Avé 2024-11-03 21:10:23 +01:00
commit cf1736f9e2
9 changed files with 1855 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Created by https://www.toptal.com/developers/gitignore/api/rust
# Edit at https://www.toptal.com/developers/gitignore?templates=rust
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# End of https://www.toptal.com/developers/gitignore/api/rust
.devenv
.envrc

1469
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "work-timer-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
clap = "4.5.20"
http = "1.1.0"
reqwest = {version="0.12.9", features = ["json"]}
serde = {version="1.0.214", features = ["derive"]}
serde_json = "1.0.132"
tokio = {version="1.41.0", features = ["full"]}
[[bin]]
name = "timer"

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM rust:alpine3.19
RUN apk add --no-cache musl-dev
ADD ./ /app
WORKDIR /app
RUN cargo build --release
CMD [ "/app/target/release/timer" ]

48
flake.lock Normal file
View File

@ -0,0 +1,48 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1727826117,
"narHash": "sha256-K5ZLCyfO/Zj9mPFldf3iwS6oZStJcU4tSpiXTMYaaL0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "3d04084d54bedc3d6b8b736c70ef449225c361b1",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1730200266,
"narHash": "sha256-l253w0XMT8nWHGXuXqyiIC/bMvh1VRszGXgdpQlfhvU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "807e9154dcb16384b1b765ebe9cd2bba2ac287fd",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View File

@ -0,0 +1,35 @@
{
description = "Generic Rust Flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
};
outputs = inputs @ {flake-parts, ...}:
flake-parts.lib.mkFlake {inherit inputs;} {
systems = ["x86_64-linux" "aarch64-linux"];
perSystem = {
pkgs,
...
}: let
app = pkgs.rustPlatform.buildRustPackage {
pname = "work-timer";
version = "0.1.0";
cargoLock = {
lockFile = ./Cargo.lock;
};
src = ./.;
buildInputs = [ pkgs.openssl ];
nativeBuildInputs = [ pkgs.pkg-config ];
PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
};
in {
packages = {
default = app;
inherit app;
};
};
};
}

93
src/bin/timer.rs Normal file
View File

@ -0,0 +1,93 @@
use clap::{arg, Command};
use work_timer_cli::commands::{start, stop, edit, status, Settings};
fn cli() -> Command {
Command::new("timer")
.about("A tracker for time spent working")
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true)
.arg(
arg!(--project <PROJECT> "The project associated with this session")
.short('p')
.default_value("trackbox")
)
.arg(
arg!(--server <URL> "The base URL of the tracking server")
.short('s')
.default_value("http://localhost:3000")
)
.arg(
arg!(--json "Use JSON output")
.short('j')
.default_value("true")
.default_missing_value("false")
)
.subcommand(
Command::new("start")
.about("Start tracking a working session")
.arg(
arg!(<DESCRIPTION> "A description to add for this session")
.required(false)
)
)
.subcommand(
Command::new("stop")
.about("Finish the working session currently being tracked")
.arg(
arg!(<ID> "The ID of the session to stop")
.required(false)
)
)
.subcommand(
Command::new("edit")
.about("Edit a list of sessions")
.arg(
arg!(-n <NUM> "The maximum number of sessions to edit")
.default_value("10")
.required(false)
)
.arg(
arg!(--since <TIMESTAMP> "A timestamp to start from. Can be ISO8601 or 'today' or a weekday")
.required(false)
)
.arg(
arg!(--until <TIMESTAMP> "A timestamp to end at. Can be ISO8601 or 'today' or a weekday")
.required(false)
)
)
.subcommand(
Command::new("status")
.about("Get an overview of recent sessions")
)
}
#[tokio::main]
async fn main() {
let matches = cli().get_matches();
let project = matches.get_one::<String>("project").unwrap();
let url = matches.get_one::<String>("server").unwrap();
let json = matches.get_one::<bool>("json").unwrap();
let settings = Settings { project: project.to_string(), url: url.to_string(), json: *json };
match matches.subcommand() {
Some(("start", sub_matches)) => {
let description = sub_matches.get_one::<String>("DESCRIPTION");
start(settings, description).await.unwrap();
}
Some(("stop", sub_matches)) => {
let id = sub_matches.get_one::<i32>("ID");
stop(settings, id).await.unwrap();
}
Some(("edit", sub_matches)) => {
let since = sub_matches.get_one::<String>("since");
let until = sub_matches.get_one::<String>("until");
let num = sub_matches.get_one::<String>("NUM");
edit(settings, since, until, num).await.unwrap();
}
Some(("status", _)) => {
status(settings).await.unwrap();
}
_ => unreachable!(), // If all subcommands are defined above, anything else is unreachable!()
}
}

165
src/commands.rs Normal file
View File

@ -0,0 +1,165 @@
use std::{collections::HashMap, io::Write};
use chrono::Datelike;
use chrono::NaiveDateTime;
use reqwest::Client;
use serde_json::json;
use serde::{Deserialize, Serialize};
use std::{
env::{temp_dir, var},
fs::File,
io::Read,
process::Command,
};
pub struct Settings {
pub url: String,
pub project: String,
pub json: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkPeriod {
pub id: i32,
pub project: String,
pub start_time: NaiveDateTime,
pub end_time: Option<NaiveDateTime>,
pub description: Option<String>,
}
pub async fn start(settings: Settings, description: Option<&String>) -> Result<(), reqwest::Error> {
let mut map = HashMap::new();
map.insert("project", settings.project);
if let Some(description) = description {
map.insert("description", description.to_string());
}
let client = Client::new();
let response = client.post(settings.url + "/api/tracking")
.json(&map)
.send()
.await?;
if !response.status().is_success() {
println!("{:?}", response.text().await);
}
Ok(())
}
pub async fn stop(settings: Settings, id: Option<&i32>) -> Result<(), reqwest::Error> {
let mut map = HashMap::new();
map.insert("project", settings.project);
if let Some(id) = id {
map.insert("id", format!("{}", id));
}
let client = Client::new();
let response = client.delete(settings.url + "/api/tracking")
.json(&map)
.send()
.await?;
if !response.status().is_success() {
println!("{:?}", response.text().await);
}
Ok(())
}
fn parse_timestamp(timestamp: &str) -> String {
let now = chrono::Local::now();
let weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
if timestamp == "today" {
return now.format("%Y-%m-%dT00:00:00").to_string();
} else if timestamp == "yesterday" {
return (now - chrono::Duration::days(1)).format("%Y-%m-%dT00:00:00").to_string();
} else {
for (i, weekday) in weekdays.iter().enumerate() {
if timestamp == *weekday {
let days = (now.weekday().num_days_from_monday() as i64 - i as i64) % 7;
return (now - chrono::Duration::days(days)).format("%Y-%m-%dT00:00:00").to_string();
}
}
}
return timestamp.to_string();
}
fn parse_periods(body: String) -> Result<Vec<WorkPeriod>, serde_json::Error> {
let periods: Vec<WorkPeriod> = serde_json::from_str(&body)?;
Ok(periods)
}
fn to_json(periods: Vec<WorkPeriod>) -> Result<String, serde_json::Error> {
let json = json!(periods);
Ok(serde_json::to_string_pretty(&json)?)
}
fn edit_periods(periods: Vec<WorkPeriod>) -> Result<Vec<WorkPeriod>, std::io::Error> {
let content = to_json(periods)?;
let editor = var("EDITOR").unwrap_or("vi".to_string());
let mut file_path = temp_dir();
file_path.push("Periods.json");
let mut file = File::create(&file_path).expect("Could not create file");
file.write_all(content.as_bytes())?;
Command::new(editor)
.arg(&file_path)
.status()
.expect("Something went wrong");
let mut editable = String::new();
File::open(file_path)
.expect("Could not open file")
.read_to_string(&mut editable)?;
let periods: Vec<WorkPeriod> = serde_json::from_str(&editable).expect("Could not parse JSON");
Ok(periods)
}
pub async fn update_period(settings: &Settings, period: WorkPeriod) -> Result<(), reqwest::Error> {
let client = Client::new();
let response = client.put(settings.url.to_string() + "/api/history/" + &period.id.to_string())
.json(&period)
.send()
.await?;
if !response.status().is_success() {
println!("{:?}", response.text().await);
}
Ok(())
}
pub async fn edit(settings: Settings, since: Option<&String>, until: Option<&String>, num: Option<&String>) -> Result<(), reqwest::Error> {
let mut params = vec![
("project", settings.project.to_owned()),
];
if let Some(since) = since {
params.push(("since", parse_timestamp(since)));
}
if let Some(until) = until {
params.push(("until", parse_timestamp(until)));
}
if let Some(num) = num {
params.push(("count", num.to_string()));
}
let client = Client::new();
let url = reqwest::Url::parse_with_params((settings.url.to_owned() + "/api/history").as_str(), &params).unwrap();
let response = client.get(url)
.send()
.await?;
if !response.status().is_success() {
println!("{:?}", response.text().await);
} else {
let body = response.text().await.unwrap();
let periods = parse_periods(body).unwrap();
let res = edit_periods(periods).unwrap();
for period in res {
update_period(&settings, period).await.unwrap();
}
}
Ok(())
}
pub async fn status(settings: Settings) -> Result<(), reqwest::Error> {
Ok(())
}

1
src/lib.rs Normal file
View File

@ -0,0 +1 @@
pub mod commands;