From d40e014aebc778939ae8d9afae225b7d4f6cc949 Mon Sep 17 00:00:00 2001 From: Mx Kookie Date: Fri, 11 Dec 2020 17:58:39 +0000 Subject: cassiopeia: adding more types to the time file abstraction --- apps/cassiopeia/Cargo.lock | 188 ++++++++++++++++++++++++++++++++++- apps/cassiopeia/Cargo.toml | 10 +- apps/cassiopeia/README.md | 64 ++++++++++++ apps/cassiopeia/src/data.rs | 66 ++++++++++-- apps/cassiopeia/src/format/mod.rs | 7 +- apps/cassiopeia/src/format/parser.rs | 22 ++-- apps/cassiopeia/src/main.rs | 3 +- 7 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 apps/cassiopeia/README.md (limited to 'apps/cassiopeia') diff --git a/apps/cassiopeia/Cargo.lock b/apps/cassiopeia/Cargo.lock index be14f5554b85..2987b5639928 100644 --- a/apps/cassiopeia/Cargo.lock +++ b/apps/cassiopeia/Cargo.lock @@ -1,5 +1,34 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "aho-corasick" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.0.1" @@ -12,14 +41,29 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "474a626a67200bd107d44179bb3d4fc61891172d11696609264589be6a0e6a43" +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + [[package]] name = "cassiopeia" -version = "0.1.0" +version = "0.3.0" dependencies = [ "chrono", + "clap", + "env_logger", + "log", "logos", ] +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "chrono" version = "0.4.19" @@ -33,18 +77,77 @@ dependencies = [ "winapi", ] +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "term_size", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "env_logger" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26ecb66b4bdca6c1409b40fb255eefc2bd4f6d135dab3c3124f80ffa2a9661e" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "hermit-abi" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" +dependencies = [ + "libc", +] + +[[package]] +name = "humantime" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1ad908cc71012b7bea4d0c53ba96a8cba9962f048fa68d143376143d863b7a" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" +[[package]] +name = "log" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" +dependencies = [ + "cfg-if", +] + [[package]] name = "logos" version = "0.11.4" @@ -69,6 +172,12 @@ dependencies = [ "utf8-ranges", ] +[[package]] +name = "memchr" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" + [[package]] name = "num-integer" version = "0.1.44" @@ -106,12 +215,30 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38cf2c13ed4745de91a5eb834e11c00bcc3709e773173b2ce4c56c9fbde04b9c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + [[package]] name = "regex-syntax" version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "syn" version = "1.0.54" @@ -123,6 +250,44 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "term_size", + "unicode-width", +] + +[[package]] +name = "thread_local" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" +dependencies = [ + "lazy_static", +] + [[package]] name = "time" version = "0.1.44" @@ -134,6 +299,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + [[package]] name = "unicode-xid" version = "0.2.1" @@ -146,6 +317,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ae116fef2b7fea257ed6440d3cfcff7f190865f170cdad00bb6465bf18ecba" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "wasi" version = "0.10.0+wasi-snapshot-preview1" @@ -168,6 +345,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/apps/cassiopeia/Cargo.toml b/apps/cassiopeia/Cargo.toml index 2901f8199c00..9840eb3629be 100644 --- a/apps/cassiopeia/Cargo.toml +++ b/apps/cassiopeia/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "cassiopeia" -version = "0.1.0" +version = "0.3.0" +description = "Simple, low effort time tracking tool for the kookie-office ecosystem" authors = ["Mx Kookie "] edition = "2018" +license = "GPL-3.0-or-later" +repository = "https://git.spacekookie.de/kookienomicon/tree/apps/cassiopeia" [dependencies] -chrono = "*" +chrono = "0.4" +clap = { version = "2.0", features = ["wrap_help", "suggestions", "color"] } +env_logger = "0.8" +log = "0.4" logos = "0.11" \ No newline at end of file diff --git a/apps/cassiopeia/README.md b/apps/cassiopeia/README.md new file mode 100644 index 000000000000..d6456bf7f403 --- /dev/null +++ b/apps/cassiopeia/README.md @@ -0,0 +1,64 @@ +# cassiopeia + +A simple time tracking tool for the kookie office ecosystem. + +The kookie office ecosystem is a set of free software, plain text +tools that I use to run my business. The other tool you may want to +look at is called [invoice]! + +[invoice]: https://git.spacekookie.de/kookienomicon/tree/infra/libkookie/overlays/kookie/invoice + +## How to use + +Each time file only tracks a single customer or project that you are +working on. The time file itself is simply a set of commands, +followed by associated data. The following four keywords exist: + +* `HEADER`: define metadata about the client, and store the time file + version to avoid parsing with an incompatible version in the future +* `START`: opens a work session with a local timezone timestamp +* `STOP`: closes a work session with a local timezone timestamp +* `INVOICE`: mark the previous section of sessions as "billed". This + is useful if a project is long-running and you want to bill parts of + the work that you have been doing. + +It is recommended not to write this file by hand, although you +definitely can. Lines starting with `;;` are comments and will be +ignored. Careful though: they will be stripped from the file next +time that cassiopeia writes it out. Comment pass-through is on the +roadmap, but not yet implemented! + +`cass(1)` comes with a few commands + +- `start` will start tracking for an account +- `stop` will stop tracking and error if no open slot exists +- `invoice` will add an invoice block to the time file + +All commands require a `-f` (or `--file`) parameter to know which time +file they are operating on. If none is provided, the `time.cass` file +in the current working directory is tried. If this does not exist, +the program exits with an error. + +By default time values are rounded to the next 15 minutes. To disable +this, pass `-r` to any command. + + +## Interaction with invoice + +cassiopeia is designed to output `.yml` configuration that can be used +to generate invoices for your clients. For this, it needs access to +your client database, and uses the `client` and `project` keys in your +time file header to look them up. If none are present, you can pass +this data to the `invoice` command manually with `--client` and +`--project`. You provide your client database path with `--client-db` + +The following command will generate an invoice for a project done for +the `ACME inc.` client: + +``` +$ cass invoice --client-db /home/office/clients.yml --client acme --project world-domination +``` + +Contrutions welcome via my [public inbox] + +[public-inbox]: https://lists.sr.ht/~spacekookie/public-inbox diff --git a/apps/cassiopeia/src/data.rs b/apps/cassiopeia/src/data.rs index 8ebc67f016c5..3911345109ca 100644 --- a/apps/cassiopeia/src/data.rs +++ b/apps/cassiopeia/src/data.rs @@ -5,25 +5,79 @@ //! analysis tasks. use crate::format::LineCfg; -use chrono::{Date, DateTime, FixedOffset as Offset}; +use chrono::{DateTime, Duration, FixedOffset as Offset, NaiveDate}; use std::collections::BTreeMap; -#[derive(Default)] +#[derive(Debug, Default)] pub struct TimeFile { + /// Raw line buffers to echo back into the file + lines: Vec, + /// A parsed header structure header: BTreeMap, + /// A parsed session structure sessions: Vec, - invoices: Vec>, + /// A parsed invoice list + invoices: Vec, } impl TimeFile { - pub(crate) fn append(self, line: LineCfg) -> Self { - println!("{:?}", line); + pub(crate) fn append(mut self, line: LineCfg) -> Self { + let lo = self.lines.len(); + match line { + LineCfg::Header(ref header) => self.header = header.clone(), + LineCfg::Start(Some(time)) => self.sessions.push(Session::start(time, lo)), + LineCfg::Stop(Some(time)) => self.get_last_session().stop(time, lo), + LineCfg::Invoice(Some(date)) => self.invoices.push(Invoice::new(date, lo)), + _ => {} + } + self.lines.push(line); self } + + fn get_last_session(&mut self) -> &mut Session { + self.sessions.last_mut().unwrap() + } } +#[derive(Debug)] pub struct Session { start: DateTime, - stop: DateTime, + stop: Option>, + /// Track the lines this session took place in + lines: (usize, usize), +} + +impl Session { + /// Create a new session with a start time + fn start(start: DateTime, line: usize) -> Self { + Self { + start, + stop: None, + lines: (line, 0), + } + } + + /// Finalise a session with a stop time + fn stop(&mut self, stop: DateTime, line: usize) { + self.stop = Some(stop); + self.lines.1 = line; + } + + /// Get the length of the session, if it was already finished + pub fn length(&self) -> Option { + self.stop.map(|stop| stop - self.start) + } +} + +#[derive(Debug)] +pub struct Invoice { + date: NaiveDate, + line: usize, +} + +impl Invoice { + fn new(date: NaiveDate, line: usize) -> Self { + Self { date, line } + } } diff --git a/apps/cassiopeia/src/format/mod.rs b/apps/cassiopeia/src/format/mod.rs index beab2f7aac66..bac0445d8387 100644 --- a/apps/cassiopeia/src/format/mod.rs +++ b/apps/cassiopeia/src/format/mod.rs @@ -9,7 +9,10 @@ pub(crate) use parser::LineCfg; use crate::TimeFile; use std::{fs::File, io::Read}; -pub(crate) fn load_file(path: &str) { +/// The cassiopeia parser/generator version to be written back into the file +pub const CASS_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub(crate) fn load_file(path: &str) -> TimeFile { let mut f = File::open(path).unwrap(); let mut content = String::new(); f.read_to_string(&mut content).unwrap(); @@ -21,5 +24,5 @@ pub(crate) fn load_file(path: &str) { .map(|line| lexer::lex(line)) .map(|lex| parser::parse(lex)) .filter(|line| line.valid()) - .fold(TimeFile::default(), |file, line| file.append(line)); + .fold(TimeFile::default(), |file, line| file.append(line)) } diff --git a/apps/cassiopeia/src/format/parser.rs b/apps/cassiopeia/src/format/parser.rs index cc4b1b7c77df..bb2c56be0f33 100644 --- a/apps/cassiopeia/src/format/parser.rs +++ b/apps/cassiopeia/src/format/parser.rs @@ -4,8 +4,7 @@ //! parsed time file. use crate::format::{LineLexer, LineToken, Token}; -use chrono::{DateTime, FixedOffset as Offset}; -use logos::Lexer; +use chrono::{NaiveDate, DateTime, FixedOffset as Offset}; use std::collections::BTreeMap; use std::iter::Iterator; @@ -19,9 +18,7 @@ pub enum LineCfg { /// A session stop line with a date and time Stop(Option>), /// An invoice line with a date - Invoice(Option>), - /// An empty line - Empty, + Invoice(Option), /// A temporary value that is invalid #[doc(hidden)] Ignore, @@ -39,7 +36,7 @@ impl LineCfg { pub(crate) fn parse<'l>(lex: LineLexer<'l>) -> LineCfg { use LineCfg::*; use Token as T; - + #[cfg_attr(rustfmt, rustfmt_skip)] lex.get_all().into_iter().fold(Ignore, |cfg, tok| match (cfg, tok) { // If the first token is a comment, we ignore it @@ -52,13 +49,10 @@ pub(crate) fn parse<'l>(lex: LineLexer<'l>) -> LineCfg { // If the first token _was_ a keyword, fill in the data (Header(map), LineToken { tt: T::HeaderData, slice }) => Header(append_data(map, slice)), - (Start(_), LineToken { tt: T::Date, slice }) => Start(parse_date(slice)), - (Stop(_), LineToken { tt: T::Date, slice }) => Stop(parse_date(slice)), + (Start(_), LineToken { tt: T::Date, slice }) => Start(parse_datetime(slice)), + (Stop(_), LineToken { tt: T::Date, slice }) => Stop(parse_datetime(slice)), (Invoice(_), LineToken { tt: T::Date, slice }) => Invoice(parse_date(slice)), - // Pass empty lines through, - (Empty, _) => Empty, - // Ignore everything else (which will be filtered) _ => Ignore, }) @@ -70,9 +64,13 @@ fn append_data(mut map: BTreeMap, slice: &str) -> BTreeMap Option> { +fn parse_datetime(slice: &str) -> Option> { Some( DateTime::parse_from_str(slice, "%Y-%m-%d %H:%M:%S%:z") .expect("Failed to parse date; invalid format!"), ) } + +fn parse_date(slice: &str) -> Option { + Some(NaiveDate::parse_from_str(slice, "%Y-%m-%d").expect("Failed to parse date; invalid format!")) +} diff --git a/apps/cassiopeia/src/main.rs b/apps/cassiopeia/src/main.rs index b28f3c3438f7..ff942731c3d5 100644 --- a/apps/cassiopeia/src/main.rs +++ b/apps/cassiopeia/src/main.rs @@ -4,5 +4,6 @@ mod data; pub use data::{TimeFile, Session}; fn main() { - format::load_file("/home/projects/clients/nyantec-nix-workshops/time.cass") + let file = format::load_file("/home/projects/clients/nyantec-nix-workshops/time.cass"); + } -- cgit v1.2.3