diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cd3c3..d870741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v1.2.0 (2021-05-11) +- Adds new keybindings for scrolling up/down a quarter of the page, + scrolling a full page up/down, and scrolling to the top or bottom + (thanks to contributor [a-kenji](https://github.com/a-kenji)) +- Adds support for customizable colors + - This is a backwards-compatible change and does not require any + modification; however, if you wish to customize the colors after + upgrading, you will need to [update your config.toml file](https://github.com/jeff-hughes/shellcaster/blob/master/config.toml) + to add the new options under the "colors" section +- Filenames of downloaded files now include the publication date, which + reduces potential conflicts with rebroadcasted episodes +- Bug fix: + - Fixed issue with "removed" episodes reappearing after syncing the + podcast again +- Some minor performance improvements, particularly when loading the app + ## v1.1.0 (2020-12-01) - Help menu showing the current keybindings (accessible by pressing "?" by default) diff --git a/Cargo.lock b/Cargo.lock index ba19742..f45d3d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,9 @@ # It is not intended for manual editing. [[package]] name = "aho-corasick" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" dependencies = [ "memchr", ] @@ -19,16 +19,22 @@ dependencies = [ ] [[package]] -name = "arrayref" -version = "0.3.6" +name = "anyhow" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" +checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" [[package]] -name = "arrayvec" -version = "0.5.2" +name = "atom_syndication" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +checksum = "2d5016bf52ff4f3ed28bf3ec1fed96b53daf4b137d5e6b9f97a8cfae7b57a3a2" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "quick-xml", +] [[package]] name = "atty" @@ -47,12 +53,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" -[[package]] -name = "base64" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" - [[package]] name = "base64" version = "0.13.0" @@ -65,34 +65,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" -[[package]] -name = "blake2b_simd" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] - [[package]] name = "bumpalo" -version = "3.4.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820" +checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" [[package]] name = "cc" -version = "1.0.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95752358c8f7552394baf48cd82695b345628ad3f170d607de3ca03b8dacca15" - -[[package]] -name = "cfg-if" -version = "0.1.10" +version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" [[package]] name = "cfg-if" @@ -115,9 +98,9 @@ dependencies = [ [[package]] name = "chunked_transfer" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7477065d45a8fe57167bf3cf8bcd3729b54cfcb81cca49bda2d038ea89ae82ca" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" [[package]] name = "clap" @@ -134,12 +117,6 @@ dependencies = [ "vec_map", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "core-foundation" version = "0.9.1" @@ -156,17 +133,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" -[[package]] -name = "crossbeam-utils" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" -dependencies = [ - "autocfg", - "cfg-if 0.1.10", - "lazy_static", -] - [[package]] name = "darling" version = "0.10.2" @@ -228,41 +194,29 @@ dependencies = [ ] [[package]] -name = "dirs" -version = "2.0.2" +name = "diligent-date-parser" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" +checksum = "e37ea528f01b8bfca1f71bcd06a8e6c898bf8fdfbf24dd9dbc7fb49338ed6d84" dependencies = [ - "cfg-if 0.1.10", - "dirs-sys", + "chrono", ] [[package]] name = "dirs-next" -version = "1.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf36e65a80337bea855cd4ef9b8401ffce06a7baedf2e85ec467b1ac3f6e82b6" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", "dirs-sys-next", ] -[[package]] -name = "dirs-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - [[package]] name = "dirs-sys-next" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99de365f605554ae33f115102a02057d4fc18b01f3284d6870be0938743cfe7d" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", "redox_users", @@ -271,11 +225,11 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.26" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801bbab217d7f79c0062f4f7205b5d4427c6d1a7bd7aafdd1475f7c59d62b283" +checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065" dependencies = [ - "cfg-if 1.0.0", + "cfg-if", ] [[package]] @@ -328,9 +282,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ece68d15c92e84fa4f19d3780f1294e5ca82a78a6d515f1efaabcc144688be00" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" dependencies = [ "matches", "percent-encoding", @@ -338,20 +292,20 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.1.15" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc587bc0ec293155d5bfa6b9891ec18a1e330c234f896ea47fbada4cadbe47e6" +checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "hermit-abi" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" dependencies = [ "libc", ] @@ -364,9 +318,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.2.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" dependencies = [ "matches", "unicode-bidi", @@ -381,9 +335,9 @@ checksum = "5f25cca2463cb19dbb1061eb3bd38a8b5e4ce1cc5a5a9fc0e02de486d92b9b05" [[package]] name = "js-sys" -version = "0.3.45" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca059e81d9486668f12d455a4ea6daa600bd408134cd17e3d3fb5a32d1f016f8" +checksum = "2d99f9e3e84b8f67f846ef5b4cbbc3b1c29f6c759fcbce6f01aa0e73d932a24c" dependencies = [ "wasm-bindgen", ] @@ -396,9 +350,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.80" +version = "0.2.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" +checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" [[package]] name = "libsqlite3-sys" @@ -413,17 +367,17 @@ dependencies = [ [[package]] name = "linked-hash-map" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd5a6d5999d9907cda8ed67bbd137d3af8085216c2ac62de5be860bd41f304a" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" [[package]] name = "log" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", ] [[package]] @@ -443,15 +397,15 @@ checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" [[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 = "native-tls" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcc7939b5edc4e4f86b1b4a04bb1498afaaf871b1a6691838ed06fcb48d3a3f" +checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" dependencies = [ "lazy_static", "libc", @@ -467,15 +421,21 @@ dependencies = [ [[package]] name = "ncurses" -version = "5.99.0" +version = "5.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15699bee2f37e9f8828c7b35b2bc70d13846db453f2d507713b758fabe536b82" +checksum = "5e2c5d34d72657dc4b638a1c25d40aae81e4f1c699062f72f467237920752032" dependencies = [ "cc", "libc", "pkg-config", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-integer" version = "0.1.44" @@ -497,21 +457,21 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.5.2" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0" +checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" [[package]] name = "openssl" -version = "0.10.30" +version = "0.10.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4" +checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577" dependencies = [ "bitflags", - "cfg-if 0.1.10", + "cfg-if", "foreign-types", - "lazy_static", "libc", + "once_cell", "openssl-sys", ] @@ -523,9 +483,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" [[package]] name = "openssl-sys" -version = "0.9.58" +version = "0.9.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a842db4709b604f0fe5d1170ae3565899be2ad3d9cbc72dedc789ac0511f78de" +checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" dependencies = [ "autocfg", "cc", @@ -588,9 +548,9 @@ checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" [[package]] name = "proc-macro2" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" dependencies = [ "unicode-xid", ] @@ -606,9 +566,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.17.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe1e430bdcf30c9fdc25053b9c459bb1a4672af4617b6c783d7d91dc17c6bbb0" +checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd" dependencies = [ "encoding_rs", "memchr", @@ -616,20 +576,19 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.7.3" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" dependencies = [ - "getrandom", "libc", "rand_chacha", "rand_core", @@ -638,9 +597,9 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" dependencies = [ "ppv-lite86", "rand_core", @@ -648,56 +607,57 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.5.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" dependencies = [ "getrandom", ] [[package]] name = "rand_hc" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" dependencies = [ "rand_core", ] [[package]] name = "redox_syscall" -version = "0.1.57" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041" +dependencies = [ + "bitflags", +] [[package]] name = "redox_users" -version = "0.3.5" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" dependencies = [ "getrandom", "redox_syscall", - "rust-argon2", ] [[package]] name = "regex" -version = "1.4.2" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38cf2c13ed4745de91a5eb834e11c00bcc3709e773173b2ce4c56c9fbde04b9c" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" dependencies = [ "aho-corasick", "memchr", "regex-syntax", - "thread_local", ] [[package]] name = "regex-syntax" -version = "0.6.21" +version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" [[package]] name = "remove_dir_all" @@ -710,9 +670,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.16.17" +version = "0.16.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5911690c9b773bab7e657471afc207f3827b249a657241327e3544d79bcabdd" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" dependencies = [ "cc", "libc", @@ -725,10 +685,11 @@ dependencies = [ [[package]] name = "rss" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99979205510c60f80a119dedbabd0b8426517384edf205322f8bcd51796bcef9" +checksum = "02e70d6ae72f8a4333af8ce9dce58942020528430eb0d46ee2fcb5e8d4d16377" dependencies = [ + "atom_syndication", "derive_builder", "quick-xml", ] @@ -748,25 +709,13 @@ dependencies = [ "time", ] -[[package]] -name = "rust-argon2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dab61250775933275e84053ac235621dfb739556d5c54a2f2e9313b7cf43a19" -dependencies = [ - "base64 0.12.3", - "blake2b_simd", - "constant_time_eq", - "crossbeam-utils", -] - [[package]] name = "rustls" -version = "0.18.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1126dcf58e93cee7d098dbda643b5f92ed724f1f6a63007c1116eed6700c81" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" dependencies = [ - "base64 0.12.3", + "base64", "log", "ring", "sct", @@ -795,9 +744,9 @@ dependencies = [ [[package]] name = "sct" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3042af939fca8c3453b7af0f1c66e533a15a86169e39de2657310ade8f98d3c" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" dependencies = [ "ring", "untrusted", @@ -805,9 +754,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1759c2e3c8580017a484a7ac56d3abc5a6c1feadf88db2f3633f12ae4268c69" +checksum = "3670b1d2fdf6084d192bc71ead7aabe6c06aa2ea3fbd9cc3ac111fa5c2b1bd84" dependencies = [ "bitflags", "core-foundation", @@ -818,9 +767,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f99b9d5e26d2a71633cc4f2ebae7cc9f874044e0c351a27e17892d76dce5678b" +checksum = "3676258fd3cfe2c9a0ec99ce3038798d847ce3e4bb17746373eb9f0f1ac16339" dependencies = [ "core-foundation-sys", "libc", @@ -843,18 +792,18 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.117" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" +checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.117" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" +checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" dependencies = [ "proc-macro2", "quote", @@ -863,13 +812,15 @@ dependencies = [ [[package]] name = "shellcaster" -version = "1.1.0" +version = "1.2.0" dependencies = [ + "anyhow", "chrono", "clap", "dirs-next", "escaper", "lazy_static", + "nohash-hasher", "opml", "pancurses", "regex", @@ -879,7 +830,7 @@ dependencies = [ "semver", "serde", "shellexpand", - "textwrap 0.12.1", + "textwrap 0.13.4", "toml", "unicode-segmentation", "ureq", @@ -887,13 +838,19 @@ dependencies = [ [[package]] name = "shellexpand" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2b22262a9aaf9464d356f656fea420634f78c881c5eebd5ef5e66d8b9bc603" +checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829" dependencies = [ - "dirs", + "dirs-next", ] +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + [[package]] name = "spin" version = "0.5.2" @@ -902,9 +859,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "strong-xml" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee06e7e5baf4508dea83506a83fcc5b80a404d4c0e9c473c9a4b38b802af3a07" +checksum = "73d7a5a280d6097649ea2254eb65d38e81f1752fa47ea69d5fa2179470c8bf4c" dependencies = [ "jetscii", "lazy_static", @@ -915,9 +872,9 @@ dependencies = [ [[package]] name = "strong-xml-derive" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e4e25fb64e61f55d495134d9e5ac68b1fa4bb2855b5a5b53857b9460e2bfde" +checksum = "2c3fa5e97f5557b119549b559e37bd3990528534110d0fdfa6c7e9b4c9a9d75a" dependencies = [ "proc-macro2", "quote", @@ -938,9 +895,9 @@ checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" [[package]] name = "syn" -version = "1.0.50" +version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "443b4178719c5a851e1bde36ce12da21d74a0e60b4d982ec3385a933c812f0f6" +checksum = "b9505f307c872bab8eb46f77ae357c8eba1fdacead58ee5a850116b1d7f82883" dependencies = [ "proc-macro2", "quote", @@ -949,11 +906,11 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "libc", "rand", "redox_syscall", @@ -972,38 +929,29 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.12.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203008d98caf094106cfaba70acfed15e18ed3ddb7d94e49baec153a2b462789" +checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835" dependencies = [ + "smawk", "unicode-width", ] -[[package]] -name = "thread_local" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" -dependencies = [ - "lazy_static", -] - [[package]] name = "time" -version = "0.1.44" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" dependencies = [ "libc", - "wasi 0.10.0+wasi-snapshot-preview1", "winapi", ] [[package]] name = "tinyvec" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf8dbc19eb42fba10e8feaaec282fb50e2c14b2726d6301dbfeed0f73306a6f" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" dependencies = [ "tinyvec_macros", ] @@ -1016,36 +964,36 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "toml" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75cf45bb0bef80604d001caaec0d09da99611b3c0fd39d3080468875cdb65645" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" dependencies = [ "serde", ] [[package]] name = "unicode-bidi" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" dependencies = [ "matches", ] [[package]] name = "unicode-normalization" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13e63ab62dbe32aeee58d1c5408d35c36c392bba5d9d3142287219721afe606" +checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8716a166f290ff49dabc18b44aa407cb7c6dbe1aa0971b44b8a24b0ca35aae" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" [[package]] name = "unicode-width" @@ -1067,11 +1015,11 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "ureq" -version = "1.5.2" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599426c7388ab189dfd0eeb84c8d879490abc73e3e62a0b6a40e286f6427ab7" +checksum = "294b85ef5dbc3670a72e82a89971608a1fcc4ed5c7c5a2895230d31a95f0569b" dependencies = [ - "base64 0.13.0", + "base64", "chunked_transfer", "log", "native-tls", @@ -1085,9 +1033,9 @@ dependencies = [ [[package]] name = "url" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5909f2b0817350449ed73e8bcd81c8c3c8d9a7a5d8acba4b27db277f1868976e" +checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b" dependencies = [ "form_urlencoded", "idna", @@ -1097,9 +1045,9 @@ dependencies = [ [[package]] name = "vcpkg" -version = "0.2.10" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6454029bf181f092ad1b853286f23e2c507d8e8194d01d92da4a55c274a5508c" +checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" [[package]] name = "vec_map" @@ -1109,31 +1057,25 @@ checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" [[package]] name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" +version = "0.10.2+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "wasm-bindgen" -version = "0.2.68" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac64ead5ea5f05873d7c12b545865ca2b8d28adfc50a49b84770a3a97265d42" +checksum = "83240549659d187488f91f33c0f8547cbfef0b2088bc470c116d1d260ef623d9" dependencies = [ - "cfg-if 0.1.10", + "cfg-if", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.68" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f22b422e2a757c35a73774860af8e112bff612ce6cb604224e8e47641a9e4f68" +checksum = "ae70622411ca953215ca6d06d3ebeb1e915f0f6613e3b495122878d7ebec7dae" dependencies = [ "bumpalo", "lazy_static", @@ -1146,9 +1088,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.68" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b13312a745c08c469f0b292dd2fcd6411dba5f7160f593da6ef69b64e407038" +checksum = "3e734d91443f177bfdb41969de821e15c516931c3c3db3d318fa1b68975d0f6f" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1156,9 +1098,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.68" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f249f06ef7ee334cc3b8ff031bfc11ec99d00f34d86da7498396dc1e3b1498fe" +checksum = "d53739ff08c8a68b0fdbcd54c372b8ab800b1449ab3c9d706503bc7dd1621b2c" dependencies = [ "proc-macro2", "quote", @@ -1169,15 +1111,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.68" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d649a3145108d7d3fbcde896a468d1bd636791823c9921135218ad89be08307" +checksum = "d9a543ae66aa233d14bb765ed9af4a33e81b8b58d1584cf1b47ff8cd0b9e4489" [[package]] name = "web-sys" -version = "0.3.45" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf6ef87ad7ae8008e15a355ce696bed26012b7caa21605188cfd8214ab51e2d" +checksum = "a905d57e488fec8861446d3393670fb50d27a262344013181c2cdf9fff5481be" dependencies = [ "js-sys", "wasm-bindgen", @@ -1185,9 +1127,9 @@ dependencies = [ [[package]] name = "webpki" -version = "0.21.3" +version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab146130f5f790d45f82aeeb09e55a256573373ec64409fc19a6fb82fb1032ae" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" dependencies = [ "ring", "untrusted", @@ -1195,9 +1137,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f20dea7535251981a9670857150d571846545088359b28e4951d350bdaf179f" +checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" dependencies = [ "webpki", ] diff --git a/Cargo.toml b/Cargo.toml index c55bf62..913882e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcaster" -version = "1.1.0" +version = "1.2.0" authors = ["Jeff Hughes "] edition = "2018" license = "GPL-3.0-or-later" @@ -17,25 +17,27 @@ readme = "README.md" [dependencies] pancurses = "0.16.1" -rss = "1.9.0" +rss = "1.10.0" rusqlite = "0.21.0" clap = "2.33.1" -toml = "0.5.6" -serde = { version = "1.0.106", features = ["derive"] } +toml = "0.5.8" +anyhow = "1.0.40" +serde = { version = "1.0.125", features = ["derive"] } chrono = "0.4.11" lazy_static = "1.4.0" -regex = "1.3.6" +regex = "1.5.4" sanitize-filename = "0.2.1" -shellexpand = "2.0.0" -dirs = { package = "dirs-next", version = "1.0.1" } +shellexpand = "2.1.0" +dirs = { package = "dirs-next", version = "2.0.0" } opml = "0.2.4" -unicode-segmentation = "1.6.0" -textwrap = "0.12.1" +nohash-hasher = "0.2.0" +unicode-segmentation = "1.7.1" +textwrap = "0.13.4" escaper = "0.1.0" semver = "0.10.0" [dependencies.ureq] -version = "1.3.0" +version = "1.5.4" default-features = false diff --git a/README.md b/README.md index 843c1c1..137e099 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,10 @@ The sample file above provides comments that should walk you through all the ava | ------- | -------------- | | ? | Open help window | | Arrow keys / h,j,k,l | Navigate menus | +| Shift+K | Up 1/4 page | +| Shift+J | Down 1/4 page | +| PgUp | Page up | +| PgDn | Page down | | a | Add new feed | | q | Quit program | | s | Synchronize selected feed | @@ -193,6 +197,10 @@ The sample file above provides comments that should walk you through all the ava **Note:** Actions can be mapped to more than one key (e.g., "Enter" and "p" both play an episode), but a single key may not do more than one action (e.g., you can't set "d" to both download and delete episodes). +#### Customizable colors + +You can set the colors in the app with either built-in terminal colors or (provided your terminal supports it) customizable colors as well. See the "colors" section in the [config.toml](https://github.com/jeff-hughes/shellcaster/blob/master/config.toml) for details about how to specify these colors! + ## Syncing without the UI Some users may wish to sync their podcasts automatically on a regular basis, e.g., every morning. The `shellcaster sync` subcommand can be used to do this without opening up the UI, and does a full sync of all podcasts in the database. This could be used to set up a cron job or systemd timer, for example. Please refer to the relevant documentation for these systems for setting it up on the schedule of your choice. diff --git a/config.toml b/config.toml index 82b1c37..b0a9bea 100644 --- a/config.toml +++ b/config.toml @@ -69,6 +69,12 @@ left = [ "Left", "h" ] right = [ "Right", "l" ] up = [ "Up", "k" ] down = [ "Down", "j" ] +big_up = [ "K" ] +big_down = [ "J" ] +page_up = [ "PgUp" ] +page_down = [ "PgDn" ] +go_top = [ "g" ] +go_bot = [ "G" ] add_feed = [ "a" ] sync = [ "s" ] @@ -86,4 +92,39 @@ remove = [ "r" ] remove_all = [ "R" ] help = [ "?" ] -quit = [ "q" ] \ No newline at end of file +quit = [ "q" ] + + +[colors] + +# Colors can be identified in three ways: +# 1. Using color names defined by your terminal: +# - black, blue, cyan, green, magenta, red, white, or yellow +# - The special color name "terminal" can also be used to specify +# your terminal's default foreground or background color; this is +# particularly useful if your terminal background is transparent -- +# use "terminal" for the background colors below. +# 2. Using a hex code in the format "#ff0000" or "#FF0000" to specify +# RGB values. +# 3. Using an RGB value in the format "rgb(255, 0, 0)" where each number +# is a value between 0 and 255. +# Note that, as might be expected, the ability to set colors depends on +# the capabilities of your terminal. Config options set below are ignored +# on terminals without the ability to add/change colors. + +# all regular text +normal_foreground = "white" +normal_background = "black" + +# colors for the currently selected podcast/episode +highlighted_active_foreground = "rgb(85, 85, 85)" +highlighted_active_background = "rgb(209, 164, 0)" + +# colors for the selected podcast, when the cursor is on the episode +# menu; podcast is selected, but not currently "active" +highlighted_foreground = "rgb(85, 85, 85)" +highlighted_background = "rgb(173, 173, 173)" + +# text for error messages +error_foreground = "red" +error_background = "black" diff --git a/src/config.rs b/src/config.rs index ff49265..8e91012 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,11 @@ +use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use std::fs::File; use std::io::Read; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::keymap::{Keybindings, UserAction}; +use crate::keymap::Keybindings; +use crate::ui::colors::ColorValue; // Specifies how long, in milliseconds, to display messages at the // bottom of the screen in the UI. @@ -25,6 +27,10 @@ pub const EPISODE_PUBDATE_LENGTH: usize = 60; // display the details panel pub const DETAILS_PANEL_LENGTH: i32 = 135; +// How many lines will be scrolled by the big scroll, +// in relation to the rows eg: 4 = 1/4 of the screen +pub const BIG_SCROLL_AMOUNT: i32 = 4; + /// Identifies the user's selection for what to do with new episodes /// when syncing. @@ -45,6 +51,7 @@ pub struct Config { pub simultaneous_downloads: usize, pub max_retries: usize, pub keybindings: Keybindings, + pub colors: AppColors, } /// A temporary struct used to deserialize data from the TOML configuration @@ -56,31 +63,116 @@ struct ConfigFromToml { download_new_episodes: Option, simultaneous_downloads: Option, max_retries: Option, - keybindings: KeybindingsFromToml, + keybindings: Option, + colors: Option, } /// A temporary struct used to deserialize keybinding data from the TOML /// configuration file. #[derive(Debug, Deserialize)] -struct KeybindingsFromToml { - left: Option>, - right: Option>, - up: Option>, - down: Option>, - add_feed: Option>, - sync: Option>, - sync_all: Option>, - play: Option>, - mark_played: Option>, - mark_all_played: Option>, - download: Option>, - download_all: Option>, - delete: Option>, - delete_all: Option>, - remove: Option>, - remove_all: Option>, - help: Option>, - quit: Option>, +pub struct KeybindingsFromToml { + pub left: Option>, + pub right: Option>, + pub up: Option>, + pub down: Option>, + pub big_up: Option>, + pub big_down: Option>, + pub go_top: Option>, + pub go_bot: Option>, + pub page_up: Option>, + pub page_down: Option>, + pub add_feed: Option>, + pub sync: Option>, + pub sync_all: Option>, + pub play: Option>, + pub mark_played: Option>, + pub mark_all_played: Option>, + pub download: Option>, + pub download_all: Option>, + pub delete: Option>, + pub delete_all: Option>, + pub remove: Option>, + pub remove_all: Option>, + pub help: Option>, + pub quit: Option>, +} + +/// Holds information about the colors to use in the application. Tuple +/// values represent (foreground, background), respectively. +#[derive(Debug, Clone)] +pub struct AppColors { + pub normal: (ColorValue, ColorValue), + pub highlighted_active: (ColorValue, ColorValue), + pub highlighted: (ColorValue, ColorValue), + pub error: (ColorValue, ColorValue), +} + +impl AppColors { + pub fn default() -> Self { + return Self { + normal: (ColorValue::White, ColorValue::Black), + highlighted_active: (ColorValue::Black, ColorValue::Yellow), + highlighted: (ColorValue::Black, ColorValue::White), + error: (ColorValue::Red, ColorValue::Black), + }; + } + + pub fn add_from_config(&mut self, config: AppColorsFromToml) { + if let Some(val) = config.normal_foreground { + if let Ok(v) = ColorValue::from_str(&val) { + self.normal.0 = v; + } + } + if let Some(val) = config.normal_background { + if let Ok(v) = ColorValue::from_str(&val) { + self.normal.1 = v; + } + } + if let Some(val) = config.highlighted_active_foreground { + if let Ok(v) = ColorValue::from_str(&val) { + self.highlighted_active.0 = v; + } + } + if let Some(val) = config.highlighted_active_background { + if let Ok(v) = ColorValue::from_str(&val) { + self.highlighted_active.1 = v; + } + } + if let Some(val) = config.highlighted_foreground { + if let Ok(v) = ColorValue::from_str(&val) { + self.highlighted.0 = v; + } + } + if let Some(val) = config.highlighted_background { + if let Ok(v) = ColorValue::from_str(&val) { + self.highlighted.1 = v; + } + } + if let Some(val) = config.error_foreground { + if let Ok(v) = ColorValue::from_str(&val) { + self.error.0 = v; + } + } + if let Some(val) = config.error_background { + if let Ok(v) = ColorValue::from_str(&val) { + self.error.1 = v; + } + } + } +} + +/// A temporary struct used to deserialize colors data from the TOML +/// configuration file. +#[derive(Debug, Deserialize)] +pub struct AppColorsFromToml { + normal_foreground: Option, + normal_background: Option, + highlighted_active_foreground: Option, + highlighted_active_background: Option, + highlighted_foreground: Option, + highlighted_background: Option, + error_foreground: Option, + error_background: Option, } @@ -88,16 +180,17 @@ impl Config { /// Given a file path, this reads a TOML config file and returns a /// Config struct with keybindings, etc. Inserts defaults if config /// file does not exist, or if specific values are not set. - pub fn new(path: &PathBuf) -> Config { + pub fn new(path: &Path) -> Result { let mut config_string = String::new(); let config_toml: ConfigFromToml; match File::open(path) { Ok(mut file) => { - file.read_to_string(&mut config_string) - .expect("Error reading config.toml. Please ensure file is readable."); + file.read_to_string(&mut config_string).with_context(|| { + "Could not read config.toml. Please ensure file is readable." + })?; config_toml = toml::from_str(&config_string) - .expect("Error parsing config.toml. Please check file syntax."); + .with_context(|| "Could not parse config.toml. Please check file syntax.")?; } Err(_) => { // if we can't find the file, set everything to empty @@ -107,6 +200,12 @@ impl Config { right: None, up: None, down: None, + big_up: None, + big_down: None, + go_top: None, + go_bot: None, + page_up: None, + page_down: None, add_feed: None, sync: None, sync_all: None, @@ -122,18 +221,30 @@ impl Config { help: None, quit: None, }; + + let colors = AppColorsFromToml { + normal_foreground: None, + normal_background: None, + highlighted_active_foreground: None, + highlighted_active_background: None, + highlighted_foreground: None, + highlighted_background: None, + error_foreground: None, + error_background: None, + }; config_toml = ConfigFromToml { download_path: None, play_command: None, download_new_episodes: None, simultaneous_downloads: None, max_retries: None, - keybindings: keybindings, + keybindings: Some(keybindings), + colors: Some(colors), }; } } - return config_with_defaults(&config_toml); + return config_with_defaults(config_toml); } } @@ -141,48 +252,27 @@ impl Config { /// that specifies user settings where indicated, and defaults for any /// settings that were not specified by the user. #[allow(clippy::type_complexity)] -fn config_with_defaults(config_toml: &ConfigFromToml) -> Config { - // specify all default keybindings for actions - #[rustfmt::skip] - let action_map: Vec<(&Option>, UserAction, Vec)> = vec![ - (&config_toml.keybindings.left, UserAction::Left, vec!["Left".to_string(), "h".to_string()]), - (&config_toml.keybindings.right, UserAction::Right, vec!["Right".to_string(), "l".to_string()]), - (&config_toml.keybindings.up, UserAction::Up, vec!["Up".to_string(), "k".to_string()]), - (&config_toml.keybindings.down, UserAction::Down, vec!["Down".to_string(), "j".to_string()]), - - (&config_toml.keybindings.add_feed, UserAction::AddFeed, vec!["a".to_string()]), - (&config_toml.keybindings.sync, UserAction::Sync, vec!["s".to_string()]), - (&config_toml.keybindings.sync_all, UserAction::SyncAll, vec!["S".to_string()]), - - (&config_toml.keybindings.play, UserAction::Play, vec!["Enter".to_string(), "p".to_string()]), - (&config_toml.keybindings.mark_played, UserAction::MarkPlayed, vec!["m".to_string()]), - (&config_toml.keybindings.mark_all_played, UserAction::MarkAllPlayed, vec!["M".to_string()]), - - (&config_toml.keybindings.download, UserAction::Download, vec!["d".to_string()]), - (&config_toml.keybindings.download_all, UserAction::DownloadAll, vec!["D".to_string()]), - (&config_toml.keybindings.delete, UserAction::Delete, vec!["x".to_string()]), - (&config_toml.keybindings.delete_all, UserAction::DeleteAll, vec!["X".to_string()]), - (&config_toml.keybindings.remove, UserAction::Remove, vec!["r".to_string()]), - (&config_toml.keybindings.remove_all, UserAction::RemoveAll, vec!["R".to_string()]), - - (&config_toml.keybindings.help, UserAction::Help, vec!["?".to_string()]), - (&config_toml.keybindings.quit, UserAction::Quit, vec!["q".to_string()]), - ]; - - // for each action, if user preference is set, use that, otherwise, - // use the default - let mut keymap = Keybindings::new(); - for (config, action, defaults) in action_map.iter() { - match config { - Some(v) => keymap.insert_from_vec(v, *action), - None => keymap.insert_from_vec(&defaults, *action), +fn config_with_defaults(config_toml: ConfigFromToml) -> Result { + // specify keybindings + let keymap = match config_toml.keybindings { + Some(kb) => Keybindings::from_config(kb), + None => Keybindings::default(), + }; + + // specify app colors + let colors = match config_toml.colors { + Some(clrs) => { + let mut colors = AppColors::default(); + colors.add_from_config(clrs); + colors } - } + None => AppColors::default(), + }; // paths are set by user, or they resolve to OS-specific path as // provided by dirs crate let download_path = - parse_create_dir(config_toml.download_path.as_deref(), dirs::data_local_dir()); + parse_create_dir(config_toml.download_path.as_deref(), dirs::data_local_dir())?; let play_command = match config_toml.play_command.as_deref() { Some(cmd) => cmd.to_string(), @@ -209,14 +299,15 @@ fn config_with_defaults(config_toml: &ConfigFromToml) -> Config { None => 3, }; - return Config { + return Ok(Config { download_path: download_path, play_command: play_command, download_new_episodes: download_new_episodes, simultaneous_downloads: simultaneous_downloads, max_retries: max_retries, keybindings: keymap, - }; + colors: colors, + }); } @@ -226,29 +317,35 @@ fn config_with_defaults(config_toml: &ConfigFromToml) -> Config { /// variables cannot be found, if OS could not produce the appropriate /// default directory, or if the specified directories in the path could /// not be created. -fn parse_create_dir(user_dir: Option<&str>, default: Option) -> PathBuf { +fn parse_create_dir(user_dir: Option<&str>, default: Option) -> Result { let final_path = match user_dir { - Some(path) => { - match shellexpand::full(path) { - Ok(realpath) => PathBuf::from(realpath.as_ref()), - Err(err) => panic!("Could not parse environment variable {} in config.toml. Reason: {}", err.var_name, err.cause), + Some(path) => match shellexpand::full(path) { + Ok(realpath) => PathBuf::from(realpath.as_ref()), + Err(err) => { + return Err(anyhow!( + "Could not parse environment variable {} in config.toml. Reason: {}", + err.var_name, + err.cause + )) } }, None => { - match default { - Some(mut path) => { - path.push("shellcaster"); - path - }, - None => panic!("Could not identify a default directory for your OS. Please specify paths manually in config.toml."), + if let Some(mut path) = default { + path.push("shellcaster"); + path + } else { + return Err(anyhow!("Could not identify a default directory for your OS. Please specify paths manually in config.toml.")); } - }, + } }; // create directories if they do not exist - if let Err(err) = std::fs::create_dir_all(&final_path) { - panic!("Could not create filepath: {}", err); - } + std::fs::create_dir_all(&final_path).with_context(|| { + format!( + "Could not create filepath: {}", + final_path.to_string_lossy() + ) + })?; - return final_path; + return Ok(final_path); } diff --git a/src/db.rs b/src/db.rs index 7c89b20..8280872 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,5 @@ -use std::path::PathBuf; +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; use chrono::{DateTime, NaiveDateTime, Utc}; use lazy_static::lazy_static; @@ -11,7 +12,7 @@ use crate::types::*; lazy_static! { /// Regex for removing "A", "An", and "The" from the beginning of /// podcast titles - static ref RE_ARTICLES: Regex = Regex::new(r"^(a|an|the) ").unwrap(); + static ref RE_ARTICLES: Regex = Regex::new(r"^(a|an|the) ").expect("Regex error."); } @@ -30,70 +31,63 @@ pub struct Database { impl Database { /// Creates a new connection to the database (and creates database if /// it does not already exist). Panics if database cannot be accessed. - pub fn connect(path: &PathBuf) -> Database { - let mut db_path = path.clone(); - if std::fs::create_dir_all(&db_path).is_err() { - panic!("Unable to create subdirectory for database."); - } + pub fn connect(path: &Path) -> Result { + let mut db_path = path.to_path_buf(); + std::fs::create_dir_all(&db_path) + .with_context(|| "Unable to create subdirectory for database.")?; db_path.push("data.db"); - match Connection::open(db_path) { - Ok(conn) => { - let db_conn = Database { - conn: Some(conn), - }; - db_conn.create(); - - { - let conn = db_conn.conn.as_ref().unwrap(); - - // SQLite defaults to foreign key support off - conn.execute("PRAGMA foreign_keys=ON;", params![]).unwrap(); - - // get version number stored in database - let mut stmt = conn - .prepare("SELECT version FROM version WHERE id = 1;") - .unwrap(); - let db_version = stmt.query_row(params![], |row| { - let vstr: String = row.get("version")?; - Ok(Version::parse(&vstr).unwrap()) - }); - - // compare to current app version - let curr_ver = Version::parse(crate::VERSION).unwrap(); - // (db_version exists, needs update) - let to_update = match db_version { - Ok(dbv) => { - if dbv < curr_ver { - (true, true) - } else { - (true, false) - } - } - Err(_) => (false, true), - }; - - if to_update.1 { - // any version checks for DB migrations should go - // here first, before we update the version - - db_conn.update_version(curr_ver, to_update.0); - } + let conn = Connection::open(db_path)?; + let db_conn = Database { + conn: Some(conn), + }; + db_conn.create()?; + + { + let conn = db_conn + .conn + .as_ref() + .expect("Error connecting to database."); + + // SQLite defaults to foreign key support off + conn.execute("PRAGMA foreign_keys=ON;", params![]) + .expect("Could not set database parameters."); + + // get version number stored in database + let mut stmt = conn.prepare("SELECT version FROM version WHERE id = 1;")?; + let vstr: Result = + stmt.query_row(params![], |row| row.get("version")); + + // compare to current app version + let curr_ver = Version::parse(crate::VERSION)?; + + // (db_version exists, needs update) + let to_update = match vstr { + Ok(vstr) => { + let db_version = Version::parse(&vstr)?; + (true, db_version < curr_ver) } + Err(_) => (false, true), + }; + + if to_update.1 { + // any version checks for DB migrations should go + // here first, before we update the version - return db_conn; + db_conn.update_version(curr_ver, to_update.0)?; } - Err(err) => panic!("Could not open database: {}", err), - }; + } + + return Ok(db_conn); } /// Creates the necessary database tables, if they do not already /// exist. Panics if database cannot be accessed, or if tables cannot /// be created. - pub fn create(&self) { - let conn = self.conn.as_ref().unwrap(); + pub fn create(&self) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); // create podcasts table - match conn.execute( + conn.execute( "CREATE TABLE IF NOT EXISTS podcasts ( id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, @@ -104,13 +98,11 @@ impl Database { last_checked INTEGER );", params![], - ) { - Ok(_) => (), - Err(err) => panic!("Could not create podcasts database table: {}", err), - } + ) + .with_context(|| "Could not create podcasts database table")?; // create episodes table - match conn.execute( + conn.execute( "CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY NOT NULL, podcast_id INTEGER NOT NULL, @@ -124,13 +116,11 @@ impl Database { FOREIGN KEY(podcast_id) REFERENCES podcasts(id) ON DELETE CASCADE );", params![], - ) { - Ok(_) => (), - Err(err) => panic!("Could not create episodes database table: {}", err), - } + ) + .with_context(|| "Could not create episodes database table")?; // create files table - match conn.execute( + conn.execute( "CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY NOT NULL, episode_id INTEGER NOT NULL, @@ -138,72 +128,62 @@ impl Database { FOREIGN KEY (episode_id) REFERENCES episodes(id) ON DELETE CASCADE );", params![], - ) { - Ok(_) => (), - Err(err) => panic!("Could not create files database table: {}", err), - } + ) + .with_context(|| "Could not create files database table")?; - match conn.execute( + conn.execute( "CREATE TABLE IF NOT EXISTS version ( id INTEGER PRIMARY KEY NOT NULL, version TEXT NOT NULL );", params![], - ) { - Ok(_) => (), - Err(err) => panic!("Could not create version database table: {}", err), - } + ) + .with_context(|| "Could not create version database table")?; + return Ok(()); } /// If version stored in database is less than the current version /// of the app, this updates the value stored in the database to /// match. - fn update_version(&self, current_version: Version, update: bool) { - let conn = self.conn.as_ref().unwrap(); + fn update_version(&self, current_version: Version, update: bool) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); if update { - let _ = conn.execute( + conn.execute( "UPDATE version SET version = ? WHERE id = ?;", params![current_version.to_string(), 1], - ); + )?; } else { - let _ = conn.execute( + conn.execute( "INSERT INTO version (id, version) VALUES (?, ?)", params![1, current_version.to_string()], - ); + )?; } + return Ok(()); } /// Inserts a new podcast and list of podcast episodes into the /// database. - pub fn insert_podcast( - &self, - podcast: PodcastNoId, - ) -> Result> - { - let conn = self.conn.as_ref().unwrap(); - let _ = conn.execute( + pub fn insert_podcast(&self, podcast: PodcastNoId) -> Result { + let conn = self.conn.as_ref().expect("Error connecting to database."); + let mut stmt = conn.prepare_cached( "INSERT INTO podcasts (title, url, description, author, explicit, last_checked) VALUES (?, ?, ?, ?, ?, ?);", - params![ - podcast.title, - podcast.url, - podcast.description, - podcast.author, - podcast.explicit, - podcast.last_checked.timestamp() - ], )?; - - let mut stmt = conn - .prepare("SELECT id FROM podcasts WHERE url = ?") - .unwrap(); - let pod_id = stmt - .query_row::(params![podcast.url], |row| row.get(0)) - .unwrap(); + stmt.execute(params![ + podcast.title, + podcast.url, + podcast.description, + podcast.author, + podcast.explicit, + podcast.last_checked.timestamp() + ])?; + + let mut stmt = conn.prepare_cached("SELECT id FROM podcasts WHERE url = ?")?; + let pod_id = stmt.query_row::(params![podcast.url], |row| row.get(0))?; let mut ep_ids = Vec::new(); for ep in podcast.episodes.iter().rev() { let id = self.insert_episode(pod_id, &ep)?; @@ -224,118 +204,99 @@ impl Database { } /// Inserts a podcast episode into the database. - pub fn insert_episode( - &self, - podcast_id: i64, - episode: &EpisodeNoId, - ) -> Result> - { - let conn = self.conn.as_ref().unwrap(); + pub fn insert_episode(&self, podcast_id: i64, episode: &EpisodeNoId) -> Result { + let conn = self.conn.as_ref().expect("Error connecting to database."); let pubdate = match episode.pubdate { Some(dt) => Some(dt.timestamp()), None => None, }; - let _ = conn.execute( + let mut stmt = conn.prepare_cached( "INSERT INTO episodes (podcast_id, title, url, description, pubdate, duration, played, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?);", - params![ - podcast_id, - episode.title, - episode.url, - episode.description, - pubdate, - episode.duration, - false, - false, - ], )?; + stmt.execute(params![ + podcast_id, + episode.title, + episode.url, + episode.description, + pubdate, + episode.duration, + false, + false, + ])?; return Ok(conn.last_insert_rowid()); } /// Inserts a filepath to a downloaded episode. - pub fn insert_file( - &self, - episode_id: i64, - path: &PathBuf, - ) -> Result<(), Box> - { - let conn = self.conn.as_ref().unwrap(); + pub fn insert_file(&self, episode_id: i64, path: &Path) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); - let _ = conn.execute( + let mut stmt = conn.prepare_cached( "INSERT INTO files (episode_id, path) VALUES (?, ?);", - params![episode_id, path.to_str(),], )?; + stmt.execute(params![episode_id, path.to_str(),])?; return Ok(()); } /// Removes a file listing for an episode from the database when the /// user has chosen to delete the file. - pub fn remove_file(&self, episode_id: i64) { - let conn = self.conn.as_ref().unwrap(); - let _ = conn - .execute("DELETE FROM files WHERE episode_id = ?;", params![ - episode_id - ]) - .unwrap(); + pub fn remove_file(&self, episode_id: i64) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); + let mut stmt = conn.prepare_cached("DELETE FROM files WHERE episode_id = ?;")?; + stmt.execute(params![episode_id])?; + return Ok(()); } /// Removes all file listings for the selected episode ids. - pub fn remove_files(&self, episode_ids: &[i64]) { - let conn = self.conn.as_ref().unwrap(); + pub fn remove_files(&self, episode_ids: &[i64]) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); // convert list of episode ids into a comma-separated String let episode_list: Vec = episode_ids.iter().map(|x| x.to_string()).collect(); let episodes = episode_list.join(", "); - let _ = conn - .execute("DELETE FROM files WHERE episode_id = (?);", params![ - episodes - ]) - .unwrap(); + let mut stmt = conn.prepare_cached("DELETE FROM files WHERE episode_id = (?);")?; + stmt.execute(params![episodes])?; + return Ok(()); } /// Removes a podcast, all episodes, and files from the database. - pub fn remove_podcast(&self, podcast_id: i64) { - let conn = self.conn.as_ref().unwrap(); + pub fn remove_podcast(&self, podcast_id: i64) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); // Note: Because of the foreign key constraints on `episodes` // and `files` tables, all associated episodes for this podcast // will also be deleted, and all associated file entries for // those episodes as well. - let _ = conn - .execute("DELETE FROM podcasts WHERE id = ?;", params![podcast_id]) - .unwrap(); + let mut stmt = conn.prepare_cached("DELETE FROM podcasts WHERE id = ?;")?; + stmt.execute(params![podcast_id])?; + return Ok(()); } /// Updates an existing podcast in the database, where metadata is /// changed if necessary, and episodes are updated (modified episodes /// are updated, new episodes are inserted). - pub fn update_podcast( - &self, - pod_id: i64, - podcast: PodcastNoId, - ) -> Result> - { - let conn = self.conn.as_ref().unwrap(); - let _ = conn.execute( + pub fn update_podcast(&self, pod_id: i64, podcast: PodcastNoId) -> Result { + let conn = self.conn.as_ref().expect("Error connecting to database."); + let mut stmt = conn.prepare_cached( "UPDATE podcasts SET title = ?, url = ?, description = ?, author = ?, explicit = ?, last_checked = ? WHERE id = ?;", - params![ - podcast.title, - podcast.url, - podcast.description, - podcast.author, - podcast.explicit, - podcast.last_checked.timestamp(), - pod_id, - ], )?; - - let result = self.update_episodes(pod_id, podcast.title, podcast.episodes); + stmt.execute(params![ + podcast.title, + podcast.url, + podcast.description, + podcast.author, + podcast.explicit, + podcast.last_checked.timestamp(), + pod_id, + ])?; + + let result = self.update_episodes(pod_id, podcast.title, podcast.episodes)?; return Ok(result); } @@ -352,11 +313,10 @@ impl Database { podcast_id: i64, podcast_title: String, episodes: Vec, - ) -> SyncResult - { - let conn = self.conn.as_ref().unwrap(); + ) -> Result { + let conn = self.conn.as_ref().expect("Error connecting to database."); - let old_episodes = self.get_episodes(podcast_id); + let old_episodes = self.get_episodes(podcast_id, true)?; let mut insert_ep = Vec::new(); let mut update_ep = Vec::new(); @@ -404,26 +364,24 @@ impl Database { match existing_id { Some(id) => { if update { - let _ = conn - .execute( - "UPDATE episodes SET title = ?, url = ?, + let mut stmt = conn.prepare_cached( + "UPDATE episodes SET title = ?, url = ?, description = ?, pubdate = ?, duration = ? WHERE id = ?;", - params![ - new_ep.title, - new_ep.url, - new_ep.description, - new_pd, - new_ep.duration, - id, - ], - ) - .unwrap(); + )?; + stmt.execute(params![ + new_ep.title, + new_ep.url, + new_ep.description, + new_pd, + new_ep.duration, + id, + ])?; update_ep.push(id); } } None => { - let id = self.insert_episode(podcast_id, &new_ep).unwrap(); + let id = self.insert_episode(podcast_id, &new_ep)?; let new_ep = NewEpisode { id: id, pod_id: podcast_id, @@ -435,125 +393,123 @@ impl Database { } } } - return SyncResult { + return Ok(SyncResult { added: insert_ep, updated: update_ep, - }; + }); } /// Updates an episode to mark it as played or unplayed. - pub fn set_played_status(&self, episode_id: i64, played: bool) { - let conn = self.conn.as_ref().unwrap(); - - let _ = conn - .execute("UPDATE episodes SET played = ? WHERE id = ?;", params![ - played, episode_id - ]) - .unwrap(); + pub fn set_played_status(&self, episode_id: i64, played: bool) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); + + let mut stmt = conn.prepare_cached("UPDATE episodes SET played = ? WHERE id = ?;")?; + stmt.execute(params![played, episode_id])?; + return Ok(()); } /// Updates an episode to "remove" it by hiding it. "Removed" /// episodes need to stay in the database so that they don't get /// re-added when the podcast is synced again. - pub fn hide_episode(&self, episode_id: i64, hide: bool) { - let conn = self.conn.as_ref().unwrap(); - - let _ = conn - .execute("UPDATE episodes SET hidden = ? WHERE id = ?;", params![ - hide, episode_id - ]) - .unwrap(); + pub fn hide_episode(&self, episode_id: i64, hide: bool) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); + + let mut stmt = conn.prepare_cached("UPDATE episodes SET hidden = ? WHERE id = ?;")?; + stmt.execute(params![hide, episode_id])?; + return Ok(()); } /// Generates list of all podcasts in database. /// TODO: This should probably use a JOIN statement instead. - pub fn get_podcasts(&self) -> Vec { - if let Some(conn) = &self.conn { - let mut stmt = conn.prepare("SELECT * FROM podcasts;").unwrap(); - let podcast_iter = stmt - .query_map(params![], |row| { - let pod_id = row.get("id")?; - let episodes = self.get_episodes(pod_id); - - // create a sort title that is lowercased and removes - // articles from the beginning - let title: String = row.get("title")?; - let title_lower = title.to_lowercase(); - let sort_title = RE_ARTICLES.replace(&title_lower, "").to_string(); - - Ok(Podcast { - id: pod_id, - title: title, - sort_title: sort_title, - url: row.get("url")?, - description: row.get("description")?, - author: row.get("author")?, - explicit: row.get("explicit")?, - last_checked: convert_date(row.get("last_checked")).unwrap(), - episodes: LockVec::new(episodes), - }) - }) - .unwrap(); - let mut podcasts = Vec::new(); - for pc in podcast_iter { - podcasts.push(pc.unwrap()); - } - podcasts.sort_unstable(); - - return podcasts; - } else { - return Vec::new(); + pub fn get_podcasts(&self) -> Result> { + let conn = self.conn.as_ref().expect("Error connecting to database."); + let mut stmt = conn.prepare_cached("SELECT * FROM podcasts;")?; + let podcast_iter = stmt.query_map(params![], |row| { + let pod_id = row.get("id")?; + let episodes = match self.get_episodes(pod_id, false) { + Ok(ep_list) => Ok(ep_list), + Err(_) => Err(rusqlite::Error::QueryReturnedNoRows), + }?; + + // create a sort title that is lowercased and removes + // articles from the beginning + let title: String = row.get("title")?; + let title_lower = title.to_lowercase(); + let sort_title = RE_ARTICLES.replace(&title_lower, "").to_string(); + + Ok(Podcast { + id: pod_id, + title: title, + sort_title: sort_title, + url: row.get("url")?, + description: row.get("description")?, + author: row.get("author")?, + explicit: row.get("explicit")?, + last_checked: convert_date(row.get("last_checked")).unwrap(), + episodes: LockVec::new(episodes), + }) + })?; + let mut podcasts = Vec::new(); + for pc in podcast_iter { + podcasts.push(pc?); } + podcasts.sort_unstable(); + + return Ok(podcasts); } /// Generates list of episodes for a given podcast. - pub fn get_episodes(&self, pod_id: i64) -> Vec { - if let Some(conn) = &self.conn { - let mut stmt = conn - .prepare( - "SELECT * FROM episodes - LEFT JOIN files ON episodes.id = files.episode_id - WHERE episodes.podcast_id = ? - AND episodes.hidden = 0 - ORDER BY pubdate DESC;", - ) - .unwrap(); - let episode_iter = stmt - .query_map(params![pod_id], |row| { - let path = match row.get::<&str, String>("path") { - Ok(val) => Some(PathBuf::from(val)), - Err(_) => None, - }; - Ok(Episode { - id: row.get("id")?, - pod_id: row.get("podcast_id")?, - title: row.get("title")?, - url: row.get("url")?, - description: row.get("description")?, - pubdate: convert_date(row.get("pubdate")), - duration: row.get("duration")?, - path: path, - played: row.get("played")?, - }) - }) - .unwrap(); - let mut episodes = Vec::new(); - for ep in episode_iter { - episodes.push(ep.unwrap()); - } - return episodes; + pub fn get_episodes(&self, pod_id: i64, include_hidden: bool) -> Result> { + let conn = self.conn.as_ref().expect("Error connecting to database."); + let mut stmt = if include_hidden { + conn.prepare_cached( + "SELECT * FROM episodes + LEFT JOIN files ON episodes.id = files.episode_id + WHERE episodes.podcast_id = ? + ORDER BY pubdate DESC;", + )? } else { - return Vec::new(); + conn.prepare_cached( + "SELECT * FROM episodes + LEFT JOIN files ON episodes.id = files.episode_id + WHERE episodes.podcast_id = ? + AND episodes.hidden = 0 + ORDER BY pubdate DESC;", + )? + }; + let episode_iter = stmt.query_map(params![pod_id], |row| { + let path = match row.get::<&str, String>("path") { + Ok(val) => Some(PathBuf::from(val)), + Err(_) => None, + }; + Ok(Episode { + id: row.get("id")?, + pod_id: row.get("podcast_id")?, + title: row.get("title")?, + url: row.get("url")?, + description: row.get("description")?, + pubdate: convert_date(row.get("pubdate")), + duration: row.get("duration")?, + path: path, + played: row.get("played")?, + }) + })?; + let mut episodes = Vec::new(); + for ep in episode_iter { + if let Ok(ep) = ep { + episodes.push(ep); + } } + return Ok(episodes); } /// Deletes all rows in all tables - pub fn clear_db(&self) -> Result<(), rusqlite::Error> { - let conn = self.conn.as_ref().unwrap(); + pub fn clear_db(&self) -> Result<()> { + let conn = self.conn.as_ref().expect("Error connecting to database."); conn.execute("DELETE FROM files;", params![])?; conn.execute("DELETE FROM episodes;", params![])?; conn.execute("DELETE FROM podcasts;", params![])?; - Ok(()) + return Ok(()); } } diff --git a/src/downloads.rs b/src/downloads.rs index a29807e..a7e72c2 100644 --- a/src/downloads.rs +++ b/src/downloads.rs @@ -1,7 +1,8 @@ use std::fs::File; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::mpsc::Sender; +use chrono::{DateTime, Utc}; use sanitize_filename::{sanitize_with_options, Options}; use crate::threadpool::Threadpool; @@ -25,6 +26,7 @@ pub struct EpData { pub pod_id: i64, pub title: String, pub url: String, + pub pubdate: Option>, pub file_path: Option, } @@ -34,19 +36,19 @@ pub struct EpData { /// by the user while there are still ongoing jobs. pub fn download_list( episodes: Vec, - dest: &PathBuf, + dest: &Path, max_retries: usize, threadpool: &Threadpool, tx_to_main: Sender, -) -{ +) { // parse episode details and push to queue for ep in episodes.into_iter() { let tx = tx_to_main.clone(); - let dest2 = dest.clone(); + let dest2 = dest.to_path_buf(); threadpool.execute(move || { let result = download_file(ep, dest2, max_retries); - tx.send(Message::Dl(result)).unwrap(); + tx.send(Message::Dl(result)) + .expect("Thread messaging error"); }); } } @@ -54,8 +56,7 @@ pub fn download_list( /// Downloads a file to a local filepath, returning DownloadMsg variant /// indicating success or failure. -fn download_file(ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> DownloadMsg { - let mut data = ep_data.clone(); +fn download_file(mut ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> DownloadMsg { let request: Result = loop { let response = ureq::get(&ep_data.url) .timeout_connect(5000) @@ -72,7 +73,7 @@ fn download_file(ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> Down }; if request.is_err() { - return DownloadMsg::ResponseError(data); + return DownloadMsg::ResponseError(ep_data); }; let response = request.unwrap(); @@ -87,25 +88,29 @@ fn download_file(ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> Down _ => "mp3", // assume .mp3 unless we figure out otherwise }; - let file_name = sanitize_with_options(&ep_data.title, Options { + let mut file_name = sanitize_with_options(&ep_data.title, Options { truncate: true, windows: true, // for simplicity, we'll just use Windows-friendly paths for everyone replacement: "", }); + if let Some(pubdate) = ep_data.pubdate { + file_name = format!("{}_{}", file_name, pubdate.format("%Y%m%d_%H%M%S")); + } + let mut file_path = dest; file_path.push(format!("{}.{}", file_name, ext)); let dst = File::create(&file_path); if dst.is_err() { - return DownloadMsg::FileCreateError(data); + return DownloadMsg::FileCreateError(ep_data); }; - data.file_path = Some(file_path); + ep_data.file_path = Some(file_path); let mut reader = response.into_reader(); return match std::io::copy(&mut reader, &mut dst.unwrap()) { - Ok(_) => DownloadMsg::Complete(data), - Err(_) => DownloadMsg::FileWriteError(data), + Ok(_) => DownloadMsg::Complete(ep_data), + Err(_) => DownloadMsg::FileWriteError(ep_data), }; } diff --git a/src/feeds.rs b/src/feeds.rs index c2d6688..da74b5c 100644 --- a/src/feeds.rs +++ b/src/feeds.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, Result}; use std::io::Read; use std::sync::mpsc; @@ -13,7 +14,7 @@ use crate::types::*; lazy_static! { /// Regex for parsing an episode "duration", which could take the form /// of HH:MM:SS, MM:SS, or SS. - static ref RE_DURATION: Regex = Regex::new(r"(\d+)(?::(\d+))?(?::(\d+))?").unwrap(); + static ref RE_DURATION: Regex = Regex::new(r"(\d+)(?::(\d+))?(?::(\d+))?").expect("Regex error"); } /// Enum for communicating back to the main thread after feed data has @@ -50,33 +51,28 @@ pub fn check_feed( max_retries: usize, threadpool: &Threadpool, tx_to_main: mpsc::Sender, -) -{ +) { threadpool.execute(move || match get_feed_data(feed.url.clone(), max_retries) { Ok(pod) => match feed.id { Some(id) => { tx_to_main .send(Message::Feed(FeedMsg::SyncData((id, pod)))) - .unwrap(); + .expect("Thread messaging error"); } None => tx_to_main .send(Message::Feed(FeedMsg::NewData(pod))) - .unwrap(), + .expect("Thread messaging error"), }, Err(_err) => tx_to_main .send(Message::Feed(FeedMsg::Error(feed))) - .unwrap(), + .expect("Thread messaging error"), }); } /// Given a URL, this attempts to pull the data about a podcast and its /// episodes from an RSS feed. -fn get_feed_data( - url: String, - mut max_retries: usize, -) -> Result> -{ - let request: Result> = loop { +fn get_feed_data(url: String, mut max_retries: usize) -> Result { + let request: Result = loop { let response = ureq::get(&url) .timeout_connect(5000) .timeout_read(15000) @@ -84,7 +80,7 @@ fn get_feed_data( if response.error() { max_retries -= 1; if max_retries == 0 { - break Err(String::from("TODO: Better error handling here.").into()); + break Err(anyhow!("No response from feed")); } } else { break Ok(response); diff --git a/src/keymap.rs b/src/keymap.rs index e4aa4b6..016d9e1 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -1,15 +1,24 @@ use pancurses::Input; use std::collections::HashMap; +use crate::config::KeybindingsFromToml; + /// Enum delineating all actions that may be performed by the user, and /// thus have keybindings associated with them. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum UserAction { Left, Right, Up, Down, + BigUp, + BigDown, + PageUp, + PageDown, + GoTop, + GoBot, + AddFeed, Sync, SyncAll, @@ -33,23 +42,71 @@ pub enum UserAction { /// keys may perform the same action, but each key may only perform one /// action. #[derive(Debug, Clone)] -pub struct Keybindings { - map: HashMap, -} +pub struct Keybindings(HashMap); impl Keybindings { /// Returns a new Keybindings struct. - pub fn new() -> Keybindings { - return Keybindings { - map: HashMap::new(), - }; + pub fn new() -> Self { + return Self(HashMap::new()); + } + + /// Returns a Keybindings struct with all default values set. + pub fn default() -> Self { + let defaults = Self::_defaults(); + let mut keymap = Self::new(); + for (action, defaults) in defaults.into_iter() { + keymap.insert_from_vec(defaults, action); + } + return keymap; + } + + /// Given a struct deserialized from config.toml (for which any or + /// all fields may be missing), create a Keybindings struct using + /// user-defined keys where specified, and default values otherwise. + pub fn from_config(config: KeybindingsFromToml) -> Self { + let defaults = Self::_defaults(); + let config_actions: Vec<(Option>, UserAction)> = vec![ + (config.left, UserAction::Left), + (config.right, UserAction::Right), + (config.up, UserAction::Up), + (config.down, UserAction::Down), + (config.big_up, UserAction::BigUp), + (config.big_down, UserAction::BigDown), + (config.page_up, UserAction::PageUp), + (config.page_down, UserAction::PageDown), + (config.go_top, UserAction::GoTop), + (config.go_bot, UserAction::GoBot), + (config.add_feed, UserAction::AddFeed), + (config.sync, UserAction::Sync), + (config.sync_all, UserAction::SyncAll), + (config.play, UserAction::Play), + (config.mark_played, UserAction::MarkPlayed), + (config.mark_all_played, UserAction::MarkAllPlayed), + (config.download, UserAction::Download), + (config.download_all, UserAction::DownloadAll), + (config.delete, UserAction::Delete), + (config.delete_all, UserAction::DeleteAll), + (config.remove, UserAction::Remove), + (config.remove_all, UserAction::RemoveAll), + (config.help, UserAction::Help), + (config.quit, UserAction::Quit), + ]; + + let mut keymap = Self::new(); + for (config, action) in config_actions.into_iter() { + keymap.insert_from_vec( + config.unwrap_or_else(|| defaults.get(&action).unwrap().clone()), + action, + ); + } + return keymap; } /// Takes an Input object from pancurses and returns the associated /// user action, if one exists. pub fn get_from_input(&self, input: Input) -> Option<&UserAction> { match input_to_str(input) { - Some(code) => self.map.get(&code), + Some(code) => self.0.get(&code), None => None, } } @@ -57,15 +114,15 @@ impl Keybindings { /// Inserts a new keybinding into the hash map. Will overwrite the /// value of a key if it already exists. pub fn insert(&mut self, code: String, action: UserAction) { - self.map.insert(code, action); + self.0.insert(code, action); } /// Inserts a set of new keybindings into the hash map, each one /// corresponding to the same UserAction. Will overwrite the value /// of keys that already exist. - pub fn insert_from_vec(&mut self, vec: &[String], action: UserAction) { - for key in vec.iter() { - self.insert(key.to_string(), action); + pub fn insert_from_vec(&mut self, vec: Vec, action: UserAction) { + for key in vec.into_iter() { + self.insert(key, action); } } @@ -73,7 +130,7 @@ impl Keybindings { /// action. pub fn keys_for_action(&self, action: UserAction) -> Vec { return self - .map + .0 .iter() .filter_map(|(key, &val)| { if val == action { @@ -84,6 +141,43 @@ impl Keybindings { }) .collect(); } + + fn _defaults() -> HashMap> { + let action_map: Vec<(UserAction, Vec)> = vec![ + (UserAction::Left, vec!["Left".to_string(), "h".to_string()]), + (UserAction::Right, vec![ + "Right".to_string(), + "l".to_string(), + ]), + (UserAction::Up, vec!["Up".to_string(), "k".to_string()]), + (UserAction::Down, vec!["Down".to_string(), "j".to_string()]), + (UserAction::BigUp, vec!["K".to_string()]), + (UserAction::BigDown, vec!["J".to_string()]), + (UserAction::PageUp, vec!["PgUp".to_string()]), + (UserAction::PageDown, vec!["PgDn".to_string()]), + (UserAction::GoTop, vec!["g".to_string()]), + (UserAction::GoBot, vec!["G".to_string()]), + (UserAction::AddFeed, vec!["a".to_string()]), + (UserAction::Sync, vec!["s".to_string()]), + (UserAction::SyncAll, vec!["S".to_string()]), + (UserAction::Play, vec!["Enter".to_string(), "p".to_string()]), + (UserAction::MarkPlayed, vec!["m".to_string()]), + (UserAction::MarkAllPlayed, vec!["M".to_string()]), + (UserAction::Download, vec!["d".to_string()]), + (UserAction::DownloadAll, vec!["D".to_string()]), + (UserAction::Delete, vec!["x".to_string()]), + (UserAction::DeleteAll, vec!["X".to_string()]), + (UserAction::Remove, vec!["r".to_string()]), + (UserAction::RemoveAll, vec!["R".to_string()]), + (UserAction::Help, vec!["?".to_string()]), + (UserAction::Quit, vec!["q".to_string()]), + ]; + let mut default_map = HashMap::new(); + for (action, defaults) in action_map.into_iter() { + default_map.insert(action, defaults); + } + return default_map; + } } /// Helper function converting a pancurses Input object to a unique @@ -217,7 +311,7 @@ pub fn input_to_str(input: Input) -> Option { } _ => "", }; - if code == "" { + if code.is_empty() { return None; } else { return Some(code.to_string()); diff --git a/src/main.rs b/src/main.rs index c15a638..d6ceba3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ use std::fs::File; use std::io::{Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process; use std::sync::mpsc; +use anyhow::{anyhow, Context, Result}; use clap::{App, Arg, SubCommand}; mod config; @@ -56,7 +57,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Connects to the sqlite database, and reads all podcasts into an OPML /// file, with the location specified from the command line arguments. #[allow(clippy::while_let_on_iterator)] -fn main() { +fn main() -> Result<()> { // SETUP ----------------------------------------------------------- // set up the possible command line arguments and subcommands @@ -111,44 +112,38 @@ fn main() { // config location for OS let config_path = get_config_path(args.value_of("config")) .unwrap_or_else(|| { - println!("Could not identify your operating system's default directory to store configuration files. Please specify paths manually using config.toml and use `-c` or `--config` flag to specify where config.toml is located when launching the program."); + eprintln!("Could not identify your operating system's default directory to store configuration files. Please specify paths manually using config.toml and use `-c` or `--config` flag to specify where config.toml is located when launching the program."); process::exit(1); }); - let config = Config::new(&config_path); + let config = Config::new(&config_path)?; let mut db_path = config_path; if !db_path.pop() { - println!("Could not correctly parse the config file location. Please specify a valid path to the config file."); - process::exit(1); + return Err(anyhow!("Could not correctly parse the config file location. Please specify a valid path to the config file.")); } - match args.subcommand() { + return match args.subcommand() { // SYNC SUBCOMMAND ---------------------------------------------- - ("sync", Some(sub_args)) => { - sync_podcasts(&db_path, config, sub_args); - } + ("sync", Some(sub_args)) => sync_podcasts(&db_path, config, sub_args), // IMPORT SUBCOMMAND -------------------------------------------- - ("import", Some(sub_args)) => { - import(&db_path, config, sub_args); - } + ("import", Some(sub_args)) => import(&db_path, config, sub_args), // EXPORT SUBCOMMAND -------------------------------------------- - ("export", Some(sub_args)) => { - export(&db_path, sub_args); - } + ("export", Some(sub_args)) => export(&db_path, sub_args), // MAIN COMMAND ------------------------------------------------- _ => { - let mut main_ctrl = MainController::new(config, &db_path); + let mut main_ctrl = MainController::new(config, &db_path)?; main_ctrl.loop_msgs(); // main loop main_ctrl.tx_to_ui.send(MainMessage::UiTearDown).unwrap(); main_ctrl.ui_thread.join().unwrap(); // wait for UI thread to finish teardown + Ok(()) } - } + }; } @@ -179,230 +174,223 @@ fn get_config_path(config: Option<&str>) -> Option { /// Synchronizes RSS feed data for all podcasts, without setting up a UI. -fn sync_podcasts(db_path: &PathBuf, config: Config, args: &clap::ArgMatches) { - let db_inst = Database::connect(db_path); - let podcast_list = db_inst.get_podcasts(); +fn sync_podcasts(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> { + let db_inst = Database::connect(db_path)?; + let podcast_list = db_inst.get_podcasts()?; if podcast_list.is_empty() { if !args.is_present("quiet") { println!("No podcasts to sync."); } - } else { - let threadpool = Threadpool::new(config.simultaneous_downloads); - let (tx_to_main, rx_to_main) = mpsc::channel(); + return Ok(()); + } - for pod in podcast_list.iter() { - let feed = PodcastFeed::new(Some(pod.id), pod.url.clone(), Some(pod.title.clone())); - feeds::check_feed(feed, config.max_retries, &threadpool, tx_to_main.clone()); - } + let threadpool = Threadpool::new(config.simultaneous_downloads); + let (tx_to_main, rx_to_main) = mpsc::channel(); - let mut msg_counter: usize = 0; - let mut failure = false; - while let Some(message) = rx_to_main.iter().next() { - match message { - Message::Feed(FeedMsg::SyncData((pod_id, pod))) => { - let title = pod.title.clone(); - let db_result; - - db_result = db_inst.update_podcast(pod_id, pod); - match db_result { - Ok(_) => { - if !args.is_present("quiet") { - println!("Synced {}", title); - } - } - Err(_err) => { - failure = true; - eprintln!("Error synchronizing {}", title); + for pod in podcast_list.iter() { + let feed = PodcastFeed::new(Some(pod.id), pod.url.clone(), Some(pod.title.clone())); + feeds::check_feed(feed, config.max_retries, &threadpool, tx_to_main.clone()); + } + + let mut msg_counter: usize = 0; + let mut failure = false; + while let Some(message) = rx_to_main.iter().next() { + match message { + Message::Feed(FeedMsg::SyncData((pod_id, pod))) => { + let title = pod.title.clone(); + let db_result; + + db_result = db_inst.update_podcast(pod_id, pod); + match db_result { + Ok(_) => { + if !args.is_present("quiet") { + println!("Synced {}", title); } } - } - - Message::Feed(FeedMsg::Error(feed)) => { - failure = true; - match feed.title { - Some(t) => eprintln!("Error retrieving RSS feed for {}.", t), - None => eprintln!("Error retrieving RSS feed."), + Err(_err) => { + failure = true; + eprintln!("Error synchronizing {}", title); } } - _ => (), } - msg_counter += 1; - if msg_counter >= podcast_list.len() { - break; + Message::Feed(FeedMsg::Error(feed)) => { + failure = true; + match feed.title { + Some(t) => eprintln!("Error retrieving RSS feed for {}.", t), + None => eprintln!("Error retrieving RSS feed."), + } } + _ => (), } - if failure { - eprintln!("Process finished with errors."); - process::exit(2); - } else if !args.is_present("quiet") { - println!("Sync successful."); + msg_counter += 1; + if msg_counter >= podcast_list.len() { + break; } } + + if failure { + return Err(anyhow!("Process finished with errors.")); + } else if !args.is_present("quiet") { + println!("Sync successful."); + } + return Ok(()); } /// Imports a list of podcasts from OPML format, either reading from a /// file or from stdin. If the `replace` flag is set, this replaces all /// existing data in the database. -fn import(db_path: &PathBuf, config: Config, args: &clap::ArgMatches) { +fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> { // read from file or from stdin let xml = match args.value_of("file") { Some(filepath) => { - let mut f = File::open(filepath).unwrap_or_else(|err| { - eprintln!("Error opening OPML file: {}", err); - process::exit(4); - }); + let mut f = File::open(filepath) + .with_context(|| format!("Could not open OPML file: {}", filepath))?; let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap_or_else(|err| { - eprintln!("Error reading from OPML file: {}", err); - process::exit(4); - }); + f.read_to_string(&mut contents) + .with_context(|| format!("Failed to read from OPML file: {}", filepath))?; contents } None => { let mut contents = String::new(); std::io::stdin() .read_to_string(&mut contents) - .unwrap_or_else(|err| { - eprintln!("Error reading from stdin: {}", err); - process::exit(5); - }); + .with_context(|| "Failed to read OPML file from stdin")?; contents } }; - let mut podcast_list = opml::import(xml).unwrap_or_else(|err| { - eprintln!("Error parsing OPML file: {}", err); - process::exit(5); - }); + let mut podcast_list = opml::import(xml).with_context(|| { + "Could not properly parse OPML file -- file may be formatted improperly or corrupted." + })?; if podcast_list.is_empty() { if !args.is_present("quiet") { println!("No podcasts to import."); } + return Ok(()); + } + + let db_inst = Database::connect(db_path)?; + + // delete database if we are replacing the data + if args.is_present("replace") { + db_inst + .clear_db() + .with_context(|| "Error clearing database")?; } else { - let db_inst = Database::connect(db_path); - - // delete database if we are replacing the data - if args.is_present("replace") { - db_inst.clear_db().unwrap_or_else(|err| { - eprintln!("Error clearing database: {}", err); - process::exit(4); - }); - } else { - let old_podcasts = db_inst.get_podcasts(); - - // if URL is already in database, remove it from import - podcast_list = podcast_list - .into_iter() - .filter(|pod| { - for op in &old_podcasts { - if pod.url == op.url { - return false; - } + let old_podcasts = db_inst.get_podcasts()?; + + // if URL is already in database, remove it from import + podcast_list = podcast_list + .into_iter() + .filter(|pod| { + for op in &old_podcasts { + if pod.url == op.url { + return false; } - return true; - }) - .collect(); + } + return true; + }) + .collect(); + } + + // check again, now that we may have removed feeds after looking at + // the database + if podcast_list.is_empty() { + if !args.is_present("quiet") { + println!("No podcasts to import."); } + return Ok(()); + } - if podcast_list.is_empty() { - if !args.is_present("quiet") { - println!("No podcasts to import."); - } - } else { - println!("Importing {} podcasts...", podcast_list.len()); - - let threadpool = Threadpool::new(config.simultaneous_downloads); - let (tx_to_main, rx_to_main) = mpsc::channel(); - - for pod in podcast_list.iter() { - feeds::check_feed( - pod.clone(), - config.max_retries, - &threadpool, - tx_to_main.clone(), - ); - } + println!("Importing {} podcasts...", podcast_list.len()); - let mut msg_counter: usize = 0; - let mut failure = false; - while let Some(message) = rx_to_main.iter().next() { - match message { - Message::Feed(FeedMsg::NewData(pod)) => { - let title = pod.title.clone(); - let db_result; - - db_result = db_inst.insert_podcast(pod); - match db_result { - Ok(_) => { - if !args.is_present("quiet") { - println!("Added {}", title); - } - } - Err(_err) => { - failure = true; - eprintln!("Error adding {}", title); - } + let threadpool = Threadpool::new(config.simultaneous_downloads); + let (tx_to_main, rx_to_main) = mpsc::channel(); + + for pod in podcast_list.iter() { + feeds::check_feed( + pod.clone(), + config.max_retries, + &threadpool, + tx_to_main.clone(), + ); + } + + let mut msg_counter: usize = 0; + let mut failure = false; + while let Some(message) = rx_to_main.iter().next() { + match message { + Message::Feed(FeedMsg::NewData(pod)) => { + let title = pod.title.clone(); + let db_result; + + db_result = db_inst.insert_podcast(pod); + match db_result { + Ok(_) => { + if !args.is_present("quiet") { + println!("Added {}", title); } } - - Message::Feed(FeedMsg::Error(feed)) => { + Err(_err) => { failure = true; - if let Some(t) = feed.title { - eprintln!("Error retrieving RSS feed: {}", t); - } else { - eprintln!("Error retrieving RSS feed"); - } + eprintln!("Error adding {}", title); } - _ => (), } + } - msg_counter += 1; - if msg_counter >= podcast_list.len() { - break; + Message::Feed(FeedMsg::Error(feed)) => { + failure = true; + if let Some(t) = feed.title { + eprintln!("Error retrieving RSS feed: {}", t); + } else { + eprintln!("Error retrieving RSS feed"); } } + _ => (), + } - if failure { - eprintln!("Process finished with errors."); - process::exit(2); - } else if !args.is_present("quiet") { - println!("Import successful."); - } + msg_counter += 1; + if msg_counter >= podcast_list.len() { + break; } } + + if failure { + return Err(anyhow!("Process finished with errors.")); + } else if !args.is_present("quiet") { + println!("Import successful."); + } + return Ok(()); } /// Exports all podcasts to OPML format, either printing to stdout or /// exporting to a file. -fn export(db_path: &PathBuf, args: &clap::ArgMatches) { - let db_inst = Database::connect(&db_path); - let podcast_list = db_inst.get_podcasts(); +fn export(db_path: &Path, args: &clap::ArgMatches) -> Result<()> { + let db_inst = Database::connect(&db_path)?; + let podcast_list = db_inst.get_podcasts()?; let opml = opml::export(podcast_list); - let xml = opml.to_xml().unwrap_or_else(|err| { - eprintln!("Error creating OPML format: {}", err); - process::exit(3); - }); + let xml = opml + .to_xml() + .map_err(|err| anyhow!(err)) + .with_context(|| "Could not create OPML format")?; match args.value_of("file") { // export to file Some(file) => { - let mut dst = File::create(file).unwrap_or_else(|err| { - eprintln!("Error creating output file: {}", err); - process::exit(4); - }); - dst.write_all(xml.as_bytes()).unwrap_or_else(|err| { - eprintln!("Error copying OPML data to output file: {}", err); - process::exit(4); - }); + let mut dst = File::create(file) + .with_context(|| format!("Could not create output file: {}", file))?; + dst.write_all(xml.as_bytes()) + .with_context(|| format!("Could not copy OPML data to output file: {}", file))?; } // print to stdout None => println!("{}", xml), } + return Ok(()); } diff --git a/src/main_controller.rs b/src/main_controller.rs index c5f1463..96de19e 100644 --- a/src/main_controller.rs +++ b/src/main_controller.rs @@ -1,6 +1,7 @@ +use anyhow::Result; use std::collections::HashSet; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::mpsc; use sanitize_filename::{sanitize_with_options, Options}; @@ -12,7 +13,7 @@ use crate::feeds::{self, FeedMsg, PodcastFeed}; use crate::play_file; use crate::threadpool::Threadpool; use crate::types::*; -use crate::ui::{UiMsg, UI}; +use crate::ui::{Ui, UiMsg}; /// Enum used for communicating with other threads. #[derive(Debug)] @@ -45,13 +46,13 @@ impl MainController { /// Instantiates the main controller (used during app startup), which /// sets up the connection to the database, download manager, and UI /// thread, and reads the list of podcasts from the database. - pub fn new(config: Config, db_path: &PathBuf) -> MainController { + pub fn new(config: Config, db_path: &Path) -> Result { // create transmitters and receivers for passing messages between threads let (tx_to_ui, rx_from_main) = mpsc::channel(); let (tx_to_main, rx_to_main) = mpsc::channel(); // get connection to the database - let db_inst = Database::connect(&db_path); + let db_inst = Database::connect(&db_path)?; // set up threadpool let threadpool = Threadpool::new(config.simultaneous_downloads); @@ -61,11 +62,11 @@ impl MainController { // "ground truth" list of podcasts, and it must be mutable, but // UI needs to check this list and update the screen when // necessary - let podcast_list = LockVec::new(db_inst.get_podcasts()); + let podcast_list = LockVec::new(db_inst.get_podcasts()?); // set up UI in new thread let tx_ui_to_main = mpsc::Sender::clone(&tx_to_main); - let ui_thread = UI::spawn( + let ui_thread = Ui::spawn( config.clone(), podcast_list.clone(), rx_from_main, @@ -73,7 +74,7 @@ impl MainController { ); // TODO: Can we do this without cloning the config? - return MainController { + return Ok(MainController { config: config, db: db_inst, threadpool: threadpool, @@ -85,7 +86,7 @@ impl MainController { tx_to_ui: tx_to_ui, tx_to_main: tx_to_main, rx_to_main: rx_to_main, - }; + }); } /// Initiates the main loop where the controller waits for messages coming in from the UI and other threads, and processes them. @@ -173,7 +174,7 @@ impl MainController { error, crate::config::MESSAGE_TIME, )) - .unwrap(); + .expect("Thread messaging error"); } /// Sends a persistent notification to the UI, which will display at @@ -181,14 +182,14 @@ impl MainController { pub fn persistent_notif_to_ui(&self, message: String, error: bool) { self.tx_to_ui .send(MainMessage::UiSpawnPersistentNotif(message, error)) - .unwrap(); + .expect("Thread messaging error"); } /// Clears persistent notifications in the UI. pub fn clear_persistent_notif(&self) { self.tx_to_ui .send(MainMessage::UiClearPersistentNotif) - .unwrap(); + .expect("Thread messaging error"); } /// Updates the persistent notification about syncing podcasts and @@ -283,9 +284,15 @@ impl MainController { match db_result { Ok(result) => { { - self.podcasts.replace_all(self.db.get_podcasts()); + self.podcasts.replace_all( + self.db + .get_podcasts() + .expect("Error retrieving info from database."), + ); } - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); if pod_id.is_some() { self.sync_tracker.push(result); @@ -324,12 +331,12 @@ impl MainController { DownloadNewEpisodes::AskSelected => { self.tx_to_ui .send(MainMessage::UiSpawnDownloadPopup(new_eps, true)) - .unwrap(); + .expect("Thread messaging error"); } DownloadNewEpisodes::AskUnselected => { self.tx_to_ui .send(MainMessage::UiSpawnDownloadPopup(new_eps, false)) - .unwrap(); + .expect("Thread messaging error"); } _ => (), } @@ -385,11 +392,13 @@ impl MainController { let mut episode = podcast.episodes.clone_episode(ep_id).unwrap(); episode.played = played; - self.db.set_played_status(episode.id, played); + let _ = self.db.set_played_status(episode.id, played); podcast.episodes.replace(ep_id, episode); self.podcasts.replace(pod_id, podcast); - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } /// Given a podcast, it marks all episodes for that podcast as @@ -400,15 +409,19 @@ impl MainController { { let borrowed_ep_list = podcast.episodes.borrow_order(); for ep in borrowed_ep_list.iter() { - self.db.set_played_status(*ep, played); + let _ = self.db.set_played_status(*ep, played); } } - podcast - .episodes - .replace_all(self.db.get_episodes(podcast.id)); + podcast.episodes.replace_all( + self.db + .get_episodes(podcast.id, false) + .expect("Error retrieving info from database."), + ); self.podcasts.replace(pod_id, podcast); - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } /// Given a podcast index (and not an episode index), this will send @@ -437,6 +450,7 @@ impl MainController { pod_id: ep.pod_id, title: ep.title.clone(), url: ep.url.clone(), + pubdate: ep.pubdate, file_path: None, }, ep.path.is_none(), @@ -456,6 +470,7 @@ impl MainController { pod_id: ep.pod_id, title: ep.title.clone(), url: ep.url.clone(), + pubdate: ep.pubdate, file_path: None, }) } else { @@ -499,7 +514,17 @@ impl MainController { /// Handles logic for what to do when a download successfully completes. pub fn download_complete(&mut self, ep_data: EpData) { let file_path = ep_data.file_path.unwrap(); - let _ = self.db.insert_file(ep_data.id, &file_path); + let res = self.db.insert_file(ep_data.id, &file_path); + if res.is_err() { + self.notif_to_ui( + format!( + "Could not add episode file to database: {}", + file_path.to_string_lossy() + ), + true, + ); + return; + } { // TODO: Try to do this without cloning the podcast... let podcast = self.podcasts.clone_podcast(ep_data.pod_id).unwrap(); @@ -514,7 +539,9 @@ impl MainController { self.notif_to_ui("Downloads complete.".to_string(), false); } - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } /// Given a podcast title, creates a download directory for that @@ -539,11 +566,20 @@ impl MainController { let title = episode.title.clone(); match fs::remove_file(episode.path.unwrap()) { Ok(_) => { - self.db.remove_file(episode.id); + let res = self.db.remove_file(episode.id); + if res.is_err() { + self.notif_to_ui( + format!("Could not remove file from database: {}", title), + true, + ); + return; + } episode.path = None; podcast.episodes.replace(ep_id, episode); - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); self.notif_to_ui(format!("Deleted \"{}\"", title), false); } Err(_) => self.notif_to_ui(format!("Error deleting \"{}\"", title), true), @@ -576,8 +612,13 @@ impl MainController { } } - self.db.remove_files(&eps_to_remove); - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + let res = self.db.remove_files(&eps_to_remove); + if res.is_err() { + success = false; + } + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); if success { self.notif_to_ui("Files successfully deleted.".to_string(), false); @@ -594,11 +635,21 @@ impl MainController { } let pod_id = self.podcasts.map_single(pod_id, |pod| pod.id).unwrap(); - self.db.remove_podcast(pod_id); + let res = self.db.remove_podcast(pod_id); + if res.is_err() { + self.notif_to_ui("Could not remove podcast from database".to_string(), true); + return; + } { - self.podcasts.replace_all(self.db.get_podcasts()); + self.podcasts.replace_all( + self.db + .get_podcasts() + .expect("Error retrieving info from database."), + ); } - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } /// Removes an episode from the list, optionally deleting local files @@ -608,13 +659,19 @@ impl MainController { self.delete_file(pod_id, ep_id); } - self.db.hide_episode(ep_id, true); + let _ = self.db.hide_episode(ep_id, true); { let mut borrowed_map = self.podcasts.borrow_map(); let podcast = borrowed_map.get_mut(&pod_id).unwrap(); - podcast.episodes.replace_all(self.db.get_episodes(pod_id)); + podcast.episodes.replace_all( + self.db + .get_episodes(pod_id, false) + .expect("Error retrieving info from database."), + ); } - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } /// Removes all episodes for a podcast from the list, optionally @@ -626,11 +683,13 @@ impl MainController { let mut podcast = self.podcasts.clone_podcast(pod_id).unwrap(); podcast.episodes.map(|ep| { - self.db.hide_episode(ep.id, true); + let _ = self.db.hide_episode(ep.id, true); }); podcast.episodes = LockVec::new(Vec::new()); self.podcasts.replace(pod_id, podcast); - self.tx_to_ui.send(MainMessage::UiUpdateMenus).unwrap(); + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); } } diff --git a/src/opml.rs b/src/opml.rs index a845dc9..fcc02fd 100644 --- a/src/opml.rs +++ b/src/opml.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, Result}; use chrono::Utc; use opml::{Body, Head, Outline, OPML}; @@ -6,37 +7,28 @@ use crate::types::*; /// Import a list of podcast feeds from an OPML file. Supports /// v1.0, v1.1, and v2.0 OPML files. -pub fn import(xml: String) -> Result, String> { +pub fn import(xml: String) -> Result> { return match OPML::new(&xml) { - Err(err) => Err(err), + Err(err) => Err(anyhow!(err)), Ok(opml) => { let mut feeds = Vec::new(); - for pod in opml.body.outlines.iter() { - if let Some(url) = pod.xml_url.clone() { + for pod in opml.body.outlines.into_iter() { + if pod.xml_url.is_some() { // match against title attribute first -- if this is // not set or empty, then match against the text // attribute; this must be set, but can be empty - let temp_title = match &pod.title { - Some(t) => { - if t.is_empty() { - None - } else { - Some(t.clone()) - } - } - None => None, - }; + let temp_title = pod.title.filter(|t| !t.is_empty()); let title = match temp_title { Some(t) => Some(t), None => { if pod.text.is_empty() { None } else { - Some(pod.text.clone()) + Some(pod.text) } } }; - feeds.push(PodcastFeed::new(None, url, title)); + feeds.push(PodcastFeed::new(None, pod.xml_url.unwrap(), title)); } } Ok(feeds) @@ -47,12 +39,14 @@ pub fn import(xml: String) -> Result, String> { /// Converts the current set of podcast feeds to the OPML format pub fn export(podcasts: Vec) -> OPML { let date = Utc::now(); - let mut opml = OPML::default(); - opml.head = Some(Head { - title: Some("Shellcaster Podcast Feeds".to_string()), - date_created: Some(date.to_rfc2822()), - ..Head::default() - }); + let mut opml = OPML { + head: Some(Head { + title: Some("Shellcaster Podcast Feeds".to_string()), + date_created: Some(date.to_rfc2822()), + ..Head::default() + }), + ..Default::default() + }; let mut outlines = Vec::new(); diff --git a/src/play_file.rs b/src/play_file.rs index 97fc27d..76d3b43 100644 --- a/src/play_file.rs +++ b/src/play_file.rs @@ -1,35 +1,27 @@ +use anyhow::{anyhow, Result}; use std::process::{Command, Stdio}; /// Execute an external shell command to play an episode file and/or URL. -pub fn execute(command: &str, path: &str) -> Result<(), std::io::Error> { +pub fn execute(command: &str, path: &str) -> Result<()> { // Command expects a command and then optional arguments (giving // everything to it in a string doesn't work), so we need to split // on white space and treat everything after the first word as args - let cmd_string = String::from(command); + let cmd_string = command.to_string(); let mut parts = cmd_string.trim().split_whitespace(); - let base_cmd = parts.next().unwrap(); - let args_iter = parts; + let base_cmd = parts.next().ok_or_else(|| anyhow!("Invalid command."))?; + let mut cmd = Command::new(base_cmd); - let mut args: Vec; if cmd_string.contains("%s") { - args = args_iter - .map(|a| { - if a == "%s" { - return a.replace("%s", path); - } else { - return a.to_string(); - } - }) - .collect(); + // if command contains "%s", replace the path with that value + cmd.args(parts.map(|a| if a == "%s" { path } else { a })); } else { - args = args_iter.map(|a| a.to_string()).collect(); - args.push(path.to_string()); + // otherwise, add path to the end of the command + cmd.args(parts.chain(vec![path].into_iter())); } - let mut cmd = Command::new(base_cmd); - cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null()); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); match cmd.spawn() { Ok(_) => Ok(()), - Err(err) => Err(err), + Err(err) => Err(anyhow!(err)), } } diff --git a/src/sanitizer.rs b/src/sanitizer.rs index 5546278..e7113b7 100644 --- a/src/sanitizer.rs +++ b/src/sanitizer.rs @@ -27,11 +27,11 @@ pub fn sanitize_rfc822_like_date>(s: S) -> String { /// Pad HH:MM:SS with exta zeros if needed. fn pad_zeros(s: String) -> String { lazy_static! { - /// If it matchers a pattern of 2:2:2, return. - static ref OK_RGX: Regex = Regex::new(r"(\d{2}):(\d{2}):(\d{2})").unwrap(); + /// If it matches a pattern of 2:2:2, return. + static ref OK_RGX: Regex = Regex::new(r"(\d{2}):(\d{2}):(\d{2})").expect("Regex error"); /// hours, minutes, seconds = cap[1], cap[2], cap[3] - static ref RE_RGX: Regex = Regex::new(r"(\d{1,2}):(\d{1,2}):(\d{1,2})").unwrap(); + static ref RE_RGX: Regex = Regex::new(r"(\d{1,2}):(\d{1,2}):(\d{1,2})").expect("Regex error"); } if OK_RGX.is_match(&s) { @@ -48,8 +48,8 @@ fn pad_zeros(s: String) -> String { tm.push_str(m_str); tm.push(':'); }); - tm.pop(); // Pop leftover last separator (at no penalty, since we only allocate once - // either way) + tm.pop(); // Pop leftover last separator (at no penalty, since + // we only allocate once either way) return s.replace(&cap[0], &tm); } diff --git a/src/threadpool.rs b/src/threadpool.rs index d16718e..b833025 100644 --- a/src/threadpool.rs +++ b/src/threadpool.rs @@ -36,7 +36,9 @@ impl Threadpool { pub fn execute(&self, func: F) where F: FnOnce() + Send + 'static { let job = Box::new(func); - self.sender.send(JobMessage::NewJob(job)).unwrap(); + self.sender + .send(JobMessage::NewJob(job)) + .expect("Thread messaging error"); } } @@ -45,13 +47,15 @@ impl Drop for Threadpool { /// all workers but allows them to complete current jobs. fn drop(&mut self) { for _ in &self.workers { - self.sender.send(JobMessage::Terminate).unwrap(); + self.sender + .send(JobMessage::Terminate) + .expect("Thread messaging error"); } for worker in &mut self.workers { if let Some(thread) = worker.thread.take() { // joins to ensure threads finish job before stopping - thread.join().unwrap(); + thread.join().expect("Error dropping threads"); } } } @@ -76,7 +80,11 @@ impl Worker { /// Threadpool. fn new(receiver: Arc>>) -> Worker { let thread = thread::spawn(move || loop { - let message = receiver.lock().unwrap().recv().unwrap(); + let message = receiver + .lock() + .expect("Threadpool error") + .recv() + .expect("Thread messaging error"); match message { JobMessage::NewJob(job) => job(), diff --git a/src/types.rs b/src/types.rs index adff788..d7f1040 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,6 +6,7 @@ use unicode_segmentation::UnicodeSegmentation; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; +use nohash_hasher::BuildNoHashHasher; use regex::Regex; use crate::downloads::DownloadMsg; @@ -15,7 +16,7 @@ use crate::ui::UiMsg; lazy_static! { /// Regex for removing "A", "An", and "The" from the beginning of /// podcast titles - static ref RE_ARTICLES: Regex = Regex::new(r"^(a|an|the) ").unwrap(); + static ref RE_ARTICLES: Regex = Regex::new(r"^(a|an|the) ").expect("Regex error"); } /// Defines interface used for both podcasts and episodes, to be @@ -163,7 +164,7 @@ impl Menuable for Episode { if let Some(pubdate) = self.pubdate { // print pubdate and duration - let pd = pubdate.format("%F").to_string(); + let pd = pubdate.format("%F"); let meta_str = format!("({}) {}", pd, meta_dur); let added_len = meta_str.chars().count(); @@ -250,11 +251,7 @@ impl Menuable for NewEpisode { /// Returns the title for the episode, up to length characters. fn get_title(&self, length: usize) -> String { - let selected = if self.selected { - "✓".to_string() - } else { - " ".to_string() - }; + let selected = if self.selected { "✓" } else { " " }; let full_string = format!("[{}] {} ({})", selected, self.title, self.pod_title); return full_string.substr(0, length); } @@ -273,14 +270,14 @@ impl Menuable for NewEpisode { pub struct LockVec where T: Clone + Menuable { - data: Arc>>, + data: Arc>>>, order: Arc>>, } impl LockVec { /// Create a new LockVec. pub fn new(data: Vec) -> LockVec { - let mut hm = HashMap::new(); + let mut hm = HashMap::with_hasher(BuildNoHashHasher::default()); let mut order = Vec::new(); for i in data.into_iter() { let id = i.get_id(); @@ -295,18 +292,27 @@ impl LockVec { } /// Lock the LockVec hashmap for reading/writing. - pub fn borrow_map(&self) -> MutexGuard> { - return self.data.lock().unwrap(); + pub fn borrow_map(&self) -> MutexGuard>> { + return self.data.lock().expect("Mutex error"); } /// Lock the LockVec order vector for reading/writing. pub fn borrow_order(&self) -> MutexGuard> { - return self.order.lock().unwrap(); + return self.order.lock().expect("Mutex error"); } /// Lock the LockVec hashmap for reading/writing. - pub fn borrow(&self) -> (MutexGuard>, MutexGuard>) { - return (self.data.lock().unwrap(), self.order.lock().unwrap()); + #[allow(clippy::type_complexity)] + pub fn borrow( + &self, + ) -> ( + MutexGuard>>, + MutexGuard>, + ) { + return ( + self.data.lock().expect("Mutex error"), + self.order.lock().expect("Mutex error"), + ); } /// Given an id, this takes a new T and replaces the old T with that @@ -335,7 +341,10 @@ impl LockVec { pub fn map(&self, mut f: F) -> Vec where F: FnMut(&T) -> B { let (map, order) = self.borrow(); - return order.iter().map(|id| f(map.get(id).unwrap())).collect(); + return order + .iter() + .map(|id| f(map.get(id).expect("Index error in LockVec"))) + .collect(); } /// Maps a closure to a single element in the LockVec, specified by @@ -361,6 +370,26 @@ impl LockVec { }; } + /// Maps a closure to every element in the LockVec, in the same way + /// as the `filter_map()` does on an Iterator, both mapping and + /// filtering, over a specified range. + /// Does not check if the range is valid! + /// However, to avoid issues with keeping the borrow + /// alive, the function returns a Vec of the collected results, + /// rather than an iterator. + pub fn map_by_range(&self, start: usize, end: usize, mut f: F) -> Vec + where F: FnMut(&T) -> Option { + let (map, order) = self.borrow(); + return (start..end) + .into_iter() + .filter_map(|id| { + f(map + .get(order.get(id).expect("Index error in LockVec")) + .expect("Index error in LockVec")) + }) + .collect(); + } + /// Maps a closure to every element in the LockVec, in the same way /// as the `filter_map()` does on an Iterator, both mapping and /// filtering. However, to avoid issues with keeping the borrow @@ -371,7 +400,7 @@ impl LockVec { let (map, order) = self.borrow(); return order .iter() - .filter_map(|id| f(map.get(id).unwrap())) + .filter_map(|id| f(map.get(id).expect("Index error in LockVec"))) .collect(); } diff --git a/src/ui/colors.rs b/src/ui/colors.rs index a095a23..aece3fe 100644 --- a/src/ui/colors.rs +++ b/src/ui/colors.rs @@ -1,73 +1,321 @@ -use std::collections::HashMap; +use anyhow::{anyhow, Result}; -/// Enum identifying relevant text states that will be associated with -/// distinct colors. -#[derive(Eq, PartialEq, Hash, Copy, Clone, Debug)] -pub enum ColorType { - Normal, - Highlighted, - HighlightedActive, - Error, +use lazy_static::lazy_static; +use regex::Regex; + +use crate::config::AppColors; + +lazy_static! { + /// Regex for parsing a color specified as hex code. + static ref RE_COLOR_HEX: Regex = Regex::new(r"(?i)#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})").expect("Regex error"); + + /// Regex for parsing a color specified as an rgb(x, y, z) value. + static ref RE_COLOR_RGB: Regex = Regex::new(r"(?i)rgb\(([0-9]+), ?([0-9]+), ?([0-9]+)\)").expect("Regex error"); } -/// Keeps a hashmap associating ColorTypes with ncurses color pairs. -#[derive(Debug, Clone)] -pub struct Colors { - map: HashMap, +/// Stores information about a single color value, specified either as +/// a word in the set black, blue, cyan, green, magenta, red, white, +/// yellow, or terminal, or an RGB code with values from 0 to 255. +#[derive(Debug, Clone, PartialEq)] +pub enum ColorValue { + Black, + Blue, + Cyan, + Green, + Magenta, + Red, + White, + Yellow, + Terminal, + Rgb(u8, u8, u8), } -impl Colors { - pub fn new() -> Colors { - return Colors { - map: HashMap::new(), +impl ColorValue { + /// Parses a string that specifies a color either in hex format + /// (e.g., "#ff0000"), in RGB format (e.g., "rgb(255, 0, 0)"), or + /// as one of a set of allowed color names. + pub fn from_str(text: &str) -> Result { + if text.starts_with('#') { + if let Some(cap) = RE_COLOR_HEX.captures(text) { + return Ok(Self::Rgb( + u8::from_str_radix(&cap[1], 16)?, + u8::from_str_radix(&cap[2], 16)?, + u8::from_str_radix(&cap[3], 16)?, + )); + } + return Err(anyhow!("Invalid color hex code")); + } else if text.starts_with("rgb") || text.starts_with("RGB") { + if let Some(cap) = RE_COLOR_RGB.captures(text) { + return Ok(Self::Rgb( + u8::from_str_radix(&cap[1], 10)?, + u8::from_str_radix(&cap[2], 10)?, + u8::from_str_radix(&cap[3], 10)?, + )); + } + return Err(anyhow!("Invalid color RGB code")); + } else { + let text_lower = text.to_lowercase(); + return match &text_lower[..] { + "black" => Ok(Self::Black), + "blue" => Ok(Self::Blue), + "cyan" => Ok(Self::Cyan), + "green" => Ok(Self::Green), + "magenta" => Ok(Self::Magenta), + "red" => Ok(Self::Red), + "white" => Ok(Self::White), + "yellow" => Ok(Self::Yellow), + "terminal" => Ok(Self::Terminal), + _ => Err(anyhow!("Invalid color code")), + }; + } + } + + /// Converts a ColorValue to one of the built-in ncurses numeric + /// color identifiers. Note that ColorValue::Rgb(_, _, _) returns + /// None and must be handled separately. + fn to_ncurses_val(&self) -> Option { + return match self { + Self::Black => Some(pancurses::COLOR_BLACK), + Self::Blue => Some(pancurses::COLOR_BLUE), + Self::Cyan => Some(pancurses::COLOR_CYAN), + Self::Green => Some(pancurses::COLOR_GREEN), + Self::Magenta => Some(pancurses::COLOR_MAGENTA), + Self::Red => Some(pancurses::COLOR_RED), + Self::White => Some(pancurses::COLOR_WHITE), + Self::Yellow => Some(pancurses::COLOR_YELLOW), + Self::Terminal => Some(-1), + Self::Rgb(_, _, _) => None, }; } - pub fn insert(&mut self, color: ColorType, num: i16) { - self.map.insert(color, num); + /// Returns whether ColorValue is of variant Terminal. + fn is_terminal(&self) -> bool { + return matches!(self, Self::Terminal); } - pub fn get(&self, color: ColorType) -> i16 { - return *self.map.get(&color).unwrap(); + /// For variant ColorValue::Rgb, returns the RGB associated values. + fn get_rgb(&self) -> Option<(u8, u8, u8)> { + return match self { + Self::Rgb(r, g, b) => Some((*r, *g, *b)), + _ => None, + }; } } +/// Enum identifying relevant text states that will be associated with +/// distinct colors. +#[derive(Debug, Copy, Clone)] +#[repr(u8)] +pub enum ColorType { + // Colorpair 0 is reserved in ncurses for white text on black, and + // can't be changed, so we just skip it + Normal = 1, + Highlighted = 2, + HighlightedActive = 3, + Error = 4, +} + /// Sets up hashmap for ColorTypes in app, initiates color palette, and /// sets up ncurses color pairs. -pub fn set_colors() -> Colors { - // set up a hashmap for easier reference - let mut colors = Colors::new(); - colors.insert(ColorType::Normal, 0); - colors.insert(ColorType::Highlighted, 1); - colors.insert(ColorType::HighlightedActive, 2); - colors.insert(ColorType::Error, 3); - - // specify some colors by RGB value - pancurses::init_color(pancurses::COLOR_WHITE, 680, 680, 680); - pancurses::init_color(pancurses::COLOR_YELLOW, 820, 643, 0); - - // instantiate curses color pairs - pancurses::init_pair( - colors.get(ColorType::Normal), - pancurses::COLOR_WHITE, - pancurses::COLOR_BLACK, - ); - pancurses::init_pair( - colors.get(ColorType::Highlighted), - pancurses::COLOR_BLACK, - pancurses::COLOR_WHITE, - ); - pancurses::init_pair( - colors.get(ColorType::HighlightedActive), - pancurses::COLOR_BLACK, - pancurses::COLOR_YELLOW, - ); - pancurses::init_pair( - colors.get(ColorType::Error), - pancurses::COLOR_RED, - pancurses::COLOR_BLACK, - ); - - return colors; +pub fn set_colors(config: &AppColors) { + pancurses::start_color(); // allows colours if available + if pancurses::has_colors() { + // if the user has specified any colors to be "terminal" (i.e., + // to use their terminal's default foreground and background + // colors), then we must tell ncurses to allow the use of those + // colors. + if check_for_terminal(config) { + pancurses::use_default_colors(); + } + + if pancurses::can_change_color() { + // set customized colors + let mut replace_counter = 10; + replace_counter = + set_color_pair(ColorType::Normal as u8, &config.normal, replace_counter); + replace_counter = set_color_pair( + ColorType::HighlightedActive as u8, + &config.highlighted_active, + replace_counter, + ); + replace_counter = set_color_pair( + ColorType::Highlighted as u8, + &config.highlighted, + replace_counter, + ); + let _ = set_color_pair(ColorType::Error as u8, &config.error, replace_counter); + } else { + // we have color, but we're limited to the built-in ones + pancurses::init_pair( + ColorType::Normal as i16, + pancurses::COLOR_WHITE, + pancurses::COLOR_BLACK, + ); + pancurses::init_pair( + ColorType::HighlightedActive as i16, + pancurses::COLOR_BLACK, + pancurses::COLOR_YELLOW, + ); + pancurses::init_pair( + ColorType::Highlighted as i16, + pancurses::COLOR_BLACK, + pancurses::COLOR_WHITE, + ); + pancurses::init_pair( + ColorType::Error as i16, + pancurses::COLOR_RED, + pancurses::COLOR_BLACK, + ); + } + } else { + // cap'n, we got no color! + pancurses::init_pair( + ColorType::Normal as i16, + pancurses::COLOR_WHITE, + pancurses::COLOR_BLACK, + ); + pancurses::init_pair( + ColorType::HighlightedActive as i16, + pancurses::COLOR_BLACK, + pancurses::COLOR_WHITE, + ); + pancurses::init_pair( + ColorType::Highlighted as i16, + pancurses::COLOR_BLACK, + pancurses::COLOR_WHITE, + ); + pancurses::init_pair( + ColorType::Error as i16, + pancurses::COLOR_WHITE, + pancurses::COLOR_BLACK, + ); + } +} + +/// Check for any app colors that are set to "Terminal", which means that +/// we should attempt to use the terminal's default foreground/background +/// colors. +fn check_for_terminal(app_colors: &AppColors) -> bool { + if app_colors.normal.0.is_terminal() { + return true; + } + if app_colors.normal.1.is_terminal() { + return true; + } + if app_colors.highlighted_active.0.is_terminal() { + return true; + } + if app_colors.highlighted_active.1.is_terminal() { + return true; + } + if app_colors.highlighted.0.is_terminal() { + return true; + } + if app_colors.highlighted.1.is_terminal() { + return true; + } + if app_colors.error.0.is_terminal() { + return true; + } + if app_colors.error.1.is_terminal() { + return true; + } + return false; +} + + +/// Helper function that takes a set of ColorValues indicating foreground +/// and background colors, initiates customized colors if necessary, and +/// adds the pair to ncurses with the key of `pair_index`. +fn set_color_pair( + pair_index: u8, + config: &(ColorValue, ColorValue), + mut replace_index: i16, +) -> i16 { + let mut c1 = config.0.to_ncurses_val(); + let mut c2 = config.1.to_ncurses_val(); + + if c1.is_none() { + let rgb = config.0.get_rgb().unwrap(); + pancurses::init_color( + replace_index, + u8_to_i16(rgb.0), + u8_to_i16(rgb.1), + u8_to_i16(rgb.2), + ); + c1 = Some(replace_index); + replace_index += 1; + } + if c2.is_none() { + let rgb = config.1.get_rgb().unwrap(); + pancurses::init_color( + replace_index, + u8_to_i16(rgb.0), + u8_to_i16(rgb.1), + u8_to_i16(rgb.2), + ); + c2 = Some(replace_index); + replace_index += 1; + } + + pancurses::init_pair(pair_index as i16, c1.unwrap(), c2.unwrap()); + return replace_index; +} + +/// Converts a value from 0 to 255 to a value from 0 to 1000, because +/// ncurses has a weird color format. +fn u8_to_i16(val: u8) -> i16 { + return (val as f32 / 255.0 * 1000.0) as i16; +} + + +// TESTS ----------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_hex() { + let color = String::from("#ff0000"); + let parsed = ColorValue::from_str(&color); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + } + + #[test] + fn color_invalid_hex() { + let color = String::from("#gg0000"); + assert!(ColorValue::from_str(&color).is_err()); + } + + #[test] + fn color_invalid_hex2() { + let color = String::from("#ff000"); + assert!(ColorValue::from_str(&color).is_err()); + } + + #[test] + fn color_rgb() { + let color = String::from("rgb(255, 0, 0)"); + let parsed = ColorValue::from_str(&color); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + } + + #[test] + fn color_rgb_upper() { + let color = String::from("RGB(255, 0, 0)"); + let parsed = ColorValue::from_str(&color); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + } + + #[test] + fn color_rgb_no_space() { + let color = String::from("rgb(255,0,0)"); + let parsed = ColorValue::from_str(&color); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + } } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index e7a409c..bf4f559 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,3 +1,4 @@ +use std::cmp::max; use std::cmp::min; use std::collections::hash_map::Entry; @@ -51,7 +52,7 @@ impl Menu { /// Prints the list of visible items to the pancurses window and /// refreshes it. pub fn init(&mut self) { - self.panel.init(); + self.panel.refresh(); self.update_items(); } @@ -76,7 +77,7 @@ impl Menu { // for visible rows, print strings from list for i in self.start_row..self.panel.get_rows() { if let Some(elem_id) = order.get(self.get_menu_idx(i)) { - let elem = map.get(&elem_id).unwrap(); + let elem = map.get(&elem_id).expect("Could not retrieve menu item."); self.panel .write_line(i, elem.get_title(self.panel.get_cols() as usize)); @@ -107,7 +108,7 @@ impl Menu { /// above the menu. fn print_header(&mut self) -> i32 { if let Some(header) = &self.header { - return self.panel.write_wrap_line(0, header.clone()) + 2; + return self.panel.write_wrap_line(0, header) + 2; } else { return 0; } @@ -121,8 +122,9 @@ impl Menu { /// represent the new visible list. pub fn scroll(&mut self, lines: i32) { let mut old_selected; - let old_played; - let new_played; + let checked_lines; + let apply_color_played; + let get_titles; let list_len = self.items.len(); if list_len == 0 { @@ -130,11 +132,18 @@ impl Menu { } let n_row = self.panel.get_rows(); + let max_lines = list_len as i32 + self.start_row; + let check_max = |lines| min(lines, max_lines); + + // check the bounds of lines and adjust accordingly + if lines.checked_add(self.top_row + n_row).is_some() { + checked_lines = lines; + } else { + checked_lines = lines - self.top_row - n_row; + } - // TODO: currently only handles scroll value of 1; need to extend - // to be able to scroll multiple lines at a time old_selected = self.selected; - self.selected += lines; + self.selected += checked_lines; // don't allow scrolling past last item in list (if shorter // than self.panel.get_rows()) @@ -143,50 +152,61 @@ impl Menu { self.selected = abs_bottom; } + // given a selection, apply correct play status and highlight + apply_color_played = |menu: &mut Menu, selected, color: ColorType| { + let played = menu + .items + .map_single_by_index(menu.get_menu_idx(selected), |el| el.is_played()) + .unwrap_or(false); + menu.set_attrs(selected, played, color); + }; + + // return a vec with sorted titles in range start, end (exclusive) + get_titles = |menu: &mut Menu, start, end| { + menu.items.map_by_range(start, end, |el| { + Some(el.get_title(menu.panel.get_cols() as usize)) + }) + }; + // scroll list if necessary: // scroll down - if self.selected > (n_row - 1) { - self.selected = n_row - 1; - if let Some(title) = self - .items - .map_single_by_index((self.top_row + n_row) as usize, |el| { - el.get_title(self.panel.get_cols() as usize) - }) - { + if (self.selected) > (n_row - 1) { + // for scrolls that don't start at the bottom + apply_color_played(self, old_selected, ColorType::Normal); + let delta = n_row - old_selected - 1; + + let titles = get_titles( + self, + (self.top_row + n_row) as usize, + (check_max(checked_lines + self.top_row + n_row - delta)) as usize, + ); + for title in titles.into_iter() { self.top_row += 1; self.panel.delete_line(self.start_row); old_selected -= 1; - self.panel.delete_line(n_row - 1); self.panel.write_line(n_row - 1, title); + apply_color_played(self, n_row - 1, ColorType::Normal); } + self.selected = n_row - 1; // scroll up } else if self.selected < self.start_row { - self.selected = self.start_row; - if let Some(title) = self - .items - .map_single_by_index((self.top_row - 1) as usize, |el| { - el.get_title(self.panel.get_cols() as usize) - }) - { + let titles = get_titles( + self, + max(0, self.top_row + self.selected) as usize, + (self.top_row) as usize, + ); + for title in titles.into_iter().rev() { self.top_row -= 1; self.panel.insert_line(self.start_row, title); + apply_color_played(self, 1, ColorType::Normal); old_selected += 1; } + self.selected = self.start_row; } - - old_played = self - .items - .map_single_by_index(self.get_menu_idx(old_selected), |el| el.is_played()) - .unwrap(); - new_played = self - .items - .map_single_by_index(self.get_menu_idx(self.selected), |el| el.is_played()) - .unwrap(); - - self.set_attrs(old_selected, old_played, ColorType::Normal); - self.set_attrs(self.selected, new_played, ColorType::HighlightedActive); + apply_color_played(self, old_selected, ColorType::Normal); + apply_color_played(self, self.selected, ColorType::HighlightedActive); self.panel.refresh(); } @@ -264,12 +284,13 @@ impl Menu { /// currently selected podcast. pub fn get_episodes(&self) -> LockVec { let index = self.get_menu_idx(self.selected); - let pod_id = self.items.borrow_order().get(index).copied().unwrap(); - return self - .items - .borrow_map() - .get(&pod_id) - .unwrap() + let (borrowed_map, borrowed_order) = self.items.borrow(); + let pod_id = borrowed_order + .get(index) + .expect("Could not retrieve podcast."); + return borrowed_map + .get(pod_id) + .expect("Could not retrieve podcast info.") .episodes .clone(); } @@ -388,15 +409,7 @@ mod tests { }); } - let panel = Panel::new( - crate::ui::colors::set_colors(), - "Episodes".to_string(), - 1, - n_row, - n_col, - 0, - 0, - ); + let panel = Panel::new("Episodes".to_string(), 1, n_row, n_col, 0, 0); return Menu { panel: panel, header: None, diff --git a/src/ui/mock_panel.rs b/src/ui/mock_panel.rs index 6bdb048..e227a53 100644 --- a/src/ui/mock_panel.rs +++ b/src/ui/mock_panel.rs @@ -1,4 +1,4 @@ -use super::{ColorType, Colors}; +use super::ColorType; use chrono::{DateTime, Utc}; /// Struct holding the raw data used for building the details panel. @@ -15,7 +15,6 @@ pub struct Details { pub struct Panel { pub window: Vec<(String, pancurses::chtype, ColorType)>, pub screen_pos: usize, - pub colors: Colors, pub title: String, pub n_row: i32, pub n_col: i32, @@ -23,15 +22,13 @@ pub struct Panel { impl Panel { pub fn new( - colors: Colors, title: String, screen_pos: usize, n_row: i32, n_col: i32, _start_y: i32, _start_x: i32, - ) -> Self - { + ) -> Self { // we represent the window as a vector of Strings instead of // the pancurses window let panel_win = @@ -40,15 +37,12 @@ impl Panel { return Panel { window: panel_win, screen_pos: screen_pos, - colors: colors, title: title, n_row: n_row, n_col: n_col, }; } - pub fn init(&self) {} - pub fn refresh(&self) {} pub fn erase(&mut self) { @@ -74,10 +68,10 @@ impl Panel { .push((String::new(), pancurses::A_NORMAL, ColorType::Normal)); } - pub fn write_wrap_line(&mut self, start_y: i32, string: String) -> i32 { + pub fn write_wrap_line(&mut self, start_y: i32, string: &str) -> i32 { let mut row = start_y; let max_row = self.get_rows(); - let wrapper = textwrap::wrap_iter(&string, self.get_cols() as usize); + let wrapper = textwrap::wrap(&string, self.get_cols() as usize); for line in wrapper { self.write_line(row, line.to_string()); row += 1; @@ -94,14 +88,14 @@ impl Panel { // podcast title match details.pod_title { - Some(t) => row = self.write_wrap_line(row + 1, t), - None => row = self.write_wrap_line(row + 1, "No title".to_string()), + Some(t) => row = self.write_wrap_line(row + 1, &t), + None => row = self.write_wrap_line(row + 1, "No title"), } // episode title match details.ep_title { - Some(t) => row = self.write_wrap_line(row + 1, t), - None => row = self.write_wrap_line(row + 1, "No title".to_string()), + Some(t) => row = self.write_wrap_line(row + 1, &t), + None => row = self.write_wrap_line(row + 1, "No title"), } row += 1; // blank line @@ -110,7 +104,7 @@ impl Panel { if let Some(date) = details.pubdate { let new_row = self.write_wrap_line( row + 1, - format!("Published: {}", date.format("%B %-d, %Y").to_string()), + &format!("Published: {}", date.format("%B %-d, %Y")), ); self.change_attr(row + 1, 0, 10, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; @@ -118,7 +112,7 @@ impl Panel { // duration if let Some(dur) = details.duration { - let new_row = self.write_wrap_line(row + 1, format!("Duration: {}", dur)); + let new_row = self.write_wrap_line(row + 1, &format!("Duration: {}", dur)); self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; } @@ -126,9 +120,9 @@ impl Panel { // explicit if let Some(exp) = details.explicit { let new_row = if exp { - self.write_wrap_line(row + 1, "Explicit: Yes".to_string()) + self.write_wrap_line(row + 1, "Explicit: Yes") } else { - self.write_wrap_line(row + 1, "Explicit: No".to_string()) + self.write_wrap_line(row + 1, "Explicit: No") }; self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; @@ -139,11 +133,11 @@ impl Panel { // description match details.description { Some(desc) => { - row = self.write_wrap_line(row + 1, "Description:".to_string()); - let _row = self.write_wrap_line(row + 1, desc); + row = self.write_wrap_line(row + 1, "Description:"); + let _row = self.write_wrap_line(row + 1, &desc); } None => { - let _row = self.write_wrap_line(row + 1, "No description.".to_string()); + let _row = self.write_wrap_line(row + 1, "No description."); } } } @@ -159,8 +153,7 @@ impl Panel { _nchars: i32, attr: pancurses::chtype, color: ColorType, - ) - { + ) { let current = &self.window[y as usize]; self.window[y as usize] = (current.0.clone(), attr, color); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a5a4e1b..17809c6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,12 +6,12 @@ use std::time::Duration; #[cfg_attr(test, path = "mock_panel.rs")] mod panel; -mod colors; +pub mod colors; mod menu; mod notification; mod popup; -use self::colors::{ColorType, Colors}; +use self::colors::ColorType; use self::menu::Menu; use self::notification::NotifWin; use self::panel::{Details, Panel}; @@ -29,13 +29,13 @@ use crate::types::*; lazy_static! { /// Regex for finding
tags -- also captures any surrounding /// line breaks - static ref RE_BR_TAGS: Regex = Regex::new(r"((\r\n)|\r|\n)*
((\r\n)|\r|\n)*").unwrap(); + static ref RE_BR_TAGS: Regex = Regex::new(r"((\r\n)|\r|\n)*
((\r\n)|\r|\n)*").expect("Regex error"); /// Regex for finding HTML tags - static ref RE_HTML_TAGS: Regex = Regex::new(r"<[^<>]*>").unwrap(); + static ref RE_HTML_TAGS: Regex = Regex::new(r"<[^<>]*>").expect("Regex error"); /// Regex for finding more than two line breaks - static ref RE_MULT_LINE_BREAKS: Regex = Regex::new(r"((\r\n)|\r|\n){3,}").unwrap(); + static ref RE_MULT_LINE_BREAKS: Regex = Regex::new(r"((\r\n)|\r|\n){3,}").expect("Regex error"); } @@ -75,12 +75,11 @@ enum ActiveMenu { /// encapsulates the pancurses windows, and holds data about the size of /// the screen. #[derive(Debug)] -pub struct UI<'a> { +pub struct Ui<'a> { stdscr: Window, n_row: i32, n_col: i32, keymap: &'a Keybindings, - colors: Colors, podcast_menu: Menu, episode_menu: Menu, active_menu: ActiveMenu, @@ -89,7 +88,7 @@ pub struct UI<'a> { popup_win: PopupWin<'a>, } -impl<'a> UI<'a> { +impl<'a> Ui<'a> { /// Spawns a UI object in a new thread, with message channels to send /// and receive messages pub fn spawn( @@ -97,10 +96,9 @@ impl<'a> UI<'a> { items: LockVec, rx_from_main: mpsc::Receiver, tx_to_main: mpsc::Sender, - ) -> thread::JoinHandle<()> - { + ) -> thread::JoinHandle<()> { return thread::spawn(move || { - let mut ui = UI::new(&config, &items); + let mut ui = Ui::new(&config, items); ui.init(); let mut message_iter = rx_from_main.try_iter(); // this is the main event loop: on each loop, we update @@ -111,7 +109,9 @@ impl<'a> UI<'a> { match ui.getch() { UiMsg::Noop => (), - input => tx_to_main.send(Message::Ui(input)).unwrap(), + input => tx_to_main + .send(Message::Ui(input)) + .expect("Thread messaging error"), } if let Some(message) = message_iter.next() { @@ -143,46 +143,23 @@ impl<'a> UI<'a> { /// Initializes the UI with a list of podcasts and podcast episodes, /// creates the pancurses window and draws it to the screen, and /// returns a UI object for future manipulation. - pub fn new(config: &'a Config, items: &LockVec) -> UI<'a> { + pub fn new(config: &'a Config, items: LockVec) -> Ui<'a> { let stdscr = pancurses::initscr(); // set some options pancurses::cbreak(); // allows characters to be read one by one pancurses::noecho(); // turns off automatic echoing of characters // to the screen as they are input - pancurses::start_color(); // allows colours if available pancurses::curs_set(0); // turn off cursor stdscr.keypad(true); // returns special characters as single // key codes stdscr.nodelay(true); // getch() will not wait for user input - // set colors - let colors = self::colors::set_colors(); + self::colors::set_colors(&config.colors); let (n_row, n_col) = stdscr.get_max_yx(); let (pod_col, ep_col, det_col) = Self::calculate_sizes(n_col); - let podcast_panel = Panel::new( - colors.clone(), - "Podcasts".to_string(), - 0, - n_row - 1, - pod_col, - 0, - 0, - ); - let podcast_menu = Menu::new(podcast_panel, None, items.clone()); - - let episode_panel = Panel::new( - colors.clone(), - "Episodes".to_string(), - 1, - n_row - 1, - ep_col, - 0, - pod_col - 1, - ); - let first_pod = match items.borrow_order().get(0) { Some(first_id) => match items.borrow_map().get(first_id) { Some(pod) => pod.episodes.clone(), @@ -191,11 +168,16 @@ impl<'a> UI<'a> { None => LockVec::new(Vec::new()), }; + let podcast_panel = Panel::new("Podcasts".to_string(), 0, n_row - 1, pod_col, 0, 0); + let podcast_menu = Menu::new(podcast_panel, None, items); + + let episode_panel = + Panel::new("Episodes".to_string(), 1, n_row - 1, ep_col, 0, pod_col - 1); + let episode_menu = Menu::new(episode_panel, None, first_pod); let details_panel = if n_col > crate::config::DETAILS_PANEL_LENGTH { Some(Self::make_details_panel( - colors.clone(), n_row - 1, det_col, 0, @@ -205,15 +187,14 @@ impl<'a> UI<'a> { None }; - let notif_win = NotifWin::new(colors.clone(), n_row, n_col); - let popup_win = PopupWin::new(colors.clone(), &config.keybindings, n_row, n_col); + let notif_win = NotifWin::new(n_row, n_col); + let popup_win = PopupWin::new(&config.keybindings, n_row, n_col); - return UI { - stdscr, - n_row, - n_col, + return Ui { + stdscr: stdscr, + n_row: n_row, + n_col: n_col, keymap: &config.keybindings, - colors: colors, podcast_menu: podcast_menu, episode_menu: episode_menu, active_menu: ActiveMenu::PodcastMenu, @@ -230,8 +211,14 @@ impl<'a> UI<'a> { self.podcast_menu.init(); self.podcast_menu.activate(); self.episode_menu.init(); + + if let Some(ref panel) = self.details_panel { + panel.refresh(); + } self.update_details_panel(); + self.notif_win.init(); + // welcome screen if user does not have any podcasts yet if self.podcast_menu.items.is_empty() { self.popup_win.spawn_welcome_win(); @@ -281,7 +268,13 @@ impl<'a> UI<'a> { Some(a @ UserAction::Down) | Some(a @ UserAction::Up) | Some(a @ UserAction::Left) - | Some(a @ UserAction::Right) => { + | Some(a @ UserAction::Right) + | Some(a @ UserAction::PageUp) + | Some(a @ UserAction::PageDown) + | Some(a @ UserAction::BigUp) + | Some(a @ UserAction::BigDown) + | Some(a @ UserAction::GoTop) + | Some(a @ UserAction::GoBot) => { self.move_cursor(a, curr_pod_id, curr_ep_id) } @@ -410,7 +403,6 @@ impl<'a> UI<'a> { } } else if det_col > 0 { self.details_panel = Some(Self::make_details_panel( - self.colors.clone(), n_row - 1, det_col, 0, @@ -444,55 +436,14 @@ impl<'a> UI<'a> { action: &UserAction, curr_pod_id: Option, curr_ep_id: Option, - ) - { + ) { match action { UserAction::Down => { - match self.active_menu { - ActiveMenu::PodcastMenu => { - if curr_pod_id.is_some() { - self.podcast_menu.scroll(1); - - self.episode_menu.top_row = 0; - self.episode_menu.selected = 0; - - // update episodes menu with new list - self.episode_menu.items = self.podcast_menu.get_episodes(); - self.episode_menu.update_items(); - self.update_details_panel(); - } - } - ActiveMenu::EpisodeMenu => { - if curr_ep_id.is_some() { - self.episode_menu.scroll(1); - self.update_details_panel(); - } - } - } + self.scroll_current_window(curr_pod_id, 1); } UserAction::Up => { - match self.active_menu { - ActiveMenu::PodcastMenu => { - if curr_pod_id.is_some() { - self.podcast_menu.scroll(-1); - - self.episode_menu.top_row = 0; - self.episode_menu.selected = 0; - - // update episodes menu with new list - self.episode_menu.items = self.podcast_menu.get_episodes(); - self.episode_menu.update_items(); - self.update_details_panel(); - } - } - ActiveMenu::EpisodeMenu => { - if curr_pod_id.is_some() { - self.episode_menu.scroll(-1); - self.update_details_panel(); - } - } - } + self.scroll_current_window(curr_pod_id, -1); } UserAction::Left => { @@ -527,20 +478,78 @@ impl<'a> UI<'a> { } } + UserAction::PageUp => { + self.scroll_current_window(curr_pod_id, -self.n_row + 3); + } + + UserAction::PageDown => { + self.scroll_current_window(curr_pod_id, self.n_row - 3); + } + + UserAction::BigUp => { + self.scroll_current_window( + curr_pod_id, + -self.n_row / crate::config::BIG_SCROLL_AMOUNT, + ); + } + + UserAction::BigDown => { + self.scroll_current_window( + curr_pod_id, + self.n_row / crate::config::BIG_SCROLL_AMOUNT, + ); + } + + UserAction::GoTop => { + self.scroll_current_window(curr_pod_id, -i32::MAX); + } + + UserAction::GoBot => { + self.scroll_current_window(curr_pod_id, i32::MAX); + } + // this shouldn't occur because we only trigger this - // function when the UserAction is Up, Down, Left, or Right. + // function when the UserAction is Up, Down, Left, Right, + // BigUp, BigDown, PageUp, PageDown, GoBot and GoTop _ => (), } } + /// Scrolls the current active menu by + /// the specified amount and refreshes + /// the window. + /// Positive Scroll is down. + pub fn scroll_current_window(&mut self, pod_id: Option, scroll: i32) { + match self.active_menu { + ActiveMenu::PodcastMenu => { + if pod_id.is_some() { + self.podcast_menu.scroll(scroll); + + self.episode_menu.top_row = 0; + self.episode_menu.selected = 0; + + // update episodes menu with new list + self.episode_menu.items = self.podcast_menu.get_episodes(); + self.episode_menu.update_items(); + self.update_details_panel(); + } + } + ActiveMenu::EpisodeMenu => { + if pod_id.is_some() { + self.episode_menu.scroll(scroll); + self.update_details_panel(); + } + } + } + } + /// Mark an episode as played or unplayed (opposite of its current /// status). pub fn mark_played( &mut self, curr_pod_id: Option, curr_ep_id: Option, - ) -> Option - { + ) -> Option { if let Some(pod_id) = curr_pod_id { if let Some(ep_id) = curr_ep_id { if let Some(played) = self @@ -586,10 +595,7 @@ impl<'a> UI<'a> { // to delete those too if self.check_for_local_files(pod_id) { let ask_delete = self.spawn_yes_no_notif("Delete local files too?"); - delete = match ask_delete { - Some(val) => val, - None => false, // default not to delete - }; + delete = ask_delete.unwrap_or(false); // default not to delete } return Some(UiMsg::RemovePodcast(pod_id, delete)); @@ -602,8 +608,7 @@ impl<'a> UI<'a> { &mut self, curr_pod_id: Option, curr_ep_id: Option, - ) -> Option - { + ) -> Option { let confirm = self.ask_for_confirmation("Are you sure you want to remove the episode?"); // If we don't get a confirmation to delete, then don't remove if !confirm { @@ -617,13 +622,10 @@ impl<'a> UI<'a> { .episode_menu .items .map_single(ep_id, |ep| ep.path.is_some()) - .unwrap(); + .unwrap_or(false); if is_downloaded { let ask_delete = self.spawn_yes_no_notif("Delete local file too?"); - delete = match ask_delete { - Some(val) => val, - None => false, // default not to delete - }; + delete = ask_delete.unwrap_or(false); // default not to delete } return Some(UiMsg::RemoveEpisode(pod_id, ep_id, delete)); @@ -641,10 +643,7 @@ impl<'a> UI<'a> { // to delete those too if self.check_for_local_files(pod_id) { let ask_delete = self.spawn_yes_no_notif("Delete local files too?"); - delete = match ask_delete { - Some(val) => val, - None => false, // default not to delete - }; + delete = ask_delete.unwrap_or(false); // default not to delete } return Some(UiMsg::RemoveAllEpisodes(pod_id, delete)); } @@ -700,7 +699,9 @@ impl<'a> UI<'a> { pub fn check_for_local_files(&self, pod_id: i64) -> bool { let mut any_downloaded = false; let borrowed_map = self.podcast_menu.items.borrow_map(); - let borrowed_pod = borrowed_map.get(&pod_id).unwrap(); + let borrowed_pod = borrowed_map + .get(&pod_id) + .expect("Could not retrieve podcast info."); let borrowed_ep_list = borrowed_pod.episodes.borrow_map(); @@ -718,10 +719,7 @@ impl<'a> UI<'a> { /// 'y', then the function returns `true`, and 'n' returns /// `false`. Cancelling the action returns `false` as well. pub fn ask_for_confirmation(&self, message: &str) -> bool { - match self.spawn_yes_no_notif(message) { - Some(val) => val, - None => false, - } + self.spawn_yes_no_notif(message).unwrap_or(false) } /// Adds a notification to the bottom of the screen that solicits @@ -802,23 +800,8 @@ impl<'a> UI<'a> { } /// Create a details panel. - pub fn make_details_panel( - colors: Colors, - n_row: i32, - n_col: i32, - start_y: i32, - start_x: i32, - ) -> Panel - { - return Panel::new( - colors, - "Details".to_string(), - 2, - n_row, - n_col, - start_y, - start_x, - ); + pub fn make_details_panel(n_row: i32, n_col: i32, start_y: i32, start_x: i32) -> Panel { + return Panel::new("Details".to_string(), 2, n_row, n_col, start_y, start_x); } /// Updates the details panel with information about the current diff --git a/src/ui/notification.rs b/src/ui/notification.rs index a256da4..bb99fa4 100644 --- a/src/ui/notification.rs +++ b/src/ui/notification.rs @@ -1,6 +1,6 @@ use std::time::{Duration, Instant}; -use super::colors::{ColorType, Colors}; +use super::ColorType; use pancurses::{Input, Window}; /// Holds details of a notification message. @@ -37,7 +37,6 @@ impl Notification { #[derive(Debug)] pub struct NotifWin { window: Window, - colors: Colors, total_rows: i32, total_cols: i32, msg_stack: Vec, @@ -47,11 +46,10 @@ pub struct NotifWin { impl NotifWin { /// Creates a new NotifWin. - pub fn new(colors: Colors, total_rows: i32, total_cols: i32) -> Self { + pub fn new(total_rows: i32, total_cols: i32) -> Self { let win = pancurses::newwin(1, total_cols, total_rows - 1, 0); return Self { window: win, - colors: colors, total_rows: total_rows, total_cols: total_cols, msg_stack: Vec::new(), @@ -60,6 +58,14 @@ impl NotifWin { }; } + /// Initiates the window -- primarily, sets the background on the + /// window. + pub fn init(&mut self) { + self.window + .bkgd(pancurses::ColorPair(ColorType::Normal as u8)); + self.window.refresh(); + } + /// Checks if the current notification needs to be changed, and /// updates the message window accordingly. pub fn check_notifs(&mut self) { @@ -67,39 +73,41 @@ impl NotifWin { // compare expiry times of all notifications to current time, // remove expired ones let now = Instant::now(); - self.msg_stack.retain(|x| now < x.expiry.unwrap()); + self.msg_stack.retain(|x| match x.expiry { + Some(exp) => now < exp, + None => true, + }); if !self.msg_stack.is_empty() { // check if last item changed, and update screen if it has - let last_item = self.msg_stack[self.msg_stack.len() - 1].clone(); + let last_item = &self.msg_stack[self.msg_stack.len() - 1]; match &self.current_msg { Some(curr) => { - if &last_item != curr { - self.display_notif(last_item.clone()); + if last_item != curr { + self.display_notif(last_item); } } - None => self.display_notif(last_item.clone()), + None => self.display_notif(last_item), }; - self.current_msg = Some(last_item); + self.current_msg = Some(last_item.clone()); } else if let Some(msg) = &self.persistent_msg { // if no other timed notifications exist, display a // persistent notification if there is one match &self.current_msg { Some(curr) => { if msg != curr { - self.display_notif(msg.clone()); + self.display_notif(msg); } } - None => self.display_notif(msg.clone()), + None => self.display_notif(msg), }; self.current_msg = Some(msg.clone()); } else { // otherwise, there was a notification before but there // isn't now, so erase self.window.erase(); - self.window.bkgdset(pancurses::ColorPair( - self.colors.get(ColorType::Normal) as u8 - )); + self.window + .bkgdset(pancurses::ColorPair(ColorType::Normal as u8)); self.window.refresh(); self.current_msg = None; } @@ -185,20 +193,15 @@ impl NotifWin { } /// Prints a notification to the window. - fn display_notif(&self, notif: Notification) { + fn display_notif(&self, notif: &Notification) { self.window.erase(); self.window.mv(self.total_rows - 1, 0); self.window.attrset(pancurses::A_NORMAL); - self.window.addstr(notif.message); + self.window.addstr(¬if.message); if notif.error { - self.window.mvchgat( - 0, - 0, - -1, - pancurses::A_BOLD, - self.colors.get(ColorType::Error), - ); + self.window + .mvchgat(0, 0, -1, pancurses::A_BOLD, ColorType::Error as i16); } self.window.refresh(); } @@ -220,7 +223,7 @@ impl NotifWin { let notif = Notification::new(message, error, None); self.persistent_msg = Some(notif.clone()); if self.msg_stack.is_empty() { - self.display_notif(notif.clone()); + self.display_notif(¬if); self.current_msg = Some(notif); } } @@ -251,11 +254,10 @@ impl NotifWin { ); oldwin.delwin(); - self.window.bkgdset(pancurses::ColorPair( - self.colors.get(ColorType::Normal) as u8 - )); + self.window + .bkgdset(pancurses::ColorPair(ColorType::Normal as u8)); if let Some(curr) = &self.current_msg { - self.display_notif(curr.clone()); + self.display_notif(curr); } self.window.refresh(); } diff --git a/src/ui/panel.rs b/src/ui/panel.rs index 179f55d..24e9186 100644 --- a/src/ui/panel.rs +++ b/src/ui/panel.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use pancurses::{Attribute, Window}; -use super::{ColorType, Colors}; +use super::ColorType; /// Struct holding the raw data used for building the details panel. pub struct Details { @@ -23,7 +23,6 @@ pub struct Details { pub struct Panel { window: Window, screen_pos: usize, - colors: Colors, title: String, n_row: i32, n_col: i32, @@ -32,37 +31,28 @@ pub struct Panel { impl Panel { /// Creates a new panel. pub fn new( - colors: Colors, title: String, screen_pos: usize, n_row: i32, n_col: i32, start_y: i32, start_x: i32, - ) -> Self - { + ) -> Self { let panel_win = pancurses::newwin(n_row, n_col, start_y, start_x); return Panel { window: panel_win, screen_pos: screen_pos, - colors: colors, title: title, n_row: n_row, n_col: n_col, }; } - /// Initiates the menu -- primarily, draws borders on the window. - pub fn init(&self) { - self.draw_border(); - } - /// Redraws borders and refreshes the window to display on terminal. pub fn refresh(&self) { - self.window.bkgdset(pancurses::ColorPair( - self.colors.get(ColorType::Normal) as u8 - )); + self.window + .bkgd(pancurses::ColorPair(ColorType::Normal as u8)); self.draw_border(); self.window.refresh(); } @@ -92,16 +82,15 @@ impl Panel { pancurses::ACS_LRCORNER(), ); - self.window.mvaddstr(0, 2, self.title.clone()); + self.window.mvaddstr(0, 2, &self.title); } /// Erases all content on the window, and redraws the border. Does /// not refresh the screen. pub fn erase(&self) { self.window.erase(); - self.window.bkgdset(pancurses::ColorPair( - self.colors.get(ColorType::Normal) as u8 - )); + self.window + .bkgdset(pancurses::ColorPair(ColorType::Normal as u8)); self.draw_border(); } @@ -132,13 +121,12 @@ impl Panel { /// when necessary. `start_y` refers to the row to start at (word /// wrapping makes it unknown where text will end). Returns the row /// on which the text ended. - pub fn write_wrap_line(&self, start_y: i32, string: String) -> i32 { + pub fn write_wrap_line(&self, start_y: i32, string: &str) -> i32 { let mut row = start_y; let max_row = self.get_rows(); - let wrapper = textwrap::wrap_iter(&string, self.get_cols() as usize); + let wrapper = textwrap::wrap(string, self.get_cols() as usize); for line in wrapper { - self.window - .mvaddstr(self.abs_y(row), self.abs_x(0), line.clone()); + self.window.mvaddstr(self.abs_y(row), self.abs_x(0), line); row += 1; if row >= max_row { @@ -156,14 +144,14 @@ impl Panel { self.window.attron(Attribute::Bold); // podcast title match details.pod_title { - Some(t) => row = self.write_wrap_line(row + 1, t), - None => row = self.write_wrap_line(row + 1, "No title".to_string()), + Some(t) => row = self.write_wrap_line(row + 1, &t), + None => row = self.write_wrap_line(row + 1, "No title"), } // episode title match details.ep_title { - Some(t) => row = self.write_wrap_line(row + 1, t), - None => row = self.write_wrap_line(row + 1, "No title".to_string()), + Some(t) => row = self.write_wrap_line(row + 1, &t), + None => row = self.write_wrap_line(row + 1, "No title"), } self.window.attroff(Attribute::Bold); @@ -173,7 +161,7 @@ impl Panel { if let Some(date) = details.pubdate { let new_row = self.write_wrap_line( row + 1, - format!("Published: {}", date.format("%B %-d, %Y").to_string()), + &format!("Published: {}", date.format("%B %-d, %Y")), ); self.change_attr(row + 1, 0, 10, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; @@ -181,7 +169,7 @@ impl Panel { // duration if let Some(dur) = details.duration { - let new_row = self.write_wrap_line(row + 1, format!("Duration: {}", dur)); + let new_row = self.write_wrap_line(row + 1, &format!("Duration: {}", dur)); self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; } @@ -189,9 +177,9 @@ impl Panel { // explicit if let Some(exp) = details.explicit { let new_row = if exp { - self.write_wrap_line(row + 1, "Explicit: Yes".to_string()) + self.write_wrap_line(row + 1, "Explicit: Yes") } else { - self.write_wrap_line(row + 1, "Explicit: No".to_string()) + self.write_wrap_line(row + 1, "Explicit: No") }; self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); row = new_row; @@ -203,12 +191,12 @@ impl Panel { match details.description { Some(desc) => { self.window.attron(Attribute::Bold); - row = self.write_wrap_line(row + 1, "Description:".to_string()); + row = self.write_wrap_line(row + 1, "Description:"); self.window.attroff(Attribute::Bold); - let _row = self.write_wrap_line(row + 1, desc); + let _row = self.write_wrap_line(row + 1, &desc); } None => { - let _row = self.write_wrap_line(row + 1, "No description.".to_string()); + let _row = self.write_wrap_line(row + 1, "No description."); } } } @@ -222,15 +210,9 @@ impl Panel { nchars: i32, attr: pancurses::chtype, color: ColorType, - ) - { - self.window.mvchgat( - self.abs_y(y), - self.abs_x(x), - nchars, - attr, - self.colors.get(color), - ); + ) { + self.window + .mvchgat(self.abs_y(y), self.abs_x(x), nchars, attr, color as i16); } /// Updates window size diff --git a/src/ui/popup.rs b/src/ui/popup.rs index c109b7f..fae5cff 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -1,8 +1,9 @@ use pancurses::Input; use std::cmp::min; -use super::{ColorType, Colors}; +use super::ColorType; use super::{Menu, Panel, UiMsg}; +use crate::config::BIG_SCROLL_AMOUNT; use crate::keymap::{Keybindings, UserAction}; use crate::types::*; @@ -38,7 +39,6 @@ impl ActivePopup { pub struct PopupWin<'a> { popup: ActivePopup, new_episodes: Vec, - colors: Colors, keymap: &'a Keybindings, total_rows: i32, total_cols: i32, @@ -49,11 +49,10 @@ pub struct PopupWin<'a> { impl<'a> PopupWin<'a> { /// Set up struct for handling popup windows. - pub fn new(colors: Colors, keymap: &'a Keybindings, total_rows: i32, total_cols: i32) -> Self { + pub fn new(keymap: &'a Keybindings, total_rows: i32, total_cols: i32) -> Self { return Self { popup: ActivePopup::None, new_episodes: Vec::new(), - colors: colors, keymap: keymap, total_rows: total_rows, total_cols: total_cols, @@ -122,7 +121,6 @@ impl<'a> PopupWin<'a> { // confused between panel.rs and mock_panel.rs #[allow(unused_mut)] let mut welcome_win = Panel::new( - self.colors.clone(), "Shellcaster".to_string(), 0, self.total_rows - 1, @@ -132,20 +130,16 @@ impl<'a> PopupWin<'a> { ); let mut row = 0; - row = welcome_win.write_wrap_line(row + 1, "Welcome to shellcaster!".to_string()); + row = welcome_win.write_wrap_line(row + 1, "Welcome to shellcaster!"); row = welcome_win.write_wrap_line(row+2, - format!("Your podcast list is currently empty. Press {} to add a new podcast feed, {} to quit, or see all available commands by typing {} to get help.", key_strs[0], key_strs[1], key_strs[2])); + &format!("Your podcast list is currently empty. Press {} to add a new podcast feed, {} to quit, or see all available commands by typing {} to get help.", key_strs[0], key_strs[1], key_strs[2])); row = welcome_win.write_wrap_line( row + 2, - "More details of how to customize shellcaster can be found on the Github repo readme:" - .to_string(), - ); - let _ = welcome_win.write_wrap_line( - row + 1, - "https://github.com/jeff-hughes/shellcaster".to_string(), + "More details of how to customize shellcaster can be found on the Github repo readme:", ); + let _ = welcome_win.write_wrap_line(row + 1, "https://github.com/jeff-hughes/shellcaster"); return welcome_win; } @@ -158,11 +152,19 @@ impl<'a> PopupWin<'a> { /// Create a new Panel holding a help window. pub fn make_help_win(&self) -> Panel { + let big_scroll_up = format!("Up 1/{} page:", BIG_SCROLL_AMOUNT); + let big_scroll_dn = format!("Down 1/{} page:", BIG_SCROLL_AMOUNT); let actions = vec![ (Some(UserAction::Left), "Left:"), (Some(UserAction::Right), "Right:"), (Some(UserAction::Up), "Up:"), (Some(UserAction::Down), "Down:"), + (Some(UserAction::BigUp), &big_scroll_up), + (Some(UserAction::BigDown), &big_scroll_dn), + (Some(UserAction::PageUp), "Page up:"), + (Some(UserAction::PageDown), "Page down:"), + (Some(UserAction::GoTop), "Go to top:"), + (Some(UserAction::GoBot), "Go to bottom:"), // (None, ""), (Some(UserAction::AddFeed), "Add feed:"), (Some(UserAction::Sync), "Sync:"), @@ -203,7 +205,6 @@ impl<'a> PopupWin<'a> { // confused between panel.rs and mock_panel.rs #[allow(unused_mut)] let mut help_win = Panel::new( - self.colors.clone(), "Help".to_string(), 0, self.total_rows - 1, @@ -213,14 +214,18 @@ impl<'a> PopupWin<'a> { ); let mut row = 0; - row = help_win.write_wrap_line(row + 1, "Available keybindings:".to_string()); + row = help_win.write_wrap_line(row + 1, "Available keybindings:"); help_win.change_attr(row, 0, 22, pancurses::A_UNDERLINE, ColorType::Normal); row += 1; // check how long our strings are, and map to two columns // if possible; `col_spacing` is the space to leave in between // the two columns - let longest_line = key_strs.iter().map(|x| x.chars().count()).max().unwrap(); + let longest_line = key_strs + .iter() + .map(|x| x.chars().count()) + .max() + .expect("Could not parse keybindings."); let col_spacing = 5; let n_cols = if help_win.get_cols() > (longest_line * 2 + col_spacing) as i32 { 2 @@ -252,7 +257,7 @@ impl<'a> PopupWin<'a> { row += 1; } - let _ = help_win.write_wrap_line(row + 2, "Press \"q\" to close this window.".to_string()); + let _ = help_win.write_wrap_line(row + 2, "Press \"q\" to close this window."); return help_win; } @@ -272,7 +277,6 @@ impl<'a> PopupWin<'a> { // confused between panel.rs and mock_panel.rs #[allow(unused_mut)] let mut download_panel = Panel::new( - self.colors.clone(), "New episodes".to_string(), 0, self.total_rows - 1,