diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index b7b2a19..85eb9d2 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,6 +1,8 @@ +# Custom Dictionary Words # SPDX-FileCopyrightText: 2022 Matteo Settenvini # SPDX-License-Identifier: CC0-1.0 -# Custom Dictionary Words +cobertura +mkdir montecristosoftware reqwest webbrowser diff --git a/.gitignore b/.gitignore index 03a17e2..7ed0de4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2022 Matteo Settenvini # SPDX-License-Identifier: CC0-1.0 +.~*# /target diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36a8bbf..7ba9af6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,10 +26,10 @@ stages: CARGO_COMMON_ARGS: --workspace --no-default-features script: - mkdir -p .git/hooks # for cargo-husky - - cargo tarpaulin ${CARGO_COMMON_ARGS} --locked --verbose -o Xml + - cargo tarpaulin ${CARGO_COMMON_ARGS} --locked -o Xml - cargo test ${CARGO_COMMON_ARGS} -- -Z unstable-options --format json | tee test-results.json - cargo2junit < test-results.json > junit.xml - - cargo bench ${CARGO_COMMON_ARGS} + # - cargo bench ${CARGO_COMMON_ARGS} # DISABLED UNTIL WE HAVE BENCH TESTS docker:build: stage: build diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2d64fc4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + // SPDX-FileCopyrightText: 2022 Matteo Settenvini + // SPDX-License-Identifier: CC0-1.0 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'cook'", + "cargo": { + "args": [ + "build", + "--bin=cook", + "--package=cooking-schedule" + ], + "filter": { + "name": "cook", + "kind": "bin" + } + }, + "args": ["schedule-csv", "Cucina", "examples/example-schedule.csv"], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'cook'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=cook", + "--package=cooking-schedule" + ], + "filter": { + "name": "cook", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c20e145..6bb7b82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android_system_properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.58" @@ -37,6 +52,18 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.10.0" @@ -67,6 +94,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time", + "winapi", +] + +[[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + [[package]] name = "ci_info" version = "0.10.2" @@ -117,14 +164,23 @@ version = "0.0.0" dependencies = [ "anyhow", "base64", + "chrono", "clap", + "csv", "directories", + "futures", + "iana-time-zone", + "ics", + "minicaldav", "reqwest", "rusty-hook", "serde", "serde_json", + "speedate", + "strum_macros", "tokio", "toml", + "ureq", "webbrowser", ] @@ -144,6 +200,37 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa 0.4.8", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.13.4" @@ -227,6 +314,16 @@ dependencies = [ "instant", ] +[[package]] +name = "flate2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -264,6 +361,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1fd087255f739f4f1aeea69f11b72f8080e9c2e7645cd06955dad4a178a49e3" +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.21" @@ -271,6 +383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -279,12 +392,34 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.21" @@ -303,8 +438,11 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -329,7 +467,7 @@ checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -357,6 +495,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -374,7 +518,7 @@ checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", - "itoa", + "itoa 1.0.2", ] [[package]] @@ -415,7 +559,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa", + "itoa 1.0.2", "pin-project-lite", "socket2", "tokio", @@ -437,6 +581,25 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c0d80ad9ca8d30ca648bf6cb1e3e3326d75071b76dbe143dd4a9cedcd58975" +dependencies = [ + "android_system_properties", + "core-foundation", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "ics" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b891481ef6353e3b97118d4650469e379a39e4373a66908c12f99763182826b1" + [[package]] name = "ident_case" version = "1.0.1" @@ -479,6 +642,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.2" @@ -553,6 +722,28 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "minicaldav" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb263a7d12c40d5f200dda93b3665b9ae714d4fe64a6467938c92d974a579edb" +dependencies = [ + "base64", + "log", + "ureq", + "url", + "xmltree", +] + +[[package]] +name = "miniz_oxide" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" +dependencies = [ + "adler", +] + [[package]] name = "mio" version = "0.8.4" @@ -561,7 +752,7 @@ checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -645,6 +836,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab250442c86f1850815b5d268639dff018c0627022bc1940eb2d642ca1ce12f0" +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.1" @@ -805,6 +1015,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -851,6 +1067,39 @@ dependencies = [ "winreg", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rustls" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustversion" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c8ad4f0c00e1eb5bc7614d236a7f1300e3dbd76b68cac8e06fb00b015ad8d8" + [[package]] name = "rusty-hook" version = "0.11.2" @@ -888,6 +1137,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.6.1" @@ -937,7 +1196,7 @@ version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" dependencies = [ - "itoa", + "itoa 1.0.2", "ryu", "serde", ] @@ -949,7 +1208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa", + "itoa 1.0.2", "ryu", "serde", ] @@ -970,12 +1229,50 @@ dependencies = [ "winapi", ] +[[package]] +name = "speedate" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519ab0d5d5dc6d050a2327ea508d20b460dba1e3a76f1933c56b7c4da2b5c620" +dependencies = [ + "strum", + "strum_macros", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4faebde00e8ff94316c01800f9054fd2ba77d30d9e922541913051d1d978918b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "1.0.98" @@ -1036,6 +1333,17 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1172,6 +1480,30 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97acb4c28a254fd7a4aeec976c46a7fa404eac4d7c134b30c75144846d7cb8f" +dependencies = [ + "base64", + "chunked_transfer", + "encoding_rs", + "flate2", + "log", + "once_cell", + "rustls", + "url", + "webpki", + "webpki-roots", +] + [[package]] name = "url" version = "2.2.2" @@ -1211,6 +1543,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1307,6 +1645,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf" +dependencies = [ + "webpki", +] + [[package]] name = "widestring" version = "0.5.1" @@ -1395,3 +1752,18 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] + +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] diff --git a/Cargo.toml b/Cargo.toml index 27288c4..06b7af8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,16 @@ version = "1.0" [dependencies.base64] version = "0.13" +[dependencies.chrono] +version = "0.4" +features = ["serde"] + +[dependencies.iana-time-zone] +version = "0.1" + +[dependencies.csv] +version = "1.1" + [dependencies.clap] version = "3.2" features = ["cargo"] @@ -29,6 +39,15 @@ features = ["cargo"] [dependencies.directories] version = "4.0" +[dependencies.futures] +version = "0.3" + +[dependencies.ics] +version = "0.5" + +[dependencies.minicaldav] +version = "0.2" + [dependencies.reqwest] version = "0.11" features = ["json", "blocking"] @@ -40,6 +59,12 @@ features = ["derive"] [dependencies.serde_json] version = "1.0" +[dependencies.speedate] +version = "0.6" + +[dependencies.strum_macros] +version = "0.24" + [dependencies.toml] version = "0.5" @@ -47,5 +72,8 @@ version = "0.5" version = "1" features = ["rt-multi-thread", "net", "macros"] +[dependencies.ureq] +version = "2.5" + [dependencies.webbrowser] version = "0.7" diff --git a/examples/example-schedule.csv b/examples/example-schedule.csv new file mode 100644 index 0000000..e0ca5eb --- /dev/null +++ b/examples/example-schedule.csv @@ -0,0 +1,366 @@ +"day","wday","lunch","dinner" +2022-01-01,"sabato",, +2022-01-02,"domenica",, +2022-01-03,"lunedì",, +2022-01-04,"martedì",, +2022-01-05,"mercoledì",, +2022-01-06,"giovedì",, +2022-01-07,"venerdì",, +2022-01-08,"sabato",, +2022-01-09,"domenica",, +2022-01-10,"lunedì",, +2022-01-11,"martedì",, +2022-01-12,"mercoledì",, +2022-01-13,"giovedì",, +2022-01-14,"venerdì",, +2022-01-15,"sabato",, +2022-01-16,"domenica",, +2022-01-17,"lunedì",, +2022-01-18,"martedì",, +2022-01-19,"mercoledì",, +2022-01-20,"giovedì",, +2022-01-21,"venerdì",, +2022-01-22,"sabato",, +2022-01-23,"domenica",, +2022-01-24,"lunedì",, +2022-01-25,"martedì",, +2022-01-26,"mercoledì",, +2022-01-27,"giovedì",, +2022-01-28,"venerdì",, +2022-01-29,"sabato",, +2022-01-30,"domenica",, +2022-01-31,"lunedì",, +2022-02-01,"martedì",, +2022-02-02,"mercoledì",, +2022-02-03,"giovedì",, +2022-02-04,"venerdì",, +2022-02-05,"sabato",, +2022-02-06,"domenica",, +2022-02-07,"lunedì",, +2022-02-08,"martedì",, +2022-02-09,"mercoledì",, +2022-02-10,"giovedì",, +2022-02-11,"venerdì",, +2022-02-12,"sabato",, +2022-02-13,"domenica",, +2022-02-14,"lunedì",, +2022-02-15,"martedì",, +2022-02-16,"mercoledì",, +2022-02-17,"giovedì",, +2022-02-18,"venerdì",, +2022-02-19,"sabato",, +2022-02-20,"domenica",, +2022-02-21,"lunedì",, +2022-02-22,"martedì",, +2022-02-23,"mercoledì",, +2022-02-24,"giovedì",, +2022-02-25,"venerdì",, +2022-02-26,"sabato",, +2022-02-27,"domenica",, +2022-02-28,"lunedì",, +2022-03-01,"martedì",, +2022-03-02,"mercoledì",, +2022-03-03,"giovedì",, +2022-03-04,"venerdì",, +2022-03-05,"sabato",, +2022-03-06,"domenica",, +2022-03-07,"lunedì",, +2022-03-08,"martedì",, +2022-03-09,"mercoledì",, +2022-03-10,"giovedì",, +2022-03-11,"venerdì",, +2022-03-12,"sabato",, +2022-03-13,"domenica",, +2022-03-14,"lunedì",, +2022-03-15,"martedì",, +2022-03-16,"mercoledì",, +2022-03-17,"giovedì",, +2022-03-18,"venerdì",, +2022-03-19,"sabato",, +2022-03-20,"domenica",, +2022-03-21,"lunedì",, +2022-03-22,"martedì",, +2022-03-23,"mercoledì",, +2022-03-24,"giovedì",, +2022-03-25,"venerdì",, +2022-03-26,"sabato",, +2022-03-27,"domenica",, +2022-03-28,"lunedì",, +2022-03-29,"martedì",, +2022-03-30,"mercoledì",, +2022-03-31,"giovedì",, +2022-04-01,"venerdì",, +2022-04-02,"sabato",, +2022-04-03,"domenica",, +2022-04-04,"lunedì",, +2022-04-05,"martedì",, +2022-04-06,"mercoledì",, +2022-04-07,"giovedì",, +2022-04-08,"venerdì",, +2022-04-09,"sabato",, +2022-04-10,"domenica",, +2022-04-11,"lunedì",, +2022-04-12,"martedì",, +2022-04-13,"mercoledì",, +2022-04-14,"giovedì",, +2022-04-15,"venerdì",, +2022-04-16,"sabato",, +2022-04-17,"domenica",, +2022-04-18,"lunedì",, +2022-04-19,"martedì",, +2022-04-20,"mercoledì",, +2022-04-21,"giovedì",, +2022-04-22,"venerdì",, +2022-04-23,"sabato",, +2022-04-24,"domenica",, +2022-04-25,"lunedì",, +2022-04-26,"martedì",, +2022-04-27,"mercoledì",, +2022-04-28,"giovedì",, +2022-04-29,"venerdì",, +2022-04-30,"sabato",, +2022-05-01,"domenica",, +2022-05-02,"lunedì",, +2022-05-03,"martedì",, +2022-05-04,"mercoledì",, +2022-05-05,"giovedì",, +2022-05-06,"venerdì",, +2022-05-07,"sabato",, +2022-05-08,"domenica",, +2022-05-09,"lunedì",, +2022-05-10,"martedì",, +2022-05-11,"mercoledì",, +2022-05-12,"giovedì",, +2022-05-13,"venerdì",, +2022-05-14,"sabato",, +2022-05-15,"domenica",, +2022-05-16,"lunedì",, +2022-05-17,"martedì",, +2022-05-18,"mercoledì",, +2022-05-19,"giovedì",, +2022-05-20,"venerdì",, +2022-05-21,"sabato",, +2022-05-22,"domenica",, +2022-05-23,"lunedì",, +2022-05-24,"martedì",, +2022-05-25,"mercoledì",, +2022-05-26,"giovedì",, +2022-05-27,"venerdì",, +2022-05-28,"sabato",, +2022-05-29,"domenica",, +2022-05-30,"lunedì",, +2022-05-31,"martedì",, +2022-06-01,"mercoledì",, +2022-06-02,"giovedì",, +2022-06-03,"venerdì",, +2022-06-04,"sabato",, +2022-06-05,"domenica",, +2022-06-06,"lunedì",, +2022-06-07,"martedì",,"https://ricette.giallozafferano.it/Pasta-fredda-con-pesto-senz-aglio.html" +2022-06-08,"mercoledì",, +2022-06-09,"giovedì",, +2022-06-10,"venerdì",, +2022-06-11,"sabato",, +2022-06-12,"domenica",, +2022-06-13,"lunedì",, +2022-06-14,"martedì",, +2022-06-15,"mercoledì",, +2022-06-16,"giovedì",, +2022-06-17,"venerdì",,"https://ricette.giallozafferano.it/Salmorejo.html" +2022-06-18,"sabato",, +2022-06-19,"domenica",, +2022-06-20,"lunedì",, +2022-06-21,"martedì",, +2022-06-22,"mercoledì",,"https://ricette.giallozafferano.it/Pasta-fredda-con-pesto-senz-aglio.html" +2022-06-23,"giovedì",, +2022-06-24,"venerdì",, +2022-06-25,"sabato",, +2022-06-26,"domenica",, +2022-06-27,"lunedì",, +2022-06-28,"martedì",, +2022-06-29,"mercoledì",, +2022-06-30,"giovedì",, +2022-07-01,"venerdì",, +2022-07-02,"sabato",, +2022-07-03,"domenica",, +2022-07-04,"lunedì",, +2022-07-05,"martedì",,"https://ricette.giallozafferano.it/Pasta-fredda-con-pesto-senz-aglio.html" +2022-07-06,"mercoledì",, +2022-07-07,"giovedì",, +2022-07-08,"venerdì",, +2022-07-09,"sabato",, +2022-07-10,"domenica",, +2022-07-11,"lunedì",, +2022-07-12,"martedì",, +2022-07-13,"mercoledì",, +2022-07-14,"giovedì",, +2022-07-15,"venerdì",,"https://ricette.giallozafferano.it/Salmorejo.html" +2022-07-16,"sabato",, +2022-07-17,"domenica",, +2022-07-18,"lunedì",, +2022-07-19,"martedì",, +2022-07-20,"mercoledì",, +2022-07-21,"giovedì",, +2022-07-22,"venerdì",, +2022-07-23,"sabato",, +2022-07-24,"domenica",, +2022-07-25,"lunedì",, +2022-07-26,"martedì",,"https://ricette.giallozafferano.it/Pasta-fredda-con-pesto-senz-aglio.html" +2022-07-27,"mercoledì",, +2022-07-28,"giovedì",, +2022-07-29,"venerdì",, +2022-07-30,"sabato",, +2022-07-31,"domenica",, +"2022-08-01","lunedì",,"https://ricette.giallozafferano.it/Riso-freddo-con-tonno-zucchine-e-limone.html" +"2022-08-02","martedì",, +"2022-08-03","mercoledì",, +"2022-08-04","giovedì",, +"2022-08-05","venerdì",, +"2022-08-06","sabato",, +"2022-08-07","domenica",, +"2022-08-08","lunedì",, +"2022-08-09","martedì",, +"2022-08-10","mercoledì",, +"2022-08-11","giovedì",, +"2022-08-12","venerdì",, +"2022-08-13","sabato",, +"2022-08-14","domenica",, +"2022-08-15","lunedì",, +"2022-08-16","martedì",, +"2022-08-17","mercoledì",,"https://ricette.giallozafferano.it/Pasta-fredda-con-pesto-senz-aglio.html" +"2022-08-18","giovedì",, +"2022-08-19","venerdì",, +"2022-08-20","sabato",,"https://ricette.giallozafferano.it/Salmorejo.html" +"2022-08-21","domenica",, +"2022-08-22","lunedì",, +"2022-08-23","martedì",, +"2022-08-24","mercoledì",, +"2022-08-25","giovedì",, +"2022-08-26","venerdì",, +"2022-08-27","sabato",, +"2022-08-28","domenica",, +"2022-08-29","lunedì",, +"2022-08-30","martedì",, +"2022-08-31","mercoledì",, +"2022-09-01","giovedì",, +"2022-09-02","venerdì",, +"2022-09-03","sabato",, +"2022-09-04","domenica",, +"2022-09-05","lunedì",, +"2022-09-06","martedì",, +"2022-09-07","mercoledì",, +"2022-09-08","giovedì",, +"2022-09-09","venerdì",, +"2022-09-10","sabato",, +"2022-09-11","domenica",, +"2022-09-12","lunedì",, +"2022-09-13","martedì",, +"2022-09-14","mercoledì",, +"2022-09-15","giovedì",, +"2022-09-16","venerdì",, +"2022-09-17","sabato",, +"2022-09-18","domenica",, +"2022-09-19","lunedì",, +"2022-09-20","martedì",, +"2022-09-21","mercoledì",, +"2022-09-22","giovedì",, +"2022-09-23","venerdì",, +"2022-09-24","sabato",, +"2022-09-25","domenica",, +"2022-09-26","lunedì",, +"2022-09-27","martedì",, +"2022-09-28","mercoledì",, +"2022-09-29","giovedì",, +"2022-09-30","venerdì",, +"2022-10-01","sabato",, +"2022-10-02","domenica",, +"2022-10-03","lunedì",, +"2022-10-04","martedì",, +"2022-10-05","mercoledì",, +"2022-10-06","giovedì",, +"2022-10-07","venerdì",, +"2022-10-08","sabato",, +"2022-10-09","domenica",, +"2022-10-10","lunedì",, +"2022-10-11","martedì",, +"2022-10-12","mercoledì",, +"2022-10-13","giovedì",, +"2022-10-14","venerdì",, +"2022-10-15","sabato",, +"2022-10-16","domenica",, +"2022-10-17","lunedì",, +"2022-10-18","martedì",, +"2022-10-19","mercoledì",, +"2022-10-20","giovedì",, +"2022-10-21","venerdì",, +"2022-10-22","sabato",, +"2022-10-23","domenica",, +"2022-10-24","lunedì",, +"2022-10-25","martedì",, +"2022-10-26","mercoledì",, +"2022-10-27","giovedì",, +"2022-10-28","venerdì",, +"2022-10-29","sabato",, +"2022-10-30","domenica",, +"2022-10-31","lunedì",, +"2022-11-01","martedì",, +"2022-11-02","mercoledì",, +"2022-11-03","giovedì",, +"2022-11-04","venerdì",, +"2022-11-05","sabato",, +"2022-11-06","domenica",, +"2022-11-07","lunedì",, +"2022-11-08","martedì",, +"2022-11-09","mercoledì",, +"2022-11-10","giovedì",, +"2022-11-11","venerdì",, +"2022-11-12","sabato",, +"2022-11-13","domenica",, +"2022-11-14","lunedì",, +"2022-11-15","martedì",, +"2022-11-16","mercoledì",, +"2022-11-17","giovedì",, +"2022-11-18","venerdì",, +"2022-11-19","sabato",, +"2022-11-20","domenica",, +"2022-11-21","lunedì",, +"2022-11-22","martedì",, +"2022-11-23","mercoledì",, +"2022-11-24","giovedì",, +"2022-11-25","venerdì",, +"2022-11-26","sabato",, +"2022-11-27","domenica",, +"2022-11-28","lunedì",, +"2022-11-29","martedì",, +"2022-11-30","mercoledì",, +"2022-12-01","giovedì",, +"2022-12-02","venerdì",, +"2022-12-03","sabato",, +"2022-12-04","domenica",, +"2022-12-05","lunedì",, +"2022-12-06","martedì",, +"2022-12-07","mercoledì",, +"2022-12-08","giovedì",, +"2022-12-09","venerdì",, +"2022-12-10","sabato",, +"2022-12-11","domenica",, +"2022-12-12","lunedì",, +"2022-12-13","martedì",, +"2022-12-14","mercoledì",, +"2022-12-15","giovedì",, +"2022-12-16","venerdì",, +"2022-12-17","sabato",, +"2022-12-18","domenica",, +"2022-12-19","lunedì",, +"2022-12-20","martedì",, +"2022-12-21","mercoledì",, +"2022-12-22","giovedì",, +"2022-12-23","venerdì",, +"2022-12-24","sabato",, +"2022-12-25","domenica",, +"2022-12-26","lunedì",, +"2022-12-27","martedì",, +"2022-12-28","mercoledì",, +"2022-12-29","giovedì",, +"2022-12-30","venerdì",, +"2022-12-31","sabato",, diff --git a/examples/example-schedule.csv.license b/examples/example-schedule.csv.license new file mode 100644 index 0000000..da07410 --- /dev/null +++ b/examples/example-schedule.csv.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 Matteo Settenvini +SPDX-License-Identifier: CC0-1.0 \ No newline at end of file diff --git a/src/api_client.rs b/src/api_client.rs new file mode 100644 index 0000000..33d7c43 --- /dev/null +++ b/src/api_client.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + crate::config::Config, crate::constants, anyhow::anyhow, anyhow::Result, + base64::write::EncoderWriter as Base64Encoder, reqwest::Url, std::io::Write, +}; + +pub struct ApiClient { + rest: reqwest::Client, + agent: ureq::Agent, + base_url: Url, + caldav_base_url: Url, + username: String, + password: String, +} + +impl ApiClient { + pub fn new(server_name: &str, configuration: &Config) -> Result { + let server = configuration + .credentials + .servers + .get(server_name) + .ok_or_else(|| { + anyhow!( + "Unknown server {}. Did you use '{} init' first? Known servers: {:#?}", + server_name, + env!("CARGO_BIN_NAME"), + configuration.credentials.servers.keys().collect::>() + ) + })?; + + use reqwest::header; + let mut default_headers = header::HeaderMap::new(); + let mut auth_header = b"Basic ".to_vec(); + { + let mut encoder = Base64Encoder::new(&mut auth_header, base64::STANDARD); + write!(encoder, "{}:{}", server.login_name, server.password).unwrap(); + } + let mut auth_header = header::HeaderValue::from_bytes(&auth_header)?; + auth_header.set_sensitive(true); + default_headers.insert(header::AUTHORIZATION, auth_header); + + let rest_client = reqwest::Client::builder() + .user_agent(constants::USER_AGENT) + .default_headers(default_headers) + .build()?; + + let base_url = Url::parse(&server.url)?; + let caldav_base_url = futures::executor::block_on( + rest_client + .head(base_url.join("/.well-known/caldav")?) + .send(), + )? + .url() + .clone(); + + Ok(ApiClient { + base_url, + caldav_base_url, + username: server.login_name.clone(), + password: server.password.clone(), + rest: rest_client, + agent: ureq::Agent::new(), + }) + } + + pub fn rest(&self) -> &reqwest::Client { + &self.rest + } + + pub fn base_url(&self) -> &Url { + &self.base_url + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn get_calendars( + &self, + ) -> core::result::Result, minicaldav::Error> { + minicaldav::get_calendars( + self.agent.clone(), + self.username(), + &self.password, + &self.caldav_base_url, + ) + } + + pub fn get_events( + &self, + calendar: &minicaldav::Calendar, + ) -> core::result::Result<(Vec, Vec), minicaldav::Error> + { + minicaldav::get_events( + self.agent.clone(), + self.username(), + &self.password, + calendar, + ) + } +} diff --git a/src/commands/import.rs b/src/commands/import.rs new file mode 100644 index 0000000..f7c0e82 --- /dev/null +++ b/src/commands/import.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::api_client::ApiClient, anyhow::Result, reqwest::StatusCode}; + +pub async fn with(api_client: &ApiClient, urls: UrlsIter) -> Result<()> +where + UrlsIter: std::iter::Iterator, + UrlsIter::Item: AsRef, +{ + for url in urls { + let response = api_client + .rest() + .post(api_client.base_url().join("apps/cookbook/import")?) + .json(&serde_json::json!({ + "url": url.as_ref(), + })) + .send() + .await?; + if ![StatusCode::OK, StatusCode::CONFLICT].contains(&response.status()) { + anyhow::bail!( + "Unable to import recipe {}, received status code {}", + url.as_ref(), + response.status() + ); + } + } + + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..5c4add4 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::config::Config, anyhow::Result, reqwest::Url}; + +pub async fn with(configuration: &mut Config, server: &str) -> Result<()> { + tokio::task::block_in_place(move || -> anyhow::Result<()> { + configuration + .credentials + .add(Url::parse(server)?) + .expect("Unable to authenticate to NextCloud instance"); + Ok(()) + }) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..32611c5 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod import; +pub mod init; +pub mod schedule; +pub mod schedule_csv; diff --git a/src/commands/schedule.rs b/src/commands/schedule.rs new file mode 100644 index 0000000..529b660 --- /dev/null +++ b/src/commands/schedule.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::api_client::ApiClient, crate::recipe, anyhow::Result}; + +pub async fn with(api_client: &ApiClient) -> Result<()> { + let recipes = api_client + .rest() + .get(api_client.base_url().join("apps/cookbook/api/recipes")?) + .send() + .await?; + println!("{:#?}", recipes.json::>().await?); + todo!(); +} diff --git a/src/commands/schedule_csv.rs b/src/commands/schedule_csv.rs new file mode 100644 index 0000000..40769fa --- /dev/null +++ b/src/commands/schedule_csv.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + crate::api_client::ApiClient, + crate::commands::import, + crate::constants, + crate::event::{Event, Meal}, + crate::recipe, + anyhow::{bail, Result}, + chrono::naive::NaiveDate, + futures::future::try_join_all, + reqwest::StatusCode, + std::collections::{HashMap, HashSet}, + std::fmt::Write, + std::iter::Iterator, + std::path::Path, + std::rc::Rc, +}; + +#[derive(serde::Deserialize)] +struct CsvRecord { + day: NaiveDate, + lunch: String, + dinner: String, +} + +pub async fn with(api_client: &ApiClient, calendar: &str, csv_file: &Path) -> Result<()> { + let mut csv = csv::Reader::from_path(csv_file)?; + let records = csv.deserialize::().flatten().collect::>(); + + let recipe_urls = urls_from_csv(records.iter())?; + import::with(&api_client, recipe_urls.into_iter()).await?; + let recipes = get_all_recipes(&api_client).await?; + + let events = records + .iter() + .flat_map(|r| { + let lunch = recipes.get(&r.lunch); + let dinner = recipes.get(&r.dinner); + + let events = [ + lunch.map(|recipe| Event::new(r.day, Meal::Lunch, recipe.clone())), + dinner.map(|recipe| Event::new(r.day, Meal::Dinner, recipe.clone())), + ]; + + events + }) + .flatten(); + + publish_events(&api_client, calendar, events).await?; + Ok(()) +} + +fn urls_from_csv<'a, RecordsIter>(records: RecordsIter) -> Result> +where + RecordsIter: Iterator, +{ + Ok( + records.fold(std::collections::HashSet::new(), |mut set, r| { + if !r.lunch.is_empty() { + set.insert(r.lunch.clone()); + } + if !r.dinner.is_empty() { + set.insert(r.dinner.clone()); + } + set + }), + ) +} + +async fn get_all_recipes(api_client: &ApiClient) -> Result>> { + let metadata = api_client + .rest() + .get(api_client.base_url().join("apps/cookbook/api/recipes")?) + .send() + .await? + .json::>() + .await?; + + let recipes = metadata.iter().map(|rm| async { + let response = api_client + .rest() + .get( + api_client + .base_url() + .join(&format!("apps/cookbook/api/recipes/{id}", id = rm.id)) + .unwrap(), + ) + .send() + .await + .expect(&format!( + "Cannot fetch recipe {} with id {}", + rm.name, rm.id + )); + response.json::().await.map(|r| Rc::new(r)) + }); + + let recipes = try_join_all(recipes).await?; + Ok(HashMap::from_iter( + recipes.into_iter().map(|r| (r.url.clone(), r)), + )) +} + +async fn publish_events<'a, EventsIter>( + api_client: &ApiClient, + calendar: &str, + events: EventsIter, +) -> Result<()> +where + EventsIter: Iterator, +{ + let calendar_prototype: ics::ICalendar = ics::ICalendar::new( + "2.0", + format!( + "-//IDN {}//{} {}//EN", + constants::VENDOR, + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + ), + ); + + let dav_base = api_client + .rest() + .head(api_client.base_url().join("/.well-known/caldav")?) + .send() + .await?; + + let calendar_url = dav_base.url().join(&format!( + "calendars/{}/{}/", + &api_client.username(), + calendar.to_lowercase().as_str().replace(" ", "-") + ))?; + + let calendar_prototype = &calendar_prototype; + let calendar_url = &calendar_url; + let update_requests = events.map(|ev| async move { + let url = calendar_url.join(&format!("{}.ics", ev.uid)).unwrap(); + let mut cal = calendar_prototype.clone(); + cal.add_event(ev.into()); + + api_client + .rest() + .put(url) + .header("Content-Type", "text/calendar; charset=utf-8") + .body(cal.to_string()) + .send() + .await + }); + + let responses = try_join_all(update_requests).await?; + let failed_responses = responses.into_iter().filter(|response| { + ![StatusCode::NO_CONTENT, StatusCode::CREATED].contains(&response.status()) + }); + + let mut errors = String::new(); + for r in failed_responses { + write!(errors, "\n{}", r.text().await.unwrap())?; + } + + if !errors.is_empty() { + bail!("Error while updating calendar events: {}", errors); + } + + Ok(()) +} diff --git a/src/constants.rs b/src/constants.rs index 8fb4a1c..4c99f75 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -2,3 +2,4 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); +pub const VENDOR: &str = "montecristosoftware.eu"; diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..03c8f50 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + crate::recipe::Recipe, + chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}, + ics::escape_text, + ics::properties as calprop, + ics::Event as CalEvent, + std::rc::Rc, +}; + +pub struct Event { + pub uid: String, + pub ends_at: NaiveDateTime, + pub recipe: Rc, +} + +#[derive(strum_macros::Display)] +pub enum Meal { + Lunch, + Dinner, +} + +impl Event { + pub fn new(date: NaiveDate, meal: Meal, recipe: Rc) -> Self { + let uid = format!( + "{}-{}@{}.montecristosoftware.eu", + date, + meal, + env!("CARGO_PKG_NAME") + ); + + let meal_time = match meal { + Meal::Lunch => NaiveTime::from_hms(12, 00, 00), + Meal::Dinner => NaiveTime::from_hms(19, 00, 00), + }; + + let ends_at = NaiveDateTime::new(date, meal_time); + + Event { + uid, + ends_at, + recipe, + } + } +} + +impl<'a> From for CalEvent<'a> { + fn from(ev: Event) -> Self { + let start_time = Local + .from_local_datetime(&(*&ev.ends_at - ev.recipe.total_time())) + .unwrap(); + let end_time = Local.from_local_datetime(&ev.ends_at).unwrap(); + let timezone = iana_time_zone::get_timezone().unwrap(); + + let mut event = ics::Event::new(ev.uid.clone(), dt_utc_fmt(&Utc::now())); + event.push(calprop::Summary::new(escape_text(ev.recipe.name.clone()))); + event.push(calprop::Description::new(format!( + "cookbook@{}", + ev.recipe.id + ))); + event.push(calprop::Location::new(escape_text(ev.recipe.url.clone()))); + + let mut dtstart = calprop::DtStart::new(dt_fmt(&start_time)); + dtstart.append(ics::parameters!("TZID" => timezone.clone())); + event.push(dtstart); + + let mut dtend = calprop::DtEnd::new(dt_fmt(&end_time)); + dtend.append(ics::parameters!("TZID" => timezone)); + event.push(dtend); + + // TODO make configurable yearly repetition, for now this suits my personal uses + const DAY_NAMES: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; + event.push(calprop::RRule::new(format!( + "FREQ=YEARLY;BYDAY={weekday};BYWEEKNO={weekno}", + weekday = DAY_NAMES + .get(start_time.weekday().num_days_from_monday() as usize) + .unwrap(), + weekno = start_time.iso_week().week(), + ))); + + let mut trigger = calprop::Trigger::new("-PT15M"); + trigger.append(ics::parameters!("RELATED" => "START")); + let alarm = ics::Alarm::display( + trigger, + calprop::Description::new(escape_text(ev.recipe.name.clone())), + ); + event.add_alarm(alarm); + event + } +} + +fn dt_fmt(datetime: &DateTime) -> String { + datetime.format("%Y%m%dT%H%M%S").to_string() +} + +fn dt_utc_fmt(datetime: &DateTime) -> String { + datetime.format("%Y%m%dT%H%M%SZ").to_string() +} diff --git a/src/main.rs b/src/main.rs index 1f81074..69aa993 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,28 @@ // SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: AGPL-3.0-or-later +mod api_client; +mod commands; mod config; mod constants; +mod event; +mod recipe; use { - self::config::Config, - anyhow::anyhow, - base64::write::EncoderWriter as Base64Encoder, + crate::api_client::ApiClient, + crate::config::Config, + anyhow::Result, clap::{arg, command, ArgMatches, Command}, - reqwest::Url, - std::io::Write, + std::path::PathBuf, }; -fn parse_args() -> ArgMatches { +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + let args = setup_args(); + parse_args(&args).await +} + +fn setup_args() -> ArgMatches { let server_arg = arg!(-s --server "NextCloud server to connect to").required(false); command!() @@ -28,15 +37,29 @@ fn parse_args() -> ArgMatches { .subcommand( Command::new("import") .about("Import the given URLs into NextCloud's cookbook") - .arg(server_arg) + .arg(server_arg.clone()) .arg(arg!( ... "One or more URLs each pointing to page with a recipe to import in NextCloud")), ) + .subcommand( + Command::new("schedule") + .about("") + .arg(server_arg.clone()) + .arg(arg!(-d --days "") + .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)) + .required(false) + .default_value("7")) + ) + .subcommand( + Command::new("schedule-csv") + .about("TEMPORARY WIP FUNCTION USED FOR INTERNAL TESTING") + .arg(server_arg.clone()) + .arg(arg!( "")) + .arg(arg!( "").value_parser(clap::value_parser!(PathBuf))) + ) .get_matches() } -#[tokio::main(flavor = "multi_thread")] -async fn main() -> anyhow::Result<()> { - let args = parse_args(); +async fn parse_args(args: &ArgMatches) -> Result<()> { let mut configuration = Config::new(); match args.subcommand() { @@ -44,87 +67,45 @@ async fn main() -> anyhow::Result<()> { let server = sub_matches .get_one::("server") .expect("Mandatory parameter "); - tokio::task::block_in_place(move || -> anyhow::Result<()> { - configuration - .credentials - .add(Url::parse(server)?) - .expect("Unable to authenticate to NextCloud instance"); - Ok(()) - })?; + commands::init::with(&mut configuration, server).await } Some(("import", sub_matches)) => { - let server_name = sub_matches - .get_one::("server") - .unwrap_or_else(|| { - let servers = &configuration.credentials.servers; - match servers.len() { - 0 => panic!("No NextCloud server set up yet, use '{} init' first", env!("CARGO_BIN_NAME")), - 1 => servers.iter().next().unwrap().0, - _ => panic!("More than one NextCloud server set up, use the '--server' option to specify which one to use. Known servers: {:#?}", servers.keys().collect::>()), - } - }); - - let api_client = ApiClient::new(&server_name, &configuration)?; - for url in sub_matches + let api_client = get_api_client(&sub_matches, &configuration)?; + let urls = sub_matches .get_many::("url") .expect("At least one url is required") - { - let response = api_client - .client - .post(api_client.base_url.join("apps/cookbook/import")?) - .json(&serde_json::json!({ - "url": url, - })) - .send() - .await?; - println!("{:#?}", response); // TODO - } + .map(|s| s.as_str()); + commands::import::with(&api_client, urls).await + } + Some(("schedule", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + commands::schedule::with(&api_client).await + } + Some(("schedule-csv", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + let csv_file = sub_matches + .get_one::("csv_file") + .expect(" is a mandatory parameter, it cannot be missing"); + let calendar_name = sub_matches + .get_one::("calendar_name") + .expect(" is a mandatory parameter, it cannot be missing"); + commands::schedule_csv::with(&api_client, calendar_name.as_str(), &csv_file).await } _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), - }; - - Ok(()) -} - -struct ApiClient { - pub base_url: Url, - pub client: reqwest::Client, -} - -impl ApiClient { - pub fn new(server_name: &str, configuration: &Config) -> anyhow::Result { - let server = configuration - .credentials - .servers - .get(server_name) - .ok_or_else(|| { - anyhow!( - "Unknown server {}. Did you use '{} init' first? Known servers: {:#?}", - server_name, - env!("CARGO_BIN_NAME"), - configuration.credentials.servers.keys().collect::>() - ) - })?; - - use reqwest::header; - let mut default_headers = header::HeaderMap::new(); - let mut auth_header = b"Basic ".to_vec(); - { - let mut encoder = Base64Encoder::new(&mut auth_header, base64::STANDARD); - write!(encoder, "{}:{}", server.login_name, server.password).unwrap(); - } - let mut auth_header = header::HeaderValue::from_bytes(&auth_header)?; - auth_header.set_sensitive(true); - default_headers.insert(header::AUTHORIZATION, auth_header); - - let client = reqwest::Client::builder() - .user_agent(constants::USER_AGENT) - .default_headers(default_headers) - .build()?; - - Ok(ApiClient { - base_url: Url::parse(&server.url)?, - client: client, - }) } } + +fn get_api_client(sub_matches: &ArgMatches, configuration: &Config) -> Result { + let server_name = sub_matches + .get_one::("server") + .unwrap_or_else(|| { + let servers = &configuration.credentials.servers; + match servers.len() { + 0 => panic!("No NextCloud server set up yet, use '{} init' first", env!("CARGO_BIN_NAME")), + 1 => servers.iter().next().unwrap().0, + _ => panic!("More than one NextCloud server set up, use the '--server' option to specify which one to use. Known servers: {:#?}", servers.keys().collect::>()), + } + }); + + ApiClient::new(&server_name, &configuration) +} diff --git a/src/recipe.rs b/src/recipe.rs new file mode 100644 index 0000000..7dfd1df --- /dev/null +++ b/src/recipe.rs @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + chrono::Duration, + serde::{Deserialize, Deserializer}, +}; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + #[serde(rename = "recipe_id")] + pub id: u32, + pub name: String, + pub keywords: String, + pub date_created: DateTime, + pub date_modified: DateTime, +} + +/// A recipe according to [schema.org](http://schema.org/Recipe) +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Recipe { + pub id: isize, + pub name: String, + pub description: String, + pub url: String, + pub keywords: String, + + #[serde(rename = "dateCreated")] + pub created: DateTime, + + #[serde(rename = "dateModified")] + pub modified: Option, + + pub image_url: String, + + #[serde(deserialize_with = "deserialize_duration")] + pub prep_time: Duration, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_maybe_duration")] + pub cook_time: Option, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_maybe_duration")] + pub total_time: Option, + + pub image: Option, + pub recipe_yield: isize, + + #[serde(rename = "recipeCategory")] + pub category: Option, + + pub tools: Option>, + #[serde(rename = "recipeIngredient")] + pub ingredients: Vec, + #[serde(rename = "recipeInstructions")] + pub instructions: Vec, + //pub nutrition: Nutrition, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Ingredient(String); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Tool(String); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Instruction(String); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Nutrition { + pub calories: Option, + pub carbohydrates_content: Option, + pub cholesterol_content: Option, + pub fat_content: Option, + pub fiber_content: Option, + pub protein_content: Option, + pub saturated_fat_content: Option, + pub serving_size: Option, + pub sodium_content: Option, + pub sugar_content: Option, + pub trans_fat_content: Option, + pub unsaturated_fat_content: Option, +} + +type DateTime = chrono::DateTime; + +fn deserialize_maybe_duration<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(deserialize_duration(deserializer)?)) +} + +fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_str(DurationVisitor) +} + +struct DurationVisitor; + +impl<'de> serde::de::Visitor<'de> for DurationVisitor { + type Value = Duration; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a duration in ISO 8601 format") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + speedate::Duration::parse_str(value) + .map(|dt| Duration::seconds(dt.signed_total_seconds())) + .map_err(|e| E::custom(e.to_string())) + } +} + +impl Recipe { + pub fn total_time(&self) -> Duration { + self.total_time + .unwrap_or_else(|| self.prep_time + self.cook_time.unwrap_or(Duration::zero())) + } +}