diff options
author | Kaiden Fey <kookie@spacekookie.de> | 2021-02-21 14:56:11 +0100 |
---|---|---|
committer | Kaiden Fey <kookie@spacekookie.de> | 2021-02-21 14:56:11 +0100 |
commit | f186a7345dfc99347673f46e0daff0cb63ac8492 (patch) | |
tree | 5179ffd4654b80ea11a1656e28ef244439648ba4 /apps | |
parent | effbdeed66e8de8e769b8ac069926ad1a9110e62 (diff) |
k-office: initial code dumpk-office/init
Diffstat (limited to 'apps')
30 files changed, 2220 insertions, 0 deletions
diff --git a/apps/koffice/.gitignore b/apps/koffice/.gitignore new file mode 100644 index 000000000000..eb5a316cbd19 --- /dev/null +++ b/apps/koffice/.gitignore @@ -0,0 +1 @@ +target diff --git a/apps/koffice/Cargo.lock b/apps/koffice/Cargo.lock new file mode 100644 index 000000000000..f79c22086be3 --- /dev/null +++ b/apps/koffice/Cargo.lock @@ -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", +] diff --git a/apps/koffice/Cargo.toml b/apps/koffice/Cargo.toml new file mode 100644 index 000000000000..4ff3f0a91819 --- /dev/null +++ b/apps/koffice/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "libko", + "invoice" +]
\ No newline at end of file diff --git a/apps/koffice/README.md b/apps/koffice/README.md new file mode 100644 index 000000000000..2e37820450a6 --- /dev/null +++ b/apps/koffice/README.md @@ -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! diff --git a/apps/koffice/invoice/Cargo.toml b/apps/koffice/invoice/Cargo.toml new file mode 100644 index 000000000000..15bb8e7e90c6 --- /dev/null +++ b/apps/koffice/invoice/Cargo.toml @@ -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"
\ No newline at end of file diff --git a/apps/koffice/invoice/src/base.rs b/apps/koffice/invoice/src/base.rs new file mode 100644 index 000000000000..a29af6480bf8 --- /dev/null +++ b/apps/koffice/invoice/src/base.rs @@ -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) +} diff --git a/apps/koffice/invoice/src/cli.rs b/apps/koffice/invoice/src/cli.rs new file mode 100644 index 000000000000..cbf27efe6cc4 --- /dev/null +++ b/apps/koffice/invoice/src/cli.rs @@ -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!(), + }, + } +} diff --git a/apps/koffice/invoice/src/main.rs b/apps/koffice/invoice/src/main.rs new file mode 100644 index 000000000000..742b74be0b0a --- /dev/null +++ b/apps/koffice/invoice/src/main.rs @@ -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) {} diff --git a/apps/koffice/invoice/src/pfile.rs b/apps/koffice/invoice/src/pfile.rs new file mode 100644 index 000000000000..ec99e5ab3049 --- /dev/null +++ b/apps/koffice/invoice/src/pfile.rs @@ -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!() +} diff --git a/apps/koffice/libko/Cargo.lock b/apps/koffice/libko/Cargo.lock new file mode 100644 index 000000000000..bcc14cca627a --- /dev/null +++ b/apps/koffice/libko/Cargo.lock @@ -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", +] diff --git a/apps/koffice/libko/Cargo.toml b/apps/koffice/libko/Cargo.toml new file mode 100644 index 000000000000..44a62071c07d --- /dev/null +++ b/apps/koffice/libko/Cargo.toml @@ -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"
\ No newline at end of file diff --git a/apps/koffice/libko/src/cass/data.rs b/apps/koffice/libko/src/cass/data.rs new file mode 100644 index 000000000000..4ff4dc93020e --- /dev/null +++ b/apps/koffice/libko/src/cass/data.rs @@ -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()), + } + } +} diff --git a/apps/koffice/libko/src/cass/date.rs b/apps/koffice/libko/src/cass/date.rs new file mode 100644 index 000000000000..e35b2a96f0f0 --- /dev/null +++ b/apps/koffice/libko/src/cass/date.rs @@ -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")) + } +} diff --git a/apps/koffice/libko/src/cass/error.rs b/apps/koffice/libko/src/cass/error.rs new file mode 100644 index 000000000000..24bbb4965494 --- /dev/null +++ b/apps/koffice/libko/src/cass/error.rs @@ -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)), + } + } +} diff --git a/apps/koffice/libko/src/cass/format/gen.rs b/apps/koffice/libko/src/cass/format/gen.rs new file mode 100644 index 000000000000..f77bcdc90e84 --- /dev/null +++ b/apps/koffice/libko/src/cass/format/gen.rs @@ -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() +} diff --git a/apps/koffice/libko/src/cass/format/ir.rs b/apps/koffice/libko/src/cass/format/ir.rs new file mode 100644 index 000000000000..d1a3a62c1508 --- /dev/null +++ b/apps/koffice/libko/src/cass/format/ir.rs @@ -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()); + } + _ => {} + }); +} diff --git a/apps/koffice/libko/src/cass/format/lexer.rs b/apps/koffice/libko/src/cass/format/lexer.rs new file mode 100644 index 000000000000..bdb89f5180e5 --- /dev/null +++ b/apps/koffice/libko/src/cass/format/lexer.rs @@ -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)); +} diff --git a/apps/koffice/libko/src/cass/format/mod.rs b/apps/koffice/libko/src/cass/format/mod.rs new file mode 100644 index 000000000000..2983653898b6 --- /dev/null +++ b/apps/koffice/libko/src/cass/format/mod.rs @@ -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(()) +} diff --git a/apps/koffice/libko/src/cass/format/parser.rs b/apps/koffice/libko/src/cass/format/parser.rs new file mode 100644 index 000000000000..8e0602d440d2 --- /dev/null +++ b/apps/koffice/libko/src/cass/format/parser.rs @@ -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!"), + ) +} diff --git a/apps/koffice/libko/src/cass/meta.rs b/apps/koffice/libko/src/cass/meta.rs new file mode 100644 index 000000000000..ec71498986e3 --- /dev/null +++ b/apps/koffice/libko/src/cass/meta.rs @@ -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"; diff --git a/apps/koffice/libko/src/cass/mod.rs b/apps/koffice/libko/src/cass/mod.rs new file mode 100644 index 000000000000..43dd79b0fbd7 --- /dev/null +++ b/apps/koffice/libko/src/cass/mod.rs @@ -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) + } +} diff --git a/apps/koffice/libko/src/cass/time.rs b/apps/koffice/libko/src/cass/time.rs new file mode 100644 index 000000000000..48d4d5f9be2e --- /dev/null +++ b/apps/koffice/libko/src/cass/time.rs @@ -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); +} diff --git a/apps/koffice/libko/src/cass/timeline.rs b/apps/koffice/libko/src/cass/timeline.rs new file mode 100644 index 000000000000..057f0b47d7da --- /dev/null +++ b/apps/koffice/libko/src/cass/timeline.rs @@ -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)) + } +} diff --git a/apps/koffice/libko/src/client.rs b/apps/koffice/libko/src/client.rs new file mode 100644 index 000000000000..8e256160d3d3 --- /dev/null +++ b/apps/koffice/libko/src/client.rs @@ -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, +} diff --git a/apps/koffice/libko/src/config.rs b/apps/koffice/libko/src/config.rs new file mode 100644 index 000000000000..bef9c6fd2377 --- /dev/null +++ b/apps/koffice/libko/src/config.rs @@ -0,0 +1,5 @@ +use std::path::PathBuf; + +pub struct AppSettings { + +} diff --git a/apps/koffice/libko/src/invoice.rs b/apps/koffice/libko/src/invoice.rs new file mode 100644 index 000000000000..737513785916 --- /dev/null +++ b/apps/koffice/libko/src/invoice.rs @@ -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()); +} diff --git a/apps/koffice/libko/src/lib.rs b/apps/koffice/libko/src/lib.rs new file mode 100644 index 000000000000..33d6b38ae1b1 --- /dev/null +++ b/apps/koffice/libko/src/lib.rs @@ -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() + } +} diff --git a/apps/koffice/libko/src/proj.rs b/apps/koffice/libko/src/proj.rs new file mode 100644 index 000000000000..1e370fc2aaaf --- /dev/null +++ b/apps/koffice/libko/src/proj.rs @@ -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 } + } +} diff --git a/apps/koffice/libko/src/store.rs b/apps/koffice/libko/src/store.rs new file mode 100644 index 000000000000..7ce33ce5fd51 --- /dev/null +++ b/apps/koffice/libko/src/store.rs @@ -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) +} diff --git a/apps/koffice/libko/src/timefile.rs b/apps/koffice/libko/src/timefile.rs new file mode 100644 index 000000000000..e69de29bb2d1 --- /dev/null +++ b/apps/koffice/libko/src/timefile.rs |