k-office: initial code dump

wip/yesman
Katharina Fey 3 years ago
parent effbdeed66
commit f186a7345d
  1. 1
      apps/koffice/.gitignore
  2. 433
      apps/koffice/Cargo.lock
  3. 5
      apps/koffice/Cargo.toml
  4. 25
      apps/koffice/README.md
  5. 13
      apps/koffice/invoice/Cargo.toml
  6. 20
      apps/koffice/invoice/src/base.rs
  7. 76
      apps/koffice/invoice/src/cli.rs
  8. 41
      apps/koffice/invoice/src/main.rs
  9. 33
      apps/koffice/invoice/src/pfile.rs
  10. 189
      apps/koffice/libko/Cargo.lock
  11. 12
      apps/koffice/libko/Cargo.toml
  12. 116
      apps/koffice/libko/src/cass/data.rs
  13. 32
      apps/koffice/libko/src/cass/date.rs
  14. 91
      apps/koffice/libko/src/cass/error.rs
  15. 32
      apps/koffice/libko/src/cass/format/gen.rs
  16. 99
      apps/koffice/libko/src/cass/format/ir.rs
  17. 151
      apps/koffice/libko/src/cass/format/lexer.rs
  18. 76
      apps/koffice/libko/src/cass/format/mod.rs
  19. 73
      apps/koffice/libko/src/cass/format/parser.rs
  20. 46
      apps/koffice/libko/src/cass/meta.rs
  21. 160
      apps/koffice/libko/src/cass/mod.rs
  22. 141
      apps/koffice/libko/src/cass/time.rs
  23. 132
      apps/koffice/libko/src/cass/timeline.rs
  24. 39
      apps/koffice/libko/src/client.rs
  25. 5
      apps/koffice/libko/src/config.rs
  26. 35
      apps/koffice/libko/src/invoice.rs
  27. 35
      apps/koffice/libko/src/lib.rs
  28. 15
      apps/koffice/libko/src/proj.rs
  29. 94
      apps/koffice/libko/src/store.rs
  30. 0
      apps/koffice/libko/src/timefile.rs

@ -0,0 +1 @@
target

@ -0,0 +1,433 @@
# 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "beef"
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 = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"libc",
"num-integer",
"num-traits",
"serde",
"time",
"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 = "dtoa"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e"
[[package]]
name = "env_logger"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f"
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.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "invoice"
version = "0.2.0"
dependencies = [
"chrono",
"clap",
"env_logger",
"libko",
"log",
"serde",
]
[[package]]
name = "libc"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7282d924be3275cec7f6756ff4121987bc6481325397dde6ba3e7802b1a8b1c"
[[package]]
name = "libko"
version = "1.0.0"
dependencies = [
"chrono",
"logos",
"serde",
"serde_yaml",
"xdg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "log"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
"cfg-if",
]
[[package]]
name = "logos"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91c49573597a5d6c094f9031617bb1fed15c0db68c81e6546d313414ce107e4"
dependencies = [
"logos-derive",
]
[[package]]
name = "logos-derive"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "797b1f8a0571b331c1b47e7db245af3dc634838da7a92b3bef4e30376ae1c347"
dependencies = [
"beef",
"fnv",
"proc-macro2",
"quote",
"regex-syntax",
"syn",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0"
[[package]]
name = "proc-macro2"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
"thread_local",
]
[[package]]
name = "regex-syntax"
version = "0.6.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581"
[[package]]
name = "serde"
version = "1.0.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d5161132722baa40d802cc70b15262b98258453e85e5d1d365c757c73869ae"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9391c295d64fc0abb2c556bad848f33cb8296276b1ad2677d1ae1ace4f258f31"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.8.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
dependencies = [
"dtoa",
"linked-hash-map",
"serde",
"yaml-rust",
]
[[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.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081"
dependencies = [
"proc-macro2",
"quote",
"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.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd"
dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "utf8-ranges"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "xdg"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d089681aa106a86fade1b0128fb5daf07d5867a509ab036d99988dec80429a57"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]

@ -0,0 +1,5 @@
[workspace]
members = [
"libko",
"invoice"
]

@ -0,0 +1,25 @@
# k-office
A set of plain-text, free software tools to run a small business.
## Set of tools
Currently k-office consists of the following tools. A support library
`libko` provides the basic building blocks for other tools.
- cassiopeia -- a plain-text time tracking tool
- invoice -- a LaTeX template based invoice generator
## How to build
Build files for the whole suite are provided in [`nix/`](./nix/). You
can also build individual tools (e.g. to hack on) via Cargo.
## Contributions
If you want to make suggestions, or send a patch in you can do so via
my public inbox. Either send the patches directly, or via a
request-pull!

@ -0,0 +1,13 @@
[package]
name = "invoice"
version = "0.2.0"
authors = ["Katharina Fey <kookie@spacekookie.de>"]
edition = "2018"
[dependencies]
chrono = { version = "0.4", features = [ "serde" ] }
clap = { version = "2.0", features = [ "wrap_help", "color", "suggestions" ] }
libko = { path = "../libko", version = "*" }
serde = { version = "1.0", features = [ "derive" ] }
env_logger = "0.8"
log = "0.4"

@ -0,0 +1,20 @@
//! Basing application initialisation
use libko::*;
use std::path::PathBuf;
pub fn init(pid: Option<&str>, tf: Option<&str>, t: Option<&str>, rev: Option<&str>) -> Meta {
let mut meta = initialise();
meta.project_id = pid.map(Into::into);
if let Some(tfpath) = tf {
meta.load_timefile(tfpath);
}
if let Some(template) = t {
meta.template = Some(PathBuf::new().join(template));
}
dbg!(meta)
}

@ -0,0 +1,76 @@
use crate::Meta;
use clap::{App, AppSettings, Arg, SubCommand};
pub struct AppState {
pub meta: Meta,
pub cmd: Command,
}
pub enum Command {
Init,
Generate,
Install,
}
pub fn parse() -> AppState {
let project_id =
Arg::with_name("project id").help("The project identifier. Format: [client/]<project>");
let timefile = Arg::with_name("timefile")
.help("Location of the project's time file")
.takes_value(true)
.long("file")
.short("f")
.default_value("./time.cass");
let template = Arg::with_name("template")
.help("Override the default application template")
.long("templ")
.short("t")
.takes_value(true)
.default_value("$XDG_CONFIG_HOME/k-office/template.tex");
let revision = Arg::with_name("revision")
.help("Override the default revision system")
.long("rev")
.short("r")
.takes_value(true);
let app = App::new("invoice")
.version(env!("CARGO_PKG_VERSION"))
.about("A k-office tool to generate and manage invoices")
.settings(&[
AppSettings::SubcommandRequired,
AppSettings::GlobalVersion,
AppSettings::ColoredHelp,
AppSettings::DontCollapseArgsInUsage,
])
.subcommand(
SubCommand::with_name("init")
.about("Initialise a new invoice config")
.arg(timefile)
.arg(revision.clone()),
)
.subcommand(
SubCommand::with_name("generate")
.about("Generate an invoice PDF for a client/ project based on a template")
.arg(revision)
.arg(template),
);
let matches = app.get_matches();
let project_id = matches.value_of("project id");
let timefile = matches.value_of("timefile");
let template = matches.value_of("template");
let revision = matches.value_of("revision");
AppState {
meta: crate::base::init(project_id, timefile, template, revision),
cmd: match matches.subcommand() {
("init", _) => Command::Init,
("generate", _) => Command::Generate,
_ => unreachable!(),
},
}
}

@ -0,0 +1,41 @@
mod base;
mod cli;
mod pfile;
pub(crate) use base::*;
pub(crate) use cli::*;
pub(crate) use pfile::*;
use libko::*;
use std::{io::Write, fs::OpenOptions as Oo};
fn main() {
let AppState { meta, cmd } = cli::parse();
match cmd {
Command::Init => init(meta),
Command::Generate => generate(meta),
Command::Install => todo!(),
}
}
fn init(meta: Meta) {
let pid = meta.project_id.as_ref().unwrap_or_else(|| {
meta.timefile
.as_ref()
.expect("No project id given, with no timefile available")
.client()
.as_ref()
.unwrap()
});
let path = meta.invoice_dir.join(pid);
let mut f = Oo::new().write(true).truncate(true).open(path).unwrap();
f.write_all(pfile::data_templ().as_bytes()).unwrap();
// let pid = meta.project_id.as_ref().unwrap_or_else(||
// let f = meta.invoice_dir.join(meta.project_id);
}
fn generate(meta: Meta) {}

@ -0,0 +1,33 @@
use crate::{Account, Address, Worker, InvoiceId};
use chrono::NaiveDate;
use serde::{Serialize, Deserialize};
/// Describes invoice metadata
#[derive(Serialize, Deserialize)]
pub struct InvoiceFile {
invoice_id: InvoiceId,
date: NaiveDate,
author: Worker,
account: Account,
client: Address,
vat: u8,
service: Vec<ServiceEntry>,
currency: String,
lang: String,
}
/// A service description
#[derive(Serialize, Deserialize)]
pub enum ServiceEntry {
Line(String),
Hash {
description: String,
price: usize,
details: Vec<String>,
}
}
pub fn data_templ() -> String {
todo!()
}

@ -0,0 +1,189 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"libc",
"num-integer",
"num-traits",
"serde",
"time",
"winapi",
]
[[package]]
name = "dtoa"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e"
[[package]]
name = "libc"
version = "0.2.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7282d924be3275cec7f6756ff4121987bc6481325397dde6ba3e7802b1a8b1c"
[[package]]
name = "libko"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_yaml",
"xdg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "num-integer"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg",
]
[[package]]
name = "proc-macro2"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d5161132722baa40d802cc70b15262b98258453e85e5d1d365c757c73869ae"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9391c295d64fc0abb2c556bad848f33cb8296276b1ad2677d1ae1ace4f258f31"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.8.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
dependencies = [
"dtoa",
"linked-hash-map",
"serde",
"yaml-rust",
]
[[package]]
name = "syn"
version = "1.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi",
"winapi",
]
[[package]]
name = "unicode-xid"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "xdg"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d089681aa106a86fade1b0128fb5daf07d5867a509ab036d99988dec80429a57"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]

@ -0,0 +1,12 @@
[package]
name = "libko"
version = "1.0.0"
authors = ["Katharina Fey <kookie@spacekookie.de>"]
edition = "2018"
[dependencies]
chrono = { version = "0.4", features = [ "serde" ] }
serde = { version = "1.0", features = [ "derive" ] }
serde_yaml = "*"
xdg = "2.2.0"
logos = "0.11"

@ -0,0 +1,116 @@
//! Typed time file for cassiopeia
//!
//! This data gets generated by the `format` module, and can later be
//! used to generate new files, and perform various lookups and
//! analysis tasks.
use crate::cass::{
error::{ParseError, ParseResult, UserResult},
format::ir::{IrItem, IrType, MakeIr},
timeline::{Entry, Timeline},
Date, Time,
};
use chrono::{DateTime, Duration, FixedOffset as Offset, Local, NaiveDate};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default)]
pub struct TimeFile {
/// A parsed header structure
pub(crate) header: BTreeMap<String, String>,
/// A parsed timeline of events
pub(crate) timeline: Timeline,
}
impl TimeFile {
pub fn project(&self) -> Option<&String> {
self.header.get("project")
}
pub fn client(&self) -> Option<&String> {
self.header.get("client")
}
/// Append entries to the timeline from the parsed IR
///
/// Report any errors that occur back to the parser, that will
/// print a message to the user and terminate the program.
pub(crate) fn append(&mut self, line: IrItem) -> ParseResult<()> {
match line {
IrItem {
tt: IrType::Header(ref header),
..
} => Ok(header.iter().for_each(|(k, v)| {
self.header.insert(k.clone(), v.clone());
})),
IrItem {
tt: IrType::Start(time),
lo,
} => Ok(self.timeline.start(time).map(|_| ())?),
IrItem {
tt: IrType::Stop(time),
lo,
} => Ok(self.timeline.stop(time).map(|_| ())?),
IrItem {
tt: IrType::Invoice(date),
lo,
} => Ok(self.timeline.invoice(date).map(|_| ())?),
_ => Err(ParseError::Unknown),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Session {
start: Time,
stop: Option<Time>,
}
impl Session {
/// Create a new session with a start time
pub(crate) fn start(start: Time) -> Self {
Self { start, stop: None }
}
/// Finalise a session with a stop time
pub(crate) fn stop(&mut self, stop: Time) {
self.stop = Some(stop);
}
/// Check whether this session was already finished
pub fn finished(&self) -> bool {
self.stop.is_some()
}
/// Get the length of the session, if it was already finished
pub fn length(&self) -> Option<Duration> {
self.stop.as_ref().map(|stop| stop - &self.start)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Invoice {
pub(crate) date: Date,
}
impl Invoice {
pub(crate) fn new(date: Date) -> Self {
Self { date }
}
}
/// Changes to the timeline are encoded in a delta
pub enum Delta {
Start(Time),
Stop(Time),
Invoice(Date),
}
impl MakeIr for Delta {
fn make_ir(&self) -> IrType {
match self {
Self::Start(ref time) => IrType::Start(time.clone()),
Self::Stop(ref time) => IrType::Stop(time.clone()),
Self::Invoice(ref date) => IrType::Invoice(date.clone()),
}
}
}

@ -0,0 +1,32 @@
use crate::cass::Time;
use chrono::{FixedOffset as Offset, NaiveDate};
/// A convenienc wrapper around [chrono::NaiveDate](chrono::NaiveDate)
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Date {
inner: NaiveDate,
}
impl Date {
pub fn today() -> Self {
Self::from(Time::now().date())
}
pub(crate) fn from(d: chrono::Date<Offset>) -> Self {
Self {
inner: d.naive_local(),
}
}
}
impl From<NaiveDate> for Date {
fn from(inner: NaiveDate) -> Self {
Self { inner }
}
}
impl ToString for Date {
fn to_string(&self) -> String {
format!("{}", self.inner.format("%Y-%m-%d"))
}
}

@ -0,0 +1,91 @@
//! A set of error types for cassiopeia
use std::fmt::{self, Display, Formatter};
use std::{error::Error, io};
/// User errors that can occur when using cassiopeia
///
/// None of these errors are the fault of the program, but rather
/// fault of the user for giving invalid commands. They must never
/// make the program crash, but instead need to print human friendly
/// error messages.
#[derive(Debug)]
pub enum UserError {
/// Trying to start a session when one exists
ActiveSessionExists,
/// Trying to stop a session when none exists
NoActiveSession,
/// Trying to create a second invoice on the same day
SameDayInvoice,
/// No work was done since the last invoice
NoWorkInvoice,
}
impl Display for UserError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "You're doing it wrong!")
}
}
impl Error for UserError {}
pub type UserResult<T> = Result<T, UserError>;
/// Errors that occur when parsing a file
///
/// These errors can pre-maturely terminate the run of the program,
/// but must print a detailed error about what is wrong. Also,
/// because they are technically a superset of
/// [`UserError`](self::UserError), one of the variants is an embedded
/// user error.
#[derive(Debug)]
pub enum ParseError {
/// An embedded user error
///
/// This error means that the structure of the parsed file is
/// wrong, with an invalid sequence of events expressed
User(UserError),
/// The requested file did not exist
NoSuchFile,
/// The file could not be read
BadPermissions,
/// The file could not be written to
FileNotWritable,
/// Other file related errors
FileUnknown(String),
/// An invalid keyword was found
BadKeyword { line: usize, tokn: String },
/// A bad timestamp was found
BadTimestamp { line: usize, tokn: String },
/// A bad date was found
BadDate { line: usize, tokn: String },
/// An unknown parse error occured
Unknown,
}
impl Display for ParseError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "The parsed file was bad :(")
}
}
impl Error for ParseError {}
pub type ParseResult<T> = Result<T, ParseError>;
impl From<UserError> for ParseError {
fn from(user: UserError) -> Self {
ParseError::User(user)
}
}
impl From<io::Error> for ParseError {
fn from(e: io::Error) -> Self {
use io::ErrorKind::*;
match e.kind() {
NotFound => Self::NoSuchFile,
PermissionDenied => Self::BadPermissions,
_ => Self::FileUnknown(format!("{}", e)),
}
}
}

@ -0,0 +1,32 @@
//! Cassiopeia line generator
//!
//! This module takes a set of IR lines, and generates strings from
//! them that are in accordance with the way that the parser of the
//! same version expects them.
use crate::cass::format::ir::{IrItem, IrType};
/// Take a line of IR and generate a string to print into a file
pub(crate) fn line(ir: &IrItem) -> String {
let IrItem { tt, lo } = ir;
match tt {
IrType::Ignore => "".into(),
IrType::Header(map) => format!(
"HEADER {}",
map.iter()
.map(|(k, v)| format!("{}={},", k, v))
.collect::<Vec<_>>()
.join("")
),
IrType::Start(time) => format!("START {}", time.to_string()),
// FIXME: find a better way to align the lines here rather
// than having to manually having to pad the 'STOP' commands
IrType::Stop(time) => format!("STOP {}", time.to_string()),
IrType::Invoice(date) => format!("INVOICE {}", date.to_string()),
}
}
pub(crate) fn head_comment() -> String {
";; generated by cassiopeia, be careful about editing by hand!".into()
}

@ -0,0 +1,99 @@
use crate::cass::{format::LineCfg, Date, Time, TimeFile};
use std::collections::BTreeMap;
/// A set of IR parsed items that makes up a whole cass file
pub(crate) type IrStream = Vec<IrItem>;
/// Intermediate representation for parsing and generating files
///
/// The CASS IR is largely based on the output of the parser's
/// [`LineCfg`](crate::format::LineCfg), but with concrete types used
/// in the data layer (namely [`Date`][date] and [`Time`][time]),
/// while also keeping track of the line numbers to allow idempotent
/// file changes.
///
/// Something not yet implemented is comment pass-through (this needs
/// to happen in the parser first), but will likely be implemented in
/// a future version.
///
/// [date]: crate::Date
/// [time]: crate::Time
#[derive(Debug, Clone)]
pub(crate) struct IrItem {
pub(crate) tt: IrType,
pub(crate) lo: usize,
}
/// Disambiguate between different IR line types with their payload
#[derive(Debug, Clone)]
pub(crate) enum IrType {
/// A line with parsed header information
Header(BTreeMap<String, String>),
/// Start a session at a given timestapm
Start(Time),
/// Stop a session at a given timestamp
Stop(Time),
/// Invoice a block of previous work
Invoice(Date),
/// An item that gets ignored
Ignore,
}
/// Generate a stream of IR items from the raw parser output
pub(crate) fn generate_ir(buf: impl Iterator<Item = LineCfg>) -> IrStream {
buf.enumerate().fold(vec![], |mut buf, (lo, item)| {
#[cfg_attr(rustfmt, rustfmt_skip)]
buf.push(match item {
LineCfg::Header(map) => IrItem { tt: IrType::Header(map.into_iter().map(|(k, v)| (k, v.replace(",", ""))).collect()), lo },
LineCfg::Start(Some(time)) => IrItem { tt: IrType::Start(time.into()), lo },
LineCfg::Stop(Some(time)) => IrItem { tt: IrType::Stop(time.into()), lo },
LineCfg::Invoice(Some(date)) => IrItem { tt: IrType::Invoice(date.into()), lo },
LineCfg::Ignore => IrItem { tt: IrType::Ignore, lo },
_ => IrItem { tt: IrType::Ignore, lo },
});
buf
})
}
pub(crate) trait MakeIr {
/// Make a new IR line from an object
fn make_ir(&self) -> IrType;
}
pub(crate) fn clean_ir(ir: &mut IrStream) {
ir.remove(0); // FIXME: this is required to remove the leading
// comment, which will be manually re-generated at
// the moment, but which would just add more blank
// lines between the new comment, and the first line
// in this current format. This is very bad, yikes
// yikes yikes, but what can I do, I have a deadline
// (not really) lol
// FIXME: this hack gets rid of a trailing empty line if it exists
// to make sure we don't have any gaps between work sessions.
if match ir.last() {
Some(IrItem {
tt: IrType::Ignore, ..
}) => true,
_ => false,
} {
ir.pop();
}
}
/// Taken an IrType and append it to an existing IR stream
pub(crate) fn append_ir(ir: &mut IrStream, tt: IrType) {
let lo = ir.last().unwrap().lo;
ir.push(IrItem { tt, lo });
}
/// Search for the header that contains the version string and update it
pub(crate) fn update_header(ir: &mut IrStream) {
ir.iter_mut().for_each(|item| match item.tt {
IrType::Header(ref mut map) if map.contains_key("version") => {
map.insert("version".into(), crate::cass::meta::VERSION.into());
}
_ => {}
});
}

@ -0,0 +1,151 @@
//! Cassiopeia file lexer
use logos::{Lexer, Logos};
use std::iter::Iterator;
/// A basic line lexer type
///
/// This lexer distinguishes between comments, and keyword lines. It
/// does not attempt to parse the line specifics. This is what the
/// content lexer is for.
#[derive(Logos, Debug, PartialEq)]
pub(crate) enum Token {
#[token("HEADER")]
Header,
#[token("START")]
Start,
#[token("STOP")]
Stop,
#[token("INVOICE")]
Invoice,
#[regex(r"\w+=[^,$]+[,$]")]
HeaderData,
// FIXME: this will have a leading whitespace that we could remove
// with ^\w, but logos does not support this at the moment
#[regex(r"[0-9-:+ ]+")]
Date,
#[token(" ", logos::skip)]
Space,
#[regex(";;.*")]
Comment,
#[error]
Error,
}
/// A single token type on a line
#[derive(Debug)]
pub(crate) struct LineToken<'l> {
pub(crate) tt: Token,
pub(crate) slice: &'l str,
}
/// A lexer wrapped for a single line
pub(crate) struct LineLexer<'l> {
lexer: Lexer<'l, Token>,
}
impl<'l> LineLexer<'l> {
pub(crate) fn get_all(self) -> Vec<LineToken<'l>> {
let mut acc = vec![];
for l in self {
acc.push(l);
}
acc
}
}
impl<'l> Iterator for LineLexer<'l> {
type Item = LineToken<'l>;
fn next(&mut self) -> Option<Self::Item> {
self.lexer.next().map(|tt| Self::Item {
tt,
slice: self.lexer.slice(),
})
}
}
/// Take a line of input and lex it into a stream of tokens
pub(crate) fn lex<'l>(line: &'l mut String) -> LineLexer<'l> {
LineLexer {
lexer: Token::lexer(line),
}
}
#[test]
fn basic_header() {
let mut lex = Token::lexer("HEADER version=0.0.0,location=Berlin Lichtenberg,");
assert_eq!(lex.next(), Some(Token::Header));
assert_eq!(lex.span(), 0..6);
assert_eq!(lex.slice(), "HEADER");
assert_eq!(lex.next(), Some(Token::HeaderData));
assert_eq!(lex.span(), 7..21);
assert_eq!(lex.slice(), "version=0.0.0,");
assert_eq!(lex.next(), Some(Token::HeaderData));
assert_eq!(lex.span(), 21..49);
assert_eq!(lex.slice(), "location=Berlin Lichtenberg,");
assert_eq!(lex.next(), None);
}
#[test]
fn basic_start() {
let mut lex = Token::lexer("START 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), Some(Token::Start));
assert_eq!(lex.span(), 0..5);
assert_eq!(lex.slice(), "START");
assert_eq!(lex.next(), Some(Token::Date));
assert_eq!(lex.span(), 5..31);
assert_eq!(lex.slice(), " 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), None);
}
#[test]
fn basic_stop() {
let mut lex = Token::lexer("STOP 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), Some(Token::Stop));
assert_eq!(lex.span(), 0..4);
assert_eq!(lex.slice(), "STOP");
assert_eq!(lex.next(), Some(Token::Date));
assert_eq!(lex.span(), 4..30);
assert_eq!(lex.slice(), " 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), None);
}
#[test]
fn basic_invoice() {
let mut lex = Token::lexer("INVOICE 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), Some(Token::Invoice));
assert_eq!(lex.span(), 0..7);
assert_eq!(lex.slice(), "INVOICE");
assert_eq!(lex.next(), Some(Token::Date));
assert_eq!(lex.span(), 7..33);
assert_eq!(lex.slice(), " 2020-11-11 13:00:00+01:00");
assert_eq!(lex.next(), None);
}
#[test]
fn basic_comment() {
let mut lex = Token::lexer(";; This file is auto generated!");
assert_eq!(lex.next(), Some(Token::Comment));
}

@ -0,0 +1,76 @@
//! cassiopeia file format
mod gen;
pub(crate) mod ir;
mod lexer;
mod parser;
pub(crate) use lexer::{LineLexer, LineToken, Token};
pub(crate) use parser::LineCfg;
use crate::{
cass::error::{ParseError, ParseResult},
cass::TimeFile,
};
use ir::{IrItem, IrStream};
use std::{
fs::{File, OpenOptions},
io::{Read, Write},
};
/// A crate internal representation of the IR stream and timefile
#[derive(Default)]
pub(crate) struct ParseOutput {
pub(crate) ir: IrStream,
pub(crate) tf: TimeFile,
}
impl ParseOutput {
fn append(mut self, ir: IrItem) -> ParseResult<Self> {
self.tf.append(ir.clone())?;
self.ir.push(ir);
Ok(self)
}
}
/// Load a file from disk and parse it into a
/// [`TimeFile`](crate::TimeFile)
pub(crate) fn load_file(path: &str) -> ParseResult<ParseOutput> {
// Load the raw file contents
let mut f = File::open(path)?;
let mut content = String::new();
f.read_to_string(&mut content)?;
// Split the file by lines - .cass is a line based format
let mut lines: Vec<String> = content.split("\n").map(|l| l.to_owned()).collect();
// Build an iterator over parsed lines
let parsed = lines
.iter_mut()
.map(|line| lexer::lex(line))
.map(|lex| parser::parse(lex));
// Generate the IR from parse output, then build the timefile
ir::generate_ir(parsed)
.into_iter()
.fold(Ok(ParseOutput::default()), |out, ir| match out {
Ok(out) => out.append(ir),
e @ Err(_) => e,
})
}
/// Write a file with the updated IR stream
pub(crate) fn write_file(path: &str, ir: &mut IrStream) -> ParseResult<()> {
ir::update_header(ir);
let mut lines = ir.into_iter().map(|ir| gen::line(ir)).collect::<Vec<_>>();
lines.insert(0, gen::head_comment());
// let mut f = OpenOptions::new()
// .write(true)
// .create(true)
// .truncate(true)
// .open(path)
// .ok()?;
// f.write_all(lines.join("\n").as_bytes()).ok()?;
Ok(())
}

@ -0,0 +1,73 @@
//! cassiopeia parser
//!
//! Takes a lexer's token stream as an input, and outputs a fully
//! parsed time file.
use crate::cass::format::{LineLexer, LineToken, Token};
use chrono::{DateTime, FixedOffset as Offset, NaiveDate};
use std::collections::BTreeMap;
use std::iter::Iterator;
/// A type-parsed line in a time file
#[derive(Debug)]
pub enum LineCfg {
/// A header line with a set of keys and values
Header(BTreeMap<String, String>),
/// A session start line with a date and time
Start(Option<DateTime<Offset>>),
/// A session stop line with a date and time
Stop(Option<DateTime<Offset>>),
/// An invoice line with a date
Invoice(Option<NaiveDate>),
/// A temporary value that is invalid
#[doc(hidden)]
Ignore,
}
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
(Ignore, LineToken { tt: T::Comment, .. }, ) => Ignore,
// If the first token is a keyword, we wait for more data
(Ignore, LineToken { tt: T::Header, .. }) => Header(Default::default()),
(Ignore, LineToken { tt: T::Start, .. }) => Start(None),
(Ignore, LineToken { tt: T::Stop, .. }) => Stop(None),
(Ignore, LineToken { tt: T::Invoice, .. }) => Invoice(None),
// 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_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,
})
}
fn append_data(mut map: BTreeMap<String, String>, slice: &str) -> BTreeMap<String, String> {
let split = slice.split("=").collect::<Vec<_>>();
map.insert(split[0].into(), split[1].into());
map
}
fn parse_datetime(slice: &str) -> Option<DateTime<Offset>> {
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<NaiveDate> {
Some(
NaiveDate::parse_from_str(slice, "%Y-%m-%d")
.expect("Failed to parse date; invalid format!"),
)
}

@ -0,0 +1,46 @@
//! Metadata and strings for this application
// TODO: translate this
pub const NAME: &'static str = env!("CARGO_PKG_NAME");
pub const VERSION: &'static str = env!("CARGO_PKG_VERSION");
pub const AUTHOR: &'static str = env!("CARGO_PKG_AUTHORS");
pub const ABOUT: &'static str = env!("CARGO_PKG_DESCRIPTION");
pub const ARG_FILE: &'static str = "CASS_FILE";
pub const ARG_FILE_ABOUT: &'static str = "Provide a .cass file to operate on";
pub const CMD_START: &'static str = "start";
pub const CMD_START_ABOUT: &'static str = "Start a work session";
pub const CMD_STOP: &'static str = "stop";
pub const CMD_STOP_ABOUT: &'static str = "Stop the current work session";
pub const ARG_ROUNDING: &'static str = "CASS_ROUNDING";
pub const ARG_ROUNDING_ABOUT: &'static str = "Disable the (default) 15 minute rounding period";
pub const CMD_INVOICE: &'static str = "invoice";
pub const CMD_INVOICE_ABOUT: &'static str = "Create an invoice. You get to choose between simply adding a \
statement to your time file, or generating .yml configuration to build an invoice generator from. See invoice(1) \
for more detail!";
pub const CMD_UPDATE: &'static str = "update";
pub const CMD_UPDATE_ABOUT: &'static str = "Update the selected file to a new version";
pub const ARG_CLIENT: &'static str = "CLIENT";
pub const ARG_CLIENT_ABOUT: &'static str =
"Provide the name of the current client for invoice generation";
pub const ARG_PROJECT: &'static str = "PROJECT";
pub const ARG_PROJECT_ABOUT: &'static str =
"Provide the name of the current project for invoice generation";
pub const ARG_GEN_YAML: &'static str = "GEN_YAML";
pub const ARG_GEN_YAML_ABOUT: &'static str =
"Specify whether to generate a .yml invoice configuration";
pub const ARG_CLIENT_DB: &'static str = "CLIENT_DB";
pub const ARG_CLIENT_DB_ABOUT: &'static str =
"Provide your client database file (.yml format) used by invoice(1)";
pub const CMD_STAT: &'static str = "stat";
pub const CMD_STAT_ABOUT: &'static str = "Get statistics of previous work sessions";

@ -0,0 +1,160 @@
//! Cassiopeia plain text time tracking tool
//!
//! Versions `0.1` and `0.2` were written in Ruby and are thus
//! deprecated. Most likely you are interested in `cass(1)`, the
//! simple plain text time tracking utility, part of the kookie-office
//! suite of commandline tools! This is the library powering it.
//!
//! For more documentation, check out:
//! https://git.spacekookie.de/kookienomicon/tree/apps/cassiopeia
mod data;
mod date;
mod format;
mod time;
mod timeline;
pub mod error;
pub mod meta;
pub use date::Date;
pub use time::Time;
pub(crate) use data::TimeFile;
use data::{Invoice, Session};
use error::{ParseError, ParseResult};
use format::{
ir::{append_ir, clean_ir, IrStream, MakeIr},
ParseOutput,
};
/// A state handler and primary API for all cass interactions
///
///
pub struct Cassiopeia {
path: String,
tf: TimeFile,
ir: IrStream,
}
impl Cassiopeia {
/// Load a cass file from disk, parsing it into a [`TimeFile`](crate::TimeFile)
pub fn load(path: &str) -> ParseResult<Self> {
let path = path.to_owned();
format::load_file(path.as_str()).map(|ParseOutput { tf, ir }| Self { path, tf, ir })
}
pub(crate) fn timefile(&self) -> TimeFile {
self.tf.clone()
}
/// Start a new work session (with optional 15 minute rounding)
pub fn start(&mut self, round: bool) -> ParseResult<()> {
let delta = self.tf.timeline.start(Time::rounded(round))?;
clean_ir(&mut self.ir);
append_ir(&mut self.ir, delta.make_ir());
format::write_file(self.path.as_str(), &mut self.ir)
}
/// Stop the existing work session (with optional 15 minute rounding)
pub fn stop(&mut self, round: bool) -> ParseResult<()> {
let delta = self.tf.timeline.stop(Time::rounded(round))?;
clean_ir(&mut self.ir);
append_ir(&mut self.ir, delta.make_ir());
format::write_file(self.path.as_str(), &mut self.ir)
}
/// Add an invoice block to the time file
pub fn invoice<'slf>(&'slf mut self) -> Invoicer<'slf> {
Invoicer::new(self)
}
/// Write out the file IR as is, updating only the header version
pub fn update(&mut self) -> ParseResult<()> {
clean_ir(&mut self.ir);
format::write_file(self.path.as_str(), &mut self.ir)
}
/// Collect statistics on previous work sessions
pub fn stat(&self) -> ParseResult<String> {
todo!()
}
}
/// An invoice generator builder
///
/// The most simple use-case of this type is to provide no parameters
/// and simply add an `INVOICE` line to the cass file. Adittionally
/// you may provide the client and project name, which will then
/// require the `client_db` path to be set as well.
///
/// ```rust,no_run
/// # let mut cass = cassiopeia::Cassiopeia::load("").unwrap();
/// cass.invoice().run();
/// ```
///
/// Additional errors can be thrown if the client or project are not
/// known in the client db.
///
/// ```rust,no_run
/// # let mut cass = cassiopeia::Cassiopeia::load("").unwrap();
/// cass.invoice()
/// .generate()
/// .db("/home/office/clients.yml".into())
/// .client("ACME".into())
/// .run();
/// ```
#[allow(unused)]
pub struct Invoicer<'cass> {
tf: &'cass mut Cassiopeia,
generate: bool,
client_db: String,
client: String,
project: String,
}
impl<'cass> Invoicer<'cass> {
pub fn new(tf: &'cass mut Cassiopeia) -> Self {
Self {
tf,
generate: false,
client_db: String::new(),
client: String::new(),
project: String::new(),
}
}
/// Enable the invoice generation feature
pub fn generate(self) -> Self {
Self {
generate: true,
..self
}
}
/// Provide the client database file (.yml format)
pub fn db(self, client_db: String) -> Self {
Self { client_db, ..self }
}
/// Provide the client to invoice
pub fn client(self, client: String) -> Self {
Self { client, ..self }
}
pub fn project(self, project: String) -> Self {
Self { project, ..self }
}
pub fn run(mut self) -> ParseResult<()> {
if self.generate {
eprintln!("Integration with invoice(1) is currently not implemented. Sorry :(");
return Err(ParseError::Unknown);
}
let delta = self.tf.tf.timeline.invoice(Date::today())?;
clean_ir(&mut self.tf.ir);
append_ir(&mut self.tf.ir, delta.make_ir());
format::write_file(self.tf.path.as_str(), &mut self.tf.ir)
}
}

@ -0,0 +1,141 @@
use crate::cass::Date;
use chrono::{
DateTime, Duration, FixedOffset as Offset, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike,
Utc,
};
use std::{cmp::Ordering, ops::Sub};
/// A convenience wrapper around [DateTime][t] with fixed timezone
///
/// [t]: chrono::DateTime
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
inner: DateTime<Offset>,
}
impl From<DateTime<Offset>> for Time {
fn from(inner: DateTime<Offset>) -> Self {
Self { inner }
}
}
impl<'t> Sub for &'t Time {
type Output = Duration;
fn sub(self, o: &'t Time) -> Self::Output {
self.inner - o.inner
}
}
impl ToString for Time {
fn to_string(&self) -> String {
format!("{}", self.inner.format("%Y-%m-%d %H:%M:%S%:z"))
}
}
impl Time {
/// Get the current local time and pin it to a fixed Tz offset
pub fn now() -> Self {
let now = Local::now();
Self {
inner: build_datetime(
now.time()
.with_second(0)
.and_then(|t| t.with_nanosecond(0))
.unwrap(),
),
}
}
/// Get the time that might be rounded to the next 15 minutes
pub(crate) fn rounded(r: bool) -> Self {
if r {
Time::now().round()
} else {
Time::now()
}
}
pub(crate) fn date(&self) -> chrono::Date<Offset> {
self.inner.date()
}
/// Check if a time stamp happened _after_ a date
pub fn after(&self, date: &Date) -> bool {
&Date::from(self.date()) > date
}
#[cfg(test)]
pub(crate) fn fixed(hour: u32, min: u32, sec: u32) -> Self {
Self {
inner: build_datetime(NaiveTime::from_hms(hour, min, sec)),
}
}
/// Return a new instance that is rounded to nearest 15 minutes
///
/// It uses the internally provided offset to do rounding, meaning
/// that the timezone information will not change, even when
/// rounding values that were created in a different timezone.
pub fn round(&self) -> Self {
let naive = self.inner.time();
let (new_min, incr_hour) = match naive.minute() {
// 0-7 => (0, false)
m if m < 7 => (0, false),
// 7-22 => (15, false)
m if m >= 7 && m < 22 => (15, false),
// 22-37 => (30, false)
m if m >= 22 && m < 37 => (30, false),
// 37-52 => (45, false)
m if m >= 37 && m < 52 => (45, false),
// 52-59 => (0, true)
m if m >= 52 && m <= 59 => (0, true),
_ => unreachable!(),
};
let hour = naive.hour();
let new = NaiveTime::from_hms(if incr_hour { hour + 1 } else { hour }, new_min, 0);
let offset = self.inner.offset();
let date = self.inner.date();
Self {
inner: DateTime::from_utc(NaiveDateTime::new(date.naive_local(), new), *offset),
}
}
pub fn hour(&self) -> u32 {
self.inner.hour()
}
pub fn minute(&self) -> u32 {
self.inner.minute()
}
pub fn second(&self) -> u32 {
self.inner.second()
}
}
/// Build a DateTime with the current local fixed offset
fn build_datetime(nt: NaiveTime) -> DateTime<Offset> {
let date = Utc::now().date().naive_local();
let offset = Local.offset_from_utc_date(&date);
DateTime::from_utc(NaiveDateTime::new(date, nt), offset)
}
#[test]
fn simple() {
let t = Time::fixed(10, 44, 0);
let round = t.round();
assert_eq!(round.minute(), 45);
let t = Time::fixed(6, 8, 0);
let round = t.round();
assert_eq!(round.minute(), 15);
let t = Time::fixed(6, 55, 0);
let round = t.round();
assert_eq!(round.minute(), 0);
assert_eq!(round.hour(), 7);
}

@ -0,0 +1,132 @@
use crate::cass::{
data::{Delta, Invoice, Session},
error::{UserError, UserResult},
Date, Time,
};
/// A timeline entry of sessions and invoices
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Entry {
Session(Session),
Invoice(Invoice),
}
impl From<Session> for Entry {
fn from(s: Session) -> Self {
Self::Session(s)
}
}
impl From<Invoice> for Entry {
fn from(i: Invoice) -> Self {
Self::Invoice(i)
}
}
/// A timeline of sessions and invoices, ordered chronologically
#[derive(Debug, Default, Clone)]
pub struct Timeline {
inner: Vec<Entry>,
}
impl Timeline {
/// Take a set of sessions and invoices to sort into a timeline
pub fn build(s: Vec<Session>, i: Vec<Invoice>) -> Self {
let mut inner: Vec<_> = s.into_iter().map(|s| Entry::Session(s)).collect();
inner.append(&mut i.into_iter().map(|i| Entry::Invoice(i)).collect());
Self { inner }
}
/// Utility function to get the last session in the timeline
fn last_session(&mut self) -> Option<&mut Session> {
self.inner
.iter_mut()
.find(|e| match e {
Entry::Session(_) => true,
_ => false,
})
.map(|e| match e {
Entry::Session(ref mut s) => s,
_ => unreachable!(),
})
}
/// Utility function to get the last invoice in the timeline
fn last_invoice(&self) -> Option<&Invoice> {
self.inner
.iter()
.find(|e| match e {
Entry::Invoice(_) => true,
_ => false,
})
.map(|e| match e {
Entry::Invoice(ref s) => s,
_ => unreachable!(),
})
}
/// Get a list of sessions that happened up to a certain invoice date
///
/// **WARNING** If there is no invoice with the given date, this
/// function will return garbage data, so don't call it with
/// invoice dates that don't exist.
///
/// Because: if the date passes other invoices on the way, the accumulator
/// will be discarded and a new count will be started.
pub fn session_iter(&self, date: &Date) -> Vec<&Session> {
self.inner
.iter()
.fold((false, vec![]), |(mut done, mut acc), entry| {
match (done, entry) {
// Put sessions into the accumulator
(false, Entry::Session(ref s)) => acc.push(s),
// When we reach the target invoice, terminate the iterator
(false, Entry::Invoice(ref i)) if &i.date == date => done = true,
// When we hit another invoice, empty accumulator
(false, Entry::Invoice(_)) => acc.clear(),
// When we are ever "done", skip all other entries
(true, _) => {}
}
(done, acc)
})
.1
}
/// Start a new session, if no active session is already in progress
pub fn start(&mut self, time: Time) -> UserResult<Delta> {
match self.last_session() {
Some(s) if !s.finished() => Err(UserError::ActiveSessionExists),
_ => Ok(()),
}?;
self.inner.push(Session::start(time.clone()).into());
Ok(Delta::Start(time))
}
/// Stop an ongoing session, if one exists
pub fn stop(&mut self, time: Time) -> UserResult<Delta> {
match self.last_session() {
Some(s) if s.finished() => Err(UserError::NoActiveSession),
_ => Ok(()),
}?;
self.last_session().unwrap().stop(time.clone());
Ok(Delta::Stop(time))
}
/// Create a new invoice on the given day
pub fn invoice(&mut self, date: Date) -> UserResult<Delta> {
match self.last_invoice() {
// If an invoice on the same day exists already
Some(i) if i.date == date => Err(UserError::SameDayInvoice),
// If there was no work since the last invoice
Some(ref i) if self.session_iter(&i.date).len() == 0 => Err(UserError::NoWorkInvoice),
// Otherwise everything is coolio
_ => Ok(()),
}?;
self.inner.push(Invoice::new(date.clone()).into());
Ok(Delta::Invoice(date))
}
}

@ -0,0 +1,39 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Worker {
pub name: String,
pub address: Address,
pub account: Account,
}
/// An entry in the client database
#[derive(Debug, Serialize, Deserialize)]
pub struct Client {
pub name: String,
pub address: Address,
pub last_project: Option<NaiveDate>,
}
/// An address with all associated data
#[derive(Debug, Serialize, Deserialize)]
pub struct Address {
pub name: String,
pub street: String,
pub no: String,
pub zip: String,
pub city: String,
pub country: String,
}
/// A bank account with a account, and bank number
///
/// This is kept as generically as possible, to allow as many
/// different account representations to work.
#[derive(Debug, Serialize, Deserialize)]
pub struct Account {
pub bank_name: String,
pub acc_num: String,
pub bank_num: String,
}

@ -0,0 +1,5 @@
use std::path::PathBuf;
pub struct AppSettings {
}

@ -0,0 +1,35 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::string::ToString;
/// A specification to build invoice IDs with
#[derive(Serialize, Deserialize)]
pub enum InvoiceId {
YearMonthId(u16, u8, usize),
}
impl ToString for InvoiceId {
fn to_string(&self) -> String {
match self {
Self::YearMonthId(yr, mo, id) => format!("#{}-{:02}-{:04}", yr, mo, id),
}
}
}
/// An invoice for a specific project
#[derive(Serialize, Deserialize)]
pub struct Invoice {
id: InvoiceId,
client: String,
project: String,
date: NaiveDate,
amount: usize,
currency: String,
vat: u8,
}
#[test]
fn invoice_id_fmt() {
let inv_id = InvoiceId::YearMonthId(2020, 06, 0055);
assert_eq!(inv_id.to_string(), "#2020-06-0055".to_string());
}

@ -0,0 +1,35 @@
//! A library that provides basic building blocks of k-office tools
pub mod cass;
mod client;
pub use client::*;
mod invoice;
pub use invoice::*;
mod proj;
pub use proj::*;
mod store;
pub use store::*;
use serde::{de::DeserializeOwned, Serialize};
pub trait Io {
fn to_yaml(&self) -> String;
fn from_yaml(s: impl Into<String>) -> Self;
}
impl<T> Io for T
where
T: Serialize + DeserializeOwned,
{
fn to_yaml(&self) -> String {
serde_yaml::to_string(self).unwrap()
}
fn from_yaml(s: impl Into<String>) -> Self {
serde_yaml::from_str(s.into().as_str()).unwrap()
}
}

@ -0,0 +1,15 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Represent a project that is being done
#[derive(Serialize, Deserialize)]
pub struct Project {
client: String,
date: NaiveDate,
}
impl Project {
pub fn new(client: String, date: NaiveDate) -> Self {
Self { client, date }
}
}

@ -0,0 +1,94 @@
use crate::{
cass::{Cassiopeia, TimeFile},
Address, Client, Io,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::{
fs::File,
io::{Read, Write},
path::PathBuf,
};
use xdg::BaseDirectories as BaseDirs;
#[derive(Debug)]
pub struct Meta {
clients: BTreeMap<String, Client>,
pub dir: BaseDirs,
pub invoice_dir: PathBuf,
pub template: Option<PathBuf>,
pub revisioning: bool,
/// Optional current timefile path
pub timefile: Option<TimeFile>,
pub project_id: Option<String>,
}
///
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub revisioning: bool,
pub invoice_dir: PathBuf,
}
impl Meta {
pub fn new(dir: BaseDirs) -> Self {
// Get the path to the configuration, and make sure a default
// configuration is created if none exists yet.
let path = dir.find_config_file("config.yml").unwrap_or_else(|| {
let path = dir.place_config_file("config.yml").unwrap();
let mut cfg = File::create(path.clone()).unwrap();
let buf = "revisioning: true
invoicedir: $HOME/.local/k-office/";
cfg.write_all(buf.as_bytes()).unwrap();
path
});
let mut cfg = File::open(path).unwrap();
let mut buf = String::new();
cfg.read_to_string(&mut buf).unwrap();
let yml = Config::from_yaml(buf);
Self {
dir,
clients: BTreeMap::new(),
invoice_dir: yml.invoice_dir,
template: None,
revisioning: yml.revisioning,
timefile: None,
project_id: None,
}
}
pub fn load_timefile(&mut self, path: &str) {
let timefile = Cassiopeia::load(path)
.expect("Timefile not found")
.timefile();
self.timefile = Some(timefile);
}
pub fn client_mut(&mut self, name: &str) -> Option<&mut Client> {
self.clients.get_mut(name)
}
pub fn new_client(&mut self, name: &str, address: Address) {
self.clients.insert(
name.to_string(),
Client {
name: name.to_string(),
address,
last_project: None,
},
);
}
}
/// Initialise a k-office application state
pub fn initialise() -> Meta {
let dir = BaseDirs::with_prefix("k-koffice").unwrap();
dir.create_config_directory("")
.expect("Couldn't create config directory");
Meta::new(dir)
}
Loading…
Cancel
Save