mirror of
https://github.com/gosticks/acari.git
synced 2025-10-16 11:45:37 +00:00
[refactor] include Everhour backend
This commit is contained in:
parent
6ea273577e
commit
cd1eaaeedd
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -12,6 +12,6 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --release
|
||||
- name: Build
|
||||
uses: docker://untoldwind/rust-musl-builder:v1.41.0
|
||||
uses: docker://untoldwind/rust-musl-builder:v1.52.0
|
||||
with:
|
||||
args: cargo build --release
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
run: cargo publish
|
||||
working-directory: ./cli
|
||||
- name: Build
|
||||
uses: docker://untoldwind/rust-musl-builder:v1.41.0
|
||||
uses: docker://untoldwind/rust-musl-builder:v1.52.0
|
||||
with:
|
||||
args: cargo build --release
|
||||
- name: Copy binary
|
||||
|
||||
130
Cargo.lock
generated
130
Cargo.lock
generated
@ -2,13 +2,13 @@
|
||||
# It is not intended for manual editing.
|
||||
[[package]]
|
||||
name = "acari-cli"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"acari-lib",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs 2.0.2",
|
||||
"itertools",
|
||||
"itertools 0.10.0",
|
||||
"openssl-probe",
|
||||
"prettytable-rs",
|
||||
"serde",
|
||||
@ -19,17 +19,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "acari-lib"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 2.0.2",
|
||||
"pact_consumer",
|
||||
"pact_mock_server",
|
||||
"percent-encoding 2.1.0",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url 2.2.1",
|
||||
"url 2.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -147,12 +148,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "0.2.15"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a40b47ad93e1a5404e6c18dec46b628214fee441c70f4ab5d6942142cc268a3d"
|
||||
checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"memchr 2.3.4",
|
||||
"memchr 2.4.0",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
@ -308,9 +309,9 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49"
|
||||
checksum = "4feb231f0d4d6af81aed15928e58ecf5816aa62a2393e2c82f46973e92a9a278"
|
||||
dependencies = [
|
||||
"autocfg 1.0.1",
|
||||
"cfg-if 1.0.0",
|
||||
@ -336,7 +337,7 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
|
||||
dependencies = [
|
||||
"memchr 2.3.4",
|
||||
"memchr 2.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -368,7 +369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"redox_users 0.3.5",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
@ -384,12 +385,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a"
|
||||
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"redox_users 0.4.0",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
@ -642,7 +643,7 @@ dependencies = [
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr 2.3.4",
|
||||
"memchr 2.4.0",
|
||||
"pin-project-lite 0.2.6",
|
||||
"pin-utils",
|
||||
"proc-macro-hack",
|
||||
@ -889,6 +890,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "0.4.7"
|
||||
@ -928,9 +938,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "lexical-core"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374"
|
||||
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
@ -941,9 +951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.93"
|
||||
version = "0.2.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
|
||||
checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@ -1004,9 +1014,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.3.4"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
|
||||
checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@ -1125,7 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
|
||||
dependencies = [
|
||||
"lexical-core",
|
||||
"memchr 2.3.4",
|
||||
"memchr 2.4.0",
|
||||
"version_check 0.9.3",
|
||||
]
|
||||
|
||||
@ -1188,9 +1198,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.33"
|
||||
version = "0.10.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577"
|
||||
checksum = "6d7830286ad6a3973c0f1d9b73738f69c76b739301d0229c4b96501695cbe4c8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if 1.0.0",
|
||||
@ -1208,9 +1218,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.61"
|
||||
version = "0.9.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f"
|
||||
checksum = "b6b0d6fb7d80f877617dfcb014e605e2b5ab2fb0afdf27935219bb6bd984cb98"
|
||||
dependencies = [
|
||||
"autocfg 1.0.1",
|
||||
"cc",
|
||||
@ -1241,7 +1251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "929be0f2ee4cf9c0054617d529ce357390748f49ca5097488e0629a915e69335"
|
||||
dependencies = [
|
||||
"futures 0.3.14",
|
||||
"itertools",
|
||||
"itertools 0.9.0",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log 0.4.14",
|
||||
@ -1251,7 +1261,7 @@ dependencies = [
|
||||
"regex 0.1.80",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url 2.2.1",
|
||||
"url 2.2.2",
|
||||
"uuid 0.6.5",
|
||||
]
|
||||
|
||||
@ -1271,7 +1281,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"hyper 0.10.16",
|
||||
"indextree",
|
||||
"itertools",
|
||||
"itertools 0.9.0",
|
||||
"lazy_static",
|
||||
"log 0.4.14",
|
||||
"maplit",
|
||||
@ -1281,7 +1291,7 @@ dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"rand 0.6.5",
|
||||
"rand_regex",
|
||||
"regex-syntax 0.6.23",
|
||||
"regex-syntax 0.6.25",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
@ -1300,7 +1310,7 @@ dependencies = [
|
||||
"bytes 0.5.6",
|
||||
"futures 0.3.14",
|
||||
"hyper 0.13.10",
|
||||
"itertools",
|
||||
"itertools 0.9.0",
|
||||
"lazy_static",
|
||||
"log 0.4.14",
|
||||
"maplit",
|
||||
@ -1342,7 +1352,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41"
|
||||
dependencies = [
|
||||
"regex 1.4.5",
|
||||
"regex 1.5.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1670,7 +1680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7d3b5a2a79d1a83bb46ea3058c3447e3db8c11ae833c7a695e286762683beb7"
|
||||
dependencies = [
|
||||
"rand 0.6.5",
|
||||
"regex-syntax 0.6.23",
|
||||
"regex-syntax 0.6.25",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1699,9 +1709,9 @@ checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.2.6"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041"
|
||||
checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
@ -1717,6 +1727,16 @@ dependencies = [
|
||||
"rust-argon2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
|
||||
dependencies = [
|
||||
"getrandom 0.2.2",
|
||||
"redox_syscall 0.2.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "0.1.80"
|
||||
@ -1732,11 +1752,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.4.5"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
|
||||
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
|
||||
dependencies = [
|
||||
"regex-syntax 0.6.23",
|
||||
"regex-syntax 0.6.25",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1756,9 +1776,9 @@ checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.23"
|
||||
version = "0.6.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548"
|
||||
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
||||
|
||||
[[package]]
|
||||
name = "remove_dir_all"
|
||||
@ -1801,7 +1821,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.14.1",
|
||||
"tokio-tls",
|
||||
"url 2.2.1",
|
||||
"url 2.2.2",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@ -1983,9 +2003,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
|
||||
checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
@ -2034,9 +2054,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.69"
|
||||
version = "1.0.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
|
||||
checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -2062,7 +2082,7 @@ dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"libc",
|
||||
"rand 0.8.3",
|
||||
"redox_syscall 0.2.6",
|
||||
"redox_syscall 0.2.8",
|
||||
"remove_dir_all",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
@ -2171,7 +2191,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"iovec",
|
||||
"lazy_static",
|
||||
"memchr 2.3.4",
|
||||
"memchr 2.4.0",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"pin-project-lite 0.1.12",
|
||||
@ -2266,9 +2286,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.25"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01ebdc2bb4498ab1ab5f5b73c5803825e60199229ccba0698170e3be0e7f959f"
|
||||
checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"log 0.4.14",
|
||||
@ -2278,9 +2298,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f50de3927f93d202783f4513cda820ab47ef17f624b03c096e86ef00c67e6b5f"
|
||||
checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
@ -2382,9 +2402,9 @@ checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
|
||||
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
@ -2405,9 +2425,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.2.1"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b"
|
||||
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna 0.2.3",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "acari-cli"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
authors = ["Bodo Junglas <junglas@objectcode.de>"]
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
@ -19,7 +19,7 @@ text_io = "0"
|
||||
prettytable-rs = "0"
|
||||
itertools = "0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
acari-lib = { version= "0.1.10", path = "../lib" }
|
||||
acari-lib = { version= "0.1.11", path = "../lib" }
|
||||
openssl-probe = "0"
|
||||
|
||||
|
||||
|
||||
@ -22,10 +22,10 @@ pub struct AddCmd {
|
||||
impl AddCmd {
|
||||
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let customer = find_customer(client, &self.customer)?;
|
||||
let project = find_project(client, customer.id, &self.project)?;
|
||||
let service = find_service(client, &self.service)?;
|
||||
let project = find_project(client, &customer.id, &self.project)?;
|
||||
let service = find_service(client, &project.id, &self.service)?;
|
||||
|
||||
client.create_time_entry(self.day, project.id, service.id, self.time, self.note.clone())?;
|
||||
client.create_time_entry(self.day, &project.id, &service.id, self.time, self.note.clone())?;
|
||||
|
||||
entries(client, output_format, self.day.into())
|
||||
}
|
||||
|
||||
@ -44,7 +44,9 @@ fn print_json(projects: Vec<Project>) -> Result<(), AcariError> {
|
||||
fn print_flat(projects: Vec<(&str, Vec<&Project>)>) {
|
||||
for (customer_name, group) in projects {
|
||||
for project in group {
|
||||
println!("{}/{}", customer_name, project.name);
|
||||
if !project.archived {
|
||||
println!("{}/{}", customer_name, project.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,8 +22,7 @@ fn print_pretty(account: Account, user: User) {
|
||||
["Name", account.name],
|
||||
["Title", account.title],
|
||||
["Currency", account.currency],
|
||||
["Created at", account.created_at.to_string()],
|
||||
["Updated at", account.updated_at.to_string()]
|
||||
["Created at", account.created_at.to_string()]
|
||||
);
|
||||
|
||||
println!("Account");
|
||||
@ -36,8 +35,7 @@ fn print_pretty(account: Account, user: User) {
|
||||
["Email", user.email],
|
||||
["Role", user.role],
|
||||
["Language", user.language],
|
||||
["Created at", user.created_at.to_string()],
|
||||
["Updated at", user.updated_at.to_string()]
|
||||
["Created at", user.created_at.to_string()]
|
||||
);
|
||||
|
||||
println!();
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
use acari_lib::{AcariError, CachedClient};
|
||||
|
||||
pub fn clear_cache() -> Result<(), AcariError> {
|
||||
CachedClient::clear_cache()
|
||||
}
|
||||
@ -22,7 +22,11 @@ fn print_pretty(customers: Vec<Customer>) {
|
||||
customers_table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
|
||||
|
||||
for customer in customers {
|
||||
customers_table.add_row(row![customer.name]);
|
||||
if customer.archived {
|
||||
customers_table.add_row(row![FY => customer.name]);
|
||||
} else {
|
||||
customers_table.add_row(row![customer.name]);
|
||||
}
|
||||
}
|
||||
customers_table.printstd();
|
||||
}
|
||||
@ -35,6 +39,8 @@ fn print_json(customers: Vec<Customer>) -> Result<(), AcariError> {
|
||||
|
||||
fn print_flat(customers: Vec<Customer>) {
|
||||
for customer in customers {
|
||||
println!("{}", customer.name);
|
||||
if !customer.archived {
|
||||
println!("{}", customer.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::OutputFormat;
|
||||
use acari_lib::{AcariError, Client, DateSpan, Minutes, TimeEntry, TrackingTimeEntry};
|
||||
use acari_lib::{AcariError, Client, DateSpan, Minutes, TimeEntry};
|
||||
use chrono::NaiveDate;
|
||||
use clap::Clap;
|
||||
use itertools::Itertools;
|
||||
@ -32,15 +32,15 @@ pub fn entries(client: &dyn Client, output_format: OutputFormat, date_span: Date
|
||||
.collect();
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(grouped, tracker.tracking_time_entry),
|
||||
OutputFormat::Json => print_json(time_entries, tracker.tracking_time_entry)?,
|
||||
OutputFormat::Pretty => print_pretty(grouped, &tracker.tracking_time_entry),
|
||||
OutputFormat::Json => print_json(time_entries, &tracker.tracking_time_entry)?,
|
||||
OutputFormat::Flat => print_flat(grouped, tracker.tracking_time_entry),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TrackingTimeEntry>) {
|
||||
fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: &Option<TimeEntry>) {
|
||||
if entries.is_empty() {
|
||||
println!("No entries found");
|
||||
return;
|
||||
@ -56,7 +56,7 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
|
||||
let sum = group
|
||||
.iter()
|
||||
.map(|e| {
|
||||
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == e.id) {
|
||||
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == e.id) {
|
||||
tracking_entry.minutes
|
||||
} else {
|
||||
e.minutes
|
||||
@ -66,7 +66,7 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
|
||||
total += sum;
|
||||
entries_table.add_row(row![bFc -> day, bFc -> sum, "", "", "", ""]);
|
||||
for entry in group {
|
||||
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
entries_table.add_row(row![FY => "", tracking_entry.minutes, entry.customer_name, entry.project_name, entry.service_name, entry.note]);
|
||||
} else if entry.locked {
|
||||
entries_table.add_row(row![Fr => "", entry.minutes, entry.customer_name, entry.project_name, entry.service_name, entry.note]);
|
||||
@ -83,12 +83,12 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
|
||||
entries_table.printstd();
|
||||
}
|
||||
|
||||
fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: Option<TrackingTimeEntry>) -> Result<(), AcariError> {
|
||||
fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: &Option<TimeEntry>) -> Result<(), AcariError> {
|
||||
let json_entries: Result<Vec<Value>, AcariError> = entries
|
||||
.into_iter()
|
||||
.map(|entry| match serde_json::to_value(&entry)? {
|
||||
Value::Object(mut fields) => {
|
||||
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
fields.insert("tracking".to_string(), Value::Bool(true));
|
||||
fields["minutes"] = json!(tracking_entry.minutes);
|
||||
} else {
|
||||
@ -104,10 +104,10 @@ fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: Option<TrackingTimeE
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_flat(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TrackingTimeEntry>) {
|
||||
fn print_flat(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TimeEntry>) {
|
||||
for (date, group) in entries {
|
||||
for entry in group {
|
||||
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
println!(
|
||||
"{}\t{}\t{}\t{}\t{}\tTRACKING",
|
||||
date, entry.customer_name, entry.project_name, entry.service_name, tracking_entry.minutes,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::config::{Config, Profile};
|
||||
use crate::config::{ClientType, Config, Profile};
|
||||
use std::io::{stdout, Write};
|
||||
use text_io::try_read;
|
||||
|
||||
@ -28,7 +28,14 @@ pub fn init(maybe_existing_config: Option<Config>, maybe_profile: &Option<String
|
||||
|
||||
match maybe_profile {
|
||||
Some(profile) => {
|
||||
config.profiles.insert(profile.to_string(), Profile { domain, token });
|
||||
config.profiles.insert(
|
||||
profile.to_string(),
|
||||
Profile {
|
||||
domain,
|
||||
token,
|
||||
client: ClientType::Mite,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {
|
||||
config.domain = domain;
|
||||
|
||||
@ -3,7 +3,6 @@ use clap::Clap;
|
||||
mod add;
|
||||
mod all_projects;
|
||||
mod check;
|
||||
mod clear_cache;
|
||||
mod customers;
|
||||
mod entries;
|
||||
mod init;
|
||||
@ -17,7 +16,6 @@ mod tracker;
|
||||
pub use add::*;
|
||||
pub use all_projects::*;
|
||||
pub use check::*;
|
||||
pub use clear_cache::*;
|
||||
pub use customers::*;
|
||||
pub use entries::*;
|
||||
pub use init::*;
|
||||
@ -28,7 +26,7 @@ pub use services::*;
|
||||
pub use set::*;
|
||||
pub use tracker::*;
|
||||
|
||||
use acari_lib::{user_error, AcariError, Client, Customer, CustomerId, Project, Service};
|
||||
use acari_lib::{user_error, AcariError, Client, Customer, CustomerId, Project, ProjectId, Service};
|
||||
|
||||
#[derive(Clap, Debug, PartialEq)]
|
||||
pub enum OutputFormat {
|
||||
@ -46,17 +44,17 @@ fn find_customer(client: &dyn Client, customer_name: &str) -> Result<Customer, A
|
||||
.ok_or_else(|| user_error!("No customer with name: {}", customer_name))
|
||||
}
|
||||
|
||||
fn find_project(client: &dyn Client, customer_id: CustomerId, project_name: &str) -> Result<Project, AcariError> {
|
||||
fn find_project(client: &dyn Client, customer_id: &CustomerId, project_name: &str) -> Result<Project, AcariError> {
|
||||
let projects = client.get_projects()?;
|
||||
|
||||
projects
|
||||
.into_iter()
|
||||
.find(|p| p.name == project_name && p.customer_id == customer_id)
|
||||
.find(|p| p.name == project_name && p.customer_id.eq(customer_id))
|
||||
.ok_or_else(|| user_error!("No project with name: {}", project_name))
|
||||
}
|
||||
|
||||
fn find_service(client: &dyn Client, service_name: &str) -> Result<Service, AcariError> {
|
||||
let services = client.get_services()?;
|
||||
fn find_service(client: &dyn Client, project_id: &ProjectId, service_name: &str) -> Result<Service, AcariError> {
|
||||
let services = client.get_services(project_id)?;
|
||||
|
||||
services
|
||||
.into_iter()
|
||||
|
||||
@ -22,8 +22,12 @@ fn print_pretty(projects: Vec<Project>) {
|
||||
projects_table.set_titles(row!["Projects"]);
|
||||
projects_table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
|
||||
|
||||
for customer in projects {
|
||||
projects_table.add_row(row![customer.name]);
|
||||
for project in projects {
|
||||
if project.archived {
|
||||
projects_table.add_row(row![FY => project.name]);
|
||||
} else {
|
||||
projects_table.add_row(row![project.name]);
|
||||
}
|
||||
}
|
||||
projects_table.printstd();
|
||||
}
|
||||
@ -36,6 +40,8 @@ fn print_json(projects: Vec<Project>) -> Result<(), AcariError> {
|
||||
|
||||
fn print_flat(projects: Vec<Project>) {
|
||||
for project in projects {
|
||||
println!("{}", project.name);
|
||||
if !project.archived {
|
||||
println!("{}", project.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use super::OutputFormat;
|
||||
use super::{find_customer, find_project};
|
||||
use acari_lib::{AcariError, Client, Service};
|
||||
use clap::Clap;
|
||||
use itertools::Itertools;
|
||||
@ -12,26 +13,32 @@ pub struct ServicesCommand {
|
||||
project: String,
|
||||
}
|
||||
|
||||
pub fn services(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let mut services = client.get_services()?;
|
||||
impl ServicesCommand {
|
||||
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let customer = find_customer(client, &self.customer)?;
|
||||
let project = find_project(client, &customer.id, &self.project)?;
|
||||
let mut services = client.get_services(&project.id)?;
|
||||
|
||||
services.sort_by(|s1, s2| s1.name.cmp(&s2.name));
|
||||
services.sort_by(|s1, s2| s1.name.cmp(&s2.name));
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(services),
|
||||
OutputFormat::Json => print_json(services)?,
|
||||
OutputFormat::Flat => print_flat(services),
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(services),
|
||||
OutputFormat::Json => print_json(services)?,
|
||||
OutputFormat::Flat => print_flat(services),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_pretty(services: Vec<Service>) {
|
||||
let service_table = table!(
|
||||
["Billable services"],
|
||||
[services.iter().filter(|s| s.billable).map(|s| &s.name).join("\n")],
|
||||
[services.iter().filter(|s| s.billable && !s.archived).map(|s| &s.name).join("\n")],
|
||||
["Not billable services"],
|
||||
[services.iter().filter(|s| !s.billable).map(|s| &s.name).join("\n")]
|
||||
[services.iter().filter(|s| !s.billable && !s.archived).map(|s| &s.name).join("\n")],
|
||||
["Archived"],
|
||||
[services.iter().filter(|s| s.archived).map(|s| &s.name).join("\n")]
|
||||
);
|
||||
service_table.printstd();
|
||||
}
|
||||
@ -44,6 +51,8 @@ fn print_json(services: Vec<Service>) -> Result<(), AcariError> {
|
||||
|
||||
fn print_flat(services: Vec<Service>) {
|
||||
for service in services {
|
||||
println!("{}", service.name);
|
||||
if !service.archived {
|
||||
println!("{}", service.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,20 +22,20 @@ pub struct SetCmd {
|
||||
impl SetCmd {
|
||||
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let customer = find_customer(client, &self.customer)?;
|
||||
let project = find_project(client, customer.id, &self.project)?;
|
||||
let service = find_service(client, &self.service)?;
|
||||
let project = find_project(client, &customer.id, &self.project)?;
|
||||
let service = find_service(client, &project.id, &self.service)?;
|
||||
let date = self.day.as_date();
|
||||
let mut time_entries = client.get_time_entries(date.into())?;
|
||||
|
||||
time_entries.retain(|e| e.date_at == date && e.customer_id == customer.id && e.project_id == project.id && e.service_id == service.id);
|
||||
time_entries.retain(|e| e.date_at == date && e.customer_id.eq(&customer.id) && e.project_id.eq(&project.id) && e.service_id.eq(&service.id));
|
||||
|
||||
if let Some(first) = time_entries.first() {
|
||||
client.update_time_entry(first.id, self.time, self.note.clone())?;
|
||||
client.update_time_entry(&first.id, self.time, self.note.clone())?;
|
||||
for remaining in &time_entries[1..] {
|
||||
client.delete_time_entry(remaining.id)?;
|
||||
client.delete_time_entry(&remaining.id)?;
|
||||
}
|
||||
} else {
|
||||
client.create_time_entry(self.day, project.id, service.id, self.time, self.note.clone())?;
|
||||
client.create_time_entry(self.day, &project.id, &service.id, self.time, self.note.clone())?;
|
||||
}
|
||||
|
||||
entries(client, output_format, date.into())
|
||||
|
||||
@ -22,8 +22,8 @@ pub struct StartCmd {
|
||||
impl StartCmd {
|
||||
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let customer = find_customer(client, &self.customer)?;
|
||||
let project = find_project(client, customer.id, &self.project)?;
|
||||
let service = find_service(client, &self.service)?;
|
||||
let project = find_project(client, &customer.id, &self.project)?;
|
||||
let service = find_service(client, &project.id, &self.service)?;
|
||||
let date = Day::Today.as_date();
|
||||
|
||||
let maybe_existing = match self.offset {
|
||||
@ -35,21 +35,21 @@ impl StartCmd {
|
||||
.filter(|e| e.date_at == date && e.customer_id == customer.id && e.project_id == project.id && e.service_id == service.id)
|
||||
.collect();
|
||||
|
||||
existing.sort_by(|e1, e2| e2.updated_at.cmp(&e1.updated_at));
|
||||
existing.sort_by(|e1, e2| e2.created_at.cmp(&e1.created_at));
|
||||
|
||||
existing.into_iter().next()
|
||||
}
|
||||
};
|
||||
let entry = match maybe_existing {
|
||||
Some(existing) => existing,
|
||||
None => client.create_time_entry(date.into(), project.id, service.id, self.offset.unwrap_or_default(), self.note.clone())?,
|
||||
None => client.create_time_entry(date.into(), &project.id, &service.id, self.offset.unwrap_or_default(), self.note.clone())?,
|
||||
};
|
||||
let tracker = client.create_tracker(entry.id)?;
|
||||
let tracker = client.create_tracker(&entry.id)?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(Some(entry), tracker),
|
||||
OutputFormat::Json => print_json(Some(entry), tracker)?,
|
||||
OutputFormat::Flat => print_flat(Some(entry), tracker),
|
||||
OutputFormat::Pretty => print_pretty(Some(&entry), &tracker),
|
||||
OutputFormat::Json => print_json(Some(&entry), &tracker)?,
|
||||
OutputFormat::Flat => print_flat(Some(&entry), &tracker),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -59,17 +59,17 @@ impl StartCmd {
|
||||
pub fn tracking(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let tracker = client.get_tracker()?;
|
||||
let maybe_entry = if let Some(tracking_entry) = &tracker.tracking_time_entry {
|
||||
Some(client.get_time_entry(tracking_entry.id)?)
|
||||
Some(tracking_entry)
|
||||
} else if let Some(tracking_entry) = &tracker.stopped_time_entry {
|
||||
Some(client.get_time_entry(tracking_entry.id)?)
|
||||
Some(tracking_entry)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(maybe_entry, tracker),
|
||||
OutputFormat::Json => print_json(maybe_entry, tracker)?,
|
||||
OutputFormat::Flat => print_flat(maybe_entry, tracker),
|
||||
OutputFormat::Pretty => print_pretty(maybe_entry, &tracker),
|
||||
OutputFormat::Json => print_json(maybe_entry, &tracker)?,
|
||||
OutputFormat::Flat => print_flat(maybe_entry, &tracker),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -78,23 +78,23 @@ pub fn tracking(client: &dyn Client, output_format: OutputFormat) -> Result<(),
|
||||
pub fn stop(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
|
||||
let current_tracker = client.get_tracker()?;
|
||||
let (update_tracker, maybe_entry) = if let Some(tracking_entry) = ¤t_tracker.tracking_time_entry {
|
||||
(client.delete_tracker(tracking_entry.id)?, Some(client.get_time_entry(tracking_entry.id)?))
|
||||
(client.delete_tracker(&tracking_entry.id)?, Some(tracking_entry))
|
||||
} else if let Some(tracking_entry) = ¤t_tracker.stopped_time_entry {
|
||||
(current_tracker.clone(), Some(client.get_time_entry(tracking_entry.id)?))
|
||||
(current_tracker.clone(), Some(tracking_entry))
|
||||
} else {
|
||||
(current_tracker, None)
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Pretty => print_pretty(maybe_entry, update_tracker),
|
||||
OutputFormat::Json => print_json(maybe_entry, update_tracker)?,
|
||||
OutputFormat::Flat => print_flat(maybe_entry, update_tracker),
|
||||
OutputFormat::Pretty => print_pretty(maybe_entry, &update_tracker),
|
||||
OutputFormat::Json => print_json(maybe_entry, &update_tracker)?,
|
||||
OutputFormat::Flat => print_flat(maybe_entry, &update_tracker),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
|
||||
fn print_pretty(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) {
|
||||
match maybe_entry {
|
||||
Some(entry) => {
|
||||
let mut entry_table = table!(
|
||||
@ -105,15 +105,15 @@ fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
|
||||
);
|
||||
entry_table.set_format(*format::consts::FORMAT_CLEAN);
|
||||
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
entry_table.add_row(row![FY => "Time", tracking_entry.minutes]);
|
||||
|
||||
match tracking_entry.since {
|
||||
match tracker.since {
|
||||
Some(since) => println!("Currently tracking since {}", since),
|
||||
None => println!("Currently tracking"),
|
||||
}
|
||||
entry_table.printstd();
|
||||
} else if let Some(tracking_entry) = tracker.stopped_time_entry.filter(|t| t.id == entry.id) {
|
||||
} else if let Some(tracking_entry) = tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
entry_table.add_row(row!["Time", tracking_entry.minutes]);
|
||||
println!("Stooped tracking");
|
||||
entry_table.printstd();
|
||||
@ -125,12 +125,12 @@ fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(maybe_entry: Option<TimeEntry>, tracker: Tracker) -> Result<(), AcariError> {
|
||||
fn print_json(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) -> Result<(), AcariError> {
|
||||
match maybe_entry {
|
||||
Some(entry) => {
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
println!("{}", serde_json::to_string_pretty(&json!({ "entry": entry, "tracking": tracking_entry }))?);
|
||||
} else if tracker.stopped_time_entry.filter(|t| t.id == entry.id).is_some() {
|
||||
} else if tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id).is_some() {
|
||||
println!("{}", serde_json::to_string_pretty(&json!({ "stopped": entry }))?);
|
||||
} else {
|
||||
println!("{}", serde_json::to_string_pretty(&json!({}))?);
|
||||
@ -141,15 +141,15 @@ fn print_json(maybe_entry: Option<TimeEntry>, tracker: Tracker) -> Result<(), Ac
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_flat(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
|
||||
fn print_flat(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) {
|
||||
match maybe_entry {
|
||||
Some(entry) => {
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
|
||||
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
|
||||
println!(
|
||||
"Tracking {}\t{}\t{}\t{}\t{}",
|
||||
entry.date_at, entry.customer_name, entry.project_name, entry.service_name, tracking_entry.minutes,
|
||||
);
|
||||
} else if tracker.stopped_time_entry.filter(|t| t.id == entry.id).is_some() {
|
||||
} else if tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id).is_some() {
|
||||
println!(
|
||||
"Stopped {}\t{}\t{}\t{}\t{}",
|
||||
entry.date_at, entry.customer_name, entry.project_name, entry.service_name, entry.minutes,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use acari_lib::{internal_error, AcariError, CachedClient, Client, StdClient};
|
||||
use acari_lib::{internal_error, AcariError, CachedClient, Client, EverhourClient, MiteClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
@ -6,16 +6,33 @@ use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ClientType {
|
||||
Mite,
|
||||
Everhour,
|
||||
}
|
||||
|
||||
impl Default for ClientType {
|
||||
fn default() -> Self {
|
||||
ClientType::Mite
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Profile {
|
||||
pub domain: String,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub client: ClientType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub domain: String,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub client: ClientType,
|
||||
#[serde(default = "default_cache_ttl")]
|
||||
pub cache_ttl_minutes: u64,
|
||||
#[serde(default)]
|
||||
@ -37,21 +54,28 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn client(&self, maybe_profile: &Option<String>, cached: bool) -> Result<Box<dyn Client>, AcariError> {
|
||||
let (domain, token) = match maybe_profile {
|
||||
let (domain, token, client) = match maybe_profile {
|
||||
Some(profile_name) => {
|
||||
let profile = self
|
||||
.profiles
|
||||
.get(profile_name)
|
||||
.ok_or_else(|| AcariError::UserError(format!("No such profile: {}", profile_name)))?;
|
||||
(&profile.domain, &profile.token)
|
||||
(&profile.domain, &profile.token, &profile.client)
|
||||
}
|
||||
None => (&self.domain, &self.token),
|
||||
None => (&self.domain, &self.token, &self.client),
|
||||
};
|
||||
if cached {
|
||||
Ok(Box::new(CachedClient::new(domain, token, Duration::from_secs(self.cache_ttl_minutes * 60))?))
|
||||
} else {
|
||||
Ok(Box::new(StdClient::new(domain, token)?))
|
||||
}
|
||||
Ok(match client {
|
||||
ClientType::Mite if cached => Box::new(CachedClient::new(
|
||||
MiteClient::new(domain, token)?,
|
||||
Duration::from_secs(self.cache_ttl_minutes * 60),
|
||||
)?),
|
||||
ClientType::Mite => Box::new(MiteClient::new(domain, token)?),
|
||||
ClientType::Everhour if cached => Box::new(CachedClient::new(
|
||||
EverhourClient::new(domain, token)?,
|
||||
Duration::from_secs(self.cache_ttl_minutes * 60),
|
||||
)?),
|
||||
ClientType::Everhour => Box::new(EverhourClient::new(domain, token)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use acari_lib::AcariError;
|
||||
use acari_lib::{clear_cache, AcariError};
|
||||
use clap::Clap;
|
||||
use std::str;
|
||||
|
||||
@ -43,7 +43,7 @@ enum SubCommand {
|
||||
#[clap(about = "List all projects")]
|
||||
Projects(commands::ProjectsCmd),
|
||||
#[clap(about = "List all services")]
|
||||
Services,
|
||||
Services(commands::ServicesCommand),
|
||||
#[clap(about = "Set time for a project at specific day")]
|
||||
Set(commands::SetCmd),
|
||||
#[clap(about = "Start tracking time")]
|
||||
@ -67,12 +67,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match opts.subcommand {
|
||||
SubCommand::Add(add_cmd) => add_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Check => commands::check(client.as_ref(), opts.output)?,
|
||||
SubCommand::ClearCache => commands::clear_cache()?,
|
||||
SubCommand::ClearCache => clear_cache()?,
|
||||
SubCommand::Customers => commands::customers(client.as_ref(), opts.output)?,
|
||||
SubCommand::Entries(entries_cmd) => entries_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Profiles => commands::profiles(config),
|
||||
SubCommand::Projects(projects_cmd) => projects_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Services => commands::services(client.as_ref(), opts.output)?,
|
||||
SubCommand::Services(services_cmd) => services_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Set(set_cmd) => set_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Start(start_cmd) => start_cmd.run(client.as_ref(), opts.output)?,
|
||||
SubCommand::Stop => commands::stop(client.as_ref(), opts.output)?,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "acari-lib"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
authors = ["Bodo Junglas <junglas@objectcode.de>"]
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
@ -16,6 +16,7 @@ serde_json = "1"
|
||||
reqwest = { version = "0.10", features = ["blocking", "json"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
pact_consumer = "0.6"
|
||||
|
||||
@ -1,41 +1,43 @@
|
||||
use crate::error::AcariError;
|
||||
use crate::model::{Account, Customer, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
|
||||
use crate::model::{Account, Customer, Minutes, Project, Service, TimeEntry, Tracker, User};
|
||||
use crate::model::{ProjectId, ServiceId, TimeEntryId};
|
||||
use crate::query::{DateSpan, Day};
|
||||
use crate::std_client::StdClient;
|
||||
use crate::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use std::fs::{self, File};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub fn clear_cache() -> Result<(), AcariError> {
|
||||
let cache_dir = cache_dir();
|
||||
|
||||
fs::remove_dir_all(cache_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CachedClient {
|
||||
client: StdClient,
|
||||
pub struct CachedClient<C> {
|
||||
client: C,
|
||||
cache_dir: PathBuf,
|
||||
cache_ttl: Duration,
|
||||
}
|
||||
|
||||
impl CachedClient {
|
||||
pub fn new(domain: &str, token: &str, cache_ttl: Duration) -> Result<CachedClient, AcariError> {
|
||||
let cache_dir = cache_dir().join(domain);
|
||||
impl<C> CachedClient<C>
|
||||
where
|
||||
C: Client,
|
||||
{
|
||||
pub fn new(client: C, cache_ttl: Duration) -> Result<CachedClient<C>, AcariError> {
|
||||
let cache_dir = cache_dir().join(client.get_domain());
|
||||
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
|
||||
Ok(CachedClient {
|
||||
client: StdClient::new(domain, token)?,
|
||||
cache_dir,
|
||||
cache_ttl,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_cache() -> Result<(), AcariError> {
|
||||
let cache_dir = cache_dir();
|
||||
|
||||
fs::remove_dir_all(cache_dir)?;
|
||||
|
||||
Ok(())
|
||||
Ok(CachedClient { client, cache_dir, cache_ttl })
|
||||
}
|
||||
|
||||
fn cache_data<T, F>(&self, cache_name: &str, fetch_data: F) -> Result<T, AcariError>
|
||||
@ -60,7 +62,14 @@ impl CachedClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl Client for CachedClient {
|
||||
impl<C> Client for CachedClient<C>
|
||||
where
|
||||
C: Client,
|
||||
{
|
||||
fn get_domain(&self) -> String {
|
||||
self.client.get_domain()
|
||||
}
|
||||
|
||||
fn get_account(&self) -> Result<Account, AcariError> {
|
||||
self.cache_data("account.json", || self.client.get_account())
|
||||
}
|
||||
@ -77,27 +86,30 @@ impl Client for CachedClient {
|
||||
self.cache_data("projects.json", || self.client.get_projects())
|
||||
}
|
||||
|
||||
fn get_services(&self) -> Result<Vec<Service>, AcariError> {
|
||||
self.cache_data("services.json", || self.client.get_services())
|
||||
}
|
||||
|
||||
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError> {
|
||||
self.client.get_time_entry(entry_id) // This should not be cached
|
||||
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError> {
|
||||
self.cache_data(&format!("services-{}.json", project_id), || self.client.get_services(project_id))
|
||||
}
|
||||
|
||||
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
|
||||
self.client.get_time_entries(date_span) // This should not be cached
|
||||
}
|
||||
|
||||
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
|
||||
fn create_time_entry(
|
||||
&self,
|
||||
day: Day,
|
||||
project_id: &ProjectId,
|
||||
service_id: &ServiceId,
|
||||
minutes: Minutes,
|
||||
note: Option<String>,
|
||||
) -> Result<TimeEntry, AcariError> {
|
||||
self.client.create_time_entry(day, project_id, service_id, minutes, note)
|
||||
}
|
||||
|
||||
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
|
||||
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
|
||||
self.client.update_time_entry(entry_id, minutes, note)
|
||||
}
|
||||
|
||||
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError> {
|
||||
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
|
||||
self.client.delete_time_entry(entry_id)
|
||||
}
|
||||
|
||||
@ -105,16 +117,16 @@ impl Client for CachedClient {
|
||||
self.client.get_tracker() // This should not be cached
|
||||
}
|
||||
|
||||
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
self.client.create_tracker(entry_id)
|
||||
}
|
||||
|
||||
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
self.client.delete_tracker(entry_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_age(path: &PathBuf) -> Result<Option<Duration>, AcariError> {
|
||||
fn file_age(path: &Path) -> Result<Option<Duration>, AcariError> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => Ok(Some(metadata.modified()?.elapsed()?)),
|
||||
Err(ref err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::num;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AcariError {
|
||||
IO(io::Error),
|
||||
Io(io::Error),
|
||||
Time(std::time::SystemTimeError),
|
||||
DateFormat(chrono::format::ParseError),
|
||||
Request(reqwest::Error),
|
||||
@ -13,12 +14,13 @@ pub enum AcariError {
|
||||
Mite(u16, String),
|
||||
UserError(String),
|
||||
InternalError(String),
|
||||
ParseNum(num::ParseIntError),
|
||||
}
|
||||
|
||||
impl fmt::Display for AcariError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
AcariError::IO(err) => write!(f, "IO error: {}", err),
|
||||
AcariError::Io(err) => write!(f, "IO error: {}", err),
|
||||
AcariError::Time(err) => write!(f, "Time error: {}", err),
|
||||
AcariError::DateFormat(err) => write!(f, "Date format error: {}", err),
|
||||
AcariError::Request(err) => write!(f, "Request error: {}", err),
|
||||
@ -27,6 +29,7 @@ impl fmt::Display for AcariError {
|
||||
AcariError::Mite(status, error) => write!(f, "Mite error ({}): {}", status, error),
|
||||
AcariError::UserError(s) => write!(f, "User error: {}", s),
|
||||
AcariError::InternalError(s) => write!(f, "Internal error: {}", s),
|
||||
AcariError::ParseNum(err) => write!(f, "Number error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,11 +37,12 @@ impl fmt::Display for AcariError {
|
||||
impl Error for AcariError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
match self {
|
||||
AcariError::IO(err) => Some(err),
|
||||
AcariError::Io(err) => Some(err),
|
||||
AcariError::Time(err) => Some(err),
|
||||
AcariError::DateFormat(err) => Some(err),
|
||||
AcariError::Request(err) => Some(err),
|
||||
AcariError::Json(err) => Some(err),
|
||||
AcariError::ParseNum(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -54,9 +58,10 @@ macro_rules! acari_error_from {
|
||||
};
|
||||
}
|
||||
|
||||
acari_error_from!(io::Error, IO);
|
||||
acari_error_from!(io::Error, Io);
|
||||
acari_error_from!(std::time::SystemTimeError, Time);
|
||||
acari_error_from!(serde_json::Error, Json);
|
||||
acari_error_from!(url::ParseError, Url);
|
||||
acari_error_from!(chrono::format::ParseError, DateFormat);
|
||||
acari_error_from!(reqwest::Error, Request);
|
||||
acari_error_from!(num::ParseIntError, ParseNum);
|
||||
|
||||
258
lib/src/everhour_client.rs
Normal file
258
lib/src/everhour_client.rs
Normal file
@ -0,0 +1,258 @@
|
||||
use crate::everhour_model::{
|
||||
build_time_entry_id, date_span_query_param, parse_time_entry_id, EverhourCreateTimeRecord, EverhourError, EverhourTask, EverhourTimeEntry, EverhourTimer,
|
||||
EverhourUser,
|
||||
};
|
||||
use crate::model::{Account, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
|
||||
use crate::query::{DateSpan, Day};
|
||||
use crate::Client;
|
||||
use crate::{error::AcariError, everhour_model::EverhourProject};
|
||||
use chrono::Utc;
|
||||
use reqwest::{blocking, header, Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
const USER_AGENT: &str = "acari-lib (https://github.com/untoldwind/acari)";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EverhourClient {
|
||||
base_url: Url,
|
||||
client: blocking::Client,
|
||||
}
|
||||
|
||||
impl EverhourClient {
|
||||
pub fn new(domain: &str, token: &str) -> Result<EverhourClient, AcariError> {
|
||||
Ok(Self::new_form_url(format!("https://{}@{}", token, domain).parse()?))
|
||||
}
|
||||
|
||||
pub fn new_form_url(base_url: Url) -> EverhourClient {
|
||||
EverhourClient {
|
||||
base_url,
|
||||
client: blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_request(&self, method: Method, uri: &str) -> Result<blocking::RequestBuilder, AcariError> {
|
||||
Ok(
|
||||
self
|
||||
.client
|
||||
.request(method, self.base_url.join(uri)?.as_str())
|
||||
.header(header::USER_AGENT, USER_AGENT)
|
||||
.header(header::HOST, self.base_url.host_str().unwrap_or(""))
|
||||
.header("X-Api-Key", self.base_url.username()),
|
||||
)
|
||||
}
|
||||
|
||||
fn request<T: DeserializeOwned>(&self, method: Method, uri: &str) -> Result<T, AcariError> {
|
||||
let response = self.base_request(method, uri)?.send()?;
|
||||
|
||||
Self::handle_response(response)
|
||||
}
|
||||
|
||||
fn request_with_body<T: DeserializeOwned, D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<T, AcariError> {
|
||||
let response = self.base_request(method, uri)?.json(&data).send()?;
|
||||
|
||||
Self::handle_response(response)
|
||||
}
|
||||
|
||||
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
|
||||
status => match response.json::<EverhourError>() {
|
||||
Ok(err) => Err(AcariError::Mite(err.code, err.message)),
|
||||
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_from_timer(&self, timer: EverhourTimer) -> Result<Option<TimeEntry>, AcariError> {
|
||||
match (timer.status.as_str(), timer.task, timer.user) {
|
||||
("active", Some(task), Some(user)) => {
|
||||
let maybe_project = match task.projects.get(0) {
|
||||
Some(project_id) => Some(self.request::<EverhourProject>(Method::GET, &format!("/projects/{}", project_id.path_encoded()))?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Some(TimeEntry {
|
||||
id: build_time_entry_id(&user.id, &task.id, &timer.started_at.naive_utc().date()),
|
||||
date_at: timer.started_at.naive_utc().date(),
|
||||
minutes: timer.duration,
|
||||
customer_id: maybe_project.as_ref().map(|p| p.workspace_id.clone()).unwrap_or_default(),
|
||||
customer_name: maybe_project.as_ref().map(|p| p.workspace_name.clone()).unwrap_or_default(),
|
||||
project_id: maybe_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
|
||||
project_name: maybe_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(),
|
||||
service_id: task.id,
|
||||
service_name: task.name,
|
||||
user_id: user.id.clone(),
|
||||
user_name: user.name.clone(),
|
||||
note: timer.comment.unwrap_or_default(),
|
||||
billable: true,
|
||||
locked: false,
|
||||
created_at: timer.started_at,
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Client for EverhourClient {
|
||||
fn get_domain(&self) -> String {
|
||||
self.base_url.host_str().unwrap_or("").to_owned()
|
||||
}
|
||||
|
||||
fn get_account(&self) -> Result<Account, AcariError> {
|
||||
Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
|
||||
}
|
||||
|
||||
fn get_myself(&self) -> Result<User, AcariError> {
|
||||
Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
|
||||
}
|
||||
|
||||
fn get_customers(&self) -> Result<Vec<Customer>, AcariError> {
|
||||
let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;
|
||||
let mut customers_map: HashMap<CustomerId, Customer> = HashMap::new();
|
||||
|
||||
for project in projects {
|
||||
let created_at = project.created_at;
|
||||
let archived = project.status != "open";
|
||||
let customer_ref = customers_map.entry(project.workspace_id.clone()).or_insert_with(|| project.into());
|
||||
|
||||
if created_at < customer_ref.created_at {
|
||||
customer_ref.created_at = created_at;
|
||||
}
|
||||
if !archived {
|
||||
customer_ref.archived = false;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(customers_map.into_iter().map(|(_, v)| v).collect())
|
||||
}
|
||||
|
||||
fn get_projects(&self) -> Result<Vec<Project>, AcariError> {
|
||||
let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;
|
||||
|
||||
Ok(projects.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError> {
|
||||
let tasks = self.request::<Vec<EverhourTask>>(Method::GET, &format!("/projects/{}/tasks", project_id.path_encoded()))?;
|
||||
|
||||
Ok(tasks.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
|
||||
let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
|
||||
let project_map: HashMap<ProjectId, EverhourProject> = self
|
||||
.request::<Vec<EverhourProject>>(Method::GET, "/projects")?
|
||||
.into_iter()
|
||||
.map(|p| (p.id.clone(), p))
|
||||
.collect();
|
||||
let entries = self.request::<Vec<EverhourTimeEntry>>(Method::GET, &format!("/users/me/time?{}", date_span_query_param(&date_span)))?;
|
||||
|
||||
Ok(entries.into_iter().filter_map(|e| e.into_entry(&project_map, &user)).collect())
|
||||
}
|
||||
|
||||
fn create_time_entry(&self, day: Day, _: &ProjectId, service_id: &ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
|
||||
let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
|
||||
let project_map: HashMap<ProjectId, EverhourProject> = self
|
||||
.request::<Vec<EverhourProject>>(Method::GET, "/projects")?
|
||||
.into_iter()
|
||||
.map(|p| (p.id.clone(), p))
|
||||
.collect();
|
||||
|
||||
let entry: EverhourTimeEntry = self.request_with_body(
|
||||
Method::POST,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
EverhourCreateTimeRecord {
|
||||
date: day.as_date(),
|
||||
user: user.id.clone(),
|
||||
time: minutes,
|
||||
comment: note,
|
||||
},
|
||||
)?;
|
||||
|
||||
entry
|
||||
.into_entry(&project_map, &user)
|
||||
.ok_or_else(|| AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()))
|
||||
}
|
||||
|
||||
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
|
||||
let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;
|
||||
|
||||
let _: EverhourTimeEntry = self.request_with_body(
|
||||
Method::POST,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
EverhourCreateTimeRecord {
|
||||
date,
|
||||
user: user_id,
|
||||
time: minutes,
|
||||
comment: note,
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
|
||||
let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;
|
||||
|
||||
let _: EverhourTimeEntry = self.request_with_body(
|
||||
Method::POST,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
json!({
|
||||
"user": user_id,
|
||||
"date": date,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_tracker(&self) -> Result<Tracker, AcariError> {
|
||||
let timer = self.request::<EverhourTimer>(Method::GET, "/timers/current")?;
|
||||
let started_at = timer.started_at;
|
||||
|
||||
match self.entry_from_timer(timer)? {
|
||||
Some(time_entry) => Ok(Tracker {
|
||||
since: Some(started_at),
|
||||
tracking_time_entry: Some(time_entry),
|
||||
stopped_time_entry: None,
|
||||
}),
|
||||
_ => Ok(Tracker {
|
||||
since: None,
|
||||
tracking_time_entry: None,
|
||||
stopped_time_entry: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
let (_, service_id, date) = parse_time_entry_id(entry_id)?;
|
||||
let timer: EverhourTimer = self.request_with_body(
|
||||
Method::POST,
|
||||
"/timers",
|
||||
json!({
|
||||
"task": service_id,
|
||||
"userDate": date,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(Tracker {
|
||||
since: Some(Utc::now()),
|
||||
tracking_time_entry: self.entry_from_timer(timer)?,
|
||||
stopped_time_entry: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_tracker(&self, _: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
let timer = self.request::<EverhourTimer>(Method::DELETE, "/timers/current")?;
|
||||
|
||||
Ok(Tracker {
|
||||
since: None,
|
||||
tracking_time_entry: None,
|
||||
stopped_time_entry: self.entry_from_timer(timer)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
343
lib/src/everhour_client_tests.rs
Normal file
343
lib/src/everhour_client_tests.rs
Normal file
@ -0,0 +1,343 @@
|
||||
use super::{Account, AccountId, Client, Customer, CustomerId, EverhourClient, Project, ProjectId, Service, ServiceId, User, UserId};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use pact_consumer::prelude::*;
|
||||
use pact_consumer::term;
|
||||
use serde_json::json;
|
||||
|
||||
const CONSUMER: &str = "acari-lib";
|
||||
const PROVIDER: &str = "everhour API";
|
||||
|
||||
#[test]
|
||||
fn test_get_account() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get account", |i| {
|
||||
i.given("User with API token");
|
||||
i.request.get().path("/users/me").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!({
|
||||
"id": 12345,
|
||||
"name": "August Ausgedacht",
|
||||
"email": "august.ausgedacht@demo.de",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"headline": "",
|
||||
"isSuspended": false,
|
||||
"createdAt": "2021-01-29 12:00:50",
|
||||
"accounts": [{
|
||||
|
||||
}],
|
||||
"team": {
|
||||
"id": 1234,
|
||||
"name": "Demo GmbH",
|
||||
"createdAt": "2021-01-14 18:59:59",
|
||||
"currencyDetails": {
|
||||
"code": "EUR",
|
||||
"name": "Euro",
|
||||
"symbol": "€",
|
||||
"favorite": 2
|
||||
}
|
||||
},
|
||||
}));
|
||||
})
|
||||
.build();
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = EverhourClient::new_form_url(url);
|
||||
|
||||
let account = client.get_account()?;
|
||||
|
||||
assert_eq!(
|
||||
Account {
|
||||
id: AccountId::Num(1234),
|
||||
name: "Demo GmbH".to_string(),
|
||||
title: "Demo GmbH".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
created_at: Utc.ymd(2021, 1, 14).and_hms(18, 59, 59),
|
||||
},
|
||||
account
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get myself", |i| {
|
||||
i.given("User with API token");
|
||||
i.request.get().path("/users/me").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!({
|
||||
"id": 12345,
|
||||
"name": "August Ausgedacht",
|
||||
"email": "august.ausgedacht@demo.de",
|
||||
"status": "active",
|
||||
"role": "member",
|
||||
"headline": "",
|
||||
"isSuspended": false,
|
||||
"createdAt": "2021-01-29 12:00:50",
|
||||
"accounts": [{
|
||||
|
||||
}],
|
||||
"team": {
|
||||
"id": 1234,
|
||||
"name": "Demo GmbH",
|
||||
"createdAt": "2021-01-14 18:59:59",
|
||||
"currencyDetails": {
|
||||
"code": "EUR",
|
||||
"name": "Euro",
|
||||
"symbol": "€",
|
||||
"favorite": 2
|
||||
}
|
||||
},
|
||||
}));
|
||||
})
|
||||
.build();
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = EverhourClient::new_form_url(url);
|
||||
|
||||
let user = client.get_myself()?;
|
||||
|
||||
assert_eq!(
|
||||
User {
|
||||
id: UserId::Num(12345),
|
||||
name: "August Ausgedacht".to_string(),
|
||||
email: "august.ausgedacht@demo.de".to_string(),
|
||||
note: "".to_string(),
|
||||
archived: false,
|
||||
role: "member".to_string(),
|
||||
language: "".to_string(),
|
||||
created_at: Utc.ymd(2021, 1, 29).and_hms(12, 0, 50),
|
||||
},
|
||||
user
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_customers() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get projects", |i| {
|
||||
i.given("User with API token");
|
||||
i.request.get().path("/projects").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!([{
|
||||
"id": "as:12345",
|
||||
"platform": "as",
|
||||
"name": "Project 1",
|
||||
"createdAt": "2021-01-14",
|
||||
"workspaceId": "as:54321",
|
||||
"workspaceName": "Workspace 1",
|
||||
"foreign": false,
|
||||
"status": "archived",
|
||||
"estimatesType": "any",
|
||||
}, {
|
||||
"id": "as:12346",
|
||||
"platform": "as",
|
||||
"name": "Project 2",
|
||||
"createdAt": "2021-01-15",
|
||||
"workspaceId": "as:54322",
|
||||
"workspaceName": "Workspace 2",
|
||||
"foreign": false,
|
||||
"status": "open",
|
||||
"estimatesType": "any",
|
||||
}, {
|
||||
"id": "as:12347",
|
||||
"platform": "as",
|
||||
"name": "Project 3",
|
||||
"createdAt": "2021-01-16",
|
||||
"workspaceId": "as:54321",
|
||||
"workspaceName": "Workspace 1",
|
||||
"foreign": false,
|
||||
"status": "open",
|
||||
"estimatesType": "any",
|
||||
}]));
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = EverhourClient::new_form_url(url);
|
||||
|
||||
let mut customers = client.get_customers()?;
|
||||
customers.sort_by(|c1, c2| c1.name.cmp(&c2.name));
|
||||
|
||||
assert_eq!(customers.len(), 2);
|
||||
assert_eq!(
|
||||
Customer {
|
||||
id: CustomerId::Str("as:54321".to_string()),
|
||||
name: "Workspace 1".to_string(),
|
||||
note: "".to_string(),
|
||||
archived: false,
|
||||
created_at: Utc.ymd(2021, 01, 14).and_hms(00, 00, 00),
|
||||
},
|
||||
customers[0]
|
||||
);
|
||||
assert_eq!(
|
||||
Customer {
|
||||
id: CustomerId::Str("as:54322".to_string()),
|
||||
name: "Workspace 2".to_string(),
|
||||
note: "".to_string(),
|
||||
archived: false,
|
||||
created_at: Utc.ymd(2021, 01, 15).and_hms(00, 00, 00),
|
||||
},
|
||||
customers[1]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_projects() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get projects", |i| {
|
||||
i.given("User with API token");
|
||||
i.request.get().path("/projects").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!([{
|
||||
"id": "as:12345",
|
||||
"platform": "as",
|
||||
"name": "Project 1",
|
||||
"createdAt": "2021-01-14",
|
||||
"workspaceId": "as:54321",
|
||||
"workspaceName": "Workspace 1",
|
||||
"foreign": false,
|
||||
"status": "archived",
|
||||
"estimatesType": "any",
|
||||
}, {
|
||||
"id": "as:12346",
|
||||
"platform": "as",
|
||||
"name": "Project 2",
|
||||
"createdAt": "2021-01-15",
|
||||
"workspaceId": "as:54322",
|
||||
"workspaceName": "Workspace 2",
|
||||
"foreign": false,
|
||||
"status": "open",
|
||||
"estimatesType": "any",
|
||||
}, {
|
||||
"id": "as:12347",
|
||||
"platform": "as",
|
||||
"name": "Project 3",
|
||||
"createdAt": "2021-01-16",
|
||||
"workspaceId": "as:54321",
|
||||
"workspaceName": "Workspace 1",
|
||||
"foreign": false,
|
||||
"status": "open",
|
||||
"estimatesType": "any",
|
||||
}]));
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = EverhourClient::new_form_url(url);
|
||||
|
||||
let projects = client.get_projects()?;
|
||||
|
||||
assert_eq!(projects.len(), 3);
|
||||
assert_eq!(
|
||||
Project {
|
||||
id: ProjectId::Str("as:12345".to_string()),
|
||||
name: "Project 1".to_string(),
|
||||
note: "".to_string(),
|
||||
customer_id: CustomerId::Str("as:54321".to_string()),
|
||||
customer_name: "Workspace 1".to_string(),
|
||||
archived: true,
|
||||
created_at: Utc.ymd(2021, 01, 14).and_hms(00, 00, 00),
|
||||
},
|
||||
projects[0]
|
||||
);
|
||||
assert_eq!(
|
||||
Project {
|
||||
id: ProjectId::Str("as:12346".to_string()),
|
||||
name: "Project 2".to_string(),
|
||||
note: "".to_string(),
|
||||
customer_id: CustomerId::Str("as:54322".to_string()),
|
||||
customer_name: "Workspace 2".to_string(),
|
||||
archived: false,
|
||||
created_at: Utc.ymd(2021, 01, 15).and_hms(00, 00, 00),
|
||||
},
|
||||
projects[1]
|
||||
);
|
||||
assert_eq!(
|
||||
Project {
|
||||
id: ProjectId::Str("as:12347".to_string()),
|
||||
name: "Project 3".to_string(),
|
||||
note: "".to_string(),
|
||||
customer_id: CustomerId::Str("as:54321".to_string()),
|
||||
customer_name: "Workspace 1".to_string(),
|
||||
archived: false,
|
||||
created_at: Utc.ymd(2021, 01, 16).and_hms(00, 00, 00),
|
||||
},
|
||||
projects[2]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_services() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get project tasks", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/projects/as%3A12345/tasks")
|
||||
.header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!([{
|
||||
"id": "as:123451234",
|
||||
"name": "Task 1",
|
||||
"iteration": "Untitled section",
|
||||
"createdAt": "2021-01-18 12:55:55",
|
||||
"status": "closed",
|
||||
"projects": [
|
||||
"as:8353429"
|
||||
],
|
||||
}, {
|
||||
"id": "as:123451235",
|
||||
"name": "Task 2",
|
||||
"iteration": "Untitled section",
|
||||
"createdAt": "2021-01-25 11:54:28",
|
||||
"status": "open",
|
||||
"projects": [
|
||||
"as:8353429"
|
||||
],
|
||||
}]));
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = EverhourClient::new_form_url(url);
|
||||
|
||||
let services = client.get_services(&ProjectId::Str("as:12345".to_string()))?;
|
||||
|
||||
assert_eq!(services.len(), 2);
|
||||
assert_eq!(
|
||||
Service {
|
||||
id: ServiceId::Str("as:123451234".to_string()),
|
||||
name: "Task 1".to_string(),
|
||||
note: "Untitled section".to_string(),
|
||||
archived: true,
|
||||
billable: true,
|
||||
created_at: Utc.ymd(2021, 01, 18).and_hms(12, 55, 55),
|
||||
},
|
||||
services[0]
|
||||
);
|
||||
assert_eq!(
|
||||
Service {
|
||||
id: ServiceId::Str("as:123451235".to_string()),
|
||||
name: "Task 2".to_string(),
|
||||
note: "Untitled section".to_string(),
|
||||
archived: false,
|
||||
billable: true,
|
||||
created_at: Utc.ymd(2021, 01, 25).and_hms(11, 54, 28),
|
||||
},
|
||||
services[1]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
341
lib/src/everhour_model.rs
Normal file
341
lib/src/everhour_model.rs
Normal file
@ -0,0 +1,341 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
model::{Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, User, UserId},
|
||||
AcariError, DateSpan, Day,
|
||||
};
|
||||
use chrono::offset::Local;
|
||||
use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct EverhourError {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct EverhourCurrency {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourTeam {
|
||||
pub id: AccountId,
|
||||
pub name: String,
|
||||
pub currency_details: EverhourCurrency,
|
||||
#[serde(with = "date_format")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourUser {
|
||||
pub id: UserId,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub status: String,
|
||||
pub role: String,
|
||||
pub headline: String,
|
||||
pub is_suspended: bool,
|
||||
pub team: EverhourTeam,
|
||||
#[serde(with = "date_format")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<EverhourUser> for Account {
|
||||
fn from(f: EverhourUser) -> Self {
|
||||
Account {
|
||||
id: f.team.id,
|
||||
name: f.team.name.clone(),
|
||||
title: f.team.name,
|
||||
currency: f.team.currency_details.code,
|
||||
created_at: f.team.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EverhourUser> for User {
|
||||
fn from(f: EverhourUser) -> Self {
|
||||
User {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
email: f.email,
|
||||
role: f.role,
|
||||
note: f.headline,
|
||||
language: "".to_string(),
|
||||
archived: f.is_suspended,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourProject {
|
||||
pub id: ProjectId,
|
||||
pub name: String,
|
||||
pub workspace_id: CustomerId,
|
||||
pub workspace_name: String,
|
||||
pub status: String,
|
||||
#[serde(with = "date_format")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<EverhourProject> for Customer {
|
||||
fn from(f: EverhourProject) -> Self {
|
||||
Customer {
|
||||
id: f.workspace_id,
|
||||
name: f.workspace_name,
|
||||
note: "".to_string(),
|
||||
archived: f.status != "open",
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EverhourProject> for Project {
|
||||
fn from(f: EverhourProject) -> Self {
|
||||
Project {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
note: "".to_string(),
|
||||
customer_id: f.workspace_id,
|
||||
customer_name: f.workspace_name,
|
||||
archived: f.status != "open",
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourTask {
|
||||
pub id: ServiceId,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub iteration: String,
|
||||
pub projects: Vec<ProjectId>,
|
||||
#[serde(with = "date_format")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<EverhourTask> for Service {
|
||||
fn from(f: EverhourTask) -> Self {
|
||||
Service {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
note: f.iteration,
|
||||
archived: f.status != "open",
|
||||
billable: true,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourTimeEntry {
|
||||
pub date: NaiveDate,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
pub task: Option<EverhourTask>,
|
||||
#[serde(with = "minutes_in_seconds")]
|
||||
pub time: Minutes,
|
||||
pub user: UserId,
|
||||
pub is_locked: bool,
|
||||
#[serde(with = "date_format")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl EverhourTimeEntry {
|
||||
pub fn into_entry(self, project_map: &HashMap<ProjectId, EverhourProject>, user: &EverhourUser) -> Option<TimeEntry> {
|
||||
match self.task {
|
||||
Some(task) => match task.projects.iter().filter_map(|p| project_map.get(p)).next() {
|
||||
Some(project) => Some(TimeEntry {
|
||||
id: build_time_entry_id(&self.user, &task.id, &self.date),
|
||||
date_at: self.date,
|
||||
minutes: self.time,
|
||||
customer_id: project.workspace_id.clone(),
|
||||
customer_name: project.workspace_name.clone(),
|
||||
project_id: project.id.clone(),
|
||||
project_name: project.name.clone(),
|
||||
service_id: task.id,
|
||||
service_name: task.name,
|
||||
user_id: user.id.clone(),
|
||||
user_name: user.name.clone(),
|
||||
note: self.comment,
|
||||
billable: true,
|
||||
locked: self.is_locked,
|
||||
created_at: self.created_at,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourCreateTimeRecord {
|
||||
pub date: NaiveDate,
|
||||
#[serde(with = "minutes_in_seconds")]
|
||||
pub time: Minutes,
|
||||
pub user: UserId,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourUserSimple {
|
||||
pub id: UserId,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EverhourTimer {
|
||||
pub status: String,
|
||||
pub task: Option<EverhourTask>,
|
||||
pub user: Option<EverhourUserSimple>,
|
||||
#[serde(with = "minutes_in_seconds", default)]
|
||||
pub duration: Minutes,
|
||||
#[serde(with = "date_format", default = "default_started_at")]
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
fn default_started_at() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
pub fn day_query_param(day: &Day) -> String {
|
||||
match day {
|
||||
Day::Today => format!("{}", Local::now().naive_local().date()),
|
||||
Day::Yesterday => format!("{}", Local::now().naive_local().date().pred()),
|
||||
Day::Date(date) => format!("{}", date),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn date_span_query_param(span: &DateSpan) -> String {
|
||||
match span {
|
||||
DateSpan::ThisWeek => {
|
||||
let now = Local::now().naive_local().date();
|
||||
let year = now.year();
|
||||
let week = now.iso_week().week();
|
||||
|
||||
let mon = NaiveDate::from_isoywd(year, week, Weekday::Mon);
|
||||
let sun = NaiveDate::from_isoywd(year, week, Weekday::Sun);
|
||||
|
||||
format!("from={}&to={}", mon, sun)
|
||||
}
|
||||
DateSpan::LastWeek => {
|
||||
let now = Local::now().naive_local().date();
|
||||
|
||||
let sun = NaiveDate::from_isoywd(now.year(), now.iso_week().week(), Weekday::Mon).succ();
|
||||
let mon = NaiveDate::from_isoywd(sun.year(), sun.iso_week().week(), Weekday::Mon);
|
||||
|
||||
format!("from={}&to={}", mon, sun)
|
||||
}
|
||||
DateSpan::ThisMonth => {
|
||||
let now = Local::now().naive_local().date();
|
||||
let year = now.year();
|
||||
let month = now.month();
|
||||
|
||||
let start = NaiveDate::from_ymd(year, month, 1);
|
||||
let end = if month == 12 {
|
||||
NaiveDate::from_ymd(year + 1, 1, 1).pred()
|
||||
} else {
|
||||
NaiveDate::from_ymd(year, month + 1, 1).pred()
|
||||
};
|
||||
|
||||
format!("from={}&to={}", start, end)
|
||||
}
|
||||
DateSpan::LastMonth => {
|
||||
let now = Local::now().naive_local().date();
|
||||
let end = NaiveDate::from_ymd(now.year(), now.month(), 1).pred();
|
||||
let year = end.year();
|
||||
let month = end.month();
|
||||
|
||||
let start = if month == 1 {
|
||||
NaiveDate::from_ymd(year - 1, 12, 1)
|
||||
} else {
|
||||
NaiveDate::from_ymd(year, month - 1, 1)
|
||||
};
|
||||
|
||||
format!("from={}&to={}", start, end)
|
||||
}
|
||||
DateSpan::Day(date) => format!("from={}&to={}", day_query_param(&date), day_query_param(&date)),
|
||||
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_time_entry_id(user_id: &UserId, service_id: &ServiceId, date: &NaiveDate) -> TimeEntryId {
|
||||
TimeEntryId::Str(format!("{}|{}|{}", user_id.str_encoded(), service_id.str_encoded(), date))
|
||||
}
|
||||
|
||||
pub fn parse_time_entry_id(time_entry_id: &TimeEntryId) -> Result<(UserId, ServiceId, NaiveDate), AcariError> {
|
||||
let parts: Vec<&str> = match time_entry_id {
|
||||
TimeEntryId::Str(s) => s.split('|').collect(),
|
||||
_ => return Err(AcariError::InternalError("Invalid time entry id (no number)".to_string())),
|
||||
};
|
||||
if parts.len() != 3 {
|
||||
return Err(AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()));
|
||||
}
|
||||
let user_id = UserId::parse_encoded(&parts[0])?;
|
||||
let service_id = ServiceId::parse_encoded(&parts[1])?;
|
||||
let date = NaiveDate::parse_from_str(parts[2], "%Y-%m-%d")?;
|
||||
|
||||
Ok((user_id, service_id, date))
|
||||
}
|
||||
|
||||
mod date_format {
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
|
||||
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s = format!("{}", date.format(FORMAT));
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Utc
|
||||
.datetime_from_str(&s, FORMAT)
|
||||
.or_else(|_| Utc.datetime_from_str(&format!("{} 00:00:00", &s), FORMAT))
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
mod minutes_in_seconds {
|
||||
use crate::model::Minutes;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(minutes: &Minutes, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_u32(minutes.0 * 60)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Minutes, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let seconds = u32::deserialize(deserializer)?;
|
||||
|
||||
Ok(Minutes(seconds / 60))
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,29 @@
|
||||
mod cached_client;
|
||||
mod error;
|
||||
mod everhour_client;
|
||||
mod everhour_model;
|
||||
mod mite_client;
|
||||
mod mite_model;
|
||||
mod model;
|
||||
mod query;
|
||||
mod std_client;
|
||||
|
||||
pub use cached_client::CachedClient;
|
||||
pub use cached_client::{clear_cache, CachedClient};
|
||||
pub use error::AcariError;
|
||||
pub use model::{
|
||||
Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, TrackingTimeEntry, User, UserId,
|
||||
};
|
||||
pub use everhour_client::EverhourClient;
|
||||
pub use mite_client::MiteClient;
|
||||
pub use model::{Account, Customer, Minutes, Project, Service, TimeEntry, Tracker, User};
|
||||
pub use model::{AccountId, CustomerId, ProjectId, ServiceId, TimeEntryId, UserId};
|
||||
pub use query::{DateSpan, Day};
|
||||
pub use std_client::StdClient;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod mite_client_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod everhour_client_tests;
|
||||
|
||||
pub trait Client {
|
||||
fn get_domain(&self) -> String;
|
||||
|
||||
fn get_account(&self) -> Result<Account, AcariError>;
|
||||
|
||||
fn get_myself(&self) -> Result<User, AcariError>;
|
||||
@ -24,23 +32,28 @@ pub trait Client {
|
||||
|
||||
fn get_projects(&self) -> Result<Vec<Project>, AcariError>;
|
||||
|
||||
fn get_services(&self) -> Result<Vec<Service>, AcariError>;
|
||||
|
||||
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError>;
|
||||
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError>;
|
||||
|
||||
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError>;
|
||||
|
||||
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError>;
|
||||
fn create_time_entry(
|
||||
&self,
|
||||
day: Day,
|
||||
project_id: &ProjectId,
|
||||
service_id: &ServiceId,
|
||||
minutes: Minutes,
|
||||
note: Option<String>,
|
||||
) -> Result<TimeEntry, AcariError>;
|
||||
|
||||
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError>;
|
||||
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError>;
|
||||
|
||||
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError>;
|
||||
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError>;
|
||||
|
||||
fn get_tracker(&self) -> Result<Tracker, AcariError>;
|
||||
|
||||
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError>;
|
||||
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError>;
|
||||
|
||||
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError>;
|
||||
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError>;
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
|
||||
@ -1,30 +1,30 @@
|
||||
use crate::error::AcariError;
|
||||
use crate::model::{Account, Customer, Minutes, MiteEntity, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
|
||||
use crate::mite_model::{date_span_query_param, MiteEntity, MiteTracker};
|
||||
use crate::model::{Account, Customer, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
|
||||
use crate::query::{DateSpan, Day};
|
||||
use crate::Client;
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use serde_json::json;
|
||||
use url::Url;
|
||||
|
||||
use reqwest::{blocking, header, StatusCode};
|
||||
use reqwest::{blocking, header, Method, StatusCode};
|
||||
|
||||
const USER_AGENT: &str = "acari-lib (https://github.com/untoldwind/acari)";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StdClient {
|
||||
pub struct MiteClient {
|
||||
base_url: Url,
|
||||
client: blocking::Client,
|
||||
}
|
||||
|
||||
impl StdClient {
|
||||
pub fn new(domain: &str, token: &str) -> Result<StdClient, AcariError> {
|
||||
impl MiteClient {
|
||||
pub fn new(domain: &str, token: &str) -> Result<MiteClient, AcariError> {
|
||||
Ok(Self::new_form_url(format!("https://{}@{}", token, domain).parse()?))
|
||||
}
|
||||
|
||||
pub fn new_form_url(base_url: Url) -> StdClient {
|
||||
StdClient {
|
||||
pub fn new_form_url(base_url: Url) -> MiteClient {
|
||||
MiteClient {
|
||||
base_url,
|
||||
client: blocking::Client::new(),
|
||||
}
|
||||
@ -44,39 +44,81 @@ impl StdClient {
|
||||
fn request<T: DeserializeOwned>(&self, method: Method, uri: &str) -> Result<T, AcariError> {
|
||||
let response = self.base_request(method, uri)?.send()?;
|
||||
|
||||
handle_response(response)
|
||||
Self::handle_response(response)
|
||||
}
|
||||
|
||||
fn request_empty(&self, method: Method, uri: &str) -> Result<(), AcariError> {
|
||||
let response = self.base_request(method, uri)?.send()?;
|
||||
|
||||
handle_empty_response(response)
|
||||
Self::handle_empty_response(response)
|
||||
}
|
||||
|
||||
fn request_with_body<T: DeserializeOwned, D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<T, AcariError> {
|
||||
let response = self.base_request(method, uri)?.json(&data).send()?;
|
||||
|
||||
handle_response(response)
|
||||
Self::handle_response(response)
|
||||
}
|
||||
|
||||
fn request_empty_with_body<D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<(), AcariError> {
|
||||
let response = self.base_request(method, uri)?.json(&data).send()?;
|
||||
|
||||
handle_empty_response(response)
|
||||
Self::handle_empty_response(response)
|
||||
}
|
||||
|
||||
fn handle_empty_response(response: blocking::Response) -> Result<(), AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(()),
|
||||
status => match response.json::<MiteEntity>() {
|
||||
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
|
||||
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
|
||||
status => match response.json::<MiteEntity>() {
|
||||
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
|
||||
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn get_time_entry(&self, entry_id: &TimeEntryId) -> Result<TimeEntry, AcariError> {
|
||||
match self.request(Method::GET, &format!("/time_entries/{}.json", entry_id))? {
|
||||
MiteEntity::TimeEntry(time_entry) => Ok(time_entry.into()),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tracker(&self, tracker: MiteTracker) -> Result<Tracker, AcariError> {
|
||||
let tracking_time_entry = tracker.tracking_time_entry.as_ref().map(|e| self.get_time_entry(&e.id)).transpose()?;
|
||||
let stopped_time_entry = tracker.stopped_time_entry.as_ref().map(|e| self.get_time_entry(&e.id)).transpose()?;
|
||||
|
||||
Ok(Tracker {
|
||||
since: tracker.tracking_time_entry.and_then(|e| e.since),
|
||||
tracking_time_entry,
|
||||
stopped_time_entry,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Client for StdClient {
|
||||
impl Client for MiteClient {
|
||||
fn get_domain(&self) -> String {
|
||||
self.base_url.host_str().unwrap_or("").to_owned()
|
||||
}
|
||||
|
||||
fn get_account(&self) -> Result<Account, AcariError> {
|
||||
match self.request(Method::GET, "/account.json")? {
|
||||
MiteEntity::Account(account) => Ok(account),
|
||||
MiteEntity::Account(account) => Ok(account.into()),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_myself(&self) -> Result<User, AcariError> {
|
||||
match self.request(Method::GET, "/myself.json")? {
|
||||
MiteEntity::User(user) => Ok(user),
|
||||
MiteEntity::User(user) => Ok(user.into()),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
@ -87,7 +129,7 @@ impl Client for StdClient {
|
||||
.request::<Vec<MiteEntity>>(Method::GET, "/customers.json")?
|
||||
.into_iter()
|
||||
.filter_map(|entity| match entity {
|
||||
MiteEntity::Customer(customer) => Some(customer),
|
||||
MiteEntity::Customer(customer) => Some(customer.into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
@ -100,47 +142,47 @@ impl Client for StdClient {
|
||||
.request::<Vec<MiteEntity>>(Method::GET, "/projects.json")?
|
||||
.into_iter()
|
||||
.filter_map(|entity| match entity {
|
||||
MiteEntity::Project(project) => Some(project),
|
||||
MiteEntity::Project(project) => Some(project.into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_services(&self) -> Result<Vec<Service>, AcariError> {
|
||||
fn get_services(&self, _: &ProjectId) -> Result<Vec<Service>, AcariError> {
|
||||
Ok(
|
||||
self
|
||||
.request::<Vec<MiteEntity>>(Method::GET, "/services.json")?
|
||||
.into_iter()
|
||||
.filter_map(|entity| match entity {
|
||||
MiteEntity::Service(service) => Some(service),
|
||||
MiteEntity::Service(service) => Some(service.into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError> {
|
||||
match self.request(Method::GET, &format!("/time_entries/{}.json", entry_id))? {
|
||||
MiteEntity::TimeEntry(time_entry) => Ok(time_entry),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
|
||||
Ok(
|
||||
self
|
||||
.request::<Vec<MiteEntity>>(Method::GET, &format!("/time_entries.json?user=current&{}", date_span.query_param()))?
|
||||
.request::<Vec<MiteEntity>>(Method::GET, &format!("/time_entries.json?user=current&{}", date_span_query_param(&date_span)))?
|
||||
.into_iter()
|
||||
.filter_map(|entity| match entity {
|
||||
MiteEntity::TimeEntry(time_entry) => Some(time_entry),
|
||||
MiteEntity::TimeEntry(time_entry) => Some(time_entry.into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
|
||||
fn create_time_entry(
|
||||
&self,
|
||||
day: Day,
|
||||
project_id: &ProjectId,
|
||||
service_id: &ServiceId,
|
||||
minutes: Minutes,
|
||||
note: Option<String>,
|
||||
) -> Result<TimeEntry, AcariError> {
|
||||
match self.request_with_body(
|
||||
Method::POST,
|
||||
"/time_entries.json",
|
||||
@ -154,12 +196,12 @@ impl Client for StdClient {
|
||||
}
|
||||
}),
|
||||
)? {
|
||||
MiteEntity::TimeEntry(time_entry) => Ok(time_entry),
|
||||
MiteEntity::TimeEntry(time_entry) => Ok(time_entry.into()),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
|
||||
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
|
||||
self.request_empty_with_body(
|
||||
Method::PATCH,
|
||||
&format!("/time_entries/{}.json", entry_id),
|
||||
@ -172,48 +214,28 @@ impl Client for StdClient {
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError> {
|
||||
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
|
||||
self.request_empty(Method::DELETE, &format!("/time_entries/{}.json", entry_id))
|
||||
}
|
||||
|
||||
fn get_tracker(&self) -> Result<Tracker, AcariError> {
|
||||
match self.request(Method::GET, "/tracker.json")? {
|
||||
MiteEntity::Tracker(tracker) => Ok(tracker),
|
||||
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
match self.request(Method::PATCH, &format!("/tracker/{}.json", entry_id))? {
|
||||
MiteEntity::Tracker(tracker) => Ok(tracker),
|
||||
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
|
||||
match self.request(Method::DELETE, &format!("/tracker/{}.json", entry_id))? {
|
||||
MiteEntity::Tracker(tracker) => Ok(tracker),
|
||||
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
|
||||
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_empty_response(response: blocking::Response) -> Result<(), AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(()),
|
||||
status => match response.json::<MiteEntity>() {
|
||||
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
|
||||
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
|
||||
status => match response.json::<MiteEntity>() {
|
||||
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
|
||||
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
Account, AccountId, Client, Customer, CustomerId, DateSpan, Day, Minutes, Project, ProjectId, Service, ServiceId, StdClient, TimeEntry, TimeEntryId, Tracker,
|
||||
TrackingTimeEntry, User, UserId,
|
||||
Account, AccountId, Client, Customer, CustomerId, DateSpan, Day, Minutes, MiteClient, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId,
|
||||
Tracker, User, UserId,
|
||||
};
|
||||
|
||||
const CONSUMER: &str = "acari-lib";
|
||||
@ -33,18 +33,17 @@ fn test_get_account() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let account = client.get_account()?;
|
||||
|
||||
assert_eq!(
|
||||
Account {
|
||||
id: AccountId(1),
|
||||
id: AccountId::Num(1),
|
||||
name: "demo".to_string(),
|
||||
title: "Demo GmbH".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
created_at: Utc.ymd(2013, 10, 12).and_hms(13, 39, 51),
|
||||
updated_at: Utc.ymd(2015, 5, 2).and_hms(12, 21, 09),
|
||||
},
|
||||
account
|
||||
);
|
||||
@ -76,13 +75,13 @@ fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let user = client.get_myself()?;
|
||||
|
||||
assert_eq!(
|
||||
User {
|
||||
id: UserId(3456),
|
||||
id: UserId::Num(3456),
|
||||
name: "August Ausgedacht".to_string(),
|
||||
email: "august.ausgedacht@demo.de".to_string(),
|
||||
note: "".to_string(),
|
||||
@ -90,7 +89,6 @@ fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
|
||||
role: "admin".to_string(),
|
||||
language: "de".to_string(),
|
||||
created_at: Utc.ymd(2013, 6, 23).and_hms(21, 0, 58),
|
||||
updated_at: Utc.ymd(2015, 7, 24).and_hms(23, 26, 35),
|
||||
},
|
||||
user
|
||||
);
|
||||
@ -132,20 +130,18 @@ fn test_get_customers() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let customers = client.get_customers()?;
|
||||
|
||||
assert_eq!(customers.len(), 1);
|
||||
assert_eq!(
|
||||
Customer {
|
||||
id: CustomerId(83241),
|
||||
id: CustomerId::Num(83241),
|
||||
name: "Acme Inc.".to_string(),
|
||||
note: "".to_string(),
|
||||
archived: false,
|
||||
hourly_rate: None,
|
||||
created_at: Utc.ymd(2015, 10, 15).and_hms(12, 33, 19),
|
||||
updated_at: Utc.ymd(2015, 10, 15).and_hms(12, 29, 03)
|
||||
},
|
||||
customers[0]
|
||||
);
|
||||
@ -191,24 +187,20 @@ fn test_get_projects() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let projects = client.get_projects()?;
|
||||
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(
|
||||
Project {
|
||||
id: ProjectId(643),
|
||||
id: ProjectId::Num(643),
|
||||
name: "Open-Source".to_string(),
|
||||
note: "valvat, memento et all.".to_string(),
|
||||
customer_id: CustomerId(291),
|
||||
customer_id: CustomerId::Num(291),
|
||||
customer_name: "Yolk".to_string(),
|
||||
budget: 0,
|
||||
budget_type: "minutes".to_string(),
|
||||
hourly_rate: Some(6000),
|
||||
archived: false,
|
||||
created_at: Utc.ymd(2011, 08, 17).and_hms(10, 06, 57),
|
||||
updated_at: Utc.ymd(2015, 02, 19).and_hms(09, 53, 10),
|
||||
},
|
||||
projects[0]
|
||||
);
|
||||
@ -240,21 +232,19 @@ fn test_get_services() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let services = client.get_services()?;
|
||||
let services = client.get_services(&ProjectId::Num(0))?;
|
||||
|
||||
assert_eq!(services.len(), 1);
|
||||
assert_eq!(
|
||||
Service {
|
||||
id: ServiceId(38672),
|
||||
id: ServiceId::Num(38672),
|
||||
name: "Website Konzeption".to_string(),
|
||||
note: "".to_string(),
|
||||
archived: false,
|
||||
billable: true,
|
||||
hourly_rate: Some(3300),
|
||||
created_at: Utc.ymd(2009, 12, 13).and_hms(11, 12, 00),
|
||||
updated_at: Utc.ymd(2015, 12, 13).and_hms(06, 20, 04)
|
||||
},
|
||||
services[0]
|
||||
);
|
||||
@ -287,23 +277,21 @@ fn test_query_entries() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
let expected = TimeEntry {
|
||||
id: TimeEntryId(36159117),
|
||||
id: TimeEntryId::Num(36159117),
|
||||
minutes: Minutes(15),
|
||||
date_at: NaiveDate::from_ymd(2015, 10, 16),
|
||||
note: "Feedback einarbeiten".to_string(),
|
||||
locked: false,
|
||||
billable: true,
|
||||
hourly_rate: 0,
|
||||
user_id: UserId(211),
|
||||
user_id: UserId::Num(211),
|
||||
user_name: "Fridolin Frei".to_string(),
|
||||
customer_id: CustomerId(3213),
|
||||
customer_id: CustomerId::Num(3213),
|
||||
customer_name: "König".to_string(),
|
||||
service_id: ServiceId(12984),
|
||||
service_id: ServiceId::Num(12984),
|
||||
service_name: "Entwurf".to_string(),
|
||||
project_id: ProjectId(88309),
|
||||
project_id: ProjectId::Num(88309),
|
||||
project_name: "API v2".to_string(),
|
||||
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
|
||||
updated_at: Utc.ymd(2015, 10, 16).and_hms(10, 39, 00),
|
||||
};
|
||||
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
@ -317,30 +305,18 @@ fn test_query_entries() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(json!([time_entry_json]));
|
||||
})
|
||||
.interaction("get time entry by id", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/time_entries/36159117.json")
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(time_entry_json);
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let entries = client.get_time_entries(DateSpan::Day(Day::Date(NaiveDate::from_ymd(2015, 10, 16))))?;
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(expected, entries[0]);
|
||||
|
||||
let entry = client.get_time_entry(TimeEntryId(36159117))?;
|
||||
|
||||
assert_eq!(expected, entry);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -390,29 +366,33 @@ fn test_create_entry() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let entry = client.create_time_entry(Day::Date(NaiveDate::from_ymd(2015, 9, 15)), ProjectId(3456), ServiceId(243), Minutes(185), None)?;
|
||||
let entry = client.create_time_entry(
|
||||
Day::Date(NaiveDate::from_ymd(2015, 9, 15)),
|
||||
&ProjectId::Num(3456),
|
||||
&ServiceId::Num(243),
|
||||
Minutes(185),
|
||||
None,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
TimeEntry {
|
||||
id: TimeEntryId(52324),
|
||||
id: TimeEntryId::Num(52324),
|
||||
minutes: Minutes(185),
|
||||
date_at: NaiveDate::from_ymd(2015, 9, 12),
|
||||
note: "".to_string(),
|
||||
locked: false,
|
||||
billable: true,
|
||||
hourly_rate: 0,
|
||||
user_id: UserId(211),
|
||||
user_id: UserId::Num(211),
|
||||
user_name: "Fridolin Frei".to_string(),
|
||||
customer_id: CustomerId(3213),
|
||||
customer_id: CustomerId::Num(3213),
|
||||
customer_name: "König".to_string(),
|
||||
service_id: ServiceId(243),
|
||||
service_id: ServiceId::Num(243),
|
||||
service_name: "Dokumentation".to_string(),
|
||||
project_id: ProjectId(3456),
|
||||
project_id: ProjectId::Num(3456),
|
||||
project_name: "Some project".to_string(),
|
||||
created_at: Utc.ymd(2015, 9, 13).and_hms(16, 54, 45),
|
||||
updated_at: Utc.ymd(2015, 9, 13).and_hms(16, 54, 45),
|
||||
},
|
||||
entry
|
||||
);
|
||||
@ -436,9 +416,9 @@ fn test_delete_entry() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
client.delete_time_entry(TimeEntryId(52324))?;
|
||||
client.delete_time_entry(&TimeEntryId::Num(52324))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -465,15 +445,55 @@ fn test_update_entry() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
client.update_time_entry(TimeEntryId(52324), Minutes(120), None)?;
|
||||
client.update_time_entry(&TimeEntryId::Num(52324), Minutes(120), None)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let time_entry_json = json!({
|
||||
"time_entry": {
|
||||
"id": 36159117,
|
||||
"minutes": 15,
|
||||
"date_at": "2015-10-16",
|
||||
"note": "Feedback einarbeiten",
|
||||
"billable": true,
|
||||
"locked": false,
|
||||
"revenue": null,
|
||||
"hourly_rate": 0,
|
||||
"user_id": 211,
|
||||
"user_name": "Fridolin Frei",
|
||||
"project_id": 88309,
|
||||
"project_name": "API v2",
|
||||
"customer_id": 3213,
|
||||
"customer_name": "König",
|
||||
"service_id": 12984,
|
||||
"service_name": "Entwurf",
|
||||
"created_at": "2015-10-16T12:19:00+02:00",
|
||||
"updated_at": "2015-10-16T12:39:00+02:00"
|
||||
}
|
||||
});
|
||||
let expected = TimeEntry {
|
||||
id: TimeEntryId::Num(36159117),
|
||||
minutes: Minutes(15),
|
||||
date_at: NaiveDate::from_ymd(2015, 10, 16),
|
||||
note: "Feedback einarbeiten".to_string(),
|
||||
locked: false,
|
||||
billable: true,
|
||||
user_id: UserId::Num(211),
|
||||
user_name: "Fridolin Frei".to_string(),
|
||||
customer_id: CustomerId::Num(3213),
|
||||
customer_name: "König".to_string(),
|
||||
service_id: ServiceId::Num(12984),
|
||||
service_name: "Entwurf".to_string(),
|
||||
project_id: ProjectId::Num(88309),
|
||||
project_name: "API v2".to_string(),
|
||||
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
|
||||
};
|
||||
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("get tracker", |i| {
|
||||
i.given("User with API token");
|
||||
@ -488,22 +508,27 @@ fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}));
|
||||
})
|
||||
.interaction("get tracker time entry by id 1", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/time_entries/36135321.json")
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(time_entry_json);
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let tracker = client.get_tracker()?;
|
||||
|
||||
assert_eq!(
|
||||
Tracker {
|
||||
tracking_time_entry: Some(TrackingTimeEntry {
|
||||
id: TimeEntryId(36135321),
|
||||
minutes: Minutes(247),
|
||||
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 05, 04))
|
||||
}),
|
||||
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 05, 04)),
|
||||
tracking_time_entry: Some(expected),
|
||||
stopped_time_entry: None,
|
||||
},
|
||||
tracker
|
||||
@ -514,6 +539,46 @@ fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
#[test]
|
||||
fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let time_entry_json = json!({
|
||||
"time_entry": {
|
||||
"id": 36159117,
|
||||
"minutes": 15,
|
||||
"date_at": "2015-10-16",
|
||||
"note": "Feedback einarbeiten",
|
||||
"billable": true,
|
||||
"locked": false,
|
||||
"revenue": null,
|
||||
"hourly_rate": 0,
|
||||
"user_id": 211,
|
||||
"user_name": "Fridolin Frei",
|
||||
"project_id": 88309,
|
||||
"project_name": "API v2",
|
||||
"customer_id": 3213,
|
||||
"customer_name": "König",
|
||||
"service_id": 12984,
|
||||
"service_name": "Entwurf",
|
||||
"created_at": "2015-10-16T12:19:00+02:00",
|
||||
"updated_at": "2015-10-16T12:39:00+02:00"
|
||||
}
|
||||
});
|
||||
let expected = TimeEntry {
|
||||
id: TimeEntryId::Num(36159117),
|
||||
minutes: Minutes(15),
|
||||
date_at: NaiveDate::from_ymd(2015, 10, 16),
|
||||
note: "Feedback einarbeiten".to_string(),
|
||||
locked: false,
|
||||
billable: true,
|
||||
user_id: UserId::Num(211),
|
||||
user_name: "Fridolin Frei".to_string(),
|
||||
customer_id: CustomerId::Num(3213),
|
||||
customer_name: "König".to_string(),
|
||||
service_id: ServiceId::Num(12984),
|
||||
service_name: "Entwurf".to_string(),
|
||||
project_id: ProjectId::Num(88309),
|
||||
project_name: "API v2".to_string(),
|
||||
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
|
||||
};
|
||||
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("create tracker", |i| {
|
||||
i.given("User with API token");
|
||||
@ -535,27 +600,36 @@ fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}));
|
||||
})
|
||||
.interaction("get tracker time entry by id 2a", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/time_entries/36135322.json")
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(time_entry_json.clone());
|
||||
})
|
||||
.interaction("get tracker time entry by id 2b", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/time_entries/36134329.json")
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(time_entry_json);
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let tracker = client.create_tracker(TimeEntryId(36135322))?;
|
||||
let tracker = client.create_tracker(&TimeEntryId::Num(36135322))?;
|
||||
|
||||
assert_eq!(
|
||||
Tracker {
|
||||
tracking_time_entry: Some(TrackingTimeEntry {
|
||||
id: TimeEntryId(36135322),
|
||||
minutes: Minutes(0),
|
||||
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 33, 52)),
|
||||
}),
|
||||
stopped_time_entry: Some(TrackingTimeEntry {
|
||||
id: TimeEntryId(36134329),
|
||||
minutes: Minutes(46),
|
||||
since: None,
|
||||
}),
|
||||
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 33, 52)),
|
||||
tracking_time_entry: Some(expected.clone()),
|
||||
stopped_time_entry: Some(expected),
|
||||
},
|
||||
tracker
|
||||
);
|
||||
@ -565,6 +639,46 @@ fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
#[test]
|
||||
fn test_delete_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let time_entry_json = json!({
|
||||
"time_entry": {
|
||||
"id": 36159117,
|
||||
"minutes": 15,
|
||||
"date_at": "2015-10-16",
|
||||
"note": "Feedback einarbeiten",
|
||||
"billable": true,
|
||||
"locked": false,
|
||||
"revenue": null,
|
||||
"hourly_rate": 0,
|
||||
"user_id": 211,
|
||||
"user_name": "Fridolin Frei",
|
||||
"project_id": 88309,
|
||||
"project_name": "API v2",
|
||||
"customer_id": 3213,
|
||||
"customer_name": "König",
|
||||
"service_id": 12984,
|
||||
"service_name": "Entwurf",
|
||||
"created_at": "2015-10-16T12:19:00+02:00",
|
||||
"updated_at": "2015-10-16T12:39:00+02:00"
|
||||
}
|
||||
});
|
||||
let expected = TimeEntry {
|
||||
id: TimeEntryId::Num(36159117),
|
||||
minutes: Minutes(15),
|
||||
date_at: NaiveDate::from_ymd(2015, 10, 16),
|
||||
note: "Feedback einarbeiten".to_string(),
|
||||
locked: false,
|
||||
billable: true,
|
||||
user_id: UserId::Num(211),
|
||||
user_name: "Fridolin Frei".to_string(),
|
||||
customer_id: CustomerId::Num(3213),
|
||||
customer_name: "König".to_string(),
|
||||
service_id: ServiceId::Num(12984),
|
||||
service_name: "Entwurf".to_string(),
|
||||
project_id: ProjectId::Num(88309),
|
||||
project_name: "API v2".to_string(),
|
||||
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
|
||||
};
|
||||
|
||||
let pact = PactBuilder::new(CONSUMER, PROVIDER)
|
||||
.interaction("delete tracker", |i| {
|
||||
i.given("User with API token");
|
||||
@ -581,23 +695,28 @@ fn test_delete_tracker() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}));
|
||||
})
|
||||
.interaction("get tracker time entry by id 3", |i| {
|
||||
i.given("User with API token");
|
||||
i.request
|
||||
.get()
|
||||
.path("/time_entries/36135322.json")
|
||||
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
|
||||
i.response.ok().json_utf8().json_body(time_entry_json);
|
||||
})
|
||||
.build();
|
||||
|
||||
let server = pact.start_mock_server();
|
||||
let mut url = server.url().clone();
|
||||
url.set_username("12345678").unwrap();
|
||||
let client = StdClient::new_form_url(url);
|
||||
let client = MiteClient::new_form_url(url);
|
||||
|
||||
let tracker = client.delete_tracker(TimeEntryId(36135322))?;
|
||||
let tracker = client.delete_tracker(&TimeEntryId::Num(36135322))?;
|
||||
|
||||
assert_eq!(
|
||||
Tracker {
|
||||
since: None,
|
||||
tracking_time_entry: None,
|
||||
stopped_time_entry: Some(TrackingTimeEntry {
|
||||
id: TimeEntryId(36135322),
|
||||
minutes: Minutes(4),
|
||||
since: None,
|
||||
}),
|
||||
stopped_time_entry: Some(expected),
|
||||
},
|
||||
tracker
|
||||
);
|
||||
221
lib/src/mite_model.rs
Normal file
221
lib/src/mite_model.rs
Normal file
@ -0,0 +1,221 @@
|
||||
use crate::{
|
||||
model::{Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, User, UserId},
|
||||
DateSpan, Day,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteAccount {
|
||||
pub id: AccountId,
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub currency: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteAccount> for Account {
|
||||
fn from(f: MiteAccount) -> Self {
|
||||
Account {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
title: f.title,
|
||||
currency: f.currency,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteUser {
|
||||
pub id: UserId,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub note: String,
|
||||
pub role: String,
|
||||
pub language: String,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteUser> for User {
|
||||
fn from(f: MiteUser) -> Self {
|
||||
User {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
email: f.email,
|
||||
note: f.note,
|
||||
role: f.role,
|
||||
language: f.language,
|
||||
archived: f.archived,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteCustomer {
|
||||
pub id: CustomerId,
|
||||
pub name: String,
|
||||
pub note: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteCustomer> for Customer {
|
||||
fn from(f: MiteCustomer) -> Self {
|
||||
Customer {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
note: f.note,
|
||||
archived: f.archived,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteProject {
|
||||
pub id: ProjectId,
|
||||
pub name: String,
|
||||
pub customer_id: CustomerId,
|
||||
pub customer_name: String,
|
||||
pub note: String,
|
||||
pub budget: u32,
|
||||
pub budget_type: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteProject> for Project {
|
||||
fn from(f: MiteProject) -> Self {
|
||||
Project {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
customer_id: f.customer_id,
|
||||
customer_name: f.customer_name,
|
||||
note: f.note,
|
||||
archived: f.archived,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteService {
|
||||
pub id: ServiceId,
|
||||
pub name: String,
|
||||
pub note: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub billable: bool,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteService> for Service {
|
||||
fn from(f: MiteService) -> Self {
|
||||
Service {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
note: f.note,
|
||||
billable: f.billable,
|
||||
archived: f.archived,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct MiteTimeEntry {
|
||||
pub id: TimeEntryId,
|
||||
pub date_at: NaiveDate,
|
||||
pub minutes: Minutes,
|
||||
pub customer_id: CustomerId,
|
||||
pub customer_name: String,
|
||||
pub project_id: ProjectId,
|
||||
pub project_name: String,
|
||||
pub service_id: ServiceId,
|
||||
pub service_name: String,
|
||||
pub user_id: UserId,
|
||||
pub user_name: String,
|
||||
pub note: String,
|
||||
pub billable: bool,
|
||||
pub locked: bool,
|
||||
pub hourly_rate: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MiteTimeEntry> for TimeEntry {
|
||||
fn from(f: MiteTimeEntry) -> Self {
|
||||
TimeEntry {
|
||||
id: f.id,
|
||||
date_at: f.date_at,
|
||||
minutes: f.minutes,
|
||||
customer_id: f.customer_id,
|
||||
customer_name: f.customer_name,
|
||||
project_id: f.project_id,
|
||||
project_name: f.project_name,
|
||||
service_id: f.service_id,
|
||||
service_name: f.service_name,
|
||||
user_id: f.user_id,
|
||||
user_name: f.user_name,
|
||||
note: f.note,
|
||||
billable: f.billable,
|
||||
locked: f.locked,
|
||||
created_at: f.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MiteTrackingTimeEntry {
|
||||
pub id: TimeEntryId,
|
||||
pub minutes: Minutes,
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MiteTracker {
|
||||
pub tracking_time_entry: Option<MiteTrackingTimeEntry>,
|
||||
pub stopped_time_entry: Option<MiteTrackingTimeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MiteEntity {
|
||||
Account(MiteAccount),
|
||||
User(MiteUser),
|
||||
Customer(MiteCustomer),
|
||||
Project(MiteProject),
|
||||
Service(MiteService),
|
||||
TimeEntry(MiteTimeEntry),
|
||||
Tracker(MiteTracker),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub fn day_query_param(day: &Day) -> String {
|
||||
match day {
|
||||
Day::Today => "today".to_string(),
|
||||
Day::Yesterday => "yesterday".to_string(),
|
||||
Day::Date(date) => format!("{}", date),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn date_span_query_param(span: &DateSpan) -> String {
|
||||
match span {
|
||||
DateSpan::ThisWeek => "at=this_week".to_string(),
|
||||
DateSpan::LastWeek => "at=last_week".to_string(),
|
||||
DateSpan::ThisMonth => "at=this_month".to_string(),
|
||||
DateSpan::LastMonth => "at=last_month".to_string(),
|
||||
DateSpan::Day(date) => format!("at={}", day_query_param(&date)),
|
||||
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
|
||||
}
|
||||
}
|
||||
175
lib/src/model.rs
175
lib/src/model.rs
@ -1,20 +1,102 @@
|
||||
use crate::error::AcariError;
|
||||
use crate::user_error;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use serde::de::{Error, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::ops;
|
||||
use std::str::FromStr;
|
||||
|
||||
macro_rules! id_wrapper {
|
||||
($name: ident) => {
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub u32);
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
|
||||
pub enum $name {
|
||||
Num(u64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn str_encoded(&self) -> String {
|
||||
match self {
|
||||
$name::Num(n) => format!("n{}", n),
|
||||
$name::Str(s) => format!("s{}", s),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_encoded(s: &str) -> Result<$name, AcariError> {
|
||||
match s.chars().next() {
|
||||
Some('n') => Ok($name::Num(s[1..].parse::<u64>()?)),
|
||||
Some('s') => Ok($name::Str(s[1..].to_string())),
|
||||
_ => Err(AcariError::InternalError("Invalid id format".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_encoded(&self) -> String {
|
||||
match self {
|
||||
$name::Num(n) => n.to_string(),
|
||||
$name::Str(s) => utf8_percent_encode(&s, NON_ALPHANUMERIC).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
$name::Num(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for $name {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
$name::Num(n) => serializer.serialize_u64(*n),
|
||||
$name::Str(s) => serializer.serialize_str(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct EnumVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for EnumVisitor {
|
||||
type Value = $name;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("integer or string")
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: Error,
|
||||
{
|
||||
Ok($name::Num(v))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: Error,
|
||||
{
|
||||
Ok($name::Str(v.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(EnumVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
match self {
|
||||
$name::Num(n) => write!(f, "{}", n),
|
||||
$name::Str(s) => write!(f, "{}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -34,7 +116,6 @@ pub struct Account {
|
||||
pub title: String,
|
||||
pub currency: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
@ -47,7 +128,6 @@ pub struct User {
|
||||
pub language: String,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
@ -55,10 +135,8 @@ pub struct Customer {
|
||||
pub id: CustomerId,
|
||||
pub name: String,
|
||||
pub note: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
@ -68,12 +146,8 @@ pub struct Project {
|
||||
pub customer_id: CustomerId,
|
||||
pub customer_name: String,
|
||||
pub note: String,
|
||||
pub budget: u32,
|
||||
pub budget_type: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
@ -81,11 +155,35 @@ pub struct Service {
|
||||
pub id: ServiceId,
|
||||
pub name: String,
|
||||
pub note: String,
|
||||
pub hourly_rate: Option<u32>,
|
||||
pub billable: bool,
|
||||
pub archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
|
||||
pub struct TimeEntry {
|
||||
pub id: TimeEntryId,
|
||||
pub date_at: NaiveDate,
|
||||
pub minutes: Minutes,
|
||||
pub customer_id: CustomerId,
|
||||
pub customer_name: String,
|
||||
pub project_id: ProjectId,
|
||||
pub project_name: String,
|
||||
pub service_id: ServiceId,
|
||||
pub service_name: String,
|
||||
pub user_id: UserId,
|
||||
pub user_name: String,
|
||||
pub note: String,
|
||||
pub billable: bool,
|
||||
pub locked: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tracker {
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
pub tracking_time_entry: Option<TimeEntry>,
|
||||
pub stopped_time_entry: Option<TimeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Default)]
|
||||
@ -138,53 +236,6 @@ impl FromStr for Minutes {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct TimeEntry {
|
||||
pub id: TimeEntryId,
|
||||
pub date_at: NaiveDate,
|
||||
pub minutes: Minutes,
|
||||
pub customer_id: CustomerId,
|
||||
pub customer_name: String,
|
||||
pub project_id: ProjectId,
|
||||
pub project_name: String,
|
||||
pub service_id: ServiceId,
|
||||
pub service_name: String,
|
||||
pub user_id: UserId,
|
||||
pub user_name: String,
|
||||
pub note: String,
|
||||
pub billable: bool,
|
||||
pub locked: bool,
|
||||
pub hourly_rate: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrackingTimeEntry {
|
||||
pub id: TimeEntryId,
|
||||
pub minutes: Minutes,
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tracker {
|
||||
pub tracking_time_entry: Option<TrackingTimeEntry>,
|
||||
pub stopped_time_entry: Option<TrackingTimeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MiteEntity {
|
||||
Account(Account),
|
||||
User(User),
|
||||
Customer(Customer),
|
||||
Project(Project),
|
||||
Service(Service),
|
||||
TimeEntry(TimeEntry),
|
||||
Tracker(Tracker),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -10,14 +10,6 @@ pub enum Day {
|
||||
}
|
||||
|
||||
impl Day {
|
||||
pub fn query_param(self) -> String {
|
||||
match self {
|
||||
Day::Today => "today".to_string(),
|
||||
Day::Yesterday => "yesterday".to_string(),
|
||||
Day::Date(date) => format!("{}", date),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_date(self) -> NaiveDate {
|
||||
match self {
|
||||
Day::Today => Local::now().naive_local().date(),
|
||||
@ -55,19 +47,6 @@ pub enum DateSpan {
|
||||
FromTo(NaiveDate, NaiveDate),
|
||||
}
|
||||
|
||||
impl DateSpan {
|
||||
pub fn query_param(&self) -> String {
|
||||
match self {
|
||||
DateSpan::ThisWeek => "at=this_week".to_string(),
|
||||
DateSpan::LastWeek => "at=last_week".to_string(),
|
||||
DateSpan::ThisMonth => "at=this_month".to_string(),
|
||||
DateSpan::LastMonth => "at=last_month".to_string(),
|
||||
DateSpan::Day(date) => format!("at={}", date.query_param()),
|
||||
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DateSpan {
|
||||
type Err = AcariError;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user