diff --git a/CHANGELOG.md b/CHANGELOG.md index 468f047..6a13c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v2.0.0 (2022-02-26) +Shellcaster version 2.0.0 brings many added features and performance improvements, but there are a few breaking changes to the configuration file to be aware of. These are listed below. You can download the default config file from here, to reference when updating your local config: https://raw.githubusercontent.com/jeff-hughes/shellcaster/master/config.toml + +### New Features +- Switched from *ncurses* C library to Rust-native *crossterm* library for drawing to the terminal. This required a significant rewrite, but drops a significant non-Rust dependency +- Added the ability to toggle "filters" -- to only show played or unplayed episodes (default key: "1" to toggle), or to only show downloaded or non-downloaded episodes (default key: "2" to toggle) +- Details panel can now be scrolled by moving the cursor all the way to the right, and using the same keys to scroll up/down by line or by page + +### Performance Improvements +- Added database transactions, which offers a dramatic speed-up when syncing podcasts +- Use of GUIDs when matching against existing episodes, which also speeds up syncing podcasts. **Note:** The first time you do a full sync after updating to v2.0.0, it may take some extra time while the app records the GUIDs of all existing episodes in the database. This is a one-time process, so please be patient! +- Switched to a non-cryptographic hashing algorithm, which offers a minor performance improvement + +### Breaking Changes +- The named terminal colors available (e.g., black, blue, cyan) now split off the "dark" versions into separate names. So instead of specifying "blue", you can either specify "blue" or "darkblue". This is a result of switching from *ncurses* to *crossterm*. There are also two additional config options for specifying app colors: `bold_foreground` and `bold_background`, which are used for titles, unplayed episodes, etc. ([see default config](https://raw.githubusercontent.com/jeff-hughes/shellcaster/master/config.toml)) +- There are two extra keybindings listed in the config file, `filter_played` and `filter_downloaded`, which are used to toggle the new filters on and off. By default, they are toggled with the "1" and "2" keys, respectively ([see default config](https://raw.githubusercontent.com/jeff-hughes/shellcaster/master/config.toml)) +- Some of the available crate features have changed: "wide" and "win32" are no longer available (and should no longer be necessary), due to the switch to *crossterm* +- In addition, the HTTP client that shellcaster uses did some significant refactoring, resulting in a few other changes in shellcaster's features as a result. The "rustls" feature has been removed -- the app now uses the *rustls* crate by default. However, you can still choose to use the *native-tls* crate instead by enabling the "native_tls" feature (note that the name has changed from "native-tls" to "native_tls"). In addition, by default *shellcaster* will use your OS's native certificate roots (via the "native_certs" feature), but if you wish to turn this off, you can instead use a bundled copy of the Mozilla Root program by building *shellcaster* with the `--no-default-features` flag + ## v1.2.1 (2021-09-08) - This is a simple patch release to fix a compilation issue resulting from the update to rustc v1.54.0. diff --git a/Cargo.lock b/Cargo.lock index 87f625a..7c18b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,38 +3,47 @@ version = 3 [[package]] -name = "aho-corasick" -version = "0.7.18" +name = "adler" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "memchr", + "getrandom", + "once_cell", + "version_check", ] [[package]] -name = "ansi_term" -version = "0.11.0" +name = "aho-corasick" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" dependencies = [ - "winapi", + "memchr", ] [[package]] name = "anyhow" -version = "1.0.40" +version = "1.0.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" +checksum = "159bb86af3a200e19a068f4224eae4c8bb2d0fa054c7e5d1cacd5cef95e684cd" [[package]] name = "atom_syndication" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5016bf52ff4f3ed28bf3ec1fed96b53daf4b137d5e6b9f97a8cfae7b57a3a2" +checksum = "21fb6a0b39c6517edafe46f8137e53c51742425a4dae1c73ee12264a37ad7541" dependencies = [ "chrono", "derive_builder", "diligent-date-parser", + "never", "quick-xml", ] @@ -63,21 +72,21 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bumpalo" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" +checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" [[package]] name = "cc" -version = "1.0.67" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" [[package]] name = "cfg-if" @@ -106,17 +115,18 @@ checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" [[package]] name = "clap" -version = "2.33.3" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +checksum = "5177fac1ab67102d8989464efd043c6ff44191b1557ec1ddd489b4f7e1447e77" dependencies = [ - "ansi_term", "atty", "bitflags", - "strsim 0.8.0", - "textwrap 0.11.0", - "unicode-width", - "vec_map", + "indexmap", + "lazy_static", + "os_str_bytes", + "strsim", + "termcolor", + "textwrap", ] [[package]] @@ -135,11 +145,45 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "crc32fast" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2209c310e29876f7f0b2721e7e26b84aff178aa3da5d091f9bfbf47669e60e3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b75a27dc8d220f1f8521ea69cd55a34d720a200ebb3a624d9aa19193d3b432" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "darling" -version = "0.10.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" dependencies = [ "darling_core", "darling_macro", @@ -147,23 +191,23 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.10.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim 0.9.3", + "strsim", "syn", ] [[package]] name = "darling_macro" -version = "0.10.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" dependencies = [ "darling_core", "quote", @@ -172,22 +216,18 @@ dependencies = [ [[package]] name = "derive_builder" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" dependencies = [ - "darling", - "derive_builder_core", - "proc-macro2", - "quote", - "syn", + "derive_builder_macro", ] [[package]] name = "derive_builder_core" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" dependencies = [ "darling", "proc-macro2", @@ -195,11 +235,21 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "diligent-date-parser" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37ea528f01b8bfca1f71bcd06a8e6c898bf8fdfbf24dd9dbc7fb49338ed6d84" +checksum = "c2d0fd95c7c02e2d6c588c6c5628466fff9bdde4b8c6196465e087b08e792720" dependencies = [ "chrono", ] @@ -242,9 +292,9 @@ checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" [[package]] name = "escaper" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39da344028c2227132b2dfa7c186e2104ecc153467583d00ed9c398f9ff693b0" +checksum = "a53eb97b7349ba1bdb31839eceafe9aaae8f1d8d944dc589b67fb0b26e1c1666" dependencies = [ "entities", ] @@ -261,6 +311,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "flate2" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6988e897c1c9c485f43b47a529cef42fde0547f9d8d41a7062518f1d8fc53f" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -294,20 +356,38 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if", "libc", "wasi", ] +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown", +] + [[package]] name = "hermit-abi" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" dependencies = [ "libc", ] @@ -329,6 +409,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" +dependencies = [ + "autocfg", + "hashbrown", +] + [[package]] name = "jetscii" version = "0.5.0" @@ -337,9 +427,9 @@ checksum = "cbaee539c385ec14c4b6fc8f1001f8e6778cec68cab4d1c0513cec4e7b2c9ff4" [[package]] name = "js-sys" -version = "0.3.50" +version = "0.3.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d99f9e3e84b8f67f846ef5b4cbbc3b1c29f6c759fcbce6f01aa0e73d932a24c" +checksum = "ce791b7ca6638aae45be056e068fc756d871eb3b3b10b8efa62d1c9cec616752" dependencies = [ "wasm-bindgen", ] @@ -352,15 +442,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.94" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" +checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" [[package]] name = "libsqlite3-sys" -version = "0.17.3" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d90181c2904c287e5390186be820e5ef311a3c62edebb7d6ca3d6a48ce041d" +checksum = "d2cafc7c74096c336d9d27145f7ebd4f4b6f95ba16aa5a282387267e6925cb58" dependencies = [ "cc", "pkg-config", @@ -368,10 +458,13 @@ dependencies = [ ] [[package]] -name = "linked-hash-map" -version = "0.5.4" +name = "lock_api" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +dependencies = [ + "scopeguard", +] [[package]] name = "log" @@ -383,31 +476,54 @@ dependencies = [ ] [[package]] -name = "lru-cache" -version = "0.1.2" +name = "matches" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" dependencies = [ - "linked-hash-map", + "adler", + "autocfg", ] [[package]] -name = "matches" -version = "0.1.8" +name = "mio" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] [[package]] -name = "memchr" -version = "2.4.0" +name = "miow" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] [[package]] name = "native-tls" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" +checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" dependencies = [ "lazy_static", "libc", @@ -422,15 +538,10 @@ dependencies = [ ] [[package]] -name = "ncurses" -version = "5.101.0" +name = "never" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5d34d72657dc4b638a1c25d40aae81e4f1c699062f72f467237920752032" -dependencies = [ - "cc", - "libc", - "pkg-config", -] +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" [[package]] name = "nohash-hasher" @@ -438,6 +549,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -459,15 +579,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" [[package]] name = "openssl" -version = "0.10.33" +version = "0.10.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577" +checksum = "8d9facdb76fec0b73c406f125d44d86fdad818d66fef0531eec9233ca425ff4a" dependencies = [ "bitflags", "cfg-if", @@ -479,15 +599,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" +checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" [[package]] name = "openssl-sys" -version = "0.9.61" +version = "0.9.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" +checksum = "1996d2d305e561b70d1ee0c53f1542833f4e1ac6ce9a6708b6ff2738ca67dc82" dependencies = [ "autocfg", "cc", @@ -498,36 +618,45 @@ dependencies = [ [[package]] name = "opml" -version = "0.3.0" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045aa841087ee62d99f3b39df48dfa891b99ab703b671a478af72106e893771d" +checksum = "8fe9e9cbee8c06ce93a3141ffdeeab4e88e60427be285b09b604d65ec05672a1" dependencies = [ - "regex", "serde", "strong-xml", + "thiserror", ] [[package]] -name = "pancurses" -version = "0.16.1" +name = "os_str_bytes" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3058bc37c433096b2ac7afef1c5cdfae49ede0a4ffec3dfc1df1df0959d0ff0" +checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" dependencies = [ - "libc", - "log", - "ncurses", - "pdcurses-sys", - "winreg", + "memchr", ] [[package]] -name = "pdcurses-sys" -version = "0.7.1" +name = "parking_lot" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084dd22796ff60f1225d4eb6329f33afaf4c85419d51d440ab6b8c6f4529166b" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" dependencies = [ - "cc", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954" +dependencies = [ + "cfg-if", "libc", + "redox_syscall", + "smallvec", + "windows-sys", ] [[package]] @@ -550,27 +679,18 @@ checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" [[package]] name = "proc-macro2" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" +checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" dependencies = [ "unicode-xid", ] -[[package]] -name = "qstring" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" -dependencies = [ - "percent-encoding", -] - [[package]] name = "quick-xml" -version = "0.20.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd" +checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b" dependencies = [ "encoding_rs", "memchr", @@ -587,9 +707,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", "rand_chacha", @@ -599,9 +719,9 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -609,27 +729,27 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ "getrandom", ] [[package]] name = "rand_hc" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ "rand_core", ] [[package]] name = "redox_syscall" -version = "0.2.6" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ "bitflags", ] @@ -670,6 +790,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "rfc822_sanitizer" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95e6ac0e635800681025bddc2fa6747cf1159bb897223a74e481ec54b4f5d44" +dependencies = [ + "chrono", + "lazy_static", + "regex", +] + [[package]] name = "ring" version = "0.16.20" @@ -687,37 +818,37 @@ dependencies = [ [[package]] name = "rss" -version = "1.10.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e70d6ae72f8a4333af8ce9dce58942020528430eb0d46ee2fcb5e8d4d16377" +checksum = "36e19e299f301be17927a7c05b8fa1c621e3227e6c3a0da65492701642901ff7" dependencies = [ "atom_syndication", "derive_builder", + "never", "quick-xml", ] [[package]] name = "rusqlite" -version = "0.21.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a656821bb6317a84b257737b7934f79c0dbb7eb694710475908280ebad3e64" +checksum = "4ba4d3462c8b2e4d7f4fcfcf2b296dc6b65404fbbc7b63daa37fd485c149daf7" dependencies = [ "bitflags", "fallible-iterator", "fallible-streaming-iterator", + "hashlink", "libsqlite3-sys", - "lru-cache", "memchr", - "time", + "smallvec", ] [[package]] name = "rustls" -version = "0.19.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +checksum = "d37e5e2290f3e040b594b1a9e04377c2c671f1a1cfd9bfdef82106ac1c113f84" dependencies = [ - "base64", "log", "ring", "sct", @@ -725,10 +856,31 @@ dependencies = [ ] [[package]] -name = "sanitize-filename" +name = "rustls-native-certs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca9ebdfa27d3fc180e42879037b5338ab1c040c06affd00d8338598e7800943" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fd0fec94ec480abfd86bb8f4f6c57e0efb36dac5c852add176ea7b04c74801" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64", +] + +[[package]] +name = "sanitize-filename" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf18934a12018228c5b55a6dae9df5d0641e3566b3630cb46cc55564068e7c2f" dependencies = [ "lazy_static", "regex", @@ -744,11 +896,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + [[package]] name = "sct" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ "ring", "untrusted", @@ -756,9 +914,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3670b1d2fdf6084d192bc71ead7aabe6c06aa2ea3fbd9cc3ac111fa5c2b1bd84" +checksum = "23a2ac85147a3a11d77ecf1bc7166ec0b92febfa4461c37944e180f319ece467" dependencies = [ "bitflags", "core-foundation", @@ -769,9 +927,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3676258fd3cfe2c9a0ec99ce3038798d847ce3e4bb17746373eb9f0f1ac16339" +checksum = "7e4effb91b4b8b6fb7732e670b6cee160278ff8e6bf485c7805d9e319d76e284" dependencies = [ "core-foundation-sys", "libc", @@ -779,33 +937,24 @@ dependencies = [ [[package]] name = "semver" -version = "0.10.0" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "394cec28fa623e00903caf7ba4fa6fb9a0e260280bb8cdbbba029611108a0190" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +checksum = "a4a3381e03edd24287172047536f20cabde766e2cd3e65e6b00fb3af51c4f38d" [[package]] name = "serde" -version = "1.0.125" +version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" +checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.125" +version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" +checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" dependencies = [ "proc-macro2", "quote", @@ -814,25 +963,28 @@ dependencies = [ [[package]] name = "shellcaster" -version = "1.2.1" +version = "2.0.0" dependencies = [ + "ahash", "anyhow", "chrono", "clap", + "crossterm", "dirs-next", "escaper", "lazy_static", + "native-tls", "nohash-hasher", "opml", - "pancurses", "regex", + "rfc822_sanitizer", "rss", "rusqlite", "sanitize-filename", "semver", "serde", "shellexpand", - "textwrap 0.13.4", + "textwrap", "toml", "unicode-segmentation", "ureq", @@ -847,6 +999,42 @@ dependencies = [ "dirs-next", ] +[[package]] +name = "signal-hook" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fd5867f1c4f2c5be079aee7a2adf1152ebb04a4bc4d341f504b7dece607ed4" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + [[package]] name = "smawk" version = "0.3.1" @@ -885,21 +1073,15 @@ dependencies = [ [[package]] name = "strsim" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" - -[[package]] -name = "strsim" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "1.0.70" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9505f307c872bab8eb46f77ae357c8eba1fdacead58ee5a850116b1d7f82883" +checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" dependencies = [ "proc-macro2", "quote", @@ -921,24 +1103,45 @@ dependencies = [ ] [[package]] -name = "textwrap" -version = "0.11.0" +name = "termcolor" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" dependencies = [ - "unicode-width", + "winapi-util", ] [[package]] name = "textwrap" -version = "0.13.4" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835" +checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" dependencies = [ "smawk", + "unicode-linebreak", "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.1.43" @@ -951,9 +1154,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.2.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +checksum = "848a1e1181b9f6753b5e96a092749e29b11d19ede67dfbbd6c7dc7e0f49b5338" dependencies = [ "tinyvec_macros", ] @@ -975,27 +1178,33 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" + +[[package]] +name = "unicode-linebreak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" dependencies = [ - "matches", + "regex", ] [[package]] name = "unicode-normalization" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" [[package]] name = "unicode-width" @@ -1005,9 +1214,9 @@ checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" [[package]] name = "unicode-xid" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" [[package]] name = "untrusted" @@ -1017,17 +1226,18 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "ureq" -version = "1.5.4" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "294b85ef5dbc3670a72e82a89971608a1fcc4ed5c7c5a2895230d31a95f0569b" +checksum = "9399fa2f927a3d327187cbd201480cee55bee6ac5d3c77dd27f0c6814cff16d5" dependencies = [ "base64", "chunked_transfer", + "flate2", "log", "native-tls", "once_cell", - "qstring", "rustls", + "rustls-native-certs", "url", "webpki", "webpki-roots", @@ -1035,9 +1245,9 @@ dependencies = [ [[package]] name = "url" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" dependencies = [ "form_urlencoded", "idna", @@ -1047,15 +1257,15 @@ dependencies = [ [[package]] name = "vcpkg" -version = "0.2.12" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "vec_map" -version = "0.8.2" +name = "version_check" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" [[package]] name = "wasi" @@ -1065,9 +1275,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "wasm-bindgen" -version = "0.2.73" +version = "0.2.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83240549659d187488f91f33c0f8547cbfef0b2088bc470c116d1d260ef623d9" +checksum = "b608ecc8f4198fe8680e2ed18eccab5f0cd4caaf3d83516fa5fb2e927fda2586" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -1075,9 +1285,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.73" +version = "0.2.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae70622411ca953215ca6d06d3ebeb1e915f0f6613e3b495122878d7ebec7dae" +checksum = "580aa3a91a63d23aac5b6b267e2d13cb4f363e31dce6c352fca4752ae12e479f" dependencies = [ "bumpalo", "lazy_static", @@ -1090,9 +1300,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.73" +version = "0.2.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e734d91443f177bfdb41969de821e15c516931c3c3db3d318fa1b68975d0f6f" +checksum = "171ebf0ed9e1458810dfcb31f2e766ad6b3a89dbda42d8901f2b268277e5f09c" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1100,9 +1310,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.73" +version = "0.2.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53739ff08c8a68b0fdbcd54c372b8ab800b1449ab3c9d706503bc7dd1621b2c" +checksum = "6c2657dd393f03aa2a659c25c6ae18a13a4048cebd220e147933ea837efc589f" dependencies = [ "proc-macro2", "quote", @@ -1113,15 +1323,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.73" +version = "0.2.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a543ae66aa233d14bb765ed9af4a33e81b8b58d1584cf1b47ff8cd0b9e4489" +checksum = "2e0c4a743a309662d45f4ede961d7afa4ba4131a59a639f29b0069c3798bbcc2" [[package]] name = "web-sys" -version = "0.3.50" +version = "0.3.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a905d57e488fec8861446d3393670fb50d27a262344013181c2cdf9fff5481be" +checksum = "01c70a82d842c9979078c772d4a1344685045f1a5628f677c2b2eab4dd7d2696" dependencies = [ "js-sys", "wasm-bindgen", @@ -1129,9 +1339,9 @@ dependencies = [ [[package]] name = "webpki" -version = "0.21.4" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" dependencies = [ "ring", "untrusted", @@ -1139,9 +1349,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.21.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +checksum = "552ceb903e957524388c4d3475725ff2c8b7960922063af6ce53c9a43da07449" dependencies = [ "webpki", ] @@ -1162,6 +1372,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1169,14 +1388,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "winreg" -version = "0.5.1" +name = "windows-sys" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27a759395c1195c4cc5cda607ef6f8f6498f64e78f7900f5de0a127a424704a" +checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" dependencies = [ - "winapi", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", ] +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + [[package]] name = "xmlparser" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 60d71e8..206c998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "shellcaster" -version = "1.2.1" +version = "2.0.0" authors = ["Jeff Hughes "] -edition = "2018" +edition = "2021" license = "GPL-3.0-or-later" description = "A terminal-based podcast manager to subscribe to and play podcasts." keywords = ["podcast", "terminal", "TUI", "curses"] @@ -16,33 +16,32 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -pancurses = "0.16.1" -rss = "1.10.0" -rusqlite = "0.21.0" -clap = "2.33.1" +crossterm = "0.23.0" +rss = "2.0.0" +rusqlite = "0.26.3" +ureq = "2.4.0" +native-tls = { version = "0.2.8", optional = true } +clap = { version = "3.1.2", features = ["cargo", "env"] } toml = "0.5.8" -anyhow = "1.0.40" -serde = { version = "1.0.125", features = ["derive"] } -chrono = "0.4.11" +anyhow = "1.0.55" +serde = { version = "1.0.136", features = ["derive"] } +chrono = "0.4.19" lazy_static = "1.4.0" regex = "1.5.4" -sanitize-filename = "0.2.1" +sanitize-filename = "0.3.0" shellexpand = "2.1.0" dirs = { package = "dirs-next", version = "2.0.0" } -opml = "0.3.0" +opml = "1.1.3" +ahash = "0.7.6" 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.5.4" -default-features = false - +unicode-segmentation = "1.8.0" +textwrap = "0.14.2" +escaper = "0.1.1" +rfc822_sanitizer = "0.3.6" +semver = "1.0.6" [features] -default = ["wide", "native-tls"] +default = ["native_certs"] # bundle sqlite library with app; recommended for Windows. This is # turned on by default, but if you are building this for a package @@ -50,15 +49,15 @@ default = ["wide", "native-tls"] # adding libsqlite3-dev or sqlite3 as a dependency on the package sqlite_bundled = ["rusqlite/bundled"] -# by default, shellcaster uses `native-tls` crate to enable TLS support; -# if this is causing issues for some websites, you can try building it -# to use `rustls` crate instead; build with `--no-default-features` and -# then specify `--features "rustls"` -native-tls = ["ureq/native-tls"] -rustls = ["ureq/tls"] - -# specific to Unix systems; see pancurses docs for more details -wide = ["pancurses/wide"] - -# specific to Windows; see pancurses docs for more details -win32 = ["pancurses/win32"] +# by default, shellcaster uses the `rustls` crate to enable TLS support; +# if this is causing issues for some URLs (e.g., those using TLS 1.0 or +# 1.1), you can try building it to use the `native-tls` crate instead by +# specifying `--features "native_tls"` +native_tls = ["native-tls", "ureq/native-tls"] + +# the `native_certs` feature (enabled by default) extracts the trusted +# certificate roots from your OS's trust store; you can instead use a +# bundled copy of the Mozilla Root program (which will thus not update +# if the program is not updated). To do so, build shellcaster with +# `--no-default-features` to turn off use of the native certificates +native_certs = ["ureq/native-certs"] diff --git a/README.md b/README.md index 39a328b..06c72be 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Shellcaster -Shellcaster is a terminal-based podcast manager, built in Rust. It provides a terminal UI (i.e., ncurses) to allow users to subscribe to podcast feeds, and sync feeds to check for new episodes. Episodes may be downloaded locally, played (with an external media player, at least for now), and marked as played/unplayed. Keybindings and other options are configurable via a config file. +Shellcaster is a terminal-based podcast manager, built in Rust. It provides a terminal UI (i.e., an ncurses-like interface) to allow users to subscribe to podcast feeds, and sync feeds to check for new episodes. Episodes may be downloaded locally, played with an external media player, and marked as played/unplayed. Keybindings and other options are configurable via a config file.
screenshot of shellcaster
@@ -24,16 +24,14 @@ First, ensure you have installed the necessary dependencies: * rust * gcc - * libncurses-dev * pkg-config * libsqlite3-dev - * libssl-dev (not needed on MacOS) **Notes:** - * The names of these dependencies may be slightly different for your system. For `libncurses-dev`, `libssl-dev`, and `libsqlite3-dev`, you are looking for the development headers for ncurses, OpenSSL, and SQLite, which may be separate from the runtime package (e.g., with a `-dev` suffix). - * If you turn off the "native-tls" feature of shellcaster (enabled by default), `libssl-dev` is not necessary. - * If you select the "sqlite-bundled" feature of shellcaster (disabled by default), `pkg-config` and `libsqlite3-dev` are not necessary. + * The names of these dependencies may be slightly different for your system. For `libsqlite3-dev`, you are looking for the development headers for SQLite, which may be separate from the runtime package (e.g., with a `-dev` suffix). + * If you enable the "native_tls" feature of shellcaster (disabled by default), you will also need `libssl-dev`, the development headers for OpenSSL (not needed on MacOS). + * If you enable the "sqlite-bundled" feature of shellcaster (disabled by default), `pkg-config` and `libsqlite3-dev` are not necessary. Next, there are two options for compiling the program: @@ -65,19 +63,17 @@ See below for the list of available features when compiling. ### On Windows -Shellcaster is **not currently supported on Windows**, although some work has been done to try to get it working. Unicode support is weak, however, and there are issues when resizing the screen. You *might* have better luck using the new Windows Terminal and building with the `win32` feature enabled, but this has not been tested. If you are a Windows user and want to help work out the bugs, pull requests are more than welcome! +Shellcaster is **not currently supported on Windows**, although some work has been done to try to get it working. Unicode support is weak, however, and there are issues when resizing the screen. You *might* have better luck using the new Windows Terminal, but this has not been tested. If you are a Windows user and want to help work out the bugs, pull requests are more than welcome! ### List of compile features -By default, `native-tls` and `wide` features are enabled. Here is the full list of features: +By default, only the `native_certs` feature is enabled. Here is the full list of features: * `sqlite_bundled`: When disabled, Rust will try to link shellcaster with SQLite header files already present on your system. If enabled, Rust will instead build SQLite from source and bundle the program with shellcaster. Bundling results in a larger application size, but may be suitable if you wish to use a different version of SQLite than the one on your system, or if you are on a system where installing SQLite is more difficult. -* `native-tls`/`rustls`: The `native-tls` enables TLS (i.e., URLs with https) support through the [native-tls](https://crates.io/crates/native-tls) crate, which uses OpenSSL on Linux, Secure Transport on MacOS, and SChannel on Windows. If this causes issues for some podcast feeds, you can try building it with the `rustls` feature instead, which uses the [rustls](https://crates.io/crates/rustls) crate. Note that one of these two features *must* be selected, otherwise you will not be able to sync any feeds or download any episodes originating from an https URL! +* `native_tls`: By default, shellcaster uses the [rustls](https://crates.io/crates/rustls) crate to enable TLS support (i.e., URLs with https). This may cause issues with some podcast feeds that use earlier versions of TLS (below TLS v1.2). If you find that some feeds are unable to update, you can try enabling the `native_tls` feature, which will instead use the [native-tls](https://crates.io/crates/native-tls) crate -- which uses OpenSSL on Linux, Secure Transport on MacOS, and SChannel on Windows. -* `wide`: Enables support for "wide" characters (i.e., Unicode) on Linux/Mac systems. Generally preferable unless you have a terminal that does not have wide character support. - -* `win32`: For Windows systems, shellcaster uses [PDCurses](https://github.com/Bill-Gray/PDCurses), which has two different "flavours": win32, and win32a. win32a is the default as it generally has better support for colours and text effects, but enabling this feature will use the win32 flavour instead. +* `native_certs`: Shellcaster will use the trusted certificate roots from the trust store for your OS in order to validate TLS certificates. Turning this feature off will instead use a bundled copy of the Mozilla Root program, which will only be updated when you recompile shellcaster. Thus, leaving this feature enabled is recommended. To specify different features when compiling, here is the format: @@ -202,6 +198,8 @@ The sample file above provides comments that should walk you through all the ava | Shift+X | Delete all downloaded files | | r | Remove selected feed/episode from list | | Shift+R | Remove all feeds/episodes from list | +| 1 | Toggle played/unplayed filter | +| 2 | Toggle downloaded/undownloaded filter | **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). diff --git a/config.toml b/config.toml index b0a9bea..4ea5bb2 100644 --- a/config.toml +++ b/config.toml @@ -91,6 +91,9 @@ delete_all = [ "X" ] remove = [ "r" ] remove_all = [ "R" ] +filter_played = [ "1" ] +filter_downloaded = [ "2" ] + help = [ "?" ] quit = [ "q" ] @@ -99,7 +102,9 @@ quit = [ "q" ] # Colors can be identified in three ways: # 1. Using color names defined by your terminal: -# - black, blue, cyan, green, magenta, red, white, or yellow +# - black, darkgrey, red, darkred, green, darkgreen, yellow, +# darkyellow, blue, darkblue, magenta, darkmagenta, cyan, +# darkcyan, white, or grey # - 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 -- @@ -113,9 +118,14 @@ quit = [ "q" ] # on terminals without the ability to add/change colors. # all regular text -normal_foreground = "white" +normal_foreground = "grey" normal_background = "black" +# colors for bolded text, including podcasts/episodes that are unplayed, +# and some titles +bold_foreground = "white" +bold_background = "black" + # colors for the currently selected podcast/episode highlighted_active_foreground = "rgb(85, 85, 85)" highlighted_active_background = "rgb(209, 164, 0)" diff --git a/src/config.rs b/src/config.rs index 8e91012..c92ce60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use crate::keymap::Keybindings; -use crate::ui::colors::ColorValue; +use crate::ui::colors::AppColors; // Specifies how long, in milliseconds, to display messages at the // bottom of the screen in the UI. @@ -25,11 +25,11 @@ pub const EPISODE_PUBDATE_LENGTH: usize = 60; // How many columns we need (total terminal window width) before we // display the details panel -pub const DETAILS_PANEL_LENGTH: i32 = 135; +pub const DETAILS_PANEL_LENGTH: u16 = 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; +pub const BIG_SCROLL_AMOUNT: u16 = 4; /// Identifies the user's selection for what to do with new episodes @@ -93,86 +93,27 @@ pub struct KeybindingsFromToml { pub delete_all: Option>, pub remove: Option>, pub remove_all: Option>, + pub filter_played: Option>, + pub filter_downloaded: 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. +/// configuration file. See crate::ui::colors module for the AppColors +/// struct which handles the final color scheme. #[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, + pub normal_foreground: Option, + pub normal_background: Option, + pub bold_foreground: Option, + pub bold_background: Option, + pub highlighted_active_foreground: Option, + pub highlighted_active_background: Option, + pub highlighted_foreground: Option, + pub highlighted_background: Option, + pub error_foreground: Option, + pub error_background: Option, } @@ -182,15 +123,14 @@ impl Config { /// file does not exist, or if specific values are not set. pub fn new(path: &Path) -> Result { let mut config_string = String::new(); - let config_toml: ConfigFromToml; - match File::open(path) { + let config_toml = match File::open(path) { Ok(mut file) => { 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) - .with_context(|| "Could not parse config.toml. Please check file syntax.")?; + toml::from_str(&config_string) + .with_context(|| "Could not parse config.toml. Please check file syntax.")? } Err(_) => { // if we can't find the file, set everything to empty @@ -218,6 +158,8 @@ impl Config { delete_all: None, remove: None, remove_all: None, + filter_played: None, + filter_downloaded: None, help: None, quit: None, }; @@ -225,6 +167,8 @@ impl Config { let colors = AppColorsFromToml { normal_foreground: None, normal_background: None, + bold_foreground: None, + bold_background: None, highlighted_active_foreground: None, highlighted_active_background: None, highlighted_foreground: None, @@ -232,7 +176,7 @@ impl Config { error_foreground: None, error_background: None, }; - config_toml = ConfigFromToml { + ConfigFromToml { download_path: None, play_command: None, download_new_episodes: None, @@ -240,9 +184,9 @@ impl Config { max_retries: None, keybindings: Some(keybindings), colors: Some(colors), - }; + } } - } + }; return config_with_defaults(config_toml); } @@ -251,7 +195,6 @@ impl Config { /// Takes the deserialized TOML configuration, and creates a Config struct /// 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) -> Result { // specify keybindings let keymap = match config_toml.keybindings { diff --git a/src/db.rs b/src/db.rs index 8280872..3b87212 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; +use ahash::AHashMap; use chrono::{DateTime, NaiveDateTime, Utc}; use lazy_static::lazy_static; use regex::Regex; @@ -25,6 +26,7 @@ pub struct SyncResult { /// with this connection. #[derive(Debug)] pub struct Database { + path: PathBuf, conn: Option, } @@ -36,8 +38,9 @@ impl Database { std::fs::create_dir_all(&db_path) .with_context(|| "Unable to create subdirectory for database.")?; db_path.push("data.db"); - let conn = Connection::open(db_path)?; + let conn = Connection::open(&db_path)?; let db_conn = Database { + path: db_path, conn: Some(conn), }; db_conn.create()?; @@ -60,20 +63,23 @@ impl Database { // compare to current app version let curr_ver = Version::parse(crate::VERSION)?; - // (db_version exists, needs update) - let to_update = match vstr { + match vstr { Ok(vstr) => { let db_version = Version::parse(&vstr)?; - (true, db_version < curr_ver) - } - Err(_) => (false, true), - }; + if db_version < curr_ver { + // any version checks for DB migrations should + // go here first, before we update the version - if to_update.1 { - // any version checks for DB migrations should go - // here first, before we update the version + // adding a column to capture episode guids + if db_version <= Version::parse("1.2.1")? { + conn.execute("ALTER TABLE episodes ADD COLUMN guid TEXT;", params![]) + .expect("Could not run database migrations."); + } - db_conn.update_version(curr_ver, to_update.0)?; + db_conn.update_version(curr_ver, true)?; + } + } + Err(_) => db_conn.update_version(curr_ver, false)?, } } @@ -108,6 +114,7 @@ impl Database { podcast_id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, + guid TEXT, description TEXT, pubdate INTEGER, duration INTEGER, @@ -167,26 +174,33 @@ impl Database { /// 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().expect("Error connecting to database."); - let mut stmt = conn.prepare_cached( - "INSERT INTO podcasts (title, url, description, author, + let mut conn = Connection::open(&self.path).expect("Error connecting to database."); + let tx = conn.transaction()?; + // let conn = self.conn.as_ref().expect("Error connecting to database."); + { + let mut stmt = tx.prepare_cached( + "INSERT INTO podcasts (title, url, description, author, explicit, last_checked) VALUES (?, ?, ?, ?, ?, ?);", - )?; - stmt.execute(params![ - podcast.title, - podcast.url, - podcast.description, - podcast.author, - podcast.explicit, - podcast.last_checked.timestamp() - ])?; + )?; + 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 pod_id; + { + let mut stmt = tx.prepare_cached("SELECT id FROM podcasts WHERE url = ?")?; + 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)?; + let id = self.insert_episode(&tx, pod_id, ep)?; let new_ep = NewEpisode { id: id, pod_id: pod_id, @@ -196,6 +210,7 @@ impl Database { }; ep_ids.push(new_ep); } + tx.commit()?; return Ok(SyncResult { added: ep_ids, @@ -204,23 +219,24 @@ 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().expect("Error connecting to database."); - - let pubdate = match episode.pubdate { - Some(dt) => Some(dt.timestamp()), - None => None, - }; + pub fn insert_episode( + &self, + conn: &Connection, + podcast_id: i64, + episode: &EpisodeNoId, + ) -> Result { + let pubdate = episode.pubdate.map(|dt| dt.timestamp()); let mut stmt = conn.prepare_cached( - "INSERT INTO episodes (podcast_id, title, url, + "INSERT INTO episodes (podcast_id, title, url, guid, description, pubdate, duration, played, hidden) - VALUES (?, ?, ?, ?, ?, ?, ?, ?);", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);", )?; stmt.execute(params![ podcast_id, episode.title, episode.url, + episode.guid, episode.description, pubdate, episode.duration, @@ -280,21 +296,23 @@ impl Database { /// 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().expect("Error connecting to database."); - let mut stmt = conn.prepare_cached( - "UPDATE podcasts SET title = ?, url = ?, description = ?, + { + 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 = ?;", - )?; - stmt.execute(params![ - podcast.title, - podcast.url, - podcast.description, - podcast.author, - podcast.explicit, - podcast.last_checked.timestamp(), - pod_id, - ])?; + )?; + 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); @@ -314,64 +332,70 @@ impl Database { podcast_title: String, episodes: Vec, ) -> Result { - let conn = self.conn.as_ref().expect("Error connecting to database."); - let old_episodes = self.get_episodes(podcast_id, true)?; + let mut old_ep_map = AHashMap::new(); + for ep in old_episodes.iter() { + if !ep.guid.is_empty() { + old_ep_map.insert(ep.guid.clone(), ep.clone()); + } + } + + let mut conn = Connection::open(&self.path).expect("Error connecting to database."); + let tx = conn.transaction()?; let mut insert_ep = Vec::new(); let mut update_ep = Vec::new(); for new_ep in episodes.iter().rev() { - let new_pd = match new_ep.pubdate { - Some(dt) => Some(dt.timestamp()), - None => None, - }; + let new_pd = new_ep.pubdate.map(|dt| dt.timestamp()); - // for each existing episode, check the title, url, and - // pubdate -- if two of the three match, we count it as an - // existing episode; otherwise, we add it as a new episode let mut existing_id = None; let mut update = false; - for old_ep in old_episodes.iter().rev() { - let mut matching = 0; - matching += (new_ep.title == old_ep.title) as i32; - matching += (new_ep.url == old_ep.url) as i32; - - let mut pd_match = false; - if let Some(pd) = new_pd { - if let Some(old_pd) = old_ep.pubdate { - matching += (pd == old_pd.timestamp()) as i32; - pd_match = true; - } - } - if matching >= 2 { + // primary matching mechanism: check guid to see if it + // already exists in database + if !new_ep.guid.is_empty() { + if let Some(old_ep) = old_ep_map.get(&new_ep.guid) { existing_id = Some(old_ep.id); + update = self.check_for_updates(old_ep, new_ep); + } + } + + // fallback matching: for each existing episode, check the + // title, url, and pubdate -- if two of the three match, we + // count it as an existing episode; otherwise, we add it as + // a new episode + if existing_id.is_none() { + for old_ep in old_episodes.iter().rev() { + let mut matching = 0; + matching += (new_ep.title == old_ep.title) as i32; + matching += (new_ep.url == old_ep.url) as i32; + + if let Some(pd) = new_pd { + if let Some(old_pd) = old_ep.pubdate { + matching += (pd == old_pd.timestamp()) as i32; + } + } - // if we have a matching episode, check whether there - // are details to update - if !(new_ep.title == old_ep.title - && new_ep.url == old_ep.url - && new_ep.description == old_ep.description - && new_ep.duration == old_ep.duration - && pd_match) - { - update = true; + if matching >= 2 { + existing_id = Some(old_ep.id); + update = self.check_for_updates(old_ep, new_ep); + break; } - break; } } match existing_id { Some(id) => { if update { - let mut stmt = conn.prepare_cached( + let mut stmt = tx.prepare_cached( "UPDATE episodes SET title = ?, url = ?, - description = ?, pubdate = ?, duration = ? - WHERE id = ?;", + guid = ?, description = ?, pubdate = ?, + duration = ? WHERE id = ?;", )?; stmt.execute(params![ new_ep.title, new_ep.url, + new_ep.guid, new_ep.description, new_pd, new_ep.duration, @@ -381,7 +405,7 @@ impl Database { } } None => { - let id = self.insert_episode(podcast_id, &new_ep)?; + let id = self.insert_episode(&tx, podcast_id, new_ep)?; let new_ep = NewEpisode { id: id, pod_id: podcast_id, @@ -393,12 +417,36 @@ impl Database { } } } + tx.commit()?; return Ok(SyncResult { added: insert_ep, updated: update_ep, }); } + /// Checks two matching episodes to see whether there are details + /// that need to be updated (e.g., same episode, but the title has + /// been changed). + fn check_for_updates(&self, old_ep: &Episode, new_ep: &EpisodeNoId) -> bool { + let new_pd = new_ep.pubdate.map(|dt| dt.timestamp()); + let mut pd_match = false; + if let Some(pd) = new_pd { + if let Some(old_pd) = old_ep.pubdate { + pd_match = pd == old_pd.timestamp(); + } + } + if !(new_ep.title == old_ep.title + && new_ep.url == old_ep.url + && new_ep.guid == old_ep.guid + && new_ep.description == old_ep.description + && new_ep.duration == old_ep.duration + && pd_match) + { + return true; + } + return false; + } + /// Updates an episode to mark it as played or unplayed. pub fn set_played_status(&self, episode_id: i64, played: bool) -> Result<()> { let conn = self.conn.as_ref().expect("Error connecting to database."); @@ -487,6 +535,9 @@ impl Database { pod_id: row.get("podcast_id")?, title: row.get("title")?, url: row.get("url")?, + guid: row + .get::<&str, Option>("guid")? + .unwrap_or_else(|| "".to_string()), description: row.get("description")?, pubdate: convert_date(row.get("pubdate")), duration: row.get("duration")?, @@ -494,12 +545,7 @@ impl Database { played: row.get("played")?, }) })?; - let mut episodes = Vec::new(); - for ep in episode_iter { - if let Ok(ep) = ep { - episodes.push(ep); - } - } + let episodes = episode_iter.flatten().collect(); return Ok(episodes); } @@ -517,10 +563,9 @@ impl Database { /// DateTime object fn convert_date(result: Result) -> Option> { return match result { - Ok(timestamp) => match NaiveDateTime::from_timestamp_opt(timestamp, 0) { - Some(ndt) => Some(DateTime::from_utc(ndt, Utc)), - None => None, - }, + Ok(timestamp) => { + NaiveDateTime::from_timestamp_opt(timestamp, 0).map(|ndt| DateTime::from_utc(ndt, Utc)) + } Err(_) => None, }; } diff --git a/src/downloads.rs b/src/downloads.rs index a7e72c2..8ce9de8 100644 --- a/src/downloads.rs +++ b/src/downloads.rs @@ -1,6 +1,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::mpsc::Sender; +use std::time::Duration; use chrono::{DateTime, Utc}; use sanitize_filename::{sanitize_with_options, Options}; @@ -57,18 +58,26 @@ pub fn download_list( /// Downloads a file to a local filepath, returning DownloadMsg variant /// indicating success or failure. fn download_file(mut ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> DownloadMsg { + let agent_builder = ureq::builder(); + #[cfg(feature = "native_tls")] + let tls_connector = std::sync::Arc::new(native_tls::TlsConnector::new().unwrap()); + #[cfg(feature = "native_tls")] + let agent_builder = agent_builder.tls_connector(tls_connector); + let agent = agent_builder.build(); + let request: Result = loop { - let response = ureq::get(&ep_data.url) - .timeout_connect(5000) - .timeout_read(30000) + let response = agent + .get(&ep_data.url) + .timeout(Duration::from_secs(30)) .call(); - if response.error() { - max_retries -= 1; - if max_retries == 0 { - break Err(()); + match response { + Ok(resp) => break Ok(resp), + Err(_) => { + max_retries -= 1; + if max_retries == 0 { + break Err(()); + } } - } else { - break Ok(response); } }; @@ -99,7 +108,7 @@ fn download_file(mut ep_data: EpData, dest: PathBuf, mut max_retries: usize) -> } let mut file_path = dest; - file_path.push(format!("{}.{}", file_name, ext)); + file_path.push(format!("{file_name}.{ext}")); let dst = File::create(&file_path); if dst.is_err() { diff --git a/src/feeds.rs b/src/feeds.rs index da74b5c..1228534 100644 --- a/src/feeds.rs +++ b/src/feeds.rs @@ -1,11 +1,12 @@ use anyhow::{anyhow, Result}; use std::io::Read; use std::sync::mpsc; +use std::time::Duration; -use crate::sanitizer::parse_from_rfc2822_with_fallback; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; use regex::{Match, Regex}; +use rfc822_sanitizer::parse_from_rfc2822_with_fallback; use rss::{Channel, Item}; use crate::threadpool::Threadpool; @@ -72,18 +73,23 @@ pub fn check_feed( /// 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 agent_builder = ureq::builder(); + #[cfg(feature = "native_tls")] + let tls_connector = std::sync::Arc::new(native_tls::TlsConnector::new().unwrap()); + #[cfg(feature = "native_tls")] + let agent_builder = agent_builder.tls_connector(tls_connector); + let agent = agent_builder.build(); + let request: Result = loop { - let response = ureq::get(&url) - .timeout_connect(5000) - .timeout_read(15000) - .call(); - if response.error() { - max_retries -= 1; - if max_retries == 0 { - break Err(anyhow!("No response from feed")); + let response = agent.get(&url).timeout(Duration::from_secs(20)).call(); + match response { + Ok(resp) => break Ok(resp), + Err(_) => { + max_retries -= 1; + if max_retries == 0 { + break Err(anyhow!("No response from feed")); + } } - } else { - break Ok(response); } }; @@ -115,10 +121,7 @@ fn parse_feed_data(channel: Channel, url: &str) -> PodcastNoId { let mut author = None; let mut explicit = None; if let Some(itunes) = channel.itunes_ext() { - author = match itunes.author() { - None => None, - Some(a) => Some(a.to_string()), - }; + author = itunes.author().map(|a| a.to_string()); explicit = match itunes.explicit() { None => None, Some(s) => { @@ -165,6 +168,10 @@ fn parse_episode_data(item: &Item) -> EpisodeNoId { Some(enc) => enc.url().to_string(), None => "".to_string(), }; + let guid = match item.guid() { + Some(guid) => guid.value().to_string(), + None => "".to_string(), + }; let description = match item.description() { Some(dsc) => dsc.to_string(), None => "".to_string(), @@ -186,15 +193,13 @@ fn parse_episode_data(item: &Item) -> EpisodeNoId { let mut duration = None; if let Some(itunes) = item.itunes_ext() { - duration = match duration_to_int(itunes.duration()) { - Some(dur) => Some(dur as i64), - None => None, - }; + duration = duration_to_int(itunes.duration()).map(|dur| dur as i64); } return EpisodeNoId { title: title, url: url, + guid: guid, description: description, pubdate: pubdate, duration: duration, @@ -208,61 +213,46 @@ fn parse_episode_data(item: &Item) -> EpisodeNoId { fn duration_to_int(duration: Option<&str>) -> Option { match duration { Some(dur) => { - match RE_DURATION.captures(&dur) { + match RE_DURATION.captures(dur) { Some(cap) => { /* * Provided that the regex succeeds, we should have * 4 capture groups (with 0th being the full match). * Depending on the string format, however, some of * these may return None. We first loop through the - * capture groups and push Some results to a vector. - * After that, we convert from a vector of Results to - * a Result with a vector, using the collect() method. - * This will fail on the first error, so the duration - * is parsed only if all components of it were - * successfully converted to integers. Finally, we - * convert hours, minutes, and seconds into a total - * duration in seconds and return. + * capture groups and push Some results to an array. + * This will fail on the first non-numeric value, + * so the duration is parsed only if all components + * of it were successfully converted to integers. + * Finally, we convert hours, minutes, and seconds + * into a total duration in seconds and return. */ - let mut times = Vec::new(); - let mut first = true; - for c in cap.iter() { - // cap[0] is always full match - if first { - first = false; - continue; - } - - if let Some(value) = c { - times.push(regex_to_int(value)); + let mut times = [None; 3]; + let mut counter = 0; + // cap[0] is always full match + for c in cap.iter().skip(1).flatten() { + if let Ok(intval) = regex_to_int(c) { + times[counter] = Some(intval); + counter += 1; + } else { + return None; } } - match times.len() { + return match counter { // HH:MM:SS - 3 => { - let result: Result, _> = times.into_iter().collect(); - match result { - Ok(v) => Some(v[0] * 60 * 60 + v[1] * 60 + v[2]), - Err(_) => None, - } - } + 3 => Some( + times[0].unwrap() * 60 * 60 + + times[1].unwrap() * 60 + + times[2].unwrap(), + ), // MM:SS - 2 => { - let result: Result, _> = times.into_iter().collect(); - match result { - Ok(v) => Some(v[0] * 60 + v[1]), - Err(_) => None, - } - } + 2 => Some(times[0].unwrap() * 60 + times[1].unwrap()), // SS - 1 => match times[0] { - Ok(i) => Some(i), - Err(_) => None, - }, + 1 => times[0], _ => None, - } + }; } None => None, } diff --git a/src/keymap.rs b/src/keymap.rs index 016d9e1..ee962b1 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -1,5 +1,5 @@ -use pancurses::Input; -use std::collections::HashMap; +use ahash::AHashMap; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::KeybindingsFromToml; @@ -34,6 +34,9 @@ pub enum UserAction { Remove, RemoveAll, + FilterPlayed, + FilterDownloaded, + Help, Quit, } @@ -42,12 +45,12 @@ pub enum UserAction { /// keys may perform the same action, but each key may only perform one /// action. #[derive(Debug, Clone)] -pub struct Keybindings(HashMap); +pub struct Keybindings(AHashMap); impl Keybindings { /// Returns a new Keybindings struct. pub fn new() -> Self { - return Self(HashMap::new()); + return Self(AHashMap::new()); } /// Returns a Keybindings struct with all default values set. @@ -64,7 +67,6 @@ impl Keybindings { /// 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), @@ -88,23 +90,24 @@ impl Keybindings { (config.delete_all, UserAction::DeleteAll), (config.remove, UserAction::Remove), (config.remove_all, UserAction::RemoveAll), + (config.filter_played, UserAction::FilterPlayed), + (config.filter_downloaded, UserAction::FilterDownloaded), (config.help, UserAction::Help), (config.quit, UserAction::Quit), ]; - let mut keymap = Self::new(); + let mut keymap = Self::default(); for (config, action) in config_actions.into_iter() { - keymap.insert_from_vec( - config.unwrap_or_else(|| defaults.get(&action).unwrap().clone()), - action, - ); + if let Some(config) = config { + keymap.insert_from_vec(config, action); + } } return keymap; } - /// Takes an Input object from pancurses and returns the associated + /// Takes an Input object from crossterm and returns the associated /// user action, if one exists. - pub fn get_from_input(&self, input: Input) -> Option<&UserAction> { + pub fn get_from_input(&self, input: KeyEvent) -> Option<&UserAction> { match input_to_str(input) { Some(code) => self.0.get(&code), None => None, @@ -142,8 +145,8 @@ impl Keybindings { .collect(); } - fn _defaults() -> HashMap> { - let action_map: Vec<(UserAction, Vec)> = vec![ + fn _defaults() -> Vec<(UserAction, Vec)> { + return vec![ (UserAction::Left, vec!["Left".to_string(), "h".to_string()]), (UserAction::Right, vec![ "Right".to_string(), @@ -169,151 +172,65 @@ impl Keybindings { (UserAction::DeleteAll, vec!["X".to_string()]), (UserAction::Remove, vec!["r".to_string()]), (UserAction::RemoveAll, vec!["R".to_string()]), + (UserAction::FilterPlayed, vec!["1".to_string()]), + (UserAction::FilterDownloaded, vec!["2".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 +/// Helper function converting a crossterm KeyEvent object to a unique /// string representing that input. -/// This function is a bit ridiculous, given that 95% of keyboards -/// probably don't even have half these special keys, but at any rate... -/// they're mapped, if anyone wants them. -pub fn input_to_str(input: Input) -> Option { +pub fn input_to_str(input: KeyEvent) -> Option { + let ctrl = if input.modifiers.intersects(KeyModifiers::CONTROL) { + "Ctrl+" + } else { + "" + }; + let alt = if input.modifiers.intersects(KeyModifiers::ALT) { + "Alt+" + } else { + "" + }; + let shift = if input.modifiers.intersects(KeyModifiers::SHIFT) { + "Shift+" + } else { + "" + }; let mut tmp = [0; 4]; - let code = match input { - Input::KeyCodeYes => "CodeYes", - Input::KeyBreak => "Break", - Input::KeyDown => "Down", - Input::KeyUp => "Up", - Input::KeyLeft => "Left", - Input::KeyRight => "Right", - Input::KeyHome => "Home", - Input::KeyBackspace => "Backspace", - Input::KeyF0 => "F0", - Input::KeyF1 => "F1", - Input::KeyF2 => "F2", - Input::KeyF3 => "F3", - Input::KeyF4 => "F4", - Input::KeyF5 => "F5", - Input::KeyF6 => "F6", - Input::KeyF7 => "F7", - Input::KeyF8 => "F8", - Input::KeyF9 => "F9", - Input::KeyF10 => "F10", - Input::KeyF11 => "F11", // F11 triggers KeyResize for me - Input::KeyF12 => "F12", - Input::KeyF13 => "F13", - Input::KeyF14 => "F14", - Input::KeyF15 => "F15", - Input::KeyDL => "DL", - Input::KeyIL => "IL", - Input::KeyDC => "Del", - Input::KeyIC => "Ins", - Input::KeyEIC => "EIC", - Input::KeyClear => "Clear", - Input::KeyEOS => "EOS", - Input::KeyEOL => "EOL", - Input::KeySF => "S_Down", - Input::KeySR => "S_Up", - Input::KeyNPage => "PgDn", - Input::KeyPPage => "PgUp", - Input::KeySTab => "STab", // this doesn't appear to be Shift+Tab - Input::KeyCTab => "C_Tab", - Input::KeyCATab => "CATab", - Input::KeyEnter => "Enter", - Input::KeySReset => "SReset", - Input::KeyReset => "Reset", - Input::KeyPrint => "Print", - Input::KeyLL => "LL", - Input::KeyAbort => "Abort", - Input::KeySHelp => "SHelp", - Input::KeyLHelp => "LHelp", - Input::KeyBTab => "S_Tab", // Shift+Tab - Input::KeyBeg => "Beg", - Input::KeyCancel => "Cancel", - Input::KeyClose => "Close", - Input::KeyCommand => "Command", - Input::KeyCopy => "Copy", - Input::KeyEnd => "End", - Input::KeyExit => "Exit", - Input::KeyFind => "Find", - Input::KeyHelp => "Help", - Input::KeyMark => "Mark", - Input::KeyMessage => "Message", - Input::KeyMove => "Move", - Input::KeyNext => "Next", - Input::KeyOpen => "Open", - Input::KeyOptions => "Options", - Input::KeyPrevious => "Previous", - Input::KeyRedo => "Redo", - Input::KeyReference => "Reference", - Input::KeyRefresh => "Refresh", - Input::KeyResume => "Resume", - Input::KeyRestart => "Restart", - Input::KeySave => "Save", - Input::KeySBeg => "S_Beg", - Input::KeySCancel => "S_Cancel", - Input::KeySCommand => "S_Command", - Input::KeySCopy => "S_Copy", - Input::KeySCreate => "S_Create", - Input::KeySDC => "S_Del", - Input::KeySDL => "S_DL", - Input::KeySelect => "Select", - Input::KeySEnd => "S_End", - Input::KeySEOL => "S_EOL", - Input::KeySExit => "S_Exit", - Input::KeySFind => "S_Find", - Input::KeySHome => "S_Home", - Input::KeySIC => "S_Ins", - Input::KeySLeft => "S_Left", - Input::KeySMessage => "S_Message", - Input::KeySMove => "S_Move", - Input::KeySNext => "S_PgDn", - Input::KeySOptions => "S_Options", - Input::KeySPrevious => "S_PgUp", - Input::KeySPrint => "S_Print", - Input::KeySRedo => "S_Redo", - Input::KeySReplace => "S_Replace", - Input::KeySRight => "S_Right", - Input::KeySResume => "S_Resume", - Input::KeySSave => "S_Save", - Input::KeySSuspend => "S_Suspend", - Input::KeySUndo => "S_Undo", - Input::KeySuspend => "Suspend", - Input::KeyUndo => "Undo", - Input::KeyResize => "F11", // I'm marking this as F11 as well - Input::KeyEvent => "Event", - Input::KeyMouse => "Mouse", - Input::KeyA1 => "A1", - Input::KeyA3 => "A3", - Input::KeyB2 => "B2", - Input::KeyC1 => "C1", - Input::KeyC3 => "C3", - Input::Character(c) => { + return match input.code { + KeyCode::Backspace => Some(format!("{ctrl}{alt}{shift}Backspace")), + KeyCode::Enter => Some(format!("{ctrl}{alt}{shift}Enter")), + KeyCode::Left => Some(format!("{ctrl}{alt}{shift}Left")), + KeyCode::Right => Some(format!("{ctrl}{alt}{shift}Right")), + KeyCode::Up => Some(format!("{ctrl}{alt}{shift}Up")), + KeyCode::Down => Some(format!("{ctrl}{alt}{shift}Down")), + KeyCode::Home => Some(format!("{ctrl}{alt}{shift}Home")), + KeyCode::End => Some(format!("{ctrl}{alt}{shift}End")), + KeyCode::PageUp => Some(format!("{ctrl}{alt}{shift}PgUp")), + KeyCode::PageDown => Some(format!("{ctrl}{alt}{shift}PgDn")), + KeyCode::Tab => Some(format!("{ctrl}{alt}{shift}Tab")), + KeyCode::BackTab => Some(format!("{ctrl}{alt}{shift}Tab")), + KeyCode::Delete => Some(format!("{ctrl}{alt}{shift}Del")), + KeyCode::Insert => Some(format!("{ctrl}{alt}{shift}Ins")), + KeyCode::Esc => Some(format!("{ctrl}{alt}{shift}Esc")), + KeyCode::F(num) => Some(format!("{ctrl}{alt}{shift}F{num}")), // Function keys + KeyCode::Char(c) => { if c == '\u{7f}' { - "Backspace" + Some(format!("{ctrl}{alt}{shift}Backspace")) } else if c == '\u{1b}' { - "Escape" + Some(format!("{ctrl}{alt}{shift}Esc")) } else if c == '\n' { - "Enter" + Some(format!("{ctrl}{alt}{shift}Enter")) } else if c == '\t' { - "Tab" + Some(format!("{ctrl}{alt}{shift}Tab")) } else { - c.encode_utf8(&mut tmp) + // here we don't include "shift" because that will + // already be encoded in the character itself + Some(format!("{}{}{}", ctrl, alt, c.encode_utf8(&mut tmp))) } } - _ => "", + _ => None, }; - if code.is_empty() { - return None; - } else { - return Some(code.to_string()); - } } diff --git a/src/main.rs b/src/main.rs index d6ceba3..09b35ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::process; use std::sync::mpsc; use anyhow::{anyhow, Context, Result}; -use clap::{App, Arg, SubCommand}; +use clap::{Arg, Command}; mod config; mod db; @@ -15,7 +15,6 @@ mod keymap; mod main_controller; mod opml; mod play_file; -mod sanitizer; mod threadpool; mod types; mod ui; @@ -56,51 +55,50 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); /// *Export subcommand:* /// 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() -> Result<()> { // SETUP ----------------------------------------------------------- // set up the possible command line arguments and subcommands - let args = App::new(clap::crate_name!()) + let args = Command::new(clap::crate_name!()) .version(clap::crate_version!()) // .author(clap::crate_authors!(", ")) .author("Jeff Hughes ") .about(clap::crate_description!()) - .arg(Arg::with_name("config") - .short("c") + .arg(Arg::new("config") + .short('c') .long("config") .env("SHELLCASTER_CONFIG") .global(true) .takes_value(true) .value_name("FILE") .help("Sets a custom config file location. Can also be set with environment variable.")) - .subcommand(SubCommand::with_name("sync") + .subcommand(Command::new("sync") .about("Syncs all podcasts in database") - .arg(Arg::with_name("quiet") - .short("q") + .arg(Arg::new("quiet") + .short('q') .long("quiet") .help("Suppresses output messages to stdout."))) - .subcommand(SubCommand::with_name("import") + .subcommand(Command::new("import") .about("Imports podcasts from an OPML file") - .arg(Arg::with_name("file") - .short("f") + .arg(Arg::new("file") + .short('f') .long("file") .takes_value(true) .value_name("FILE") .help("Specifies the filepath to the OPML file to be imported. If this flag is not set, the command will read from stdin.")) - .arg(Arg::with_name("replace") - .short("r") + .arg(Arg::new("replace") + .short('r') .long("replace") .takes_value(false) .help("If set, the contents of the OPML file will replace all existing data in the shellcaster database.")) - .arg(Arg::with_name("quiet") - .short("q") + .arg(Arg::new("quiet") + .short('q') .long("quiet") .help("Suppresses output messages to stdout."))) - .subcommand(SubCommand::with_name("export") + .subcommand(Command::new("export") .about("Exports podcasts to an OPML file") - .arg(Arg::with_name("file") - .short("f") + .arg(Arg::new("file") + .short('f') .long("file") .takes_value(true) .value_name("FILE") @@ -125,13 +123,13 @@ fn main() -> Result<()> { return match args.subcommand() { // SYNC SUBCOMMAND ---------------------------------------------- - ("sync", Some(sub_args)) => sync_podcasts(&db_path, config, sub_args), + Some(("sync", sub_args)) => sync_podcasts(&db_path, config, sub_args), // IMPORT SUBCOMMAND -------------------------------------------- - ("import", Some(sub_args)) => import(&db_path, config, sub_args), + Some(("import", sub_args)) => import(&db_path, config, sub_args), // EXPORT SUBCOMMAND -------------------------------------------- - ("export", Some(sub_args)) => export(&db_path, sub_args), + Some(("export", sub_args)) => export(&db_path, sub_args), // MAIN COMMAND ------------------------------------------------- _ => { @@ -199,18 +197,16 @@ fn sync_podcasts(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Res 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); + let db_result = db_inst.update_podcast(pod_id, pod); match db_result { Ok(_) => { if !args.is_present("quiet") { - println!("Synced {}", title); + println!("Synced {title}"); } } Err(_err) => { failure = true; - eprintln!("Error synchronizing {}", title); + eprintln!("Error synchronizing {title}"); } } } @@ -248,10 +244,10 @@ fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> let xml = match args.value_of("file") { Some(filepath) => { let mut f = File::open(filepath) - .with_context(|| format!("Could not open OPML file: {}", filepath))?; + .with_context(|| format!("Could not open OPML file: {filepath}"))?; let mut contents = String::new(); f.read_to_string(&mut contents) - .with_context(|| format!("Failed to read from OPML file: {}", filepath))?; + .with_context(|| format!("Failed to read from OPML file: {filepath}"))?; contents } None => { @@ -327,18 +323,16 @@ fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> match message { Message::Feed(FeedMsg::NewData(pod)) => { let title = pod.title.clone(); - let db_result; - - db_result = db_inst.insert_podcast(pod); + let db_result = db_inst.insert_podcast(pod); match db_result { Ok(_) => { if !args.is_present("quiet") { - println!("Added {}", title); + println!("Added {title}"); } } Err(_err) => { failure = true; - eprintln!("Error adding {}", title); + eprintln!("Error adding {title}"); } } } @@ -346,7 +340,7 @@ fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> Message::Feed(FeedMsg::Error(feed)) => { failure = true; if let Some(t) = feed.title { - eprintln!("Error retrieving RSS feed: {}", t); + eprintln!("Error retrieving RSS feed: {t}"); } else { eprintln!("Error retrieving RSS feed"); } @@ -372,12 +366,12 @@ fn import(db_path: &Path, config: Config, args: &clap::ArgMatches) -> Result<()> /// Exports all podcasts to OPML format, either printing to stdout or /// exporting to a file. fn export(db_path: &Path, args: &clap::ArgMatches) -> Result<()> { - let db_inst = Database::connect(&db_path)?; + 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() + .to_string() .map_err(|err| anyhow!(err)) .with_context(|| "Could not create OPML format")?; @@ -385,12 +379,12 @@ fn export(db_path: &Path, args: &clap::ArgMatches) -> Result<()> { // export to file Some(file) => { let mut dst = File::create(file) - .with_context(|| format!("Could not create output file: {}", 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))?; + .with_context(|| format!("Could not copy OPML data to output file: {file}"))?; } // print to stdout - None => println!("{}", xml), + None => println!("{xml}"), } return Ok(()); } diff --git a/src/main_controller.rs b/src/main_controller.rs index 96de19e..f11a038 100644 --- a/src/main_controller.rs +++ b/src/main_controller.rs @@ -16,6 +16,7 @@ use crate::types::*; use crate::ui::{Ui, UiMsg}; /// Enum used for communicating with other threads. +#[allow(clippy::enum_variant_names)] #[derive(Debug)] pub enum MainMessage { UiUpdateMenus, @@ -33,6 +34,7 @@ pub struct MainController { db: Database, threadpool: Threadpool, podcasts: LockVec, + filters: Filters, sync_counter: usize, sync_tracker: Vec, download_tracker: HashSet, @@ -52,7 +54,7 @@ impl MainController { 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); @@ -79,6 +81,7 @@ impl MainController { db: db_inst, threadpool: threadpool, podcasts: podcast_list, + filters: Filters::default(), ui_thread: ui_thread, sync_counter: 0, sync_tracker: Vec::new(), @@ -101,7 +104,7 @@ impl MainController { Message::Feed(FeedMsg::Error(feed)) => match feed.title { Some(t) => { - self.notif_to_ui(format!("Error retrieving RSS feed for {}.", t), true) + self.notif_to_ui(format!("Error retrieving RSS feed for {t}."), true) } None => self.notif_to_ui("Error retrieving RSS feed.".to_string(), true), }, @@ -160,6 +163,57 @@ impl MainController { self.remove_all_episodes(pod_id, delete_files) } + Message::Ui(UiMsg::FilterChange(filter_type)) => { + let new_filter; + let message; + match filter_type { + // we need to handle these separately because the + // order that makes the most sense to me is + // different: + // played goes from all -> neg -> pos; + // downloaded goes from all -> pos -> neg; + // this is purely based on the idea that people + // are most likely to want to specifically find + // unplayed episodes, or downloaded episodes + FilterType::Played => { + match self.filters.played { + FilterStatus::All => { + new_filter = FilterStatus::NegativeCases; + message = "Unplayed only"; + } + FilterStatus::NegativeCases => { + new_filter = FilterStatus::PositiveCases; + message = "Played only"; + } + FilterStatus::PositiveCases => { + new_filter = FilterStatus::All; + message = "Played and unplayed"; + } + } + self.filters.played = new_filter; + } + FilterType::Downloaded => { + match self.filters.downloaded { + FilterStatus::All => { + new_filter = FilterStatus::PositiveCases; + message = "Downloaded only"; + } + FilterStatus::PositiveCases => { + new_filter = FilterStatus::NegativeCases; + message = "Undownloaded only"; + } + FilterStatus::NegativeCases => { + new_filter = FilterStatus::All; + message = "Downloaded and undownloaded"; + } + } + self.filters.downloaded = new_filter; + } + } + self.notif_to_ui(format!("Filter: {message}"), false); + self.update_filters(self.filters, true); + } + Message::Ui(UiMsg::Noop) => (), } } @@ -202,15 +256,13 @@ impl MainController { if sync_len > 0 && dl_len > 0 { let notif = format!( - "Syncing {} podcast{}, downloading {} episode{}...", - sync_len, sync_plural, dl_len, dl_plural - ); + "Syncing {sync_len} podcast{sync_plural}, downloading {dl_len} episode{dl_plural}..."); self.persistent_notif_to_ui(notif, false); } else if sync_len > 0 { - let notif = format!("Syncing {} podcast{}...", sync_len, sync_plural); + let notif = format!("Syncing {sync_len} podcast{sync_plural}..."); self.persistent_notif_to_ui(notif, false); } else if dl_len > 0 { - let notif = format!("Downloading {} episode{}...", dl_len, dl_plural); + let notif = format!("Downloading {dl_len} episode{dl_plural}..."); self.persistent_notif_to_ui(notif, false); } else { self.clear_persistent_notif(); @@ -247,9 +299,10 @@ impl MainController { ), // get all of 'em! None => { - pod_data = self.podcasts.map(|pod| { - PodcastFeed::new(Some(pod.id), pod.url.clone(), Some(pod.title.clone())) - }) + pod_data = self.podcasts.map( + |pod| PodcastFeed::new(Some(pod.id), pod.url.clone(), Some(pod.title.clone())), + false, + ) } } for feed in pod_data.into_iter() { @@ -268,7 +321,6 @@ impl MainController { /// synchronizing data from the RSS feed of an existing podcast. /// `pod_id` will be None if a new podcast is being added (i.e., /// the database has not given it an id yet). - #[allow(clippy::useless_let_if_seq)] pub fn add_or_sync_data(&mut self, pod: PodcastNoId, pod_id: Option) { let title = pod.title.clone(); let db_result; @@ -276,7 +328,7 @@ impl MainController { if let Some(id) = pod_id { db_result = self.db.update_podcast(id, pod); - failure = format!("Error synchronizing {}.", title); + failure = format!("Error synchronizing {title}."); } else { db_result = self.db.insert_podcast(pod); failure = "Error adding podcast to database.".to_string(); @@ -290,9 +342,7 @@ impl MainController { .expect("Error retrieving info from database."), ); } - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); + self.update_filters(self.filters, true); if pod_id.is_some() { self.sync_tracker.push(result); @@ -312,10 +362,7 @@ impl MainController { } self.sync_tracker = Vec::new(); self.notif_to_ui( - format!( - "Sync complete: Added {}, updated {} episodes.", - added, updated - ), + format!("Sync complete: Added {added}, updated {updated} episodes."), false, ); @@ -363,7 +410,7 @@ impl MainController { // if there is a local file, try to play that Some(path) => match path.to_str() { Some(p) => { - if play_file::execute(&self.config.play_command, &p).is_err() { + if play_file::execute(&self.config.play_command, p).is_err() { self.notif_to_ui( "Error: Could not play file. Check configuration.".to_string(), true, @@ -396,9 +443,7 @@ impl MainController { podcast.episodes.replace(ep_id, episode); self.podcasts.replace(pod_id, podcast); - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); + self.update_filters(self.filters, true); } /// Given a podcast, it marks all episodes for that podcast as @@ -419,9 +464,7 @@ impl MainController { ); self.podcasts.replace(pod_id, podcast); - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); + self.update_filters(self.filters, true); } /// Given a podcast index (and not an episode index), this will send @@ -505,7 +548,7 @@ impl MainController { self.tx_to_main.clone(), ); } - Err(_) => self.notif_to_ui(format!("Could not create dir: {}", pod_title), true), + Err(_) => self.notif_to_ui(format!("Could not create dir: {pod_title}"), true), } self.update_tracker_notif(); } @@ -539,9 +582,7 @@ impl MainController { self.notif_to_ui("Downloads complete.".to_string(), false); } - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); + self.update_filters(self.filters, true); } /// Given a podcast title, creates a download directory for that @@ -569,7 +610,7 @@ impl MainController { let res = self.db.remove_file(episode.id); if res.is_err() { self.notif_to_ui( - format!("Could not remove file from database: {}", title), + format!("Could not remove file from database: {title}"), true, ); return; @@ -577,12 +618,10 @@ impl MainController { episode.path = None; podcast.episodes.replace(ep_id, episode); - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); - self.notif_to_ui(format!("Deleted \"{}\"", title), false); + self.update_filters(self.filters, true); + self.notif_to_ui(format!("Deleted \"{title}\""), false); } - Err(_) => self.notif_to_ui(format!("Error deleting \"{}\"", title), true), + Err(_) => self.notif_to_ui(format!("Error deleting \"{title}\""), true), } } } @@ -616,9 +655,7 @@ impl MainController { if res.is_err() { success = false; } - self.tx_to_ui - .send(MainMessage::UiUpdateMenus) - .expect("Thread messaging error"); + self.update_filters(self.filters, true); if success { self.notif_to_ui("Files successfully deleted.".to_string(), false); @@ -682,9 +719,12 @@ impl MainController { } let mut podcast = self.podcasts.clone_podcast(pod_id).unwrap(); - podcast.episodes.map(|ep| { - let _ = self.db.hide_episode(ep.id, true); - }); + podcast.episodes.map( + |ep| { + let _ = self.db.hide_episode(ep.id, true); + }, + false, + ); podcast.episodes = LockVec::new(Vec::new()); self.podcasts.replace(pod_id, podcast); @@ -692,4 +732,44 @@ impl MainController { .send(MainMessage::UiUpdateMenus) .expect("Thread messaging error"); } + + /// Updates the user-selected filters to show only played/unplayed + /// or downloaded/not downloaded episodes. + pub fn update_filters(&self, filters: Filters, update_menus: bool) { + { + let (pod_map, pod_order, mut pod_filtered_order) = self.podcasts.borrow(); + let mut new_filtered_pods = Vec::new(); + for pod_id in pod_order.iter() { + let pod = pod_map.get(pod_id).unwrap(); + let new_filter = pod.episodes.filter_map(|ep| { + let play_filter = match filters.played { + FilterStatus::All => false, + FilterStatus::PositiveCases => !ep.is_played(), + FilterStatus::NegativeCases => ep.is_played(), + }; + let download_filter = match filters.downloaded { + FilterStatus::All => false, + FilterStatus::PositiveCases => ep.path.is_none(), + FilterStatus::NegativeCases => ep.path.is_some(), + }; + if !(play_filter | download_filter) { + return Some(ep.id); + } else { + return None; + } + }); + if !new_filter.is_empty() { + new_filtered_pods.push(pod.id); + } + let mut filtered_order = pod.episodes.borrow_filtered_order(); + *filtered_order = new_filter; + } + *pod_filtered_order = new_filtered_pods; + } + if update_menus { + self.tx_to_ui + .send(MainMessage::UiUpdateMenus) + .expect("Thread messaging error"); + } + } } diff --git a/src/opml.rs b/src/opml.rs index fcc02fd..2ddaf9a 100644 --- a/src/opml.rs +++ b/src/opml.rs @@ -8,7 +8,7 @@ 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> { - return match OPML::new(&xml) { + return match OPML::from_str(&xml) { Err(err) => Err(anyhow!(err)), Ok(opml) => { let mut feeds = Vec::new(); diff --git a/src/sanitizer.rs b/src/sanitizer.rs deleted file mode 100644 index e7113b7..0000000 --- a/src/sanitizer.rs +++ /dev/null @@ -1,1814 +0,0 @@ -/// Note: This code comes from the `rfc822_sanitizer` crate -/// (https://gitlab.com/alatiera/rfc822_sanitizer), a small helper crate -/// which appears to be unmaintained and which has out-of-date -/// dependencies. Given that the library is only ~150 lines of code, I -/// opted to include it directly in my crate to update the dependencies. -/// -/// All credit goes to the original authors; see here for the GPLv3 -/// license: https://gitlab.com/alatiera/rfc822_sanitizer/-/blob/master/LICENSE -use chrono::{DateTime, FixedOffset, ParseResult}; -use lazy_static::lazy_static; -use regex::Regex; -use std::borrow::Cow; - -/// Tries to fix common ways date generators misshandle rfc822/rfc2822. -/// -/// For more check the source code, Its ~70 lines of code. -#[allow(clippy::let_and_return)] -pub fn sanitize_rfc822_like_date>(s: S) -> String { - let s = s.into(); - let s = pad_zeros(s); - let s = remove_weekday(s); - let s = replace_month(s); - let s = replace_leading_zeros(s); - s -} - -/// Pad HH:MM:SS with exta zeros if needed. -fn pad_zeros(s: String) -> String { - lazy_static! { - /// 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})").expect("Regex error"); - } - - if OK_RGX.is_match(&s) { - return s; - } - - if let Some(cap) = RE_RGX.captures(&s) { - let mut tm = String::with_capacity(2 + 1 + 2 + 1 + 2 + 1); - cap.iter().skip(1).filter_map(|m| m).for_each(|mtch| { - let m_str = mtch.as_str(); - if m_str.len() == 1 { - tm.push('0'); - } - tm.push_str(m_str); - tm.push(':'); - }); - tm.pop(); // Pop leftover last separator (at no penalty, since - // we only allocate once either way) - - return s.replace(&cap[0], &tm); - } - - s -} - -/// Weekday name is not required for rfc2822 -fn remove_weekday(s: String) -> String { - static WEEKDAYS: &[&str] = &[ - "Mon,", - "Tue,", - "Wed,", - "Thu,", - "Fri,", - "Sat,", - "Sun,", - "Monday,", - "Tuesday,", - "Wednesday,", - "Thursday,", - "Friday,", - "Saturday,", - "Sunday,", - ]; - - WEEKDAYS - .iter() - .find(|&w| s.starts_with(w)) - .map(|w| s[w.len()..].trim().to_string()) - .unwrap_or(s) -} - -/// Replace long month names with 3 letter Abr as specified in RFC2822. -fn replace_month(s: String) -> String { - static MONTHS: &[(&str, &str)] = &[ - ("January", "Jan"), - ("February", "Feb"), - ("March", "Mar"), - ("April ", "Apr"), - ("May", "May"), - ("June", "Jun"), - ("July", "Jul"), - ("August", "Aug"), - ("September", "Sep"), - ("October", "Oct"), - ("November", "Nov"), - ("December", "Dec"), - ]; - - MONTHS - .iter() - .find(|&&(k, _)| s.contains(k)) - .map(|&(k, v)| s.replace(k, v)) - .unwrap_or(s) -} - -/// Convert -0000 to +0000. -/// See [#102](https://github.com/chronotope/chrono/issues/102) -fn replace_leading_zeros(s: String) -> String { - if s.ends_with("-0000") { - format!("{}+0000", &s[..s.len() - 5]) - } else { - s - } -} - -/// Calls `DateTime::parse_from_rfc2822()`. If it succedes returns, -/// Else it calls `sanitize_rfc822_like_date` and retries. -/// -/// Basic usage: -/// -/// ```rust -/// # extern crate chrono; extern crate rfc822_sanitizer; -/// # use chrono::DateTime; -/// # use rfc822_sanitizer::parse_from_rfc2822_with_fallback; -/// -/// # fn main() { -/// let bad_input = parse_from_rfc2822_with_fallback("Thu, 05 Aug 2016 06:00:00 -0400"); -/// let correct_result = DateTime::parse_from_rfc2822("Fri, 05 Aug 2016 06:00:00 -0400"); -/// assert_eq!(bad_input, correct_result); -/// # } -/// ``` -pub fn parse_from_rfc2822_with_fallback<'s, S: Into>>( - s: S, -) -> ParseResult> { - let s = s.into(); - let date = DateTime::parse_from_rfc2822(&s); - match date { - Ok(_) => date, - Err(err) => { - let san = sanitize_rfc822_like_date(s); - if let Ok(dt) = DateTime::parse_from_rfc2822(&san) { - return Ok(dt); - } - Err(err) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::DateTime; - - #[test] - fn test_invalid_dates() { - // left is raw date extracted from rss feeds. - // right is corresponding valid rfc2822 - let dates = vec![ - ("Thu, 6 July 2017 15:30:00 PDT", "6 Jul 2017 15:30:00 PDT"), - ("Mon, 10 July 2017 16:00:00 PDT", "10 Jul 2017 16:00:00 PDT"), - ("Mon, 17 July 2017 17:00:00 PDT", "17 Jul 2017 17:00:00 PDT"), - ("Mon, 24 July 2017 16:00:00 PDT", "24 Jul 2017 16:00:00 PDT"), - ("Mon, 31 July 2017 16:00:00 PDT", "31 Jul 2017 16:00:00 PDT"), - ("Thu, 30 Aug 2017 1:30:00 PDT", "30 Aug 2017 01:30:00 PDT"), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "20 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "13 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "09 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "02 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "26 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "19 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "12 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "28 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "21 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "14 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "07 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "31 May 2017 10:00:00 +0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "24 May 2017 10:00:00 +0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "17 May 2017 10:00:00 +0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "10 May 2017 10:00:00 +0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "03 May 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "19 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "12 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "05 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "29 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "22 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "15 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "08 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "01 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "22 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "15 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "08 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "01 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "25 Jan 2017 11:00:00 +0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "13 Jan 2017 18:38:00 +0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "20 Sep 2017 03:30:00 +0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "13 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "06 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "30 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "23 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "16 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "09 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "02 Aug 2017 03:00:00 +0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "11 Jul 2017 17:14:45 +0000", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "03 Aug 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "27 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "20 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "13 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "06 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "28 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "02 Apr 2014 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "14 Jan 2016 06:00:03 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "22 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "15 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "7 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "1 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "05 Aug 2016 06:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "09 Jun 2016 12:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "15 Feb 2017 06:00:00 -0400", - ), - ]; - - dates.iter().for_each(|&(bad, good)| { - assert_eq!( - parse_from_rfc2822_with_fallback(bad), - DateTime::parse_from_rfc2822(good) - ) - }); - } - - #[test] - fn test_sanitize_rfc822_like_date() { - // left is raw date extracted from rss feeds. - // right is corresponding valid rfc2822 - let dates = vec![ - ("Thu, 6 July 2017 15:30:00 PDT", "6 Jul 2017 15:30:00 PDT"), - ("Mon, 10 July 2017 16:00:00 PDT", "10 Jul 2017 16:00:00 PDT"), - ("Mon, 17 July 2017 17:00:00 PDT", "17 Jul 2017 17:00:00 PDT"), - ("Mon, 24 July 2017 16:00:00 PDT", "24 Jul 2017 16:00:00 PDT"), - ("Mon, 31 July 2017 16:00:00 PDT", "31 Jul 2017 16:00:00 PDT"), - ("Thu, 30 Aug 2017 1:30:00 PDT", "30 Aug 2017 01:30:00 PDT"), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "20 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "13 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "09 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "02 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "26 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "19 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "12 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "28 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "21 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "14 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "07 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "31 May 2017 10:00:00 +0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "24 May 2017 10:00:00 +0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "17 May 2017 10:00:00 +0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "10 May 2017 10:00:00 +0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "03 May 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "19 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "12 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "05 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "29 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "22 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "15 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "08 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "01 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "22 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "15 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "08 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "01 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "25 Jan 2017 11:00:00 +0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "13 Jan 2017 18:38:00 +0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "20 Sep 2017 03:30:00 +0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "13 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "06 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "30 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "23 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "16 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "09 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "02 Aug 2017 03:00:00 +0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "11 Jul 2017 17:14:45 +0000", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "03 Aug 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "27 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "20 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "13 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "06 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "28 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "02 Apr 2014 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "14 Jan 2016 06:00:03 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "22 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "15 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "7 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "1 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "05 Aug 2016 06:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "09 Jun 2016 12:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "15 Feb 2017 06:00:00 -0400", - ), - ]; - - dates - .iter() - .for_each(|&(bad, good)| assert_eq!(sanitize_rfc822_like_date(bad.to_string()), good)); - } - - #[test] - fn test_remove_weekday() { - let foo = vec![ - ("Thu, 6 July 2017 15:30:00 PDT", "6 July 2017 15:30:00 PDT"), - ( - "Mon, 10 July 2017 16:00:00 PDT", - "10 July 2017 16:00:00 PDT", - ), - ( - "Mon, 17 July 2017 17:00:00 PDT", - "17 July 2017 17:00:00 PDT", - ), - ( - "Mon, 24 July 2017 16:00:00 PDT", - "24 July 2017 16:00:00 PDT", - ), - ( - "Mon, 31 July 2017 16:00:00 PDT", - "31 July 2017 16:00:00 PDT", - ), - ("Thu, 30 Aug 2017 1:30:00 PDT", "30 Aug 2017 1:30:00 PDT"), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "20 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "13 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "09 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "02 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "26 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "19 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "12 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "28 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "21 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "14 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "07 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "31 May 2017 10:00:00 -0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "24 May 2017 10:00:00 -0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "17 May 2017 10:00:00 -0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "10 May 2017 10:00:00 -0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "03 May 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "19 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "12 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "05 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "29 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "22 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "15 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "08 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "01 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "22 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "15 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "08 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "01 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "25 Jan 2017 11:00:00 -0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "13 Jan 2017 18:38:00 -0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "20 Sep 2017 03:30:00 -0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "13 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "06 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "30 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "23 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "16 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "09 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "02 Aug 2017 03:00:00 -0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "11 Jul 2017 17:14:45 -0000", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "03 August 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "27 July 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "20 July 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "13 July 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "06 July 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "28 June 2017 06:00:00 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "02 Apr 2014 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "14 Jan 2016 06:00:03 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "22 June 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "15 June 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "7 June 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "1 June 2017 06:00:00 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "05 Aug 2016 06:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "09 Jun 2016 12:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "15 Feb 2017 06:00:00 -0400", - ), - ]; - - foo.iter() - .for_each(|&(bad, good)| assert_eq!(remove_weekday(bad.to_string()), good)); - } - - #[test] - fn test_pad_zeros() { - // Would be nice If we had more test cases, - // If you stumble(d) upon any online please consider opening a Pullrequest. - let foo = vec![( - "Thu, 30 Aug 2017 1:30:00 PDT", - "Thu, 30 Aug 2017 01:30:00 PDT", - )]; - - foo.iter() - .for_each(|&(bad, good)| assert_eq!(pad_zeros(bad.to_string()), good)); - } - - #[test] - fn test_replace_month() { - let foo = vec![ - ( - "Thu, 6 July 2017 15:30:00 PDT", - "Thu, 6 Jul 2017 15:30:00 PDT", - ), - ( - "Thu, 6 July 2017 15:30:00 PDT", - "Thu, 6 Jul 2017 15:30:00 PDT", - ), - ( - "Mon, 10 July 2017 16:00:00 PDT", - "Mon, 10 Jul 2017 16:00:00 PDT", - ), - ( - "Mon, 10 July 2017 16:00:00 PDT", - "Mon, 10 Jul 2017 16:00:00 PDT", - ), - ( - "Mon, 17 July 2017 17:00:00 PDT", - "Mon, 17 Jul 2017 17:00:00 PDT", - ), - ( - "Mon, 17 July 2017 17:00:00 PDT", - "Mon, 17 Jul 2017 17:00:00 PDT", - ), - ( - "Mon, 24 July 2017 16:00:00 PDT", - "Mon, 24 Jul 2017 16:00:00 PDT", - ), - ( - "Mon, 24 July 2017 16:00:00 PDT", - "Mon, 24 Jul 2017 16:00:00 PDT", - ), - ( - "Mon, 31 July 2017 16:00:00 PDT", - "Mon, 31 Jul 2017 16:00:00 PDT", - ), - ( - "Mon, 31 July 2017 16:00:00 PDT", - "Mon, 31 Jul 2017 16:00:00 PDT", - ), - ( - "Thu, 30 Aug 2017 1:30:00 PDT", - "Thu, 30 Aug 2017 1:30:00 PDT", - ), - ( - "Thu, 30 Aug 2017 1:30:00 PDT", - "Thu, 30 Aug 2017 1:30:00 PDT", - ), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "Wed, 20 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "Wed, 20 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "Wed, 13 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "Wed, 13 Sep 2017 10:00:00 -0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "Wed, 09 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "Wed, 09 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "Wed, 02 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "Wed, 02 Aug 2017 10:00:00 -0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "Wed, 26 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "Wed, 26 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "Wed, 19 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "Wed, 19 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "Wed, 12 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "Wed, 12 Jul 2017 10:00:00 -0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "Wed, 28 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "Wed, 28 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "Wed, 21 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "Wed, 21 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "Wed, 14 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "Wed, 14 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "Wed, 07 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "Wed, 07 Jun 2017 10:00:00 -0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "Wed, 31 May 2017 10:00:00 -0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "Wed, 31 May 2017 10:00:00 -0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "Wed, 24 May 2017 10:00:00 -0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "Wed, 24 May 2017 10:00:00 -0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "Wed, 17 May 2017 10:00:00 -0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "Wed, 17 May 2017 10:00:00 -0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "Wed, 10 May 2017 10:00:00 -0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "Wed, 10 May 2017 10:00:00 -0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "Wed, 03 May 2017 10:00:00 -0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "Wed, 03 May 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "Wed, 19 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "Wed, 19 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "Wed, 12 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "Wed, 12 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "Wed, 05 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "Wed, 05 Apr 2017 10:00:00 -0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "Wed, 29 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "Wed, 29 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "Wed, 22 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "Wed, 22 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "Wed, 15 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "Wed, 15 Mar 2017 10:00:00 -0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "Wed, 08 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "Wed, 08 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "Wed, 01 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "Wed, 01 Mar 2017 11:00:00 -0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "Wed, 22 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "Wed, 22 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "Wed, 15 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "Wed, 15 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "Wed, 08 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "Wed, 08 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "Wed, 01 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "Wed, 01 Feb 2017 11:00:00 -0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "Wed, 25 Jan 2017 11:00:00 -0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "Wed, 25 Jan 2017 11:00:00 -0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "Fri, 13 Jan 2017 18:38:00 -0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "Fri, 13 Jan 2017 18:38:00 -0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "Wed, 20 Sep 2017 03:30:00 -0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "Wed, 20 Sep 2017 03:30:00 -0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "Wed, 13 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "Wed, 13 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "Wed, 06 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "Wed, 06 Sep 2017 03:15:00 -0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "Wed, 30 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "Wed, 30 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "Wed, 23 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "Wed, 23 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "Wed, 16 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "Wed, 16 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "Wed, 09 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "Wed, 09 Aug 2017 03:15:00 -0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "Wed, 02 Aug 2017 03:00:00 -0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "Wed, 02 Aug 2017 03:00:00 -0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "Tue, 11 Jul 2017 17:14:45 -0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "Tue, 11 Jul 2017 17:14:45 -0000", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "Thu, 03 Aug 2017 06:00:00 -0400", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "Thu, 03 Aug 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "Thu, 27 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "Thu, 27 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "Thu, 20 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "Thu, 20 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "Thu, 13 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "Thu, 13 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "Thu, 06 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "Thu, 06 Jul 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "Thu, 28 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "Thu, 28 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "Thu, 17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "Thu, 17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "Thu, 02 Apr 2014 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "Thu, 02 Apr 2014 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "Wed, 14 Jan 2016 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "Wed, 14 Jan 2016 06:00:03 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "Thu, 22 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "Thu, 22 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "Thu, 15 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "Thu, 15 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "Thu, 7 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "Thu, 7 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "Thu, 1 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "Thu, 1 Jun 2017 06:00:00 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "Thu, 23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "Thu, 23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "Thu, 14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "Thu, 14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "Thu, 04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "Thu, 04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "Thu, 20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "Thu, 20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "Thu, 23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "Thu, 23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "Thu, 05 Aug 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "Thu, 05 Aug 2016 06:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "Fri, 09 Jun 2016 12:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "Fri, 09 Jun 2016 12:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "Thu, 10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "Thu, 10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "Thu, 22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "Thu, 22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "Thu, 15 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "Thu, 15 Feb 2017 06:00:00 -0400", - ), - ]; - - foo.iter() - .for_each(|&(bad, good)| assert_eq!(replace_month(bad.to_string()), good)); - } - - #[test] - fn test_replace_leading_zeroes() { - let foo = vec![ - ( - "Thu, 6 July 2017 15:30:00 PDT", - "Thu, 6 July 2017 15:30:00 PDT", - ), - ( - "Mon, 10 July 2017 16:00:00 PDT", - "Mon, 10 July 2017 16:00:00 PDT", - ), - ( - "Mon, 17 July 2017 17:00:00 PDT", - "Mon, 17 July 2017 17:00:00 PDT", - ), - ( - "Mon, 24 July 2017 16:00:00 PDT", - "Mon, 24 July 2017 16:00:00 PDT", - ), - ( - "Mon, 31 July 2017 16:00:00 PDT", - "Mon, 31 July 2017 16:00:00 PDT", - ), - ( - "Thu, 30 Aug 2017 1:30:00 PDT", - "Thu, 30 Aug 2017 1:30:00 PDT", - ), - ( - "Wed, 20 Sep 2017 10:00:00 -0000", - "Wed, 20 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 13 Sep 2017 10:00:00 -0000", - "Wed, 13 Sep 2017 10:00:00 +0000", - ), - ( - "Wed, 09 Aug 2017 10:00:00 -0000", - "Wed, 09 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 02 Aug 2017 10:00:00 -0000", - "Wed, 02 Aug 2017 10:00:00 +0000", - ), - ( - "Wed, 26 Jul 2017 10:00:00 -0000", - "Wed, 26 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Jul 2017 10:00:00 -0000", - "Wed, 19 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Jul 2017 10:00:00 -0000", - "Wed, 12 Jul 2017 10:00:00 +0000", - ), - ( - "Wed, 28 Jun 2017 10:00:00 -0000", - "Wed, 28 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 21 Jun 2017 10:00:00 -0000", - "Wed, 21 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 14 Jun 2017 10:00:00 -0000", - "Wed, 14 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 07 Jun 2017 10:00:00 -0000", - "Wed, 07 Jun 2017 10:00:00 +0000", - ), - ( - "Wed, 31 May 2017 10:00:00 -0000", - "Wed, 31 May 2017 10:00:00 +0000", - ), - ( - "Wed, 24 May 2017 10:00:00 -0000", - "Wed, 24 May 2017 10:00:00 +0000", - ), - ( - "Wed, 17 May 2017 10:00:00 -0000", - "Wed, 17 May 2017 10:00:00 +0000", - ), - ( - "Wed, 10 May 2017 10:00:00 -0000", - "Wed, 10 May 2017 10:00:00 +0000", - ), - ( - "Wed, 03 May 2017 10:00:00 -0000", - "Wed, 03 May 2017 10:00:00 +0000", - ), - ( - "Wed, 19 Apr 2017 10:00:00 -0000", - "Wed, 19 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 12 Apr 2017 10:00:00 -0000", - "Wed, 12 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 05 Apr 2017 10:00:00 -0000", - "Wed, 05 Apr 2017 10:00:00 +0000", - ), - ( - "Wed, 29 Mar 2017 10:00:00 -0000", - "Wed, 29 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 22 Mar 2017 10:00:00 -0000", - "Wed, 22 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 15 Mar 2017 10:00:00 -0000", - "Wed, 15 Mar 2017 10:00:00 +0000", - ), - ( - "Wed, 08 Mar 2017 11:00:00 -0000", - "Wed, 08 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Mar 2017 11:00:00 -0000", - "Wed, 01 Mar 2017 11:00:00 +0000", - ), - ( - "Wed, 22 Feb 2017 11:00:00 -0000", - "Wed, 22 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 15 Feb 2017 11:00:00 -0000", - "Wed, 15 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 08 Feb 2017 11:00:00 -0000", - "Wed, 08 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 01 Feb 2017 11:00:00 -0000", - "Wed, 01 Feb 2017 11:00:00 +0000", - ), - ( - "Wed, 25 Jan 2017 11:00:00 -0000", - "Wed, 25 Jan 2017 11:00:00 +0000", - ), - ( - "Fri, 13 Jan 2017 18:38:00 -0000", - "Fri, 13 Jan 2017 18:38:00 +0000", - ), - ( - "Wed, 20 Sep 2017 03:30:00 -0000", - "Wed, 20 Sep 2017 03:30:00 +0000", - ), - ( - "Wed, 13 Sep 2017 03:15:00 -0000", - "Wed, 13 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 06 Sep 2017 03:15:00 -0000", - "Wed, 06 Sep 2017 03:15:00 +0000", - ), - ( - "Wed, 30 Aug 2017 03:15:00 -0000", - "Wed, 30 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 23 Aug 2017 03:15:00 -0000", - "Wed, 23 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 16 Aug 2017 03:15:00 -0000", - "Wed, 16 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 09 Aug 2017 03:15:00 -0000", - "Wed, 09 Aug 2017 03:15:00 +0000", - ), - ( - "Wed, 02 Aug 2017 03:00:00 -0000", - "Wed, 02 Aug 2017 03:00:00 +0000", - ), - ( - "Tue, 11 Jul 2017 17:14:45 -0000", - "Tue, 11 Jul 2017 17:14:45 +0000", - ), - ( - "Thu, 03 August 2017 06:00:00 -0400", - "Thu, 03 August 2017 06:00:00 -0400", - ), - ( - "Thu, 27 July 2017 06:00:00 -0400", - "Thu, 27 July 2017 06:00:00 -0400", - ), - ( - "Thu, 20 July 2017 06:00:00 -0400", - "Thu, 20 July 2017 06:00:00 -0400", - ), - ( - "Thu, 13 July 2017 06:00:00 -0400", - "Thu, 13 July 2017 06:00:00 -0400", - ), - ( - "Thu, 06 July 2017 06:00:00 -0400", - "Thu, 06 July 2017 06:00:00 -0400", - ), - ( - "Thu, 28 June 2017 06:00:00 -0400", - "Thu, 28 June 2017 06:00:00 -0400", - ), - ( - "Thu, 17 Jul 2013 06:00:03 -0400", - "Thu, 17 Jul 2013 06:00:03 -0400", - ), - ( - "Thu, 02 Apr 2014 06:00:03 -0400", - "Thu, 02 Apr 2014 06:00:03 -0400", - ), - ( - "Wed, 14 Jan 2016 06:00:03 -0400", - "Wed, 14 Jan 2016 06:00:03 -0400", - ), - ( - "Thu, 22 June 2017 06:00:00 -0400", - "Thu, 22 June 2017 06:00:00 -0400", - ), - ( - "Thu, 15 June 2017 06:00:00 -0400", - "Thu, 15 June 2017 06:00:00 -0400", - ), - ( - "Thu, 7 June 2017 06:00:00 -0400", - "Thu, 7 June 2017 06:00:00 -0400", - ), - ( - "Thu, 1 June 2017 06:00:00 -0400", - "Thu, 1 June 2017 06:00:00 -0400", - ), - ( - "Thu, 23 Dec 2015 06:00:03 -0400", - "Thu, 23 Dec 2015 06:00:03 -0400", - ), - ( - "Thu, 14 Feb 2014 06:00:03 -0400", - "Thu, 14 Feb 2014 06:00:03 -0400", - ), - ( - "Thu, 04 Dec 2013 06:00:03 -0400", - "Thu, 04 Dec 2013 06:00:03 -0400", - ), - ( - "Thu, 20 Dec 2016 06:00:00 -0400", - "Thu, 20 Dec 2016 06:00:00 -0400", - ), - ( - "Thu, 23 Nov 2016 06:00:00 -0400", - "Thu, 23 Nov 2016 06:00:00 -0400", - ), - ( - "Thu, 05 Aug 2016 06:00:00 -0400", - "Thu, 05 Aug 2016 06:00:00 -0400", - ), - ( - "Fri, 09 Jun 2016 12:00:00 -0400", - "Fri, 09 Jun 2016 12:00:00 -0400", - ), - ( - "Thu, 10 May 2017 06:00:00 -0400", - "Thu, 10 May 2017 06:00:00 -0400", - ), - ( - "Thu, 22 Feb 2017 06:00:00 -0400", - "Thu, 22 Feb 2017 06:00:00 -0400", - ), - ( - "Thu, 15 Feb 2017 06:00:00 -0400", - "Thu, 15 Feb 2017 06:00:00 -0400", - ), - ]; - - foo.iter() - .for_each(|&(bad, good)| assert_eq!(replace_leading_zeros(bad.to_string()), good)); - } -} diff --git a/src/types.rs b/src/types.rs index d7f1040..e382694 100644 --- a/src/types.rs +++ b/src/types.rs @@ -47,7 +47,7 @@ impl Podcast { fn num_unplayed(&self) -> usize { return self .episodes - .map(|ep| !ep.is_played() as usize) + .map(|ep| !ep.is_played() as usize, false) .iter() .sum(); } @@ -66,20 +66,17 @@ impl Menuable for Podcast { // if the size available is big enough, we add the unplayed data // to the end if length > crate::config::PODCAST_UNPLAYED_TOTALS_LENGTH { - let meta_str = format!("({}/{})", self.num_unplayed(), self.episodes.len()); - title_length = length - meta_str.chars().count(); + let meta_str = format!("({}/{})", self.num_unplayed(), self.episodes.len(false)); + title_length = length - meta_str.chars().count() - 3; let out = self.title.substr(0, title_length); return format!( - "{} {:>width$}", - out, - meta_str, - width = length - out.grapheme_len() - ); - // this pads spaces between title and totals + " {out} {meta_str:>width$} ", + width = length - out.grapheme_len() - 3 + ); // this pads spaces between title and totals } else { - return self.title.substr(0, title_length); + return format!(" {} ", self.title.substr(0, title_length - 2)); } } @@ -118,6 +115,7 @@ pub struct Episode { pub pod_id: i64, pub title: String, pub url: String, + pub guid: String, pub description: String, pub pubdate: Option>, pub duration: Option, @@ -135,7 +133,7 @@ impl Episode { seconds -= hours * 3600; let minutes = seconds / 60; seconds -= minutes * 60; - format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + format!("{hours:02}:{minutes:02}:{seconds:02}") } None => "--:--:--".to_string(), }; @@ -153,50 +151,43 @@ impl Menuable for Episode { let out = match self.path { Some(_) => { let title = self.title.substr(0, length - 4); - format!("[D] {}", title) + format!("[D] {title}") } None => self.title.substr(0, length), }; - let out_len = out.grapheme_len(); if length > crate::config::EPISODE_PUBDATE_LENGTH { let dur = self.format_duration(); - let meta_dur = format!("[{}]", dur); + let meta_dur = format!("[{dur}]"); if let Some(pubdate) = self.pubdate { // print pubdate and duration let pd = pubdate.format("%F"); - let meta_str = format!("({}) {}", pd, meta_dur); + let meta_str = format!("({pd}) {meta_dur}"); let added_len = meta_str.chars().count(); - let out_added = out.substr(0, length - added_len); + let out_added = out.substr(0, length - added_len - 3); return format!( - "{} {:>width$}", - out_added, - meta_str, - width = length - out_len + " {out_added} {meta_str:>width$} ", + width = length - out_added.grapheme_len() - 3 ); } else { // just print duration - let out_added = out.substr(0, length - meta_dur.chars().count()); + let out_added = out.substr(0, length - meta_dur.chars().count() - 3); return format!( - "{} {:>width$}", - out_added, - meta_dur, - width = length - out_len + " {out_added} {meta_dur:>width$} ", + width = length - out_added.grapheme_len() - 3 ); } } else if length > crate::config::EPISODE_DURATION_LENGTH { let dur = self.format_duration(); - let meta_dur = format!("[{}]", dur); - let out_added = out.substr(0, length - meta_dur.chars().count()); + let meta_dur = format!("[{dur}]"); + let out_added = out.substr(0, length - meta_dur.chars().count() - 3); return format!( - "{} {:>width$}", - out_added, - meta_dur, - width = length - out_len + " {out_added} {meta_dur:>width$} ", + width = length - out_added.grapheme_len() - 3 ); } else { - return out; + return format!(" {} ", out.substr(0, length - 2)); } } @@ -226,6 +217,7 @@ pub struct PodcastNoId { pub struct EpisodeNoId { pub title: String, pub url: String, + pub guid: String, pub description: String, pub pubdate: Option>, pub duration: Option, @@ -252,7 +244,20 @@ 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 { "✓" } else { " " }; - let full_string = format!("[{}] {} ({})", selected, self.title, self.pod_title); + + let title_len = self.title.grapheme_len(); + let pod_title_len = self.pod_title.grapheme_len(); + let empty_string = if length > title_len + pod_title_len + 9 { + let empty = vec![" "; length - title_len - pod_title_len - 9]; + empty.join("") + } else { + "".to_string() + }; + + let full_string = format!( + " [{}] {} ({}){} ", + selected, self.title, self.pod_title, empty_string + ); return full_string.substr(0, length); } @@ -266,12 +271,22 @@ impl Menuable for NewEpisode { /// Primarily, the LockVec is used to provide methods that abstract /// away some of the logic necessary for borrowing and locking the /// Arc>. +/// +/// The data is structured in a way to allow for quick access both by +/// item ID (using a hash map), as well as by the order of an item in +/// the list (using a vector of the item IDs). The `order` vector +/// provides the full order of all the podcasts/episodes that are +/// present in the hash map; the `filtered_order` vector provides the +/// order only for the items that are currently filtered in, if the +/// user has set an active filter for played/unplayed or downloaded/ +/// undownloaded. #[derive(Debug)] pub struct LockVec where T: Clone + Menuable { data: Arc>>>, order: Arc>>, + filtered_order: Arc>>, } impl LockVec { @@ -287,7 +302,8 @@ impl LockVec { return LockVec { data: Arc::new(Mutex::new(hm)), - order: Arc::new(Mutex::new(order)), + order: Arc::new(Mutex::new(order.clone())), + filtered_order: Arc::new(Mutex::new(order)), }; } @@ -301,6 +317,11 @@ impl LockVec { return self.order.lock().expect("Mutex error"); } + /// Lock the LockVec filtered order vector for reading/writing. + pub fn borrow_filtered_order(&self) -> MutexGuard> { + return self.filtered_order.lock().expect("Mutex error"); + } + /// Lock the LockVec hashmap for reading/writing. #[allow(clippy::type_complexity)] pub fn borrow( @@ -308,10 +329,12 @@ impl LockVec { ) -> ( MutexGuard>>, MutexGuard>, + MutexGuard>, ) { return ( self.data.lock().expect("Mutex error"), self.order.lock().expect("Mutex error"), + self.filtered_order.lock().expect("Mutex error"), ); } @@ -324,13 +347,15 @@ impl LockVec { /// Empty out and replace all the data in the LockVec. pub fn replace_all(&self, data: Vec) { - let (mut map, mut order) = self.borrow(); + let (mut map, mut order, mut filtered_order) = self.borrow(); map.clear(); order.clear(); + filtered_order.clear(); for i in data.into_iter() { let id = i.get_id(); map.insert(i.get_id(), i); order.push(id); + filtered_order.push(id); } } @@ -338,13 +363,20 @@ impl LockVec { /// as an Iterator. 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(&self, mut f: F) -> Vec + pub fn map(&self, mut f: F, filtered: bool) -> Vec where F: FnMut(&T) -> B { - let (map, order) = self.borrow(); - return order - .iter() - .map(|id| f(map.get(id).expect("Index error in LockVec"))) - .collect(); + let (map, order, filtered_order) = self.borrow(); + if filtered { + return filtered_order + .iter() + .map(|id| f(map.get(id).expect("Index error in LockVec"))) + .collect(); + } else { + 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 @@ -363,41 +395,28 @@ impl LockVec { /// this returns None. pub fn map_single_by_index(&self, index: usize, f: F) -> Option where F: FnOnce(&T) -> B { - let order = self.borrow_order(); + let order = self.borrow_filtered_order(); return match order.get(index) { Some(id) => self.map_single(*id, f), None => None, }; } - /// 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 /// alive, the function returns a Vec of the collected results, /// rather than an iterator. + /// + /// Note that the word "filter" in this sense represents the concept + /// from functional programming, providing a function that evaluates + /// items in the list and returns a boolean value. The word "filter" + /// is used elsewhere in the code to represent user-selected + /// filters to show only selected podcasts/episodes, but this is + /// *not* the sense of the word here. pub fn filter_map(&self, mut f: F) -> Vec where F: FnMut(&T) -> Option { - let (map, order) = self.borrow(); + let (map, order, _) = self.borrow(); return order .iter() .filter_map(|id| f(map.get(id).expect("Index error in LockVec"))) @@ -405,8 +424,12 @@ impl LockVec { } /// Returns the number of items in the LockVec. - pub fn len(&self) -> usize { - return self.borrow_order().len(); + pub fn len(&self, filtered: bool) -> usize { + if filtered { + return self.borrow_filtered_order().len(); + } else { + return self.borrow_order().len(); + } } /// Returns whether or not there are any items in the LockVec. @@ -420,6 +443,7 @@ impl Clone for LockVec { return LockVec { data: Arc::clone(&self.data), order: Arc::clone(&self.order), + filtered_order: Arc::clone(&self.filtered_order), }; } } @@ -428,10 +452,7 @@ impl LockVec { /// This clones the podcast with the given id. pub fn clone_podcast(&self, id: i64) -> Option { let pod_map = self.borrow_map(); - return match pod_map.get(&id) { - Some(pod) => Some(pod.clone()), - None => None, - }; + return pod_map.get(&id).cloned(); } /// This clones the episode with the given id (`ep_id`), from @@ -453,10 +474,7 @@ impl LockVec { /// and can be used at that level as well if given a podcast id. pub fn clone_episode(&self, ep_id: i64) -> Option { let ep_map = self.borrow_map(); - return match ep_map.get(&ep_id) { - Some(ep) => Some(ep.clone()), - None => None, - }; + return ep_map.get(&ep_id).cloned(); } } @@ -471,6 +489,39 @@ pub enum Message { } +/// Simple enum to designate the status of a filter. "Positive" and +/// "Negative" cases represent, e.g., "played" vs. "unplayed". +#[derive(Debug, Clone, Copy)] +pub enum FilterStatus { + PositiveCases, + NegativeCases, + All, +} + +/// Enum to identify which filters has been changed +#[derive(Debug, Clone, Copy)] +pub enum FilterType { + Played, + Downloaded, +} + +/// Struct holding information about all active filters. +#[derive(Debug, Clone, Copy)] +pub struct Filters { + pub played: FilterStatus, + pub downloaded: FilterStatus, +} + +impl Default for Filters { + fn default() -> Self { + return Self { + played: FilterStatus::All, + downloaded: FilterStatus::All, + }; + } +} + + /// Some helper functions for dealing with Unicode strings. pub trait StringUtils { fn substr(&self, start: usize, length: usize) -> String; diff --git a/src/ui/colors.rs b/src/ui/colors.rs index aece3fe..fde2e1a 100644 --- a/src/ui/colors.rs +++ b/src/ui/colors.rs @@ -1,9 +1,10 @@ use anyhow::{anyhow, Result}; +use crossterm::style::Color; use lazy_static::lazy_static; use regex::Regex; -use crate::config::AppColors; +use crate::config::AppColorsFromToml; lazy_static! { /// Regex for parsing a color specified as hex code. @@ -13,260 +14,135 @@ lazy_static! { static ref RE_COLOR_RGB: Regex = Regex::new(r"(?i)rgb\(([0-9]+), ?([0-9]+), ?([0-9]+)\)").expect("Regex error"); } -/// 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), + +/// Holds information about the colors to use in the application. Tuple +/// values represent (foreground, background), respectively. +#[derive(Debug, Clone)] +pub struct AppColors { + pub normal: (Color, Color), + pub bold: (Color, Color), + pub highlighted_active: (Color, Color), + pub highlighted: (Color, Color), + pub error: (Color, Color), } -impl ColorValue { +impl AppColors { + /// Creates an AppColors struct with default color values. + pub fn default() -> Self { + return Self { + normal: (Color::Grey, Color::Black), + bold: (Color::White, Color::Black), + highlighted_active: (Color::Black, Color::DarkYellow), + highlighted: (Color::Black, Color::Grey), + error: (Color::Red, Color::Black), + }; + } + + /// Reading in values that were set in the config file, this changes + /// the associated colors. Note that this only modifies colors that + /// were set in the config, so this is most useful in conjunction + /// with `default()` to set default colors and then change + /// the ones that the user has set. + pub fn add_from_config(&mut self, config: AppColorsFromToml) { + if let Some(val) = config.normal_foreground { + if let Ok(v) = Self::color_from_str(&val) { + self.normal.0 = v; + } + } + if let Some(val) = config.normal_background { + if let Ok(v) = Self::color_from_str(&val) { + self.normal.1 = v; + } + } + if let Some(val) = config.bold_foreground { + if let Ok(v) = Self::color_from_str(&val) { + self.bold.0 = v; + } + } + if let Some(val) = config.bold_background { + if let Ok(v) = Self::color_from_str(&val) { + self.bold.1 = v; + } + } + if let Some(val) = config.highlighted_active_foreground { + if let Ok(v) = Self::color_from_str(&val) { + self.highlighted_active.0 = v; + } + } + if let Some(val) = config.highlighted_active_background { + if let Ok(v) = Self::color_from_str(&val) { + self.highlighted_active.1 = v; + } + } + if let Some(val) = config.highlighted_foreground { + if let Ok(v) = Self::color_from_str(&val) { + self.highlighted.0 = v; + } + } + if let Some(val) = config.highlighted_background { + if let Ok(v) = Self::color_from_str(&val) { + self.highlighted.1 = v; + } + } + if let Some(val) = config.error_foreground { + if let Ok(v) = Self::color_from_str(&val) { + self.error.0 = v; + } + } + if let Some(val) = config.error_background { + if let Ok(v) = Self::color_from_str(&val) { + self.error.1 = v; + } + } + } + /// 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 { + pub fn color_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 Ok(Color::Rgb { + r: u8::from_str_radix(&cap[1], 16)?, + g: u8::from_str_radix(&cap[2], 16)?, + b: u8::from_str_radix(&cap[3], 16)?, + }); } return Err(anyhow!("Invalid color hex code")); } else if text.starts_with("rgb") || text.starts_with("RGB") { + #[allow(clippy::from_str_radix_10)] 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 Ok(Color::Rgb { + r: u8::from_str_radix(&cap[1], 10)?, + g: u8::from_str_radix(&cap[2], 10)?, + b: 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), + "black" => Ok(Color::Black), + "darkgrey" | "darkgray" => Ok(Color::DarkGrey), + "red" => Ok(Color::Red), + "darkred" => Ok(Color::DarkRed), + "green" => Ok(Color::Green), + "darkgreen" => Ok(Color::DarkGreen), + "yellow" => Ok(Color::Yellow), + "darkyellow" => Ok(Color::DarkYellow), + "blue" => Ok(Color::Blue), + "darkblue" => Ok(Color::DarkBlue), + "magenta" => Ok(Color::Magenta), + "darkmagenta" => Ok(Color::DarkMagenta), + "cyan" => Ok(Color::Cyan), + "darkcyan" => Ok(Color::DarkCyan), + "white" => Ok(Color::White), + "grey" | "gray" => Ok(Color::Grey), + "terminal" => Ok(Color::Reset), _ => 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, - }; - } - - /// Returns whether ColorValue is of variant Terminal. - fn is_terminal(&self) -> bool { - return matches!(self, Self::Terminal); - } - - /// 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(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; } @@ -278,44 +154,60 @@ mod tests { #[test] fn color_hex() { let color = String::from("#ff0000"); - let parsed = ColorValue::from_str(&color); + let parsed = AppColors::color_from_str(&color); assert!(parsed.is_ok()); - assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + assert_eq!(parsed.unwrap(), Color::Rgb { + r: 255, + g: 0, + b: 0 + }); } #[test] fn color_invalid_hex() { let color = String::from("#gg0000"); - assert!(ColorValue::from_str(&color).is_err()); + assert!(AppColors::color_from_str(&color).is_err()); } #[test] fn color_invalid_hex2() { let color = String::from("#ff000"); - assert!(ColorValue::from_str(&color).is_err()); + assert!(AppColors::color_from_str(&color).is_err()); } #[test] fn color_rgb() { let color = String::from("rgb(255, 0, 0)"); - let parsed = ColorValue::from_str(&color); + let parsed = AppColors::color_from_str(&color); assert!(parsed.is_ok()); - assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + assert_eq!(parsed.unwrap(), Color::Rgb { + r: 255, + g: 0, + b: 0 + }); } #[test] fn color_rgb_upper() { let color = String::from("RGB(255, 0, 0)"); - let parsed = ColorValue::from_str(&color); + let parsed = AppColors::color_from_str(&color); assert!(parsed.is_ok()); - assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + assert_eq!(parsed.unwrap(), Color::Rgb { + r: 255, + g: 0, + b: 0 + }); } #[test] fn color_rgb_no_space() { let color = String::from("rgb(255,0,0)"); - let parsed = ColorValue::from_str(&color); + let parsed = AppColors::color_from_str(&color); assert!(parsed.is_ok()); - assert_eq!(parsed.unwrap(), ColorValue::Rgb(255, 0, 0)); + assert_eq!(parsed.unwrap(), Color::Rgb { + r: 255, + g: 0, + b: 0 + }); } } diff --git a/src/ui/details_panel.rs b/src/ui/details_panel.rs new file mode 100644 index 0000000..dd87acd --- /dev/null +++ b/src/ui/details_panel.rs @@ -0,0 +1,247 @@ +use std::rc::Rc; + +use chrono::{DateTime, Utc}; +use crossterm::style::{self, Stylize}; + +use super::panel::Panel; +use super::AppColors; +use super::Scroll; + +/// Used to hold one line of content used in the details panel. +#[derive(Debug)] +pub enum DetailsLine { + Blank, + Line(String, Option), + KeyValueLine( + (String, Option), + (String, Option), + ), +} + + +/// Struct holding the raw data used for building the details panel. +#[derive(Debug)] +pub struct Details { + pub pod_title: Option, + pub ep_title: Option, + pub pubdate: Option>, + pub duration: Option, + pub explicit: Option, + pub description: Option, +} + +#[derive(Debug)] +pub struct DetailsPanel { + pub panel: Panel, + pub details: Option
, + pub content: Vec, + pub top_row: u16, // top row of text shown in window + pub total_rows: u16, // the total number of rows the details take up +} + +impl DetailsPanel { + /// Creates a new details panel. + pub fn new( + title: String, + screen_pos: usize, + colors: Rc, + n_row: u16, + n_col: u16, + start_x: u16, + margins: (u16, u16, u16, u16), + ) -> Self { + let panel = Panel::new(title, screen_pos, colors, n_row, n_col, start_x, margins); + return Self { + panel: panel, + details: None, + content: Vec::new(), + top_row: 0, + total_rows: 0, + }; + } + + /// Redraws borders and refreshes the window to display on terminal. + pub fn redraw(&self) { + self.panel.redraw(); + } + + /// Insert new details into the details pane. + pub fn change_details(&mut self, details: Details) { + self.top_row = 0; + self.details = Some(details); + self.stringify_content(); + self.redraw(); + self.write_details(); + } + + /// Updates window size. + pub fn resize(&mut self, n_row: u16, n_col: u16, start_x: u16) { + self.panel.resize(n_row, n_col, start_x); + self.stringify_content(); + self.redraw(); + self.write_details(); + } + + /// Scrolls the details panel up or down by `lines` lines. + /// + /// This function examines the new selected value, ensures it does + /// not fall out of bounds, and then updates the panel to + /// represent the new visible list. + pub fn scroll(&mut self, lines: Scroll) { + if self.content.is_empty() { + return; + } + let total_rows = self.content.len() as u16; + let old_top_row = self.top_row; + + match lines { + Scroll::Up(v) => { + if let Some(top) = self.top_row.checked_sub(v) { + self.top_row = top; + } else { + self.top_row = 0; + } + if self.top_row != old_top_row { + self.panel.clear_inner(); + // self.details_template(self.top_row); + self.write_details(); + } + } + Scroll::Down(v) => { + let n_row = self.panel.get_rows(); + // can't scroll if details are shorter than screen + if total_rows <= n_row { + return; + } + let move_dist = std::cmp::min(v, total_rows - self.top_row - n_row); + self.top_row += move_dist; + if self.top_row != old_top_row { + self.panel.clear_inner(); + // self.details_template(self.top_row); + self.write_details(); + } + } + } + } + + /// Format the details content to fit the panel as currently sized + /// and save it as Strings. This needs to be done to allow the + /// content to be scrollable. + fn stringify_content(&mut self) { + if let Some(details) = &self.details { + let num_cols = self.panel.get_cols() as usize; + let bold = style::ContentStyle::new() + .with(self.panel.colors.bold.0) + .on(self.panel.colors.bold.1) + .attribute(style::Attribute::Bold); + let underlined = style::ContentStyle::new() + .with(self.panel.colors.normal.0) + .on(self.panel.colors.normal.1) + .attribute(style::Attribute::Underlined); + + self.content.clear(); + + // podcast title + let text = match &details.pod_title { + Some(t) => t, + None => "No title", + }; + let wrapper = textwrap::wrap(text, num_cols); + for line in wrapper { + self.content + .push(DetailsLine::Line(line.to_string(), Some(bold))); + } + + // episode title + let text = match &details.ep_title { + Some(t) => t, + None => "No title", + }; + let wrapper = textwrap::wrap(text, num_cols); + for line in wrapper { + self.content + .push(DetailsLine::Line(line.to_string(), Some(bold))); + } + + self.content.push(DetailsLine::Blank); // blank line + + // published date + if let Some(date) = details.pubdate { + self.content.push(DetailsLine::KeyValueLine( + ("Published".to_string(), Some(underlined)), + (format!("{}", date.format("%B %-d, %Y")), None), + )); + } + + // duration + if let Some(dur) = &details.duration { + self.content.push(DetailsLine::KeyValueLine( + ("Duration".to_string(), Some(underlined)), + (dur.clone(), None), + )); + } + + // explicit + if let Some(exp) = details.explicit { + let exp_string = if exp { + "Yes".to_string() + } else { + "No".to_string() + }; + self.content.push(DetailsLine::KeyValueLine( + ("Explicit".to_string(), Some(underlined)), + (exp_string, None), + )); + } + + self.content.push(DetailsLine::Blank); // blank line + + // description + match &details.description { + Some(desc) => { + let wrapper = textwrap::wrap("Description:", num_cols); + for line in wrapper { + self.content + .push(DetailsLine::Line(line.to_string(), Some(bold))); + } + let wrapper = textwrap::wrap(desc, num_cols); + for line in wrapper { + self.content.push(DetailsLine::Line(line.to_string(), None)); + } + } + None => { + let wrapper = textwrap::wrap("No description.", num_cols); + for line in wrapper { + self.content.push(DetailsLine::Line(line.to_string(), None)); + } + } + } + } + } + + /// Write the details content to the screen. + pub fn write_details(&mut self) { + if !self.content.is_empty() { + let mut row = 0; + for line in self.content.iter().skip(self.top_row as usize) { + match line { + DetailsLine::Blank => row += 1, + DetailsLine::Line(text, style) => { + row = self.panel.write_wrap_line(row, text, *style); + row += 1; + } + DetailsLine::KeyValueLine((key, key_style), (val, val_style)) => { + self.panel.write_key_value_line( + row, + key.clone(), + val.clone(), + *key_style, + *val_style, + ); + row += 1; + } + } + } + } + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index bf4f559..59b5e0f 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,18 +1,18 @@ -use std::cmp::max; use std::cmp::min; use std::collections::hash_map::Entry; -use super::ColorType; -use super::Panel; +use crossterm::style::{self, Stylize}; + +use super::{Panel, Scroll}; use crate::types::*; /// Generic struct holding details about a list menu. These menus are /// contained by the UI, and hold the list of podcasts or podcast -/// episodes. They also hold the pancurses window used to display the menu -/// to the user. +/// episodes. They also hold the Panel used to draw all elements to the +/// screen. /// -/// * `screen_pos` stores the position of the window on the screen, from -/// left to right +/// * `header` is an optional String of text that is printed above the +/// menu; the scrollable menu effectively starts below the header. /// * `start_row` indicates the first row that is used for the menu; /// this will be 0 if there is no header; otherwise, `start_row` will /// be the first row below the header. Calculated relative to the @@ -24,6 +24,9 @@ use crate::types::*; /// * `selected` indicates which item on screen is currently highlighted. /// It is calculated relative to the panel, i.e., a value between /// 0 and (n_row - 1) +/// * `active` indicates whether the menu is currently interactive, e.g., +/// if the user scrolls up or down, this is the menu that will receive +/// those events. #[derive(Debug)] pub struct Menu where T: Clone + Menuable @@ -31,9 +34,10 @@ where T: Clone + Menuable pub panel: Panel, pub header: Option, pub items: LockVec, - pub start_row: i32, // beginning of first row of menu - pub top_row: i32, // top row of text shown in window - pub selected: i32, // which line of text is highlighted + pub start_row: u16, // beginning of first row of menu + pub top_row: u16, // top row of text shown in window + pub selected: u16, // which line of text is highlighted + pub active: bool, } impl Menu { @@ -46,218 +50,213 @@ impl Menu { start_row: 0, top_row: 0, selected: 0, + active: false, }; } - /// Prints the list of visible items to the pancurses window and - /// refreshes it. - pub fn init(&mut self) { - self.panel.refresh(); + /// Clears the terminal, and then prints the list of visible items + /// to the terminal. + pub fn redraw(&mut self) { + self.panel.redraw(); self.update_items(); } - /// Prints or reprints the list of visible items to the pancurses - /// window and refreshes it. + /// Prints the list of visible items to the terminal. pub fn update_items(&mut self) { - self.panel.erase(); self.start_row = self.print_header(); if self.selected < self.start_row { self.selected = self.start_row; } - let (map, order) = self.items.borrow(); + let (map, _, order) = self.items.borrow(); if !order.is_empty() { // update selected item if list has gotten shorter - let current_selected = self.get_menu_idx(self.selected) as i32; - let list_len = order.len() as i32; + let current_selected = self.get_menu_idx(self.selected); + let list_len = order.len(); if current_selected >= list_len { - self.selected = self.selected - (current_selected - list_len) - 1; + self.selected = (self.selected as usize - (current_selected - list_len) - 1) as u16; } // 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).expect("Could not retrieve menu item."); - self.panel - .write_line(i, elem.get_title(self.panel.get_cols() as usize)); - - // this is literally the same logic as - // self.set_attrs(), but it's complaining about - // immutable borrows, so... - let attr = if elem.is_played() { - pancurses::A_NORMAL + let elem = map.get(elem_id).expect("Could not retrieve menu item."); + + if i == self.selected || !elem.is_played() { + let style = if !elem.is_played() { + style::ContentStyle::new() + .with(self.panel.colors.bold.0) + .on(self.panel.colors.bold.1) + .attribute(style::Attribute::Bold) + } else { + style::ContentStyle::new() + .with(self.panel.colors.normal.0) + .on(self.panel.colors.normal.1) + }; + self.panel.write_line( + i, + elem.get_title(self.panel.get_cols() as usize), + Some(style), + ); } else { - pancurses::A_BOLD - }; - self.panel.change_attr( - i, - -1, - self.panel.get_cols() + 3, - attr, - ColorType::Normal, - ); + self.panel.write_line( + i, + elem.get_title(self.panel.get_cols() as usize), + None, + ); + } } else { break; } } } - self.panel.refresh(); } /// If a header exists, prints lines of text to the panel to appear /// above the menu. - fn print_header(&mut self) -> i32 { + fn print_header(&mut self) -> u16 { if let Some(header) = &self.header { - return self.panel.write_wrap_line(0, header) + 2; + return self.panel.write_wrap_line(0, header, None) + 2; } else { return 0; } } - /// Scrolls the menu up or down by `lines` lines. Negative values of - /// `lines` will scroll the menu up. + /// Scrolls the menu up or down by `lines` lines. /// /// This function examines the new selected value, ensures it does - /// not fall out of bounds, and then updates the pancurses window to + /// not fall out of bounds, and then updates the panel to /// represent the new visible list. - pub fn scroll(&mut self, lines: i32) { - let mut old_selected; - let checked_lines; - let apply_color_played; - let get_titles; - - let list_len = self.items.len(); + pub fn scroll(&mut self, lines: Scroll) { + let list_len = self.items.len(true) as u16; if list_len == 0 { return; } - 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; - } - - old_selected = self.selected; - self.selected += checked_lines; - - // don't allow scrolling past last item in list (if shorter - // than self.panel.get_rows()) - let abs_bottom = min(self.panel.get_rows(), list_len as i32 + self.start_row - 1); - if self.selected > abs_bottom { - 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) { - // 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); + match lines { + Scroll::Up(v) => { + let selected_adj = self.selected - self.start_row; + if v <= selected_adj { + self.unhighlight_item(self.selected); + self.selected -= v; + } else { + let list_scroll_amount = v - selected_adj; + if let Some(top) = self.top_row.checked_sub(list_scroll_amount) { + self.top_row = top; + } else { + self.top_row = 0; + } + self.selected = self.start_row; + self.panel.clear_inner(); + self.update_items(); + } + self.highlight_item(self.selected, self.active); } - self.selected = n_row - 1; + Scroll::Down(v) => { + if self.get_menu_idx(self.selected) >= list_len as usize - 1 { + // we're at the bottom of the list + return; + } + + let n_row = self.panel.get_rows(); + let select_max = if list_len < n_row - self.start_row { + self.start_row + list_len - 1 + } else { + n_row - 1 + }; - // scroll up - } else if self.selected < self.start_row { - 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; + if v <= (select_max - self.selected) { + self.unhighlight_item(self.selected); + self.selected += v; + } else { + let list_scroll_amount = v - (n_row - self.selected - 1); + let visible_rows = n_row - self.start_row; + // can't scroll list if list is shorter than full screen + if list_len > visible_rows { + self.top_row = + min(self.top_row + list_scroll_amount, list_len - visible_rows); + } + self.selected = select_max; + self.panel.clear_inner(); + self.update_items(); + } + self.highlight_item(self.selected, self.active); } - self.selected = self.start_row; } - apply_color_played(self, old_selected, ColorType::Normal); - apply_color_played(self, self.selected, ColorType::HighlightedActive); - self.panel.refresh(); - } - - /// Sets font style and color of menu item. `index` is the position - /// of the menu item to be changed. `played` is an indicator of - /// whether that item has been played or not. `color` is a ColorType - /// representing the appropriate state of the item (e.g., Normal, - /// Highlighted). - pub fn set_attrs(&mut self, index: i32, played: bool, color: ColorType) { - let attr = if played { - pancurses::A_NORMAL - } else { - pancurses::A_BOLD - }; - self.panel - .change_attr(index, -1, self.panel.get_cols() + 3, attr, color); } - /// Highlights the currently selected item in the menu, based on - /// whether the menu is currently active or not. - pub fn highlight_selected(&mut self, active_menu: bool) { - let is_played = self + /// Highlights the item in the menu, given a y-value. + pub fn highlight_item(&mut self, item_y: u16, active: bool) { + // if list is empty, will return None + let el_details = self .items - .map_single_by_index(self.get_menu_idx(self.selected), |el| el.is_played()); + .map_single_by_index(self.get_menu_idx(item_y), |el| { + (el.get_title(self.panel.get_cols() as usize), el.is_played()) + }); - if let Some(played) = is_played { - if active_menu { - self.set_attrs(self.selected, played, ColorType::HighlightedActive); + if let Some((title, is_played)) = el_details { + let mut style = style::ContentStyle::new(); + if active { + style = style.with(self.panel.colors.highlighted_active.0).on(self + .panel + .colors + .highlighted_active + .1); } else { - self.set_attrs(self.selected, played, ColorType::Highlighted); + style = + style + .with(self.panel.colors.highlighted.0) + .on(self.panel.colors.highlighted.1); } - self.panel.refresh(); + style = if is_played { + style.attribute(style::Attribute::NormalIntensity) + } else { + style.attribute(style::Attribute::Bold) + }; + self.panel.write_line(item_y, title, Some(style)); } } - /// Controls how the window changes when it is active (i.e., available - /// for user input to modify state). - pub fn activate(&mut self) { + /// Removes highlight on the item in the menu, given a y-value. + pub fn unhighlight_item(&mut self, item_y: u16) { // if list is empty, will return None - if let Some(played) = self + let el_details = self .items - .map_single_by_index(self.get_menu_idx(self.selected), |el| el.is_played()) - { - self.set_attrs(self.selected, played, ColorType::HighlightedActive); - self.panel.refresh(); + .map_single_by_index(self.get_menu_idx(item_y), |el| { + (el.get_title(self.panel.get_cols() as usize), el.is_played()) + }); + + if let Some((title, is_played)) = el_details { + let style = if is_played { + style::ContentStyle::new() + .with(self.panel.colors.normal.0) + .on(self.panel.colors.normal.1) + } else { + style::ContentStyle::new() + .with(self.panel.colors.bold.0) + .on(self.panel.colors.bold.1) + .attribute(style::Attribute::Bold) + }; + self.panel.write_line(item_y, title, Some(style)); } } - /// Updates window size - pub fn resize(&mut self, n_row: i32, n_col: i32, start_y: i32, start_x: i32) { - self.panel.resize(n_row, n_col, start_y, start_x); + /// Highlights the currently selected item in the menu, based on + /// whether the menu is currently active or not. + pub fn highlight_selected(&mut self) { + self.highlight_item(self.selected, self.active); + } + + /// Controls how the window changes when it is active (i.e., + /// available for user input to modify state). + pub fn activate(&mut self) { + self.active = true; + self.highlight_selected(); + } + + /// Updates window size. + pub fn resize(&mut self, n_row: u16, n_col: u16, start_x: u16) { + self.panel.resize(n_row, n_col, start_x); let n_row = self.panel.get_rows(); // if resizing moves selected item off screen, scroll the list @@ -266,6 +265,7 @@ impl Menu { self.top_row = self.top_row + self.selected - (n_row - 1); self.selected = n_row - 1; } + self.redraw(); } /// Given a row on the panel, this translates it into the @@ -273,7 +273,7 @@ impl Menu { /// do any checks to ensure `screen_y` is between 0 and `n_rows`, /// or that the resulting menu index is between 0 and `n_items`. /// It's merely a straight translation. - pub fn get_menu_idx(&self, screen_y: i32) -> usize { + pub fn get_menu_idx(&self, screen_y: u16) -> usize { return (self.top_row + screen_y - self.start_row) as usize; } } @@ -284,7 +284,7 @@ impl Menu { /// currently selected podcast. pub fn get_episodes(&self) -> LockVec { let index = self.get_menu_idx(self.selected); - let (borrowed_map, borrowed_order) = self.items.borrow(); + let (borrowed_map, _, borrowed_order) = self.items.borrow(); let pod_id = borrowed_order .get(index) .expect("Could not retrieve podcast."); @@ -298,28 +298,23 @@ impl Menu { /// Controls how the window changes when it is inactive (i.e., not /// available for user input to modify state). pub fn deactivate(&mut self) { - // if list is empty, will return None - if let Some(played) = self - .items - .map_single_by_index(self.get_menu_idx(self.selected), |el| el.is_played()) - { - self.set_attrs(self.selected, played, ColorType::Highlighted); - self.panel.refresh(); - } + self.active = false; + self.highlight_item(self.selected, false); } } impl Menu { /// Controls how the window changes when it is inactive (i.e., not - /// available for user input to modify state). - pub fn deactivate(&mut self) { - // if list is empty, will return None - if let Some(played) = self - .items - .map_single_by_index(self.get_menu_idx(self.selected), |el| el.is_played()) - { - self.set_attrs(self.selected, played, ColorType::Normal); - self.panel.refresh(); + /// available for user input to modify state). If true, + /// `keep_highlighted` will switch the currently selected item to + /// the "highlighted" cursor style (as opposed to the + /// "highlighted_active" style). + pub fn deactivate(&mut self, keep_highlighted: bool) { + self.active = false; + if keep_highlighted { + self.highlight_item(self.selected, false); + } else { + self.unhighlight_item(self.selected); } } } @@ -332,7 +327,7 @@ impl Menu { let changed = self.change_item_selections(vec![self.get_menu_idx(self.selected)], None); if changed { self.update_items(); - self.highlight_selected(true); + self.highlight_selected(); } } @@ -341,12 +336,12 @@ impl Menu { /// selected; if all are selected already, only then will it convert /// all to unselected. pub fn select_all_items(&mut self) { - let all_selected = self.items.map(|ep| ep.selected).iter().all(|x| *x); + let all_selected = self.items.map(|ep| ep.selected, false).iter().all(|x| *x); let changed = - self.change_item_selections((0..self.items.len()).collect(), Some(!all_selected)); + self.change_item_selections((0..self.items.len(false)).collect(), Some(!all_selected)); if changed { self.update_items(); - self.highlight_selected(true); + self.highlight_selected(); } } @@ -358,7 +353,7 @@ impl Menu { fn change_item_selections(&mut self, indexes: Vec, selection: Option) -> bool { let mut changed = false; { - let (mut borrowed_map, borrowed_order) = self.items.borrow(); + let (mut borrowed_map, borrowed_order, _) = self.items.borrow(); for idx in indexes { if let Some(ep_id) = borrowed_order.get(idx) { if let Entry::Occupied(mut ep) = borrowed_map.entry(*ep_id) { @@ -382,8 +377,10 @@ impl Menu { mod tests { use super::*; use chrono::Utc; + use std::rc::Rc; - fn create_menu(n_row: i32, n_col: i32, top_row: i32, selected: i32) -> Menu { + fn create_menu(n_row: u16, n_col: u16, top_row: u16, selected: u16) -> Menu { + let colors = Rc::new(crate::ui::AppColors::default()); let titles = vec![ "A Very Cool Episode", "This is a very long episode title but we'll get through it together", @@ -401,6 +398,7 @@ mod tests { pod_id: 1, title: t.to_string(), url: String::new(), + guid: String::new(), description: String::new(), pubdate: Some(Utc::now()), duration: Some(12345), @@ -409,7 +407,15 @@ mod tests { }); } - let panel = Panel::new("Episodes".to_string(), 1, n_row, n_col, 0, 0); + let panel = Panel::new( + "Episodes".to_string(), + 1, + colors.clone(), + n_row, + n_col, + 0, + (0, 0, 0, 0), + ); return Menu { panel: panel, header: None, @@ -417,6 +423,7 @@ mod tests { start_row: 0, top_row: top_row, selected: selected, + active: true, }; } @@ -424,10 +431,10 @@ mod tests { fn scroll_up() { let real_rows = 5; let real_cols = 65; - let mut menu = create_menu(real_rows + 2, real_cols + 5, 2, 0); + let mut menu = create_menu(real_rows + 2, real_cols + 3, 2, 0); menu.update_items(); - menu.scroll(-1); + menu.scroll(Scroll::Up(1)); let expected_top = menu .items @@ -438,18 +445,18 @@ mod tests { .map_single_by_index(5, |ep| ep.get_title(real_cols as usize)) .unwrap(); - assert_eq!(menu.panel.get_row(0).0, expected_top); - assert_eq!(menu.panel.get_row(4).0, expected_bot); + assert_eq!(menu.panel.get_row(0), expected_top); + assert_eq!(menu.panel.get_row(4), expected_bot); } #[test] fn scroll_down() { let real_rows = 5; let real_cols = 65; - let mut menu = create_menu(real_rows + 2, real_cols + 5, 0, 4); + let mut menu = create_menu(real_rows + 2, real_cols + 3, 0, 4); menu.update_items(); - menu.scroll(1); + menu.scroll(Scroll::Down(1)); let expected_top = menu .items @@ -460,18 +467,18 @@ mod tests { .map_single_by_index(5, |ep| ep.get_title(real_cols as usize)) .unwrap(); - assert_eq!(menu.panel.get_row(0).0, expected_top); - assert_eq!(menu.panel.get_row(4).0, expected_bot); + assert_eq!(menu.panel.get_row(0), expected_top); + assert_eq!(menu.panel.get_row(4), expected_bot); } #[test] fn resize_bigger() { let real_rows = 5; let real_cols = 65; - let mut menu = create_menu(real_rows + 2, real_cols + 5, 0, 4); + let mut menu = create_menu(real_rows + 2, real_cols + 3, 0, 4); menu.update_items(); - menu.resize(real_rows + 2 + 5, real_cols + 5 + 5, 0, 0); + menu.resize(real_rows + 2 + 5, real_cols + 3 + 5, 0); menu.update_items(); assert_eq!(menu.top_row, 0); @@ -479,27 +486,21 @@ mod tests { let non_empty: Vec = menu .panel - .window + .buffer .iter() - .filter_map(|x| { - if x.0.is_empty() { - None - } else { - Some(x.0.clone()) - } - }) + .filter_map(|x| if x.is_empty() { None } else { Some(x.clone()) }) .collect(); - assert_eq!(non_empty.len(), menu.items.len()); + assert_eq!(non_empty.len(), menu.items.len(true)); } #[test] fn resize_smaller() { let real_rows = 7; let real_cols = 65; - let mut menu = create_menu(real_rows + 2, real_cols + 5, 0, 6); + let mut menu = create_menu(real_rows + 2, real_cols + 3, 0, 6); menu.update_items(); - menu.resize(real_rows + 2 - 2, real_cols + 5 - 5, 0, 0); + menu.resize(real_rows + 2 - 2, real_cols + 3 - 5, 0); menu.update_items(); assert_eq!(menu.top_row, 2); @@ -507,15 +508,9 @@ mod tests { let non_empty: Vec = menu .panel - .window + .buffer .iter() - .filter_map(|x| { - if x.0.is_empty() { - None - } else { - Some(x.0.clone()) - } - }) + .filter_map(|x| if x.is_empty() { None } else { Some(x.clone()) }) .collect(); assert_eq!(non_empty.len(), (real_rows - 2) as usize); } @@ -527,9 +522,9 @@ mod tests { let mut menu = create_menu(real_rows + 2, real_cols + 5, 0, 0); menu.update_items(); - let expected = "An episode with le Unicod".to_string(); + let expected = " An episode with le Unicod ".to_string(); - assert_eq!(menu.panel.get_row(2).0, expected); + assert_eq!(menu.panel.get_row(2), expected); } #[test] @@ -539,8 +534,8 @@ mod tests { let mut menu = create_menu(real_rows + 2, real_cols + 5, 0, 0); menu.update_items(); - let expected = "How does an episode with emoji sound? ".to_string(); + let expected = " How does an episode with emoji sound? ".to_string(); - assert_eq!(menu.panel.get_row(3).0, expected); + assert_eq!(menu.panel.get_row(3), expected); } } diff --git a/src/ui/mock_panel.rs b/src/ui/mock_panel.rs index e227a53..84835a1 100644 --- a/src/ui/mock_panel.rs +++ b/src/ui/mock_panel.rs @@ -1,79 +1,84 @@ -use super::ColorType; -use chrono::{DateTime, Utc}; +use std::rc::Rc; -/// Struct holding the raw data used for building the details panel. -pub struct Details { - pub pod_title: Option, - pub ep_title: Option, - pub pubdate: Option>, - pub duration: Option, - pub explicit: Option, - pub description: Option, -} +use crossterm::style; +use super::AppColors; + +/// Struct holding the raw data used for building the details panel. #[derive(Debug)] pub struct Panel { - pub window: Vec<(String, pancurses::chtype, ColorType)>, + pub buffer: Vec, pub screen_pos: usize, + pub colors: Rc, pub title: String, - pub n_row: i32, - pub n_col: i32, + pub start_x: u16, + pub n_row: u16, + pub n_col: u16, + pub margins: (u16, u16, u16, u16), } impl Panel { pub fn new( title: String, screen_pos: usize, - n_row: i32, - n_col: i32, - _start_y: i32, - _start_x: i32, + colors: Rc, + n_row: u16, + n_col: u16, + start_x: u16, + margins: (u16, u16, u16, u16), ) -> Self { // we represent the window as a vector of Strings instead of - // the pancurses window - let panel_win = - vec![(String::new(), pancurses::A_NORMAL, ColorType::Normal); (n_row - 2) as usize]; + // printing to the terminal buffer + let buffer = vec![String::new(); (n_row - 2) as usize]; return Panel { - window: panel_win, + buffer: buffer, screen_pos: screen_pos, + colors: colors, title: title, + start_x: start_x, n_row: n_row, n_col: n_col, + margins: margins, }; } - pub fn refresh(&self) {} + pub fn redraw(&self) {} - pub fn erase(&mut self) { - self.window = - vec![(String::new(), pancurses::A_NORMAL, ColorType::Normal); self.n_row as usize]; - } + // pub fn clear(&mut self) { + // self.clear_inner(); + // } - pub fn write_line(&mut self, y: i32, string: String) { - self.window[y as usize] = (string, pancurses::A_NORMAL, ColorType::Normal); + pub fn clear_inner(&mut self) { + self.buffer = vec![String::new(); (self.n_row - 2) as usize]; } - pub fn insert_line(&mut self, y: i32, string: String) { - self.window - .insert(y as usize, (string, pancurses::A_NORMAL, ColorType::Normal)); - let _ = self.window.pop(); + pub fn write_line(&mut self, y: u16, string: String, _style: Option) { + self.buffer[y as usize] = string; } - pub fn delete_line(&mut self, y: i32) { - let _ = self.window.remove(y as usize); - // add a new empty line to the end so the vector stays the - // same size - self.window - .push((String::new(), pancurses::A_NORMAL, ColorType::Normal)); + pub fn write_key_value_line( + &mut self, + y: u16, + key: String, + value: String, + _key_style: Option, + _value_style: Option, + ) { + self.buffer[y as usize] = format!("{key}: {value}"); } - pub fn write_wrap_line(&mut self, start_y: i32, string: &str) -> i32 { + pub fn write_wrap_line( + &mut self, + start_y: u16, + string: &str, + _style: Option, + ) -> u16 { let mut row = start_y; let max_row = self.get_rows(); let wrapper = textwrap::wrap(&string, self.get_cols() as usize); for line in wrapper { - self.write_line(row, line.to_string()); + self.write_line(row, line.to_string(), None); row += 1; if row >= max_row { @@ -83,107 +88,33 @@ impl Panel { return row - 1; } - pub fn details_template(&mut self, start_y: i32, details: Details) { - let mut row = start_y - 1; - - // 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"), - } - - // 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"), - } - - row += 1; // blank line - - // published date - if let Some(date) = details.pubdate { - let new_row = self.write_wrap_line( - row + 1, - &format!("Published: {}", date.format("%B %-d, %Y")), - ); - self.change_attr(row + 1, 0, 10, pancurses::A_UNDERLINE, ColorType::Normal); - row = new_row; - } - - // duration - if let Some(dur) = details.duration { - 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; - } - - // explicit - if let Some(exp) = details.explicit { - let new_row = if exp { - self.write_wrap_line(row + 1, "Explicit: Yes") - } else { - self.write_wrap_line(row + 1, "Explicit: No") - }; - self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); - row = new_row; - } - - row += 1; // blank line - - // description - match details.description { - Some(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."); - } - } - } - - // This doesn't fully replicate the functionality of Panel, as it - // only applies the attribute to the line as a whole, rather than - // specific characters. But I'm primarily using it to change whole - // lines anyway. - pub fn change_attr( - &mut self, - y: i32, - _x: i32, - _nchars: i32, - attr: pancurses::chtype, - color: ColorType, - ) { - let current = &self.window[y as usize]; - self.window[y as usize] = (current.0.clone(), attr, color); - } - - pub fn resize(&mut self, n_row: i32, n_col: i32, _start_y: i32, _start_x: i32) { + pub fn resize(&mut self, n_row: u16, n_col: u16, start_x: u16) { self.n_row = n_row; self.n_col = n_col; + self.start_x = start_x; let new_len = (n_row - 2) as usize; - let len = self.window.len(); + let len = self.buffer.len(); if new_len < len { - self.window.truncate(new_len); + self.buffer.truncate(new_len); } else if new_len > len { for _ in (new_len - len)..new_len { - self.window - .push((String::new(), pancurses::A_NORMAL, ColorType::Normal)); + self.buffer.push(String::new()); } } } - pub fn get_rows(&self) -> i32 { - return self.n_row - 2; // border on top and bottom + pub fn get_rows(&self) -> u16 { + // 2 for border on top and bottom + return self.n_row - self.margins.0 - self.margins.2 - 2; } - pub fn get_cols(&self) -> i32 { - return self.n_col - 5; // 2 for border, 2 for margins, and 1 - // extra for some reason... + pub fn get_cols(&self) -> u16 { + // 2 for border, and 1 extra for some reason... + return self.n_col - self.margins.1 - self.margins.3 - 3; } - pub fn get_row(&self, row: usize) -> (String, pancurses::chtype, ColorType) { - return self.window[row].clone(); + pub fn get_row(&self, row: usize) -> String { + return self.buffer[row].clone(); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17809c6..fe3a19c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,31 +1,42 @@ +use std::io::{self, Write}; +use std::rc::Rc; use std::sync::mpsc; use std::thread; use std::time::Duration; +use crossterm::{ + self, cursor, + event::{self, Event}, + execute, terminal, +}; +use lazy_static::lazy_static; +use regex::Regex; + #[cfg_attr(not(test), path = "panel.rs")] #[cfg_attr(test, path = "mock_panel.rs")] mod panel; pub mod colors; +mod details_panel; mod menu; mod notification; mod popup; -use self::colors::ColorType; +use self::colors::AppColors; +use self::details_panel::{Details, DetailsPanel}; use self::menu::Menu; use self::notification::NotifWin; -use self::panel::{Details, Panel}; +use self::panel::Panel; use self::popup::PopupWin; -use lazy_static::lazy_static; -use pancurses::{Input, Window}; -use regex::Regex; - use super::MainMessage; use crate::config::Config; use crate::keymap::{Keybindings, UserAction}; use crate::types::*; +/// Amount of time between ticks in the event loop +const TICK_RATE: u64 = 20; + lazy_static! { /// Regex for finding
tags -- also captures any surrounding /// line breaks @@ -59,31 +70,39 @@ pub enum UiMsg { RemovePodcast(i64, bool), RemoveEpisode(i64, i64, bool), RemoveAllEpisodes(i64, bool), + FilterChange(FilterType), Quit, Noop, } +/// Holds a value for how much to scroll the menu up or down, without +/// having to deal with positive/negative values. +pub enum Scroll { + Up(u16), + Down(u16), +} + /// Simple enum to identify which menu is currently active. #[derive(Debug)] -enum ActiveMenu { +enum ActivePanel { PodcastMenu, EpisodeMenu, + DetailsPanel, } - -/// Struct containing all interface elements of the TUI. Functionally, it -/// encapsulates the pancurses windows, and holds data about the size of -/// the screen. +/// Struct containing all interface elements of the TUI. Functionally, +/// it encapsulates the terminal menus and panels, and holds data about +/// the size of the screen. #[derive(Debug)] pub struct Ui<'a> { - stdscr: Window, - n_row: i32, - n_col: i32, + n_row: u16, + n_col: u16, keymap: &'a Keybindings, + colors: Rc, podcast_menu: Menu, episode_menu: Menu, - active_menu: ActiveMenu, - details_panel: Option, + details_panel: Option, + active_panel: ActivePanel, notif_win: NotifWin, popup_win: PopupWin<'a>, } @@ -134,33 +153,33 @@ impl<'a> Ui<'a> { } } + io::stdout().flush().unwrap(); + // slight delay to avoid excessive CPU usage - thread::sleep(Duration::from_millis(10)); + thread::sleep(Duration::from_millis(TICK_RATE)); } }); } /// 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. + /// creates the menus and panels, and returns a UI object for future + /// manipulation. 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::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 - - self::colors::set_colors(&config.colors); - - let (n_row, n_col) = stdscr.get_max_yx(); + terminal::enable_raw_mode().expect("Terminal can't run in raw mode."); + execute!( + io::stdout(), + terminal::EnterAlternateScreen, + terminal::Clear(terminal::ClearType::All), + cursor::Hide + ) + .expect("Can't draw to screen."); + + let colors = Rc::new(config.colors.clone()); + + let (n_col, n_row) = terminal::size().expect("Can't get terminal size"); let (pod_col, ep_col, det_col) = Self::calculate_sizes(n_col); - let first_pod = match items.borrow_order().get(0) { + let first_pod = match items.borrow_filtered_order().get(0) { Some(first_id) => match items.borrow_map().get(first_id) { Some(pod) => pod.episodes.clone(), None => LockVec::new(Vec::new()), @@ -168,37 +187,55 @@ 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_panel = Panel::new( + "Podcasts".to_string(), + 0, + colors.clone(), + n_row - 1, + pod_col, + 0, + (0, 0, 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_panel = Panel::new( + "Episodes".to_string(), + 1, + colors.clone(), + n_row - 1, + ep_col, + pod_col - 1, + (0, 0, 0, 0), + ); 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( + Some(DetailsPanel::new( + "Details".to_string(), + 2, + colors.clone(), n_row - 1, det_col, - 0, pod_col + ep_col - 2, + (0, 1, 0, 1), )) } else { None }; - let notif_win = NotifWin::new(n_row, n_col); - let popup_win = PopupWin::new(&config.keybindings, n_row, n_col); + let notif_win = NotifWin::new(colors.clone(), n_row - 1, n_row, n_col); + let popup_win = PopupWin::new(&config.keybindings, colors.clone(), 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, details_panel: details_panel, + active_panel: ActivePanel::PodcastMenu, notif_win: notif_win, popup_win: popup_win, }; @@ -207,230 +244,235 @@ impl<'a> Ui<'a> { /// This should be called immediately after creating the UI, in order /// to draw everything to the screen. pub fn init(&mut self) { - self.stdscr.refresh(); - self.podcast_menu.init(); + self.podcast_menu.redraw(); + self.episode_menu.redraw(); 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(); + self.notif_win.redraw(); // welcome screen if user does not have any podcasts yet if self.podcast_menu.items.is_empty() { self.popup_win.spawn_welcome_win(); } + io::stdout().flush().unwrap(); } /// Waits for user input and, where necessary, provides UiMsgs /// back to the main controller. /// - /// Anything UI-related (e.g., scrolling up and down menus) is handled - /// internally, producing an empty UiMsg. This allows for some - /// greater degree of abstraction; for example, input to add a new - /// podcast feed spawns a UI window to capture the feed URL, and only - /// then passes this data back to the main controller. + /// Anything UI-related (e.g., scrolling up and down menus) is + /// handled internally, producing an empty UiMsg. This allows for + /// some greater degree of abstraction; for example, input to add a + /// new podcast feed spawns a UI window to capture the feed URL, and + /// only then passes this data back to the main controller. pub fn getch(&mut self) -> UiMsg { - match self.stdscr.getch() { - Some(Input::KeyResize) => self.resize(), - - Some(input) => { - let (curr_pod_id, curr_ep_id) = self.get_current_ids(); - - // get rid of the "welcome" window once the podcast list - // is no longer empty - if self.popup_win.welcome_win && !self.podcast_menu.items.is_empty() { - self.popup_win.turn_off_welcome_win(); - } - - // if there is a popup window active (apart from the - // welcome window which takes no input), then - // redirect user input there - if self.popup_win.is_non_welcome_popup_active() { - let popup_msg = self.popup_win.handle_input(input); - - // need to check if popup window is still active, as - // handling character input above may involve - // closing the popup window - if !self.popup_win.is_popup_active() { - self.stdscr.refresh(); - self.update_menus(); - if self.details_panel.is_some() { - self.update_details_panel(); - } + if event::poll(Duration::from_secs(0)).expect("Can't poll for inputs") { + match event::read().expect("Can't read inputs") { + Event::Resize(n_col, n_row) => self.resize(n_col, n_row), + Event::Key(input) => { + let (curr_pod_id, curr_ep_id) = self.get_current_ids(); + + // get rid of the "welcome" window once the podcast + // list is no longer empty + if self.popup_win.welcome_win && !self.podcast_menu.items.is_empty() { + self.popup_win.turn_off_welcome_win(); } - return popup_msg; - } else { - match self.keymap.get_from_input(input) { - Some(a @ UserAction::Down) - | Some(a @ UserAction::Up) - | Some(a @ UserAction::Left) - | 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) - } - Some(UserAction::AddFeed) => { - let url = &self.spawn_input_notif("Feed URL: "); - if !url.is_empty() { - return UiMsg::AddFeed(url.to_string()); + // if there is a popup window active (apart from the + // welcome window which takes no input), then + // redirect user input there + if self.popup_win.is_non_welcome_popup_active() { + let popup_msg = self.popup_win.handle_input(input); + + // need to check if popup window is still active, + // as handling character input above may involve + // closing the popup window + if !self.popup_win.is_popup_active() { + self.update_menus(); + if self.details_panel.is_some() { + self.update_details_panel(); } + io::stdout().flush().unwrap(); } - - Some(UserAction::Sync) => { - if let Some(pod_id) = curr_pod_id { - return UiMsg::Sync(pod_id); - } - } - Some(UserAction::SyncAll) => { - if curr_pod_id.is_some() { - return UiMsg::SyncAll; + return popup_msg; + } else { + match self.keymap.get_from_input(input) { + Some(a @ UserAction::Down) + | Some(a @ UserAction::Up) + | Some(a @ UserAction::Left) + | 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) } - } - Some(UserAction::Play) => { - if let Some(pod_id) = curr_pod_id { - if let Some(ep_id) = curr_ep_id { - return UiMsg::Play(pod_id, ep_id); + Some(UserAction::AddFeed) => { + let url = &self.spawn_input_notif("Feed URL: "); + if !url.is_empty() { + return UiMsg::AddFeed(url.to_string()); } } - } - Some(UserAction::MarkPlayed) => match self.active_menu { - ActiveMenu::PodcastMenu => (), - ActiveMenu::EpisodeMenu => { - if let Some(ui_msg) = self.mark_played(curr_pod_id, curr_ep_id) { - return ui_msg; + + Some(UserAction::Sync) => { + if let Some(pod_id) = curr_pod_id { + return UiMsg::Sync(pod_id); } } - }, - Some(UserAction::MarkAllPlayed) => { - if let Some(ui_msg) = self.mark_all_played(curr_pod_id) { - return ui_msg; + Some(UserAction::SyncAll) => { + if curr_pod_id.is_some() { + return UiMsg::SyncAll; + } } - } - Some(UserAction::Download) => { - if let Some(pod_id) = curr_pod_id { - if let Some(ep_id) = curr_ep_id { - return UiMsg::Download(pod_id, ep_id); + Some(UserAction::Play) => { + if let Some(pod_id) = curr_pod_id { + if let Some(ep_id) = curr_ep_id { + return UiMsg::Play(pod_id, ep_id); + } } } - } - Some(UserAction::DownloadAll) => { - if let Some(pod_id) = curr_pod_id { - return UiMsg::DownloadAll(pod_id); + Some(UserAction::MarkPlayed) => { + if let ActivePanel::EpisodeMenu = self.active_panel { + if let Some(ui_msg) = self.mark_played(curr_pod_id, curr_ep_id) + { + return ui_msg; + } + } + } + Some(UserAction::MarkAllPlayed) => { + if let Some(ui_msg) = self.mark_all_played(curr_pod_id) { + return ui_msg; + } } - } - Some(UserAction::Delete) => match self.active_menu { - ActiveMenu::PodcastMenu => (), - ActiveMenu::EpisodeMenu => { + Some(UserAction::Download) => { if let Some(pod_id) = curr_pod_id { if let Some(ep_id) = curr_ep_id { - return UiMsg::Delete(pod_id, ep_id); + return UiMsg::Download(pod_id, ep_id); } } } - }, - Some(UserAction::DeleteAll) => { - if let Some(pod_id) = curr_pod_id { - return UiMsg::DeleteAll(pod_id); + Some(UserAction::DownloadAll) => { + if let Some(pod_id) = curr_pod_id { + return UiMsg::DownloadAll(pod_id); + } } - } - Some(UserAction::Remove) => match self.active_menu { - ActiveMenu::PodcastMenu => { - if let Some(ui_msg) = self.remove_podcast(curr_pod_id) { - return ui_msg; + Some(UserAction::Delete) => { + if let ActivePanel::EpisodeMenu = self.active_panel { + if let Some(pod_id) = curr_pod_id { + if let Some(ep_id) = curr_ep_id { + return UiMsg::Delete(pod_id, ep_id); + } + } } } - ActiveMenu::EpisodeMenu => { - if let Some(ui_msg) = self.remove_episode(curr_pod_id, curr_ep_id) { + Some(UserAction::DeleteAll) => { + if let Some(pod_id) = curr_pod_id { + return UiMsg::DeleteAll(pod_id); + } + } + + Some(UserAction::Remove) => match self.active_panel { + ActivePanel::PodcastMenu => { + if let Some(ui_msg) = self.remove_podcast(curr_pod_id) { + return ui_msg; + } + } + ActivePanel::EpisodeMenu => { + if let Some(ui_msg) = + self.remove_episode(curr_pod_id, curr_ep_id) + { + return ui_msg; + } + } + _ => (), + }, + Some(UserAction::RemoveAll) => { + let ui_msg = match self.active_panel { + ActivePanel::PodcastMenu => self.remove_podcast(curr_pod_id), + ActivePanel::EpisodeMenu => { + self.remove_all_episodes(curr_pod_id) + } + _ => None, + }; + if let Some(ui_msg) = ui_msg { return ui_msg; } } - }, - Some(UserAction::RemoveAll) => { - let ui_msg = match self.active_menu { - ActiveMenu::PodcastMenu => self.remove_podcast(curr_pod_id), - ActiveMenu::EpisodeMenu => self.remove_all_episodes(curr_pod_id), - }; - if let Some(ui_msg) = ui_msg { - return ui_msg; + + Some(UserAction::FilterPlayed) => { + return UiMsg::FilterChange(FilterType::Played); + } + Some(UserAction::FilterDownloaded) => { + return UiMsg::FilterChange(FilterType::Downloaded); } - } - Some(UserAction::Help) => self.popup_win.spawn_help_win(), + Some(UserAction::Help) => self.popup_win.spawn_help_win(), - Some(UserAction::Quit) => { - return UiMsg::Quit; - } - None => (), - } // end of input match + Some(UserAction::Quit) => { + return UiMsg::Quit; + } + None => (), + } // end of input match + } } + _ => (), } - None => (), - }; // end of getch() match + } // end of poll() return UiMsg::Noop; } - /// Resize all the windows on the screen and refresh. - pub fn resize(&mut self) { - pancurses::resize_term(0, 0); - let (n_row, n_col) = self.stdscr.get_max_yx(); + /// Resize all the windows on the screen and redraw them. + pub fn resize(&mut self, n_col: u16, n_row: u16) { self.n_row = n_row; self.n_col = n_col; let (pod_col, ep_col, det_col) = Self::calculate_sizes(n_col); - self.podcast_menu.resize(n_row - 1, pod_col, 0, 0); - self.episode_menu.resize(n_row - 1, ep_col, 0, pod_col - 1); + self.podcast_menu.resize(n_row - 1, pod_col, 0); + self.episode_menu.resize(n_row - 1, ep_col, pod_col - 1); + self.highlight_items(); if self.details_panel.is_some() { if det_col > 0 { let det = self.details_panel.as_mut().unwrap(); - det.resize(n_row - 1, det_col, 0, pod_col + ep_col - 2); + det.resize(n_row - 1, det_col, pod_col + ep_col - 2); + // resizing the menus may change which item is selected + self.update_details_panel(); } else { self.details_panel = None; + // if the details panel is currently active, but the + // terminal is resized so the panel disappears, switch + // the active focus to the episode menu automatically + if let ActivePanel::DetailsPanel = self.active_panel { + self.active_panel = ActivePanel::EpisodeMenu; + self.episode_menu.activate(); + } } } else if det_col > 0 { - self.details_panel = Some(Self::make_details_panel( + self.details_panel = Some(DetailsPanel::new( + "Details".to_string(), + 2, + self.colors.clone(), n_row - 1, det_col, - 0, pod_col + ep_col - 2, + (0, 1, 0, 1), )); - } - - self.stdscr.refresh(); - self.update_menus(); - - match self.active_menu { - ActiveMenu::PodcastMenu => self.podcast_menu.activate(), - ActiveMenu::EpisodeMenu => { - self.podcast_menu.activate(); - self.episode_menu.activate(); - } - } - - if self.details_panel.is_some() { self.update_details_panel(); } self.popup_win.resize(n_row, n_col); self.notif_win.resize(n_row, n_col); - self.stdscr.refresh(); } - /// Move the menu cursor around and refresh menus when necessary. + /// Move the menu cursor around and redraw menus when necessary. pub fn move_cursor( &mut self, action: &UserAction, @@ -439,73 +481,77 @@ impl<'a> Ui<'a> { ) { match action { UserAction::Down => { - self.scroll_current_window(curr_pod_id, 1); + self.scroll_current_window(curr_pod_id, Scroll::Down(1)); } UserAction::Up => { - self.scroll_current_window(curr_pod_id, -1); + self.scroll_current_window(curr_pod_id, Scroll::Up(1)); } UserAction::Left => { if curr_pod_id.is_some() { - match self.active_menu { - ActiveMenu::PodcastMenu => (), - ActiveMenu::EpisodeMenu => { - self.active_menu = ActiveMenu::PodcastMenu; + match self.active_panel { + ActivePanel::PodcastMenu => (), + ActivePanel::EpisodeMenu => { + self.active_panel = ActivePanel::PodcastMenu; self.podcast_menu.activate(); - self.episode_menu.deactivate(); + self.episode_menu.deactivate(false); + } + ActivePanel::DetailsPanel => { + self.active_panel = ActivePanel::EpisodeMenu; + self.episode_menu.activate(); } } } - if let Some(det) = &self.details_panel { - det.refresh(); - } } UserAction::Right => { if curr_pod_id.is_some() && curr_ep_id.is_some() { - match self.active_menu { - ActiveMenu::PodcastMenu => { - self.active_menu = ActiveMenu::EpisodeMenu; + match self.active_panel { + ActivePanel::PodcastMenu => { + self.active_panel = ActivePanel::EpisodeMenu; self.podcast_menu.deactivate(); self.episode_menu.activate(); } - ActiveMenu::EpisodeMenu => (), + ActivePanel::EpisodeMenu => { + if self.details_panel.is_some() { + self.active_panel = ActivePanel::DetailsPanel; + self.episode_menu.deactivate(true); + } + } + ActivePanel::DetailsPanel => (), } } - if let Some(det) = &self.details_panel { - det.refresh(); - } } UserAction::PageUp => { - self.scroll_current_window(curr_pod_id, -self.n_row + 3); + self.scroll_current_window(curr_pod_id, Scroll::Up(self.n_row - 3)); } UserAction::PageDown => { - self.scroll_current_window(curr_pod_id, self.n_row - 3); + self.scroll_current_window(curr_pod_id, Scroll::Down(self.n_row - 3)); } UserAction::BigUp => { self.scroll_current_window( curr_pod_id, - -self.n_row / crate::config::BIG_SCROLL_AMOUNT, + Scroll::Up(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, + Scroll::Down(self.n_row / crate::config::BIG_SCROLL_AMOUNT), ); } UserAction::GoTop => { - self.scroll_current_window(curr_pod_id, -i32::MAX); + self.scroll_current_window(curr_pod_id, Scroll::Up(u16::MAX)); } UserAction::GoBot => { - self.scroll_current_window(curr_pod_id, i32::MAX); + self.scroll_current_window(curr_pod_id, Scroll::Down(u16::MAX)); } // this shouldn't occur because we only trigger this @@ -515,13 +561,11 @@ impl<'a> Ui<'a> { } } - /// 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 => { + /// Scrolls the current active menu by the specified amount and + /// refreshes the window. + pub fn scroll_current_window(&mut self, pod_id: Option, scroll: Scroll) { + match self.active_panel { + ActivePanel::PodcastMenu => { if pod_id.is_some() { self.podcast_menu.scroll(scroll); @@ -530,16 +574,21 @@ impl<'a> Ui<'a> { // update episodes menu with new list self.episode_menu.items = self.podcast_menu.get_episodes(); - self.episode_menu.update_items(); + self.episode_menu.redraw(); self.update_details_panel(); } } - ActiveMenu::EpisodeMenu => { + ActivePanel::EpisodeMenu => { if pod_id.is_some() { self.episode_menu.scroll(scroll); self.update_details_panel(); } } + ActivePanel::DetailsPanel => { + if let Some(ref mut det) = self.details_panel { + det.scroll(scroll); + } + } } } @@ -661,13 +710,13 @@ impl<'a> Ui<'a> { let current_pod_id = self .podcast_menu .items - .borrow_order() + .borrow_filtered_order() .get(current_pod_index) .copied(); let current_ep_id = self .episode_menu .items - .borrow_order() + .borrow_filtered_order() .get(current_ep_index) .copied(); return (current_pod_id, current_ep_id); @@ -677,18 +726,17 @@ impl<'a> Ui<'a> { /// main panels: podcast menu, episodes menu, and details panel; if /// the screen is too small to display the details panel, this size /// will be 0 - #[allow(clippy::useless_let_if_seq)] - pub fn calculate_sizes(n_col: i32) -> (i32, i32, i32) { + pub fn calculate_sizes(n_col: u16) -> (u16, u16, u16) { let pod_col; let ep_col; let det_col; if n_col > crate::config::DETAILS_PANEL_LENGTH { - pod_col = n_col / 3; - ep_col = n_col / 3 + 1; - det_col = n_col - pod_col - ep_col + 2; + pod_col = (n_col + 2) / 3; + ep_col = (n_col + 2) / 3; + det_col = n_col + 2 - pod_col - ep_col; } else { - pod_col = n_col / 2; - ep_col = n_col - pod_col + 1; + pod_col = (n_col + 1) / 2; + ep_col = n_col + 1 - pod_col; det_col = 0; } return (pod_col, ep_col, det_col); @@ -739,9 +787,7 @@ impl<'a> Ui<'a> { /// return None. pub fn spawn_yes_no_notif(&self, prefix: &str) -> Option { let mut out_val = None; - let input = self - .notif_win - .input_notif(&format!("{} {}", prefix, "(y/n) ")); + let input = self.notif_win.input_notif(&format!("{prefix} (y/n) ")); if let Some(c) = input.trim().chars().next() { if c == 'Y' || c == 'y' { out_val = Some(true); @@ -753,7 +799,7 @@ impl<'a> Ui<'a> { } /// Adds a notification to the bottom of the screen for `duration` - /// time (in milliseconds). Useful for presenting error messages, + /// time (in milliseconds). Useful for presenting error messages, /// among other things. pub fn timed_notif(&mut self, message: String, duration: u64, error: bool) { self.notif_win.timed_notif(message, duration, error); @@ -775,33 +821,42 @@ impl<'a> Ui<'a> { /// Forces the menus to check the list of podcasts/episodes again and /// update. pub fn update_menus(&mut self) { - self.podcast_menu.update_items(); + self.podcast_menu.redraw(); self.episode_menu.items = if !self.podcast_menu.items.is_empty() { self.podcast_menu.get_episodes() } else { LockVec::new(Vec::new()) }; - self.episode_menu.update_items(); + self.episode_menu.redraw(); + self.highlight_items(); + } - match self.active_menu { - ActiveMenu::PodcastMenu => self.podcast_menu.highlight_selected(true), - ActiveMenu::EpisodeMenu => { - self.podcast_menu.highlight_selected(false); - self.episode_menu.highlight_selected(true); + /// Forces the menus to redraw the highlighted item. + pub fn highlight_items(&mut self) { + match self.active_panel { + ActivePanel::PodcastMenu => { + self.podcast_menu.highlight_selected(); } + ActivePanel::EpisodeMenu => { + self.podcast_menu.highlight_selected(); + self.episode_menu.highlight_selected(); + } + _ => (), } } /// When the program is ending, this performs tear-down functions so /// that the terminal is properly restored to its prior settings. pub fn tear_down(&self) { - pancurses::endwin(); - } - - /// Create a details panel. - 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); + terminal::disable_raw_mode().unwrap(); + execute!( + io::stdout(), + terminal::Clear(terminal::ClearType::All), + terminal::LeaveAlternateScreen, + cursor::Show + ) + .unwrap(); } /// Updates the details panel with information about the current @@ -810,7 +865,6 @@ impl<'a> Ui<'a> { if self.details_panel.is_some() { let (curr_pod_id, curr_ep_id) = self.get_current_ids(); let det = self.details_panel.as_mut().unwrap(); - det.erase(); if let Some(pod_id) = curr_pod_id { if let Some(ep_id) = curr_ep_id { // get a couple details from the current podcast @@ -862,10 +916,8 @@ impl<'a> Ui<'a> { explicit: pod_explicit, description: desc, }; - det.details_template(0, details); + det.change_details(details); }; - - det.refresh(); } } } diff --git a/src/ui/notification.rs b/src/ui/notification.rs index bb99fa4..e025aca 100644 --- a/src/ui/notification.rs +++ b/src/ui/notification.rs @@ -1,9 +1,19 @@ +use std::io; +use std::rc::Rc; use std::time::{Duration, Instant}; -use super::ColorType; -use pancurses::{Input, Window}; +use crossterm::{ + cursor, + event::{self, KeyCode}, + execute, queue, style, + style::Stylize, +}; -/// Holds details of a notification message. +use super::AppColors; + +/// Holds details of a notification message. The `expiry` is optional, +/// and is used to create timed notifications -- `Instant` should refer +/// to the timestamp when the message should disappear. #[derive(Debug, Clone, PartialEq)] struct Notification { message: String, @@ -36,9 +46,10 @@ impl Notification { /// not necessarily. #[derive(Debug)] pub struct NotifWin { - window: Window, - total_rows: i32, - total_cols: i32, + colors: Rc, + start_y: u16, + total_rows: u16, + total_cols: u16, msg_stack: Vec, persistent_msg: Option, current_msg: Option, @@ -46,10 +57,10 @@ pub struct NotifWin { impl NotifWin { /// Creates a new NotifWin. - pub fn new(total_rows: i32, total_cols: i32) -> Self { - let win = pancurses::newwin(1, total_cols, total_rows - 1, 0); + pub fn new(colors: Rc, start_y: u16, total_rows: u16, total_cols: u16) -> Self { return Self { - window: win, + colors: colors, + start_y: start_y, total_rows: total_rows, total_cols: total_cols, msg_stack: Vec::new(), @@ -60,18 +71,28 @@ 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(); + pub fn redraw(&self) { + // clear the panel + let empty = vec![" "; self.total_cols as usize]; + let empty_string = empty.join(""); + queue!( + io::stdout(), + cursor::MoveTo(0, self.start_y), + style::PrintStyledContent( + style::style(&empty_string) + .with(self.colors.normal.0) + .on(self.colors.normal.1) + ), + ) + .unwrap(); } /// Checks if the current notification needs to be changed, and /// updates the message window accordingly. pub fn check_notifs(&mut self) { if !self.msg_stack.is_empty() { - // compare expiry times of all notifications to current time, - // remove expired ones + // compare expiry times of all notifications to current + // time, remove expired ones let now = Instant::now(); self.msg_stack.retain(|x| match x.expiry { Some(exp) => now < exp, @@ -105,10 +126,7 @@ impl NotifWin { } else { // otherwise, there was a notification before but there // isn't now, so erase - self.window.erase(); - self.window - .bkgdset(pancurses::ColorPair(ColorType::Normal as u8)); - self.window.refresh(); + self.redraw(); self.current_msg = None; } } @@ -119,72 +137,98 @@ impl NotifWin { /// input line. This returns the user's input; if the user cancels /// their input, the String will be empty. pub fn input_notif(&self, prefix: &str) -> String { - self.window.mv(self.total_rows - 1, 0); - self.window.addstr(&prefix); - self.window.keypad(true); - self.window.refresh(); - pancurses::curs_set(2); + execute!( + io::stdout(), + cursor::MoveTo(0, self.start_y), + style::Print(&prefix), + cursor::Show + ) + .unwrap(); let mut inputs = String::new(); let mut cancelled = false; - let min_x = prefix.len() as i32; - let mut current_x = prefix.len() as i32; - let mut cursor_x = prefix.len() as i32; + let min_x = prefix.len() as u16; + let mut current_max_x = prefix.len() as u16; + let mut cursor_x = prefix.len() as u16; loop { - match self.window.getch() { - // Cancel input - Some(Input::KeyExit) | Some(Input::Character('\u{1b}')) => { - cancelled = true; - break; - } - // Complete input - Some(Input::KeyEnter) | Some(Input::Character('\n')) => { - break; - } - Some(Input::KeyBackspace) | Some(Input::Character('\u{7f}')) => { - if current_x > min_x { - current_x -= 1; - cursor_x -= 1; - let _ = inputs.remove((cursor_x as usize) - prefix.len()); - self.window.mv(0, cursor_x); - self.window.delch(); + if let event::Event::Key(input) = event::read().expect("") { + let cursor_idx = (cursor_x - min_x) as usize; + match input.code { + // Cancel input + KeyCode::Esc | KeyCode::Char('\u{1b}') => { + cancelled = true; + break; } - } - Some(Input::KeyDC) => { - if cursor_x < current_x { - let _ = inputs.remove((cursor_x as usize) - prefix.len()); - self.window.delch(); + // Complete input + KeyCode::Enter | KeyCode::Char('\n') => { + break; } - } - Some(Input::KeyLeft) => { - if cursor_x > min_x { - cursor_x -= 1; - self.window.mv(0, cursor_x); + KeyCode::Backspace | KeyCode::Char('\u{7f}') => { + if current_max_x > min_x { + current_max_x -= 1; + cursor_x -= 1; + let _ = inputs.remove(cursor_idx - 1); + execute!(io::stdout(), cursor::MoveLeft(1)).unwrap(); + for i in inputs.chars().skip(cursor_idx - 1) { + execute!(io::stdout(), style::Print(i)).unwrap(); + } + execute!( + io::stdout(), + style::Print(" "), + cursor::MoveTo(cursor_x, self.start_y) + ) + .unwrap(); + } } - } - Some(Input::KeyRight) => { - if cursor_x < current_x { + KeyCode::Delete => { + if cursor_x < current_max_x { + current_max_x -= 1; + let _ = inputs.remove(cursor_idx); + for i in inputs.chars().skip(cursor_idx) { + execute!(io::stdout(), style::Print(i)).unwrap(); + } + execute!( + io::stdout(), + style::Print(" "), + cursor::MoveTo(cursor_x, self.start_y) + ) + .unwrap(); + } + } + KeyCode::Left => { + if cursor_x > min_x { + cursor_x -= 1; + execute!(io::stdout(), cursor::MoveLeft(1)).unwrap(); + } + } + KeyCode::Right => { + if cursor_x < current_max_x { + cursor_x += 1; + execute!(io::stdout(), cursor::MoveRight(1)).unwrap(); + } + } + KeyCode::Char(c) => { + current_max_x += 1; cursor_x += 1; - self.window.mv(0, cursor_x); + if cursor_x < current_max_x { + inputs.insert(cursor_idx, c); + for i in inputs.chars().skip(cursor_idx) { + execute!(io::stdout(), style::Print(i)).unwrap(); + } + execute!(io::stdout(), cursor::MoveTo(cursor_x, self.start_y)).unwrap(); + } else { + inputs.push(c); + execute!(io::stdout(), style::Print(c)).unwrap(); + } } + _ => (), } - Some(Input::Character(c)) => { - current_x += 1; - cursor_x += 1; - self.window.insch(c); - self.window.mv(0, cursor_x); - inputs.push(c); - } - Some(_) => (), - None => (), } - self.window.refresh(); } - pancurses::curs_set(0); - self.window.clear(); - self.window.refresh(); + execute!(io::stdout(), cursor::Hide).unwrap(); + self.redraw(); if cancelled { return String::from(""); @@ -194,16 +238,23 @@ impl NotifWin { /// Prints a notification to the window. 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(¬if.message); - - if notif.error { - self.window - .mvchgat(0, 0, -1, pancurses::A_BOLD, ColorType::Error as i16); - } - self.window.refresh(); + self.redraw(); + let styled = if notif.error { + style::style(¬if.message) + .with(self.colors.error.0) + .on(self.colors.error.1) + .attribute(style::Attribute::Bold) + } else { + style::style(¬if.message) + .with(self.colors.normal.0) + .on(self.colors.normal.1) + }; + queue!( + io::stdout(), + cursor::MoveTo(0, self.start_y), + style::PrintStyledContent(styled) + ) + .unwrap(); } /// Adds a notification to the user. `duration` indicates how long @@ -233,32 +284,19 @@ impl NotifWin { pub fn clear_persistent_notif(&mut self) { self.persistent_msg = None; if self.msg_stack.is_empty() { - self.window.erase(); - self.window.refresh(); + self.redraw(); self.current_msg = None; } } /// Updates window size/location - pub fn resize(&mut self, total_rows: i32, total_cols: i32) { + pub fn resize(&mut self, total_rows: u16, total_cols: u16) { self.total_rows = total_rows; self.total_cols = total_cols; - // apparently pancurses does not implement `wresize()` - // from ncurses, so instead we create an entirely new - // window every time the terminal is resized...not ideal, - // but c'est la vie - let oldwin = std::mem::replace( - &mut self.window, - pancurses::newwin(1, total_cols, total_rows - 1, 0), - ); - oldwin.delwin(); - - self.window - .bkgdset(pancurses::ColorPair(ColorType::Normal as u8)); + self.redraw(); if let Some(curr) = &self.current_msg { self.display_notif(curr); } - self.window.refresh(); } } diff --git a/src/ui/panel.rs b/src/ui/panel.rs index 24e9186..c59ffbe 100644 --- a/src/ui/panel.rs +++ b/src/ui/panel.rs @@ -1,31 +1,39 @@ -use chrono::{DateTime, Utc}; -use pancurses::{Attribute, Window}; +use std::io; +use std::rc::Rc; -use super::ColorType; +use crossterm::style::{self, Stylize}; +use crossterm::{cursor, queue}; + +use super::AppColors; + +pub const VERTICAL: &str = "│"; +pub const HORIZONTAL: &str = "─"; +pub const TOP_RIGHT: &str = "┐"; +pub const TOP_LEFT: &str = "┌"; +pub const BOTTOM_RIGHT: &str = "┘"; +pub const BOTTOM_LEFT: &str = "└"; +pub const TOP_TEE: &str = "┬"; +pub const BOTTOM_TEE: &str = "┴"; -/// Struct holding the raw data used for building the details panel. -pub struct Details { - pub pod_title: Option, - pub ep_title: Option, - pub pubdate: Option>, - pub duration: Option, - pub explicit: Option, - pub description: Option, -} -/// Panels abstract away a pancurses window, and handles all methods -/// associated with writing data to that window. A panel includes a -/// border and margin around the edge of the window, and a title that -/// appears at the top. The Panel will translate the x and y coordinates -/// to account for the border and margins, so users of the methods can -/// calculate rows and columns relative to the Panel. +/// Panels abstract away a terminal "window" (section of the screen), +/// and handle all methods associated with writing data to that window. +/// A panel includes a border and margin around the edge of the window, +/// and a title that appears at the top. Margins are set individually, +/// in the order (top, right, bottom, left). The Panel will translate +/// the x and y coordinates to account for the border and margins, so +/// users of the methods can calculate rows and columns relative to the +/// Panel (i.e., x = 0 and y = 0 represent the top-left printable +/// cell in the window). #[derive(Debug)] pub struct Panel { - window: Window, screen_pos: usize, + pub colors: Rc, title: String, - n_row: i32, - n_col: i32, + start_x: u16, + n_row: u16, + n_col: u16, + margins: (u16, u16, u16, u16), } impl Panel { @@ -33,28 +41,64 @@ impl Panel { pub fn new( title: String, screen_pos: usize, - n_row: i32, - n_col: i32, - start_y: i32, - start_x: i32, + colors: Rc, + n_row: u16, + n_col: u16, + start_x: u16, + margins: (u16, u16, u16, u16), ) -> 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, + start_x: start_x, n_row: n_row, n_col: n_col, + margins: margins, }; } /// Redraws borders and refreshes the window to display on terminal. - pub fn refresh(&self) { - self.window - .bkgd(pancurses::ColorPair(ColorType::Normal as u8)); + pub fn redraw(&self) { + self.clear(); self.draw_border(); - self.window.refresh(); + } + + /// Clears the whole Panel. + pub fn clear(&self) { + let empty = vec![" "; self.n_col as usize]; + let empty_string = empty.join(""); + for r in 0..(self.n_row - 1) { + queue!( + io::stdout(), + cursor::MoveTo(self.start_x, r), + style::PrintStyledContent( + style::style(&empty_string) + .with(self.colors.normal.0) + .on(self.colors.normal.1) + ), + ) + .unwrap(); + } + } + + /// Clears the inner section of the Panel, leaving the borders + /// intact. + pub fn clear_inner(&self) { + let empty = vec![" "; self.n_col as usize - 2]; + let empty_string = empty.join(""); + for r in 1..(self.n_row - 1) { + queue!( + io::stdout(), + cursor::MoveTo(self.start_x + 1, r), + style::PrintStyledContent( + style::style(&empty_string) + .with(self.colors.normal.0) + .on(self.colors.normal.1) + ), + ) + .unwrap(); + } } /// Draws a border around the window. @@ -63,70 +107,139 @@ impl Panel { let bot_left; match self.screen_pos { 0 => { - top_left = pancurses::ACS_ULCORNER(); - bot_left = pancurses::ACS_LLCORNER(); + top_left = TOP_LEFT; + bot_left = BOTTOM_LEFT; } _ => { - top_left = pancurses::ACS_TTEE(); - bot_left = pancurses::ACS_BTEE(); + top_left = TOP_TEE; + bot_left = BOTTOM_TEE; } } - self.window.border( - pancurses::ACS_VLINE(), - pancurses::ACS_VLINE(), - pancurses::ACS_HLINE(), - pancurses::ACS_HLINE(), - top_left, - pancurses::ACS_URCORNER(), - bot_left, - pancurses::ACS_LRCORNER(), - ); + let mut border_top = vec![top_left]; + let mut border_bottom = vec![bot_left]; + for _ in 0..(self.n_col - 2) { + border_top.push(HORIZONTAL); + border_bottom.push(HORIZONTAL); + } + border_top.push(TOP_RIGHT); + border_bottom.push(BOTTOM_RIGHT); - self.window.mvaddstr(0, 2, &self.title); - } + queue!( + io::stdout(), + style::SetColors(style::Colors::new( + self.colors.normal.0, + self.colors.normal.1 + )), + cursor::MoveTo(self.start_x, 0), + style::Print(border_top.join("")), + cursor::MoveTo(self.start_x, self.n_row - 1), + style::Print(border_bottom.join("")), + ) + .unwrap(); - /// 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(ColorType::Normal as u8)); - self.draw_border(); + for r in 1..(self.n_row - 1) { + queue!( + io::stdout(), + cursor::MoveTo(self.start_x, r), + style::Print(VERTICAL.to_string()), + cursor::MoveTo(self.start_x + self.n_col - 1, r), + style::Print(VERTICAL.to_string()), + ) + .unwrap(); + } + + queue!( + io::stdout(), + cursor::MoveTo(self.start_x + 2, 0), + style::Print(&self.title), + style::ResetColor, + ) + .unwrap(); } /// Writes a line of text to the window. Note that this does not do /// checking for line length, so strings that are too long will end - /// up wrapping and may mess up the format. use `write_wrap_line()` + /// up wrapping and may mess up the format. Use `write_wrap_line()` /// if you need line wrapping. - pub fn write_line(&self, y: i32, string: String) { - self.window.mvaddstr(self.abs_y(y), self.abs_x(0), string); + pub fn write_line(&self, y: u16, string: String, style: Option) { + let styled = match style { + Some(style) => style.apply(string), + None => style::style(string) + .with(self.colors.normal.0) + .on(self.colors.normal.1), + }; + queue!( + io::stdout(), + cursor::MoveTo(self.abs_x(0), self.abs_y(y)), + style::PrintStyledContent(styled) + ) + .unwrap(); } - /// Writes a line of text to the window, first moving all text on - /// line `y` and below down one row. - pub fn insert_line(&self, y: i32, string: String) { - self.window.mv(self.abs_y(y), 0); - self.window.insertln(); - self.window.mv(self.abs_y(y), self.abs_x(0)); - self.window.addstr(string); - } + /// Writes a line of styled text to the window, representing a key + /// and value. The text will be shown as "key: value", and styled + /// with the provided styles. Note that this does not do checking + /// for line length, so strings that are too long will end up + /// wrapping and may mess up the format. Use `write_wrap_line()` if + /// you need line wrapping. + pub fn write_key_value_line( + &self, + y: u16, + mut key: String, + mut value: String, + key_style: Option, + value_style: Option, + ) { + key.push(':'); + value.insert(0, ' '); + + queue!(io::stdout(), cursor::MoveTo(self.abs_x(0), self.abs_y(y))).unwrap(); - /// Deletes a line of text from the window. - pub fn delete_line(&self, y: i32) { - self.window.mv(self.abs_y(y), self.abs_x(-1)); - self.window.deleteln(); + let key_styled = match key_style { + Some(kstyle) => kstyle.apply(key), + None => style::style(key) + .with(self.colors.normal.0) + .on(self.colors.normal.1), + }; + queue!(io::stdout(), style::PrintStyledContent(key_styled)).unwrap(); + let value_styled = match value_style { + Some(vstyle) => vstyle.apply(value), + None => style::style(value) + .with(self.colors.normal.0) + .on(self.colors.normal.1), + }; + queue!(io::stdout(), style::PrintStyledContent(value_styled)).unwrap(); } /// Writes one or more lines of text from a String, word wrapping /// 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: &str) -> i32 { + pub fn write_wrap_line( + &self, + start_y: u16, + string: &str, + style: Option, + ) -> u16 { let mut row = start_y; let max_row = self.get_rows(); + if row >= max_row { + return row; + } + let content_style = match style { + Some(style) => style, + None => style::ContentStyle::new() + .with(self.colors.normal.0) + .on(self.colors.normal.1), + }; 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); + queue!( + io::stdout(), + cursor::MoveTo(self.abs_x(0), self.abs_y(row)), + style::PrintStyledContent(content_style.apply(line)) + ) + .unwrap(); row += 1; if row >= max_row { @@ -136,123 +249,36 @@ impl Panel { return row - 1; } - /// Write the specific template used for the details panel. This is - /// not the most elegant code, but it works. - pub fn details_template(&self, start_y: i32, details: Details) { - let mut row = start_y - 1; - - 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"), - } - - // 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"), - } - self.window.attroff(Attribute::Bold); - - row += 1; // blank line - - // published date - if let Some(date) = details.pubdate { - let new_row = self.write_wrap_line( - row + 1, - &format!("Published: {}", date.format("%B %-d, %Y")), - ); - self.change_attr(row + 1, 0, 10, pancurses::A_UNDERLINE, ColorType::Normal); - row = new_row; - } - - // duration - if let Some(dur) = details.duration { - 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; - } - - // explicit - if let Some(exp) = details.explicit { - let new_row = if exp { - self.write_wrap_line(row + 1, "Explicit: Yes") - } else { - self.write_wrap_line(row + 1, "Explicit: No") - }; - self.change_attr(row + 1, 0, 9, pancurses::A_UNDERLINE, ColorType::Normal); - row = new_row; - } - - row += 1; // blank line - - // description - match details.description { - Some(desc) => { - self.window.attron(Attribute::Bold); - row = self.write_wrap_line(row + 1, "Description:"); - self.window.attroff(Attribute::Bold); - let _row = self.write_wrap_line(row + 1, &desc); - } - None => { - let _row = self.write_wrap_line(row + 1, "No description."); - } - } - } - - /// Changes the attributes (text style and color) for a line of - /// text. - pub fn change_attr( - &self, - y: i32, - x: i32, - nchars: i32, - attr: pancurses::chtype, - color: ColorType, - ) { - self.window - .mvchgat(self.abs_y(y), self.abs_x(x), nchars, attr, color as i16); - } - - /// Updates window size - pub fn resize(&mut self, n_row: i32, n_col: i32, start_y: i32, start_x: i32) { + /// Updates window size. + pub fn resize(&mut self, n_row: u16, n_col: u16, start_x: u16) { self.n_row = n_row; self.n_col = n_col; - - // apparently pancurses does not implement `wresize()` - // from ncurses, so instead we create an entirely new - // window every time the terminal is resized...not ideal, - // but c'est la vie - let oldwin = std::mem::replace( - &mut self.window, - pancurses::newwin(n_row, n_col, start_y, start_x), - ); - oldwin.delwin(); + self.start_x = start_x; } /// Returns the effective number of rows (accounting for borders /// and margins). - pub fn get_rows(&self) -> i32 { - return self.n_row - 2; // border on top and bottom + pub fn get_rows(&self) -> u16 { + // 2 for borders on top and bottom + return self.n_row - self.margins.0 - self.margins.2 - 2; } /// Returns the effective number of columns (accounting for /// borders and margins). - pub fn get_cols(&self) -> i32 { - return self.n_col - 5; // 2 for border, 2 for margins, and 1 - // extra for some reason... + pub fn get_cols(&self) -> u16 { + // 2 for borders on left and right + return self.n_col - self.margins.1 - self.margins.3 - 2; } - /// Calculates the y-value relative to the window rather than to the - /// panel (i.e., taking into account borders and margins). - fn abs_y(&self, y: i32) -> i32 { - return y + 1; + /// Calculates the y-value relative to the terminal rather than to + /// the panel (i.e., taking into account borders and margins). + fn abs_y(&self, y: u16) -> u16 { + return y + self.margins.0 + 1; } - /// Calculates the x-value relative to the window rather than to the - /// panel (i.e., taking into account borders and margins). - fn abs_x(&self, x: i32) -> i32 { - return x + 2; + /// Calculates the x-value relative to the terminal rather than to + /// the panel (i.e., taking into account borders and margins). + fn abs_x(&self, x: u16) -> u16 { + return x + self.start_x + self.margins.3 + 1; } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index fae5cff..ca71369 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -1,8 +1,13 @@ -use pancurses::Input; use std::cmp::min; +use std::rc::Rc; -use super::ColorType; -use super::{Menu, Panel, UiMsg}; +use crossterm::{ + event::{KeyCode, KeyEvent}, + style, + style::Stylize, +}; + +use super::{AppColors, Menu, Panel, Scroll, UiMsg}; use crate::config::BIG_SCROLL_AMOUNT; use crate::keymap::{Keybindings, UserAction}; use crate::types::*; @@ -34,14 +39,20 @@ impl ActivePopup { } } -/// Holds all state relevant for handling popup windows. +/// Holds all state relevant for handling popup windows. Holds an +/// ActivePopup enum that itself contains the Panel/Menu displayed with +/// the current popup window (if any). The `bool` values provide an +/// indicator of which popup menus currently exist, with the possibility +/// for multiple popup windows to exist (though only one is "active" at +/// any given time). #[derive(Debug)] pub struct PopupWin<'a> { popup: ActivePopup, new_episodes: Vec, keymap: &'a Keybindings, - total_rows: i32, - total_cols: i32, + colors: Rc, + total_rows: u16, + total_cols: u16, pub welcome_win: bool, pub help_win: bool, pub download_win: bool, @@ -49,11 +60,17 @@ pub struct PopupWin<'a> { impl<'a> PopupWin<'a> { /// Set up struct for handling popup windows. - pub fn new(keymap: &'a Keybindings, total_rows: i32, total_cols: i32) -> Self { + pub fn new( + keymap: &'a Keybindings, + colors: Rc, + total_rows: u16, + total_cols: u16, + ) -> Self { return Self { popup: ActivePopup::None, new_episodes: Vec::new(), keymap: keymap, + colors: colors, total_rows: total_rows, total_cols: total_cols, welcome_win: false, @@ -75,30 +92,25 @@ impl<'a> PopupWin<'a> { } /// Resize the currently active popup window if one exists. - pub fn resize(&mut self, total_rows: i32, total_cols: i32) { + pub fn resize(&mut self, total_rows: u16, total_cols: u16) { self.total_rows = total_rows; self.total_cols = total_cols; match &self.popup { ActivePopup::WelcomeWin(_win) => { let welcome_win = self.make_welcome_win(); - welcome_win.refresh(); self.popup = ActivePopup::WelcomeWin(welcome_win); } ActivePopup::HelpWin(_win) => { let help_win = self.make_help_win(); - help_win.refresh(); self.popup = ActivePopup::HelpWin(help_win); } ActivePopup::DownloadWin(_win) => { let mut download_win = self.make_download_win(); - download_win.highlight_selected(true); + download_win.activate(); self.popup = ActivePopup::DownloadWin(download_win); } ActivePopup::None => (), } - // if let Some(panel) = &self.panel { - // panel.refresh(); - // } } /// Create a welcome window and draw it to the screen. @@ -123,23 +135,30 @@ impl<'a> PopupWin<'a> { let mut welcome_win = Panel::new( "Shellcaster".to_string(), 0, + self.colors.clone(), self.total_rows - 1, self.total_cols, 0, - 0, + (1, 1, 1, 1), ); + welcome_win.redraw(); let mut row = 0; - row = welcome_win.write_wrap_line(row + 1, "Welcome to shellcaster!"); + row = welcome_win.write_wrap_line(row, "Welcome to shellcaster!", None); - 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])); + 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]), None); row = welcome_win.write_wrap_line( row + 2, "More details of how to customize shellcaster can be found on the Github repo readme:", + None, + ); + let _ = welcome_win.write_wrap_line( + row + 1, + "https://github.com/jeff-hughes/shellcaster", + None, ); - let _ = welcome_win.write_wrap_line(row + 1, "https://github.com/jeff-hughes/shellcaster"); return welcome_win; } @@ -152,8 +171,8 @@ 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 big_scroll_up = format!("Up 1/{BIG_SCROLL_AMOUNT} page:"); + let big_scroll_dn = format!("Down 1/{BIG_SCROLL_AMOUNT} page:"); let actions = vec![ (Some(UserAction::Left), "Left:"), (Some(UserAction::Right), "Right:"), @@ -207,15 +226,25 @@ impl<'a> PopupWin<'a> { let mut help_win = Panel::new( "Help".to_string(), 0, + self.colors.clone(), self.total_rows - 1, self.total_cols, 0, - 0, + (1, 1, 1, 1), ); + help_win.redraw(); let mut row = 0; - row = help_win.write_wrap_line(row + 1, "Available keybindings:"); - help_win.change_attr(row, 0, 22, pancurses::A_UNDERLINE, ColorType::Normal); + row = help_win.write_wrap_line( + row, + "Available keybindings:", + Some( + style::ContentStyle::new() + .with(self.colors.normal.0) + .on(self.colors.normal.1) + .attribute(style::Attribute::Underlined), + ), + ); row += 1; // check how long our strings are, and map to two columns @@ -227,12 +256,12 @@ impl<'a> PopupWin<'a> { .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 { + let n_cols = if help_win.get_cols() > (longest_line * 2 + col_spacing) as u16 { 2 } else { 1 }; - let keys_per_row = key_strs.len() as i32 / n_cols; + let keys_per_row = key_strs.len() as u16 / n_cols; // write each line of keys -- the list will be presented "down" // rather than "across", but we print to the screen a line at a @@ -250,14 +279,14 @@ impl<'a> PopupWin<'a> { } else { longest_line }; - line += &format!("{: PopupWin<'a> { let mut download_panel = Panel::new( "New episodes".to_string(), 0, + self.colors.clone(), self.total_rows - 1, self.total_cols, 0, - 0, + (1, 0, 0, 0), ); let header = format!( @@ -295,11 +325,12 @@ impl<'a> PopupWin<'a> { Some(header), LockVec::new(self.new_episodes.clone()), ); - download_win.init(); + download_win.redraw(); return download_win; } + /// Appends a new episode to the list of new episodes. pub fn _add_episodes(&mut self, mut episodes: Vec) { self.new_episodes.append(&mut episodes); } @@ -325,25 +356,22 @@ impl<'a> PopupWin<'a> { /// When there is a change to the active popup window, this should /// be called to check for other popup windows that are "in the /// queue" -- this lets one popup window appear over top of another - /// one, while keeping that second one in reserve. This function will - /// check for other popup windows to appear and change the active - /// window accordingly. + /// one, while keeping that second one in reserve. This function + /// will check for other popup windows to appear and change the + /// active window accordingly. fn change_win(&mut self) { - // The help window takes precedence over all other popup windows; - // the welcome window is lowest priority and only appears if all - // other windows are inactive + // The help window takes precedence over all other popup + // windows; the welcome window is lowest priority and only + // appears if all other windows are inactive if self.help_win && !self.popup.is_help_win() { let win = self.make_help_win(); - win.refresh(); self.popup = ActivePopup::HelpWin(win); } else if self.download_win && !self.popup.is_download_win() { let mut win = self.make_download_win(); - win.update_items(); - win.highlight_selected(true); + win.activate(); self.popup = ActivePopup::DownloadWin(win); } else if self.welcome_win && !self.popup.is_welcome_win() { let win = self.make_welcome_win(); - win.refresh(); self.popup = ActivePopup::WelcomeWin(win); } else if !self.help_win && !self.download_win && !self.welcome_win && !self.popup.is_none() { @@ -353,23 +381,23 @@ impl<'a> PopupWin<'a> { /// When a popup window is active, this handles the user's keyboard /// input that is relevant for that window. - pub fn handle_input(&mut self, input: Input) -> UiMsg { + pub fn handle_input(&mut self, input: KeyEvent) -> UiMsg { let mut msg = UiMsg::Noop; match self.popup { ActivePopup::HelpWin(ref mut _win) => { - match input { - Input::KeyExit - | Input::Character('\u{1b}') // Esc - | Input::Character('q') - | Input::Character('Q') => { + match input.code { + KeyCode::Esc + | KeyCode::Char('\u{1b}') // Esc + | KeyCode::Char('q') + | KeyCode::Char('Q') => { self.turn_off_help_win(); } _ => (), } } ActivePopup::DownloadWin(ref mut menu) => match self.keymap.get_from_input(input) { - Some(UserAction::Down) => menu.scroll(1), - Some(UserAction::Up) => menu.scroll(-1), + Some(UserAction::Down) => menu.scroll(Scroll::Down(1)), + Some(UserAction::Up) => menu.scroll(Scroll::Up(1)), Some(UserAction::MarkPlayed) => { menu.select_item(); @@ -421,9 +449,9 @@ impl<'a> PopupWin<'a> { let mut s = "".to_string(); for (i, key) in keys.iter().enumerate().take(max_keys) { if i == max_keys - 1 { - s = format!("{}, \"{}\"", s, key); + s = format!("{s}, \"{key}\""); } else { - s = format!("{}, or \"{}\"", s, key); + s = format!("{s}, or \"{key}\""); } } s