aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaiden Fey <kookie@spacekookie.de>2021-02-21 14:56:11 +0100
committerKaiden Fey <kookie@spacekookie.de>2021-02-21 14:56:11 +0100
commitf186a7345dfc99347673f46e0daff0cb63ac8492 (patch)
tree5179ffd4654b80ea11a1656e28ef244439648ba4
parenteffbdeed66e8de8e769b8ac069926ad1a9110e62 (diff)
k-office: initial code dumpk-office/init
-rw-r--r--apps/koffice/.gitignore1
-rw-r--r--apps/koffice/Cargo.lock433
-rw-r--r--apps/koffice/Cargo.toml5
-rw-r--r--apps/koffice/README.md25
-rw-r--r--apps/koffice/invoice/Cargo.toml13
-rw-r--r--apps/koffice/invoice/src/base.rs20
-rw-r--r--apps/koffice/invoice/src/cli.rs76
-rw-r--r--apps/koffice/invoice/src/main.rs41
-rw-r--r--apps/koffice/invoice/src/pfile.rs33
-rw-r--r--apps/koffice/libko/Cargo.lock189
-rw-r--r--apps/koffice/libko/Cargo.toml12
-rw-r--r--apps/koffice/libko/src/cass/data.rs116
-rw-r--r--apps/koffice/libko/src/cass/date.rs32
-rw-r--r--apps/koffice/libko/src/cass/error.rs91
-rw-r--r--apps/koffice/libko/src/cass/format/gen.rs32
-rw-r--r--apps/koffice/libko/src/cass/format/ir.rs99
-rw-r--r--apps/koffice/libko/src/cass/format/lexer.rs151
-rw-r--r--apps/koffice/libko/src/cass/format/mod.rs76
-rw-r--r--apps/koffice/libko/src/cass/format/parser.rs73
-rw-r--r--apps/koffice/libko/src/cass/meta.rs46
-rw-r--r--apps/koffice/libko/src/cass/mod.rs160
-rw-r--r--apps/koffice/libko/src/cass/time.rs141
-rw-r--r--apps/koffice/libko/src/cass/timeline.rs132
-rw-r--r--apps/koffice/libko/src/client.rs39
-rw-r--r--apps/koffice/libko/src/config.rs5
-rw-r--r--apps/koffice/libko/src/invoice.rs35
-rw-r--r--apps/koffice/libko/src/lib.rs35
-rw-r--r--apps/koffice/libko/src/proj.rs15
-rw-r--r--apps/koffice/libko/src/store.rs94
-rw-r--r--apps/koffice/libko/src/timefile.rs0
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