Add basic version of the CLI
This commit is contained in:
commit
cf1736f9e2
|
@ -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
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
|
@ -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" ]
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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!()
|
||||
}
|
||||
}
|
|
@ -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(), ¶ms).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(())
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
pub mod commands;
|
Loading…
Reference in New Issue