parent
effbdeed66
commit
f186a7345d
@ -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…
Reference in new issue