├── src ├── ui │ ├── mod.rs │ ├── states │ │ ├── mod.rs │ │ ├── themestate.rs │ │ ├── feedtreestate.rs │ │ └── feedentrystate.rs │ └── screens │ │ ├── mod.rs │ │ ├── urldialog.rs │ │ ├── helpdialog.rs │ │ ├── themedialog.rs │ │ ├── readerscreen.rs │ │ └── mainscreen.rs ├── core │ ├── ui │ │ ├── mod.rs │ │ ├── dialog.rs │ │ └── appscreen.rs │ ├── feed │ │ ├── mod.rs │ │ ├── feedentry.rs │ │ └── feedparser.rs │ ├── mod.rs │ ├── library │ │ ├── data │ │ │ ├── mod.rs │ │ │ ├── opml.rs │ │ │ ├── config.rs │ │ │ └── librarydata.rs │ │ ├── settings │ │ │ ├── mod.rs │ │ │ ├── theme.rs │ │ │ ├── usersettings.rs │ │ │ ├── appearance.rs │ │ │ └── themedata.rs │ │ ├── mod.rs │ │ ├── feedcategory.rs │ │ ├── feeditem.rs │ │ ├── updater.rs │ │ └── feedlibrary.rs │ └── defs.rs ├── mainui.rs ├── main.rs ├── logging.rs ├── cli.rs └── app.rs ├── img └── screenshot.gif ├── site ├── docs │ ├── stylesheets │ │ └── extra.css │ ├── docs │ │ └── _reference.md │ ├── _contributing.md │ ├── _index.md │ └── install.md ├── img │ └── screenshot.gif ├── mkdocs.yml └── make.sh ├── .gitignore ├── res └── themes │ ├── nord.toml │ ├── zenburn.toml │ ├── bulletty.toml │ ├── vice.toml │ ├── sagelight.toml │ ├── summercamp.toml │ ├── woodland.toml │ ├── onedark.toml │ ├── sakura.toml │ ├── twilight.toml │ ├── black-metal.toml │ ├── decaf.toml │ ├── onelight.toml │ ├── rosepine.toml │ ├── silk-dark.toml │ └── porple.toml ├── .github ├── dependabot.yml └── workflows │ ├── deploysite.yml │ ├── ci.yml │ └── nightly.yml ├── LICENSE ├── Cargo.toml ├── CONTRIBUTING.md └── README.md /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod screens; 2 | pub mod states; 3 | -------------------------------------------------------------------------------- /src/core/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod appscreen; 2 | pub mod dialog; 3 | -------------------------------------------------------------------------------- /src/core/feed/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod feedentry; 2 | pub mod feedparser; 3 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod defs; 2 | pub mod feed; 3 | pub mod library; 4 | pub mod ui; 5 | -------------------------------------------------------------------------------- /img/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/bulletty/main/img/screenshot.gif -------------------------------------------------------------------------------- /src/core/library/data/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod librarydata; 3 | pub mod opml; 4 | -------------------------------------------------------------------------------- /site/docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | [data-md-color-scheme="slate"] { 2 | --md-hue: 230; 3 | } 4 | -------------------------------------------------------------------------------- /site/img/screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/bulletty/main/site/img/screenshot.gif -------------------------------------------------------------------------------- /src/ui/states/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod feedentrystate; 2 | pub mod feedtreestate; 3 | pub mod themestate; 4 | -------------------------------------------------------------------------------- /src/core/library/settings/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod appearance; 2 | pub mod theme; 3 | pub mod themedata; 4 | pub mod usersettings; 5 | -------------------------------------------------------------------------------- /site/docs/docs/_reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Reference 3 | summary: The code reference for bulletty 4 | show_datetime: false 5 | --- 6 | -------------------------------------------------------------------------------- /site/docs/_contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | summary: How to contribute to bulletty 4 | show_datetime: false 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /src/ui/screens/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod helpdialog; 2 | pub mod mainscreen; 3 | pub mod readerscreen; 4 | pub mod themedialog; 5 | pub mod urldialog; 6 | -------------------------------------------------------------------------------- /src/core/library/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod data; 2 | pub mod feedcategory; 3 | pub mod feeditem; 4 | pub mod feedlibrary; 5 | pub mod settings; 6 | pub mod updater; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | site/site 3 | site/docs/img 4 | site/docs/index.md 5 | site/docs/docs/bulletty.md 6 | site/docs/docs/reference.md 7 | site/docs/contributing.md 8 | -------------------------------------------------------------------------------- /site/docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: bulletty 3 | summary: The TUI RSS/ATOM feed reader that lets you decide where to store your data. 4 | show_datetime: false 5 | sidebar_title: Home 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /src/core/library/feedcategory.rs: -------------------------------------------------------------------------------- 1 | use crate::core::library::feeditem::FeedItem; 2 | 3 | #[derive(Clone)] 4 | pub struct FeedCategory { 5 | pub title: String, 6 | pub feeds: Vec, 7 | } 8 | -------------------------------------------------------------------------------- /src/mainui.rs: -------------------------------------------------------------------------------- 1 | use tracing::info; 2 | 3 | use crate::app; 4 | 5 | pub fn run_main_ui() -> color_eyre::Result<()> { 6 | info!("Initializing UI"); 7 | 8 | let terminal = ratatui::init(); 9 | 10 | let mut app = app::App::new(); 11 | app.initmain(); 12 | 13 | let result = app.run(terminal); 14 | ratatui::restore(); 15 | result 16 | } 17 | -------------------------------------------------------------------------------- /src/core/defs.rs: -------------------------------------------------------------------------------- 1 | pub const CONFIG_PATH: &str = "bulletty"; 2 | pub const CONFIG_FILE: &str = "config.toml"; 3 | pub const DATA_DIR: &str = "bulletty"; 4 | pub const DATA_CATEGORIES_DIR: &str = "categories"; 5 | pub const DATA_CATEGORY_DEFAULT: &str = "General"; 6 | pub const DATA_FEED: &str = ".feed.toml"; 7 | pub const LOG_DIR: &str = "bulletty"; 8 | pub const DATA_READ_LATER: &str = ".later.toml"; 9 | -------------------------------------------------------------------------------- /src/core/ui/dialog.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::Rect; 2 | 3 | use super::appscreen::AppScreen; 4 | 5 | pub trait Dialog { 6 | /// Get the sizes of dialog 7 | /// - x, y: min size, if zero, uses the percentage 8 | /// - width, height: percentage size 9 | fn get_size(&self) -> Rect; 10 | 11 | fn as_screen(&self) -> &dyn AppScreen; 12 | fn as_screen_mut(&mut self) -> &mut dyn AppScreen; 13 | } 14 | -------------------------------------------------------------------------------- /res/themes/nord.toml: -------------------------------------------------------------------------------- 1 | scheme = "Nord" 2 | author = "arcticicestudio" 3 | base00 = "2E3440" 4 | base01 = "3B4252" 5 | base02 = "434C5E" 6 | base03 = "4C566A" 7 | base04 = "D8DEE9" 8 | base05 = "E5E9F0" 9 | base06 = "ECEFF4" 10 | base07 = "8FBCBB" 11 | base08 = "88C0D0" 12 | base09 = "81A1C1" 13 | base0A = "5E81AC" 14 | base0B = "BF616A" 15 | base0C = "D08770" 16 | base0D = "EBCB8B" 17 | base0E = "A3BE8C" 18 | base0F = "B48EAD" 19 | -------------------------------------------------------------------------------- /res/themes/zenburn.toml: -------------------------------------------------------------------------------- 1 | scheme = "Zenburn" 2 | author = "elnawe" 3 | base00 = "383838" 4 | base01 = "404040" 5 | base02 = "606060" 6 | base03 = "6f6f6f" 7 | base04 = "808080" 8 | base05 = "dcdccc" 9 | base06 = "c0c0c0" 10 | base07 = "ffffff" 11 | base08 = "dca3a3" 12 | base09 = "dfaf8f" 13 | base0A = "e0cf9f" 14 | base0B = "5f7f5f" 15 | base0C = "93e0e3" 16 | base0D = "7cb8bb" 17 | base0E = "dc8cc3" 18 | base0F = "000000" 19 | -------------------------------------------------------------------------------- /res/themes/bulletty.toml: -------------------------------------------------------------------------------- 1 | scheme = "bulletty" 2 | author = "Bruno Croci" 3 | base00 = "1c1c1c" 4 | base01 = "262626" 5 | base02 = "3a3a3a" 6 | base03 = "4d4d4d" 7 | base04 = "707070" 8 | base05 = "a0a0a0" 9 | base06 = "b9b9b9" 10 | base07 = "999999" 11 | base08 = "be5b5b" 12 | base09 = "86ad80" 13 | base0A = "5f9341" 14 | base0B = "479b5a" 15 | base0C = "3d997d" 16 | base0D = "4a5f74" 17 | base0E = "5980b6" 18 | base0F = "b16557" 19 | -------------------------------------------------------------------------------- /res/themes/vice.toml: -------------------------------------------------------------------------------- 1 | scheme = "Vice Dark" 2 | author = "Thomas Leon Highbaugh" 3 | base00 = "181818" 4 | base01 = "222222" 5 | base02 = "323232" 6 | base03 = "3f3f3f" 7 | base04 = "666666" 8 | base05 = "818181" 9 | base06 = "c6c6c6" 10 | base07 = "e9e9e9" 11 | base08 = "ff29a8" 12 | base09 = "85ffe0" 13 | base0A = "f0ffaa" 14 | base0B = "0badff" 15 | base0C = "8265ff" 16 | base0D = "00eaff" 17 | base0E = "00f6d9" 18 | base0F = "ff3d81" 19 | -------------------------------------------------------------------------------- /res/themes/sagelight.toml: -------------------------------------------------------------------------------- 1 | scheme = "Sagelight" 2 | author = "Carter Veldhuizen" 3 | base00 = "f8f8f8" 4 | base01 = "e8e8e8" 5 | base02 = "d8d8d8" 6 | base03 = "b8b8b8" 7 | base04 = "585858" 8 | base05 = "383838" 9 | base06 = "282828" 10 | base07 = "181818" 11 | base08 = "fa8480" 12 | base09 = "ffaa61" 13 | base0A = "ffdc61" 14 | base0B = "a0d2c8" 15 | base0C = "a2d6f5" 16 | base0D = "a0a7d2" 17 | base0E = "c8a0d2" 18 | base0F = "d2b2a0" 19 | -------------------------------------------------------------------------------- /res/themes/summercamp.toml: -------------------------------------------------------------------------------- 1 | scheme = "summercamp" 2 | author = "zoe firi (zoefiri.github.io)" 3 | base00 = "1c1810" 4 | base01 = "2a261c" 5 | base02 = "3a3527" 6 | base03 = "504b38" 7 | base04 = "5f5b45" 8 | base05 = "736e55" 9 | base06 = "bab696" 10 | base07 = "f8f5de" 11 | base08 = "e35142" 12 | base09 = "fba11b" 13 | base0A = "f2ff27" 14 | base0B = "5ceb5a" 15 | base0C = "5aebbc" 16 | base0D = "489bf0" 17 | base0E = "FF8080" 18 | base0F = "F69BE7" 19 | -------------------------------------------------------------------------------- /res/themes/woodland.toml: -------------------------------------------------------------------------------- 1 | scheme = "Woodland" 2 | author = "Jay Cornwall (https://jcornwall.com)" 3 | base00 = "231e18" 4 | base01 = "302b25" 5 | base02 = "48413a" 6 | base03 = "9d8b70" 7 | base04 = "b4a490" 8 | base05 = "cabcb1" 9 | base06 = "d7c8bc" 10 | base07 = "e4d4c8" 11 | base08 = "d35c5c" 12 | base09 = "ca7f32" 13 | base0A = "e0ac16" 14 | base0B = "b7ba53" 15 | base0C = "6eb958" 16 | base0D = "88a4d3" 17 | base0E = "bb90e2" 18 | base0F = "b49368" 19 | -------------------------------------------------------------------------------- /res/themes/onedark.toml: -------------------------------------------------------------------------------- 1 | scheme = "OneDark" 2 | author = "Lalit Magant (http://github.com/tilal6991)" 3 | base00 = "282c34" 4 | base01 = "353b45" 5 | base02 = "3e4451" 6 | base03 = "545862" 7 | base04 = "565c64" 8 | base05 = "abb2bf" 9 | base06 = "b6bdca" 10 | base07 = "c8ccd4" 11 | base08 = "e06c75" 12 | base09 = "d19a66" 13 | base0A = "e5c07b" 14 | base0B = "98c379" 15 | base0C = "56b6c2" 16 | base0D = "61afef" 17 | base0E = "c678dd" 18 | base0F = "be5046" 19 | -------------------------------------------------------------------------------- /res/themes/sakura.toml: -------------------------------------------------------------------------------- 1 | scheme = "Sakura" 2 | author = "Misterio77 (http://github.com/Misterio77)" 3 | base00 = "feedf3" 4 | base01 = "f8e2e7" 5 | base02 = "e0ccd1" 6 | base03 = "755f64" 7 | base04 = "665055" 8 | base05 = "564448" 9 | base06 = "42383a" 10 | base07 = "33292b" 11 | 12 | base08 = "df2d52" 13 | base09 = "f6661e" 14 | base0A = "c29461" 15 | base0B = "2e916d" 16 | base0C = "1d8991" 17 | base0D = "006e93" 18 | base0E = "5e2180" 19 | base0F = "ba0d35" 20 | -------------------------------------------------------------------------------- /res/themes/twilight.toml: -------------------------------------------------------------------------------- 1 | scheme = "Twilight" 2 | author = "David Hart (https://github.com/hartbit)" 3 | base00 = "1e1e1e" 4 | base01 = "323537" 5 | base02 = "464b50" 6 | base03 = "5f5a60" 7 | base04 = "838184" 8 | base05 = "a7a7a7" 9 | base06 = "c3c3c3" 10 | base07 = "ffffff" 11 | base08 = "cf6a4c" 12 | base09 = "cda869" 13 | base0A = "f9ee98" 14 | base0B = "8f9d6a" 15 | base0C = "afc4db" 16 | base0D = "7587a6" 17 | base0E = "9b859d" 18 | base0F = "9b703f" 19 | -------------------------------------------------------------------------------- /res/themes/black-metal.toml: -------------------------------------------------------------------------------- 1 | scheme = "Black Metal" 2 | author = "metalelf0 (https://github.com/metalelf0)" 3 | base00 = "000000" 4 | base01 = "121212" 5 | base02 = "222222" 6 | base03 = "333333" 7 | base04 = "999999" 8 | base05 = "c1c1c1" 9 | base06 = "999999" 10 | base07 = "c1c1c1" 11 | base08 = "5f8787" 12 | base09 = "aaaaaa" 13 | base0A = "a06666" 14 | base0B = "dd9999" 15 | base0C = "aaaaaa" 16 | base0D = "888888" 17 | base0E = "999999" 18 | base0F = "444444" 19 | -------------------------------------------------------------------------------- /res/themes/decaf.toml: -------------------------------------------------------------------------------- 1 | scheme = "Decaf" 2 | author = "Alex Mirrington (https://github.com/alexmirrington)" 3 | base00 = "2d2d2d" 4 | base01 = "393939" 5 | base02 = "515151" 6 | base03 = "777777" 7 | base04 = "b4b7b4" 8 | base05 = "cccccc" 9 | base06 = "e0e0e0" 10 | base07 = "ffffff" 11 | base08 = "ff7f7b" 12 | base09 = "ffbf70" 13 | base0A = "ffd67c" 14 | base0B = "beda78" 15 | base0C = "bed6ff" 16 | base0D = "90bee1" 17 | base0E = "efb3f7" 18 | base0F = "ff93b3" 19 | -------------------------------------------------------------------------------- /res/themes/onelight.toml: -------------------------------------------------------------------------------- 1 | scheme = "One Light" 2 | author = "Daniel Pfeifer (http://github.com/purpleKarrot)" 3 | base00 = "fafafa" 4 | base01 = "f0f0f1" 5 | base02 = "e5e5e6" 6 | base03 = "a0a1a7" 7 | base04 = "696c77" 8 | base05 = "383a42" 9 | base06 = "202227" 10 | base07 = "090a0b" 11 | base08 = "ca1243" 12 | base09 = "d75f00" 13 | base0A = "c18401" 14 | base0B = "50a14f" 15 | base0C = "0184bc" 16 | base0D = "4078f2" 17 | base0E = "a626a4" 18 | base0F = "986801" 19 | -------------------------------------------------------------------------------- /res/themes/rosepine.toml: -------------------------------------------------------------------------------- 1 | scheme = "Rosé Pine" 2 | author = "Emilia Dunfelt " 3 | slug = "rose-pine" 4 | base00 = "191724" 5 | base01 = "1f1d2e" 6 | base02 = "26233a" 7 | base03 = "6e6a86" 8 | base04 = "908caa" 9 | base05 = "e0def4" 10 | base06 = "e0def4" 11 | base07 = "524f67" 12 | base08 = "eb6f92" 13 | base09 = "f6c177" 14 | base0A = "ebbcba" 15 | base0B = "31748f" 16 | base0C = "9ccfd8" 17 | base0D = "c4a7e7" 18 | base0E = "f6c177" 19 | base0F = "524f67" 20 | -------------------------------------------------------------------------------- /res/themes/silk-dark.toml: -------------------------------------------------------------------------------- 1 | scheme = "Silk Dark" 2 | author = "Gabriel Fontes (https://github.com/Misterio77)" 3 | 4 | base00 = "0e3c46" 5 | base01 = "1D494E" 6 | base02 = "2A5054" 7 | base03 = "587073" 8 | base04 = "9DC8CD" 9 | base05 = "C7DBDD" 10 | base06 = "CBF2F7" 11 | base07 = "D2FAFF" 12 | 13 | base08 = "fb6953" 14 | base09 = "fcab74" 15 | base0A = "fce380" 16 | base0B = "73d8ad" 17 | base0C = "3fb2b9" 18 | base0D = "46bddd" 19 | base0E = "756b8a" 20 | base0F = "9b647b" 21 | -------------------------------------------------------------------------------- /src/core/library/feeditem.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Clone, Default, Debug, Serialize, Deserialize)] 5 | pub struct FeedItem { 6 | pub title: String, 7 | pub description: String, 8 | pub url: String, 9 | pub feed_url: String, 10 | pub author: String, 11 | pub slug: String, 12 | 13 | pub lastupdated: DateTime, 14 | 15 | #[serde(skip_serializing, skip_deserializing)] 16 | pub category: String, 17 | } 18 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod cli; 3 | pub mod core; 4 | pub mod logging; 5 | pub mod mainui; 6 | pub mod ui; 7 | 8 | use clap::Parser; 9 | 10 | pub fn run() -> color_eyre::Result<()> { 11 | let _guard = logging::init(); 12 | color_eyre::install()?; 13 | 14 | let cli = cli::Cli::parse(); 15 | 16 | if cli.command.is_none() { 17 | mainui::run_main_ui() 18 | } else { 19 | cli::run_main_cli(cli) 20 | } 21 | } 22 | 23 | fn main() -> color_eyre::Result<()> { 24 | run() 25 | } 26 | -------------------------------------------------------------------------------- /src/core/feed/feedentry.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use chrono::{DateTime, Utc}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Default, Debug, Serialize, Deserialize, Clone)] 7 | pub struct FeedEntry { 8 | pub title: String, 9 | pub description: String, 10 | pub date: DateTime, 11 | pub url: String, 12 | pub author: String, 13 | pub text: String, 14 | 15 | pub lastupdated: DateTime, 16 | pub seen: bool, 17 | 18 | #[serde(skip_serializing, skip_deserializing)] 19 | pub filepath: PathBuf, 20 | } 21 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use crate::core::defs; 2 | use std::path::Path; 3 | use tracing_appender::{non_blocking::WorkerGuard, rolling}; 4 | 5 | pub fn init() -> Option { 6 | if let Some(log_dir) = dirs::state_dir() { 7 | let log_dir = Path::new(&log_dir).join(defs::LOG_DIR); 8 | 9 | let file_appender = rolling::daily(&log_dir, "app.log"); 10 | let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender); 11 | 12 | tracing_subscriber::fmt() 13 | .with_writer(non_blocking_appender) 14 | .init(); 15 | 16 | Some(guard) 17 | } else { 18 | None 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for Cargo 9 | - package-ecosystem: "cargo" 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "weekly" 13 | # Maintain dependencies for GitHub Actions 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /src/core/library/settings/theme.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize, Default, Clone)] 4 | #[allow(non_snake_case)] 5 | pub struct Theme { 6 | pub scheme: String, 7 | pub author: String, 8 | 9 | pub base00: String, 10 | pub base01: String, 11 | pub base02: String, 12 | pub base03: String, 13 | pub base04: String, 14 | pub base05: String, 15 | pub base06: String, 16 | pub base07: String, 17 | pub base08: String, 18 | pub base09: String, 19 | pub base0A: String, 20 | pub base0B: String, 21 | pub base0C: String, 22 | pub base0D: String, 23 | pub base0E: String, 24 | pub base0F: String, 25 | 26 | #[serde(skip)] 27 | pub base: [u32; 16], 28 | } 29 | -------------------------------------------------------------------------------- /site/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: bulletty 2 | site_url: https://bulletty.croci.dev/ 3 | repo_url: https://github.com/CrociDB/bulletty 4 | repo_name: CrociDB/bulletty 5 | theme: 6 | name: material 7 | features: 8 | - navigation.tabs 9 | icon: 10 | logo: material/rss-box 11 | palette: 12 | scheme: slate 13 | primary: black 14 | accent: amber 15 | show_title: true 16 | show_stargazers: true 17 | font: 18 | text: Roboto 19 | 20 | extra_css: 21 | - stylesheets/extra.css 22 | 23 | markdown_extensions: 24 | - codehilite 25 | - admonition 26 | 27 | nav: 28 | - Getting Started: 29 | - bulletty: 'index.md' 30 | - Install: 'install.md' 31 | - Contributing: 'contributing.md' 32 | - Docs: 33 | - Reference: 'docs/reference.md' 34 | -------------------------------------------------------------------------------- /site/make.sh: -------------------------------------------------------------------------------- 1 | # Generating rustdoc using `rustdoc-md`. It uses a nightly version or rust 2 | RUSTC_BOOTSTRAP=1 RUSTDOCFLAGS="-Z unstable-options --output-format json" cargo doc --no-deps 3 | rustdoc-md --path ../target/doc/bulletty.json --output docs/docs/bulletty.md 4 | cat ./docs/docs/_reference.md > ./docs/docs/reference.md 5 | echo "" >> ./docs/docs/reference.md 6 | cat ./docs/docs/bulletty.md >> ./docs/docs/reference.md 7 | 8 | # Generate index page using README.md 9 | cat ./docs/_index.md > ./docs/index.md 10 | tail -n +3 ../README.md >> ./docs/index.md 11 | 12 | # Generate contributing page using CONTRIBUTING.md 13 | cat ./docs/_contributing.md > ./docs/contributing.md 14 | tail -n +3 ../CONTRIBUTING.md >> ./docs/contributing.md 15 | 16 | cp -R ../img ./docs/img 17 | uv venv --clear 18 | uv tool install mkdocs 19 | uv pip install mkdocs-material 20 | uv run mkdocs build 21 | -------------------------------------------------------------------------------- /src/core/ui/appscreen.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::KeyEvent; 3 | use ratatui::{Frame, layout::Rect}; 4 | 5 | use crate::app::AppWorkStatus; 6 | 7 | use super::dialog::Dialog; 8 | 9 | pub enum AppScreenEvent { 10 | None, 11 | 12 | ChangeState(Box), 13 | ExitState, 14 | 15 | OpenDialog(Box), 16 | CloseDialog, 17 | 18 | ExitApp, 19 | } 20 | 21 | pub trait AppScreen { 22 | fn start(&mut self); 23 | fn quit(&mut self); 24 | 25 | fn pause(&mut self); 26 | fn unpause(&mut self); 27 | 28 | fn render(&mut self, frame: &mut Frame, area: Rect); 29 | fn handle_events(&mut self) -> Result; 30 | fn handle_keypress(&mut self, key: KeyEvent) -> Result; 31 | 32 | fn get_work_status(&self) -> AppWorkStatus; 33 | fn get_title(&self) -> String; 34 | fn get_instructions(&self) -> String; 35 | fn get_full_instructions(&self) -> String; 36 | } 37 | -------------------------------------------------------------------------------- /src/core/library/settings/usersettings.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, path::Path}; 2 | 3 | use crate::core::library::settings::{appearance::Appearance, theme::Theme, themedata}; 4 | 5 | pub struct UserSettings { 6 | pub appearance: Appearance, 7 | themes: HashMap, 8 | } 9 | 10 | impl UserSettings { 11 | pub fn new(datapath: &Path) -> color_eyre::Result { 12 | Ok(Self { 13 | appearance: Appearance::new(datapath)?, 14 | themes: themedata::get_themes(), 15 | }) 16 | } 17 | 18 | pub fn get_theme(&self) -> Option<&Theme> { 19 | if let Some(theme) = self.themes.get(&self.appearance.theme) { 20 | return Some(theme); 21 | } 22 | 23 | if let Some((_, value)) = self.themes.iter().next() { 24 | Some(value) 25 | } else { 26 | None 27 | } 28 | } 29 | 30 | pub fn get_theme_list(&self) -> Vec { 31 | self.themes.keys().map(|t| t.to_string()).collect() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Bruno Croci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /res/themes/porple.toml: -------------------------------------------------------------------------------- 1 | scheme = "Porple" 2 | author = "Niek den Breeje (https://github.com/AuditeMarlow)" 3 | base00 = "292c36" # ---- 4 | base01 = "333344" # --- 5 | base02 = "474160" # -- 6 | base03 = "65568a" # - 7 | base04 = "b8b8b8" # + 8 | base05 = "d8d8d8" # ++ 9 | base06 = "e8e8e8" # +++ 10 | base07 = "f8f8f8" # ++++ 11 | base08 = "f84547" # red 12 | base09 = "d28e5d" # orange 13 | base0A = "efa16b" # yellow 14 | base0B = "95c76f" # green 15 | base0C = "64878f" # cyan 16 | base0D = "8485ce" # blue 17 | base0E = "b74989" # purple 18 | base0F = "986841" # brown 19 | -------------------------------------------------------------------------------- /site/docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Install 3 | summary: Install bulletty 4 | show_datetime: false 5 | --- 6 | 7 | # Install 8 | 9 | ## 🟩 Stable Version 10 | 11 | ### Prebuilt binary 12 | 13 | [Download the latest version](https://github.com/CrociDB/bulletty/releases/latest) of **bulletty** through GitHub. 14 | 15 | **bulletty** runs natively on all the major three platforms: **Linux**, **MacOS** and **Windows**. One thing to be aware of though is that it does make use of some symbols found in [NerdFonts](https://www.nerdfonts.com/), so it's highly recommended to have it setup in your terminal emulator. 16 | 17 | ### Through _Cargo_ 18 | 19 | Considering you have `cargo 1.90+` installed in your system: 20 | 21 | ```shell 22 | cargo install bulletty 23 | ``` 24 | [bulletty on crates.io](https://crates.io/crates/bulletty) 25 | 26 | ## 🌃 Nightly Builds 27 | 28 | [Download a nightly build](https://github.com/CrociDB/bulletty/releases) 29 | 30 | A nightly build can be more unstable, but it's very appreciated if you want to test new features. 31 | 32 | Another option is installing a nightly version from `cargo`: 33 | 34 | ```shell 35 | cargo install --git https://github.com/CrociDB/bulletty.git 36 | ``` 37 | 38 | ## 👩‍💻 Getting the source and building it 39 | 40 | ```shell 41 | git clone https://github.com/CrociDB/bulletty.git 42 | cd bulletty 43 | cargo build --release 44 | ``` 45 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bulletty" 3 | version = "0.1.9" 4 | description = "a pretty TUI feed reader (RSS+ATOM) that stores articles locally as Markdown files" 5 | authors = [ "Bruno Croci " ] 6 | license = "MIT" 7 | edition = "2024" 8 | rust-version = "1.90" 9 | repository = "https://github.com/CrociDB/bulletty" 10 | readme = "README.md" 11 | homepage = "https://github.com/CrociDB/bulletty" 12 | documentation = "https://docs.rs/bulletty" 13 | 14 | [dependencies] 15 | crossterm = "0.29.0" 16 | ratatui = "0.29.0" 17 | color-eyre = "0.6.5" 18 | dirs = "6" 19 | toml = "0.9.8" 20 | serde = { version = "1.0.228", features = [ "derive" ] } 21 | reqwest = { version = "0.12.24", features = [ "blocking" ] } 22 | clap = { version = "4.5.50", features = [ "derive" ] } 23 | roxmltree = "0.21.1" 24 | openssl = { version = "0.10", features = [ "vendored" ] } 25 | slug = "0.1" 26 | html2md = { git = "https://gitlab.com/CrociDB/html2md.git", branch = "master" } 27 | regex = "1.12.2" 28 | chrono = { version = "0.4", features = [ "serde" ] } 29 | tui-markdown = "0.3.5" 30 | tracing = "0.1" 31 | tracing-subscriber = { version = "0.3", features = [ "env-filter" ] } 32 | tracing-appender = "0.2" 33 | tracing-error = "0.2" 34 | unicode-width = "0.2.0" 35 | open = "5.3.2" 36 | fuzzt = { version = "0.3.1", default-features = false, features = ["levenshtein"] } 37 | 38 | [build-dependencies] 39 | serde = { version = "1.0", features = ["derive"] } 40 | toml = "0.9.8" 41 | 42 | [dev-dependencies] 43 | tempfile = "3.23.0" 44 | -------------------------------------------------------------------------------- /src/core/library/settings/appearance.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{ 3 | fs, 4 | path::{Path, PathBuf}, 5 | }; 6 | use tracing::error; 7 | 8 | const APPEARANCE_PATH: &str = ".appearance.toml"; 9 | 10 | #[derive(Serialize, Deserialize, Debug)] 11 | pub struct Appearance { 12 | #[serde(default = "default_tree_width")] 13 | pub main_screen_tree_width: u16, 14 | #[serde(default = "default_reader_width")] 15 | pub reader_width: u16, 16 | #[serde(default = "default_theme")] 17 | pub theme: String, 18 | 19 | #[serde(skip)] 20 | path: PathBuf, 21 | } 22 | 23 | // Defaults 24 | fn default_tree_width() -> u16 { 25 | 30 26 | } 27 | 28 | fn default_reader_width() -> u16 { 29 | 60 30 | } 31 | 32 | fn default_theme() -> String { 33 | "bulletty".to_string() 34 | } 35 | 36 | impl Appearance { 37 | pub fn new(datapath: &Path) -> color_eyre::Result { 38 | let path = datapath.join(APPEARANCE_PATH); 39 | 40 | if !path.exists() { 41 | let mut appearance: Self = toml::from_str("")?; 42 | appearance.path = path.clone(); 43 | return Ok(appearance); 44 | } 45 | 46 | let data = fs::read_to_string(&path)?; 47 | let mut appearance: Appearance = match toml::from_str(&data) { 48 | Ok(a) => a, 49 | Err(e) => { 50 | error!("Error parsing {path:?}: {e:?}"); 51 | toml::from_str("")? 52 | } 53 | }; 54 | 55 | appearance.path = path.clone(); 56 | Ok(appearance) 57 | } 58 | 59 | pub fn save(&mut self) -> color_eyre::Result<()> { 60 | let toml_string = toml::to_string_pretty(self)?; 61 | fs::write(&self.path, toml_string)?; 62 | Ok(()) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/deploysite.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Site 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on push events but only for the "main" branch 6 | push: 7 | branches: [ "main" ] 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions for the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and the latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build and deploy job 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v5 33 | 34 | - name: Set up Python 35 | uses: actions/setup-python@v6 36 | with: 37 | python-version: '3.9' 38 | 39 | - name: Install Rust toolchain 40 | uses: actions-rs/toolchain@v1 41 | with: 42 | toolchain: stable 43 | override: true 44 | 45 | - name: Install dependencies and build website 46 | run: | 47 | curl -LsSf https://astral.sh/uv/install.sh | sh 48 | cargo install rustdoc-md 49 | cd site/ 50 | ./make.sh 51 | 52 | - name: Setup Pages 53 | uses: actions/configure-pages@v5 54 | 55 | - name: Upload artifact 56 | uses: actions/upload-pages-artifact@v4 57 | with: 58 | path: './site/site' 59 | 60 | - name: Deploy to GitHub Pages 61 | id: deployment 62 | uses: actions/deploy-pages@v4 63 | -------------------------------------------------------------------------------- /src/core/library/updater.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{ 3 | Arc, Mutex, 4 | atomic::{AtomicBool, AtomicU16, Ordering::Relaxed}, 5 | }, 6 | thread::{self, JoinHandle}, 7 | }; 8 | 9 | use tracing::{error, info}; 10 | 11 | use crate::core::library::{feedcategory::FeedCategory, feedlibrary::FeedLibrary}; 12 | 13 | pub struct Updater { 14 | pub last_completed: Arc>, 15 | pub total_completed: Arc, 16 | pub finished: Arc, 17 | 18 | _thread: Option>, 19 | } 20 | 21 | impl Updater { 22 | pub fn new(feedcategories: Vec) -> Self { 23 | let completed = Arc::new(Mutex::new(String::from("Working..."))); 24 | let finished = Arc::new(AtomicBool::new(false)); 25 | let total_completed = Arc::new(AtomicU16::new(0)); 26 | 27 | let completed_clone = Arc::clone(&completed); 28 | let finished_clone = Arc::clone(&finished); 29 | let total_completed_clone = Arc::clone(&total_completed); 30 | 31 | let handle = Some(thread::spawn(move || { 32 | info!("Starting updater"); 33 | let library = FeedLibrary::new(); 34 | 35 | for category in feedcategories.iter() { 36 | for feed in category.feeds.iter() { 37 | if let Err(e) = library 38 | .data 39 | .update_feed_entries(&category.title, feed, None) 40 | { 41 | error!("Something happened when updating {}: {:?}", &feed.title, e); 42 | break; 43 | } 44 | 45 | info!("Updated {}", &feed.title); 46 | 47 | total_completed_clone.fetch_add(1, Relaxed); 48 | *completed_clone.lock().unwrap() = feed.title.clone(); 49 | } 50 | } 51 | 52 | finished_clone.store(true, Relaxed); 53 | })); 54 | 55 | Self { 56 | last_completed: completed, 57 | total_completed, 58 | _thread: handle, 59 | finished, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/states/themestate.rs: -------------------------------------------------------------------------------- 1 | use ratatui::widgets::{ListItem, ListState}; 2 | 3 | use crate::core::library::feedlibrary::FeedLibrary; 4 | 5 | #[derive(Default)] 6 | pub struct ThemeState { 7 | pub themes: Vec, 8 | pub state: ListState, 9 | } 10 | 11 | impl ThemeState { 12 | pub fn update(&mut self, library: &FeedLibrary) { 13 | self.themes = library.settings.get_theme_list(); 14 | self.themes.sort_by_key(|a| a.to_lowercase()); 15 | 16 | let selected = &library.settings.appearance.theme; 17 | if let Some(id) = self 18 | .themes 19 | .iter() 20 | .position(|theme| theme.as_str() == selected) 21 | { 22 | self.state.select(Some(id)); 23 | } else { 24 | self.state.select(Some(0)) 25 | } 26 | } 27 | 28 | pub fn get_items(&self) -> Vec> { 29 | self.themes 30 | .iter() 31 | .map(|t| ListItem::new(t.to_string())) 32 | .collect() 33 | } 34 | 35 | pub fn get_selected(&self) -> Option { 36 | if !self.themes.is_empty() { 37 | Some(self.themes[self.state.selected().unwrap_or(0)].to_string()) 38 | } else { 39 | None 40 | } 41 | } 42 | 43 | pub fn select_next(&mut self) { 44 | if self.themes.is_empty() { 45 | return; 46 | } 47 | 48 | let selected = self.state.selected().unwrap_or(0); 49 | if selected < self.themes.len().saturating_sub(1) { 50 | self.state.select_next(); 51 | } 52 | } 53 | 54 | pub fn select_previous(&mut self) { 55 | if self.themes.is_empty() { 56 | return; 57 | } 58 | 59 | let selected = self.state.selected().unwrap_or(0); 60 | if selected >= self.themes.len() { 61 | self.state.select(Some(self.themes.len().saturating_sub(1))); 62 | } 63 | 64 | let selected = self.state.selected().unwrap_or(0); 65 | 66 | if selected > 0 { 67 | self.state.select_previous(); 68 | } 69 | } 70 | 71 | pub fn select_first(&mut self) { 72 | if self.themes.is_empty() { 73 | return; 74 | } 75 | 76 | self.state.select_first(); 77 | } 78 | 79 | pub fn select_last(&mut self) { 80 | if self.themes.is_empty() { 81 | return; 82 | } 83 | 84 | self.state.select(Some(self.themes.len().saturating_sub(1))); 85 | } 86 | 87 | pub fn scroll_max(&self) -> usize { 88 | self.themes.len().saturating_sub(1) 89 | } 90 | 91 | pub fn scroll(&self) -> usize { 92 | self.state.selected().unwrap_or(0) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/core/library/data/opml.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{Result, eyre}; 2 | use roxmltree::Node; 3 | 4 | use crate::core::library::feedcategory::FeedCategory; 5 | 6 | pub struct OpmlFeed { 7 | pub url: String, 8 | pub category: Option, 9 | } 10 | 11 | pub fn get_opml_feeds(filename: &str) -> Result> { 12 | let doc = std::fs::read_to_string(filename)?; 13 | let doc = roxmltree::Document::parse(&doc)?; 14 | 15 | let body = doc.descendants().find(|n| n.has_tag_name("body")); 16 | if body.is_none() { 17 | return Err(eyre::eyre!("No body found in {:?}", filename)); 18 | } 19 | 20 | let outlines: Vec = body 21 | .unwrap() 22 | .children() 23 | .filter(|n| n.is_element() && n.has_tag_name("outline")) 24 | .collect(); 25 | 26 | let mut opml_feeds = Vec::::new(); 27 | 28 | for o in outlines.iter() { 29 | if o.has_attribute("xmlUrl") { 30 | if let Ok(feed) = get_opml_feed(o, None) { 31 | opml_feeds.push(feed); 32 | } 33 | } else { 34 | let title = o.attribute("title").unwrap_or(""); 35 | let feeds: Vec = o 36 | .children() 37 | .map(|c| get_opml_feed(&c, Some(title.to_string()))) 38 | .filter_map(Result::ok) 39 | .collect(); 40 | 41 | opml_feeds.extend(feeds); 42 | } 43 | } 44 | 45 | Ok(opml_feeds) 46 | } 47 | 48 | fn get_opml_feed(node: &Node, category: Option) -> Result { 49 | if let Some(xml_url) = node.attribute("xmlUrl") { 50 | Ok(OpmlFeed { 51 | url: xml_url.to_string(), 52 | category, 53 | }) 54 | } else { 55 | Err(eyre::eyre!("No xml attribute found in element")) 56 | } 57 | } 58 | 59 | pub fn save_opml(categories: &[FeedCategory], filename: &str) -> Result<()> { 60 | let mut text_categories = String::new(); 61 | for category in categories.iter() { 62 | let mut text_feeds = String::new(); 63 | for feed in category.feeds.iter() { 64 | text_feeds.push_str(&format!("\n ", feed.title, feed.title, feed.description, feed.feed_url)); 65 | } 66 | 67 | text_categories.push_str(&format!( 68 | "\n {}\n ", 69 | category.title, category.title, text_feeds 70 | )); 71 | } 72 | 73 | let opml = format!( 74 | r#" 75 | 76 | 77 | Generated from bulletty 78 | https://github.com/CrociDB/bulletty 79 | 80 | {text_categories} 81 | 82 | 83 | "# 84 | ); 85 | 86 | std::fs::write(filename, opml)?; 87 | 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /src/core/library/data/config.rs: -------------------------------------------------------------------------------- 1 | use dirs; 2 | use serde::{Deserialize, Serialize}; 3 | use std::fs; 4 | use std::fs::OpenOptions; 5 | use std::io::Write; 6 | use std::path::{Path, PathBuf}; 7 | use toml; 8 | use tracing::error; 9 | 10 | use crate::core::defs; 11 | 12 | #[derive(Serialize, Deserialize)] 13 | pub struct Config { 14 | pub datapath: PathBuf, 15 | } 16 | 17 | impl Default for Config { 18 | fn default() -> Self { 19 | Self::new() 20 | } 21 | } 22 | 23 | impl Config { 24 | pub fn new() -> Self { 25 | if let Some(config_dir) = dirs::config_dir() { 26 | let config_file = Path::new(&config_dir) 27 | .join(defs::CONFIG_PATH) 28 | .join(defs::CONFIG_FILE); 29 | 30 | if !config_file.exists() { 31 | let config_path = Path::new(&config_dir).join(defs::CONFIG_PATH); 32 | if !config_path.exists() 33 | && let Err(e) = fs::create_dir_all(config_path) 34 | { 35 | error!("Failed to create directory: {}", e); 36 | std::process::exit(1); 37 | } 38 | 39 | match OpenOptions::new() 40 | .write(true) 41 | .create_new(true) 42 | .open(&config_file) 43 | { 44 | Ok(mut file) => { 45 | if let Some(data_dir) = dirs::data_dir() { 46 | let config = Config { 47 | datapath: data_dir.join(defs::DATA_DIR), 48 | }; 49 | 50 | if let Err(e) = 51 | file.write_all(&toml::to_string(&config).unwrap().into_bytes()) 52 | { 53 | error!("Failed to write config: {}", e); 54 | std::process::exit(1); 55 | } 56 | 57 | config 58 | } else { 59 | error!("Error: data dir not found"); 60 | std::process::exit(1); 61 | } 62 | } 63 | Err(e) => { 64 | error!("Failed to create new config file: {}", e); 65 | std::process::exit(1); 66 | } 67 | } 68 | } else if let Ok(configstr) = std::fs::read_to_string(&config_file) { 69 | match toml::from_str(&configstr) { 70 | Ok(config) => config, 71 | Err(e) => { 72 | error!("Config file can't be parsed: {}", e); 73 | std::process::exit(1); 74 | } 75 | } 76 | } else { 77 | error!("Can't read config file"); 78 | std::process::exit(1); 79 | } 80 | } else { 81 | error!("No config dir"); 82 | std::process::exit(1); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ui/screens/urldialog.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 3 | use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; 4 | use ratatui::style::{Color, Style}; 5 | use ratatui::widgets::{Paragraph, Wrap}; 6 | 7 | use crate::app::AppWorkStatus; 8 | 9 | use crate::core::ui::appscreen::{AppScreen, AppScreenEvent}; 10 | use crate::core::ui::dialog::Dialog; 11 | 12 | pub struct UrlDialog { 13 | url: String, 14 | } 15 | 16 | impl UrlDialog { 17 | pub fn new(url: String) -> Self { 18 | Self { url } 19 | } 20 | } 21 | 22 | impl Dialog for UrlDialog { 23 | fn get_size(&self) -> ratatui::prelude::Rect { 24 | Rect::new((self.url.len() as u16) + 10, 7, 0, 0) 25 | } 26 | 27 | fn as_screen(&self) -> &dyn AppScreen { 28 | self 29 | } 30 | 31 | fn as_screen_mut(&mut self) -> &mut dyn AppScreen { 32 | self 33 | } 34 | } 35 | 36 | impl AppScreen for UrlDialog { 37 | fn start(&mut self) {} 38 | 39 | fn quit(&mut self) {} 40 | 41 | fn pause(&mut self) {} 42 | 43 | fn unpause(&mut self) {} 44 | 45 | fn render(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) { 46 | let contentlayout = Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]) 47 | .split(area.inner(Margin::new(2, 1))); 48 | 49 | let title = Paragraph::new(self.get_title()) 50 | .style(Style::new().fg(Color::LightRed)) 51 | .alignment(Alignment::Center) 52 | .wrap(Wrap { trim: true }); 53 | 54 | let content = Paragraph::new(self.url.to_string()) 55 | .alignment(Alignment::Center) 56 | .wrap(Wrap { trim: true }); 57 | 58 | frame.render_widget(title, contentlayout[0]); 59 | frame.render_widget(content, contentlayout[1]); 60 | } 61 | 62 | fn handle_events(&mut self) -> Result { 63 | match event::read()? { 64 | Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key), 65 | Event::Mouse(_) => Ok(AppScreenEvent::None), 66 | Event::Resize(_, _) => Ok(AppScreenEvent::None), 67 | _ => Ok(AppScreenEvent::None), 68 | } 69 | } 70 | 71 | fn handle_keypress(&mut self, key: KeyEvent) -> Result { 72 | match (key.modifiers, key.code) { 73 | (_, KeyCode::Esc | KeyCode::Char('q')) 74 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 75 | Ok(AppScreenEvent::CloseDialog) 76 | } 77 | _ => Ok(AppScreenEvent::None), 78 | } 79 | } 80 | 81 | fn get_work_status(&self) -> AppWorkStatus { 82 | AppWorkStatus::None 83 | } 84 | 85 | fn get_title(&self) -> String { 86 | String::from("Couldn't open browser automatically") 87 | } 88 | 89 | fn get_instructions(&self) -> String { 90 | String::from("Esc/q: close url") 91 | } 92 | 93 | fn get_full_instructions(&self) -> String { 94 | self.get_instructions() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | - develop 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | # ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel 15 | # and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | fmt: 22 | name: fmt 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v5 27 | - name: Install Rust stable 28 | uses: dtolnay/rust-toolchain@stable 29 | with: 30 | components: rustfmt 31 | - name: check formatting 32 | run: cargo fmt -- --check 33 | - name: Cache Cargo dependencies 34 | uses: Swatinem/rust-cache@v2 35 | clippy: 36 | name: clippy 37 | runs-on: ubuntu-latest 38 | permissions: 39 | checks: write 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v5 43 | - name: Install Rust stable 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | components: clippy 47 | - name: Run clippy action 48 | uses: clechasseur/rs-clippy-check@v5 49 | - name: Cache Cargo dependencies 50 | uses: Swatinem/rust-cache@v2 51 | doc: 52 | # run docs generation on nightly rather than stable. This enables features like 53 | # https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an 54 | # API be documented as only available in some specific platforms. 55 | name: doc 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v5 59 | - name: Install Rust nightly 60 | uses: dtolnay/rust-toolchain@nightly 61 | - name: Run cargo doc 62 | run: cargo doc --no-deps --all-features 63 | env: 64 | RUSTDOCFLAGS: --cfg docsrs 65 | test: 66 | runs-on: ${{ matrix.os }} 67 | name: test ${{ matrix.os }} 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | os: [macos-latest, windows-latest] 72 | steps: 73 | # if your project needs OpenSSL, uncomment this to fix Windows builds. 74 | # it's commented out by default as the install command takes 5-10m. 75 | # - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append 76 | # if: runner.os == 'Windows' 77 | # - run: vcpkg install openssl:x64-windows-static-md 78 | # if: runner.os == 'Windows' 79 | - uses: actions/checkout@v5 80 | - name: Install Rust 81 | uses: dtolnay/rust-toolchain@stable 82 | # enable this ci template to run regardless of whether the lockfile is checked in or not 83 | - name: cargo generate-lockfile 84 | if: hashFiles('Cargo.lock') == '' 85 | run: cargo generate-lockfile 86 | - name: cargo test --locked 87 | run: cargo test --locked --all-features --all-targets 88 | - name: Cache Cargo dependencies 89 | uses: Swatinem/rust-cache@v2 90 | -------------------------------------------------------------------------------- /src/ui/screens/helpdialog.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 3 | use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; 4 | use ratatui::style::{Color, Style}; 5 | use ratatui::widgets::{Paragraph, Wrap}; 6 | 7 | use crate::app::AppWorkStatus; 8 | 9 | use crate::core::ui::appscreen::{AppScreen, AppScreenEvent}; 10 | use crate::core::ui::dialog::Dialog; 11 | 12 | pub struct HelpDialog { 13 | help_string: String, 14 | } 15 | 16 | impl HelpDialog { 17 | pub fn new(help_string: String) -> HelpDialog { 18 | HelpDialog { help_string } 19 | } 20 | } 21 | 22 | impl Dialog for HelpDialog { 23 | fn get_size(&self) -> ratatui::prelude::Rect { 24 | let line_breaks = self.help_string.matches('\n').count() as u16; 25 | Rect::new(60, line_breaks + 7, 0, 0) 26 | } 27 | 28 | fn as_screen(&self) -> &dyn AppScreen { 29 | self 30 | } 31 | 32 | fn as_screen_mut(&mut self) -> &mut dyn AppScreen { 33 | self 34 | } 35 | } 36 | 37 | impl AppScreen for HelpDialog { 38 | fn start(&mut self) {} 39 | 40 | fn quit(&mut self) {} 41 | 42 | fn pause(&mut self) {} 43 | 44 | fn unpause(&mut self) {} 45 | 46 | fn render(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) { 47 | let contentlayout = Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]) 48 | .split(area.inner(Margin::new(2, 1))); 49 | 50 | let title = Paragraph::new(self.get_title()) 51 | .style(Style::new().fg(Color::from_u32(0xc64b3a))) 52 | .alignment(Alignment::Center) 53 | .wrap(Wrap { trim: true }); 54 | 55 | let content = Paragraph::new(self.help_string.to_string()) 56 | .alignment(Alignment::Center) 57 | .wrap(Wrap { trim: true }); 58 | 59 | frame.render_widget(title, contentlayout[0]); 60 | frame.render_widget(content, contentlayout[1]); 61 | } 62 | 63 | fn handle_events(&mut self) -> Result { 64 | match event::read()? { 65 | Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key), 66 | Event::Mouse(_) => Ok(AppScreenEvent::None), 67 | Event::Resize(_, _) => Ok(AppScreenEvent::None), 68 | _ => Ok(AppScreenEvent::None), 69 | } 70 | } 71 | 72 | fn handle_keypress(&mut self, key: KeyEvent) -> Result { 73 | match (key.modifiers, key.code) { 74 | (_, KeyCode::Esc | KeyCode::Char('q')) 75 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 76 | Ok(AppScreenEvent::CloseDialog) 77 | } 78 | _ => Ok(AppScreenEvent::None), 79 | } 80 | } 81 | 82 | fn get_work_status(&self) -> AppWorkStatus { 83 | AppWorkStatus::None 84 | } 85 | 86 | fn get_title(&self) -> String { 87 | String::from("Help") 88 | } 89 | 90 | fn get_instructions(&self) -> String { 91 | String::from("Esc/q: close help") 92 | } 93 | 94 | fn get_full_instructions(&self) -> String { 95 | self.get_instructions() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly Build 2 | on: 3 | schedule: 4 | - cron: '0 1 * * *' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: Build on ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | include: 14 | - os: ubuntu-latest 15 | target: x86_64-unknown-linux-gnu 16 | artifact_name: bulletty-nightly-linux-x86_64 17 | archive_format: tar.gz 18 | - os: ubuntu-latest 19 | target: aarch64-unknown-linux-gnu 20 | artifact_name: bulletty-nightly-linux-aarch64 21 | archive_format: tar.gz 22 | - os: windows-latest 23 | target: x86_64-pc-windows-msvc 24 | artifact_name: bulletty-nightly-windows-x86_64 25 | archive_format: zip 26 | - os: macos-latest 27 | target: x86_64-apple-darwin 28 | artifact_name: bulletty-nightly-macos-x86_64 29 | archive_format: tar.gz 30 | - os: macos-14 31 | target: aarch64-apple-darwin 32 | artifact_name: bulletty-nightly-macos-aarch64 33 | archive_format: tar.gz 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v5 37 | 38 | - name: Install Rust toolchain 39 | uses: dtolnay/rust-toolchain@stable 40 | with: 41 | target: ${{ matrix.target }} 42 | 43 | - name: Install cross (for arm64 builds) 44 | if: matrix.target == 'aarch64-unknown-linux-gnu' 45 | run: cargo install cross 46 | 47 | - name: Build (arm64 build) 48 | if: matrix.target == 'aarch64-unknown-linux-gnu' 49 | run: cross build --release --target ${{ matrix.target }} 50 | 51 | - name: Build 52 | if: matrix.target != 'aarch64-unknown-linux-gnu' 53 | run: cargo build --release --target ${{ matrix.target }} 54 | 55 | - name: Archive binaries (Windows) 56 | if: matrix.os == 'windows-latest' 57 | run: | 58 | cd target/${{ matrix.target }}/release 59 | 7z a ../../../${{ matrix.artifact_name }}.zip bulletty.exe 60 | 61 | - name: Archive binaries (Unix) 62 | if: matrix.os != 'windows-latest' 63 | run: tar -czvf ${{ matrix.artifact_name }}.tar.gz -C target/${{ matrix.target }}/release bulletty 64 | 65 | - name: Upload artifact 66 | uses: actions/upload-artifact@v5 67 | with: 68 | name: ${{ matrix.artifact_name }} 69 | path: ${{ matrix.artifact_name }}.* 70 | 71 | release: 72 | name: Create Nightly Release 73 | needs: build 74 | runs-on: ubuntu-latest 75 | permissions: 76 | contents: write 77 | steps: 78 | - name: Download all build artifacts 79 | uses: actions/download-artifact@v6 80 | with: 81 | path: ./artifacts 82 | merge-multiple: true 83 | 84 | - name: Create Nightly Release 85 | id: create_release 86 | uses: softprops/action-gh-release@v2 87 | with: 88 | tag_name: nightly 89 | name: Nightly Build ${{ github.run_number }} 90 | prerelease: true 91 | draft: false 92 | files: | 93 | ./artifacts/* 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to bulletty 2 | 3 | I'm excited you're interested in contributing to **bulletty**, a TUI (Terminal User Interface) RSS/ATOM feed reader that puts data ownership back in the user's hands. As this project is under active development, your contributions are incredibly valuable. I hope you're having a good time using the tool, in the first place, and want to contribute because you believe in the project or because you think there's something that's very necessary to improve your and other people's experience. I assume you are familiar with the idea of this project and with its functionalities. 4 | 5 | ## Reporting Bugs 6 | 7 | If you find a bug, please check the Issues to see if it has already been reported. If not, open a new issue with a clear title and description. Include as much detail as possible to help us reproduce the issue: 8 | 9 | - A clear and concise description of the bug. 10 | - Steps to reproduce the behavior. 11 | - Expected behavior. 12 | - Your operating system and terminal emulator. 13 | - Screenshots or a video if applicable. 14 | 15 | ## Suggesting Enhancements 16 | 17 | All ideas for new features or improvements are welcome. If you have a suggestion, please create a new topic on the [discussions page](https://github.com/CrociDB/bulletty/discussions). Describe your idea and why you think it would be a good addition to the project. 18 | 19 | ## Coding 20 | 21 | So you want to contribute with code. That's no doubt the best way to have an influence on **bulletty**. Ideally, you would work on a previously reported issue, either by yourself or someone else. 22 | 23 | ### Working on Issues 24 | 25 | First requirement: use the program. I've seen people wanting to contribute without using it. 26 | 27 | Issues will only be assigned to users when enough discussion about their implementation has taken place. It's important that nobody keeps an issue assigned without making progress, as this prevents others from contributing. So, if you want to write code for an existing issue, start by discussing the issue and your proposed solution first. 28 | 29 | I do think it's fine if you submit a PR for a bugfix you made without prior discussion, as long as you take the time to explain the **why** and the *how*. In that case, the issue won't be assigned to you until the merge is complete. 30 | 31 | ### Generative AI use 32 | 33 | I don't want to go as far as prohibiting anyone from using AI. After all, at this point, _some AI use_ is inevitable. However, **purely vibe-coded PRs are not going to be approved**. 34 | 35 | If you're using AI to generate code, you must make it very clear. And you'll have to own it and maintain it. I will review and ask as many questions as necessary about the code, and I reserve the right to judge whether I think the contribution is worth it or not. 36 | 37 | Also, not properly communicating that you're using generated code in your PR is considered dishonest. If I find out, I'll have to close the PR. 38 | 39 | ## Submitting a Pull Request 40 | 41 | 1. Fork the repository and create your branch from main. Call it `feature/my-feature` or `bug/by-bug`. 42 | 2. Clone your forked repository to your local machine. 43 | 3. Implement your changes. Please ensure your code is: 44 | - well-written 45 | - formatted with `cargo fmt` 46 | - has unit tests when applicable (library managing, feed logic, filesystem, etc) 47 | 4. Write clear, concise commit messages. 48 | 5. Push your changes to your fork. 49 | 50 | Open a new pull request from your branch to the main branch of **bulletty**. 51 | 52 | Provide a clear description of the changes in your pull request. If your PR addresses an existing issue, please reference it. Images and videos are always appreciated, for a quicker understanding of what has been implemented. 53 | 54 | ## Setting up Your Development Environment 55 | 56 | To start contributing, you'll need to set up your local environment. 57 | 58 | Clone the repository: 59 | 60 | ```shell 61 | git clone https://github.com/CrociDB/bulletty.git 62 | cd bulletty 63 | ``` 64 | 65 | Follow the instructions in the project's README.md to install dependencies and run the application. 66 | 67 | Thank you for helping us build **bulletty**! 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

📰 bulletty

2 |

The TUI RSS/ATOM feed reader that lets you decide where to store your data.

3 | 4 |

5 | bulletty 6 |

7 | 8 | **bulletty** is a TUI feed reader (RSS and ATOM). Read your subscriptions within your terminal. It downloads the entries for offline reading so all the data is local and yours: your subscriptions, highlights, comments, etc. All in an universal format: Markdown. Backup and sync your `data` directory your own way. 9 | 10 | It's in active development. 11 | 12 | ## 🔨 Features 13 | 14 | - Subscribe to RSS and ATOM feed types 15 | - All your feed sources and entries are stored in Markdown in one place: `$HOME/.local/share/bulletty/` 16 | - Download entries automatically 17 | - Add articles to the Read Later category 18 | - Read the articles with the embedded Markdown reader 19 | - Import/export OPML feed list 20 | 21 | ## 🚀 Install 22 | 23 | [Download bulletty pre-built binaries](https://github.com/CrociDB/bulletty/releases) 24 | 25 | ### 🚚 Through Cargo 26 | 27 | It requires **cargo 1.90+**: 28 | 29 | ```shell 30 | cargo install bulletty 31 | ``` 32 | 33 | ### ☂️ Pre-requisites 34 | 35 | **bulletty** runs in most platforms, however there are some pre-requisites to have it run the best way possible: 36 | 37 | - Use a modern terminal emulator such as **Kitty**, **Ghostty**, **Alacritty**, **WezTerm**, **Windows Terminal**, etc. They provide modern features and true color support, on top of being really fast and usually hardware-redered 38 | - Use a [NerdFont](http://nerdfonts.com/). They are patched versions of common coding fonts with several icons 39 | 40 | ## 🚄 Usage 41 | 42 | ### 🗞️ Adding new feed sources 43 | 44 | For now, you can only add new feed sources through the CLI: 45 | 46 | ```shell 47 | bulletty add https://crocidb.com/index.xml [Category] 48 | ``` 49 | 50 | If no category is passed, the feed source will be added to the `General` category. **bulletty** will syncronize all your sources when you open the TUI, by just invoking `bulletty`. 51 | 52 | More on the CLI commands with: 53 | 54 | ```shell 55 | bulletty help 56 | ``` 57 | 58 | ### 🧩 TUI 59 | 60 | On any screen, you can press question mark `?` and it will show you the available commands for that screen. Also, on the bottom right, it shows the most important commands for that context. 61 | 62 | In general, it supports `j/k/up/down` to select items, navigate and scroll, as well as `g/G/Home/End` to go to the begginning/end of a list or file and `Enter` and `q/Esc` to navigate into and out of Categories and Entries. In order to open an Entry externally, press `o`. 63 | 64 | ## 🏫 Philosophy 65 | 66 | The whole idea is bringing back the descentralized internet. You subscribe to the sources you like the most and you get their content whenever it's available. When you get it, it's local, it's yours. **bulletty** will generate a Markdown file of each entry from each source. You can read through the embedded reader, straight from your terminal, or using any text editor. 67 | 68 | All your feed data will be at `$HOME/.local/share/bulletty/`, in this structure: 69 | 70 | ```shell 71 | [~/.local/share/bulletty]$ tree 72 | . 73 | └── categories 74 | ├── Programming 75 | │   ├── bruno-croci 76 | │   │   ├── .feed.toml 77 | │   │   ├── about.md 78 | │   │   ├── demystifying-the-shebang-kernel-adventures.md 79 | │   │   ├── from-ides-to-the-terminal.md 80 | │   │   ├── i-wrote-a-webserver-in-haskell.md 81 | │   │   ├── ... 82 | ├── General 83 | │   ├── another-website 84 | │   │   ├── .feed.toml 85 | │   │   ├── some-post.md 86 | │   │   ├── ... 87 | 88 | ``` 89 | 90 | All the needs to be done is to synchronize the `bulletty` directory to save your data, similarly to an Obsidian vault. 91 | 92 | ## 📜 Feature Roadmap 93 | 94 | - Themes 95 | - Highlight 96 | - Notes 97 | - Web view 98 | - Mouse support 99 | - Image support 100 | 101 | ## 💻 Build 102 | 103 | ```shell 104 | git clone https://github.com/CrociDB/bulletty.git 105 | cd bulletty 106 | cargo build --release 107 | ``` 108 | 109 | ## 👩‍💻 Contributing to bulletty 110 | 111 | I am very open for contributions to help make **bulletty** the best feed reader out there. For more information on how to contribute, refer to the **CONTRIBUTING.md**. 112 | 113 | ## 📃 License 114 | 115 | Copyright (c) Bruno Croci 116 | 117 | This project is licensed under the MIT license ([LICENSE] or ) 118 | 119 | [LICENSE]: ./LICENSE 120 | -------------------------------------------------------------------------------- /src/ui/states/feedtreestate.rs: -------------------------------------------------------------------------------- 1 | use ratatui::widgets::{ListItem, ListState}; 2 | use tracing::error; 3 | 4 | use crate::core::library::feedlibrary::FeedLibrary; 5 | 6 | pub enum FeedItemInfo { 7 | /// Represents the category title 8 | Category(String), 9 | /// Represents an item in the feed tree with a title, categore, and slug 10 | Item(String, String, String), 11 | /// Represents a separator in the menu 12 | Separator, 13 | /// Represents the Read Later category 14 | ReadLater, 15 | } 16 | 17 | pub struct FeedTreeState { 18 | pub treeitems: Vec, 19 | pub listatate: ListState, 20 | } 21 | 22 | impl Default for FeedTreeState { 23 | fn default() -> Self { 24 | Self::new() 25 | } 26 | } 27 | 28 | impl FeedTreeState { 29 | pub fn new() -> Self { 30 | Self { 31 | treeitems: vec![], 32 | listatate: ListState::default().with_selected(Some(0)), 33 | } 34 | } 35 | 36 | pub fn update(&mut self, library: &mut FeedLibrary) { 37 | self.treeitems.clear(); 38 | 39 | for category in library.feedcategories.iter() { 40 | self.treeitems 41 | .push(FeedItemInfo::Category(category.title.clone())); 42 | for item in category.feeds.iter() { 43 | self.treeitems.push(FeedItemInfo::Item( 44 | item.title.clone(), 45 | category.title.clone(), 46 | item.slug.clone(), 47 | )); 48 | } 49 | } 50 | 51 | // display Read Later section if it has entries 52 | if library.has_read_later_entries() { 53 | self.treeitems.push(FeedItemInfo::Separator); 54 | self.treeitems.push(FeedItemInfo::ReadLater); 55 | } 56 | } 57 | 58 | pub fn get_items(&self, library: &mut FeedLibrary) -> Vec> { 59 | self.treeitems 60 | .iter() 61 | .map(|item| { 62 | let title = match item { 63 | FeedItemInfo::Category(t) => format!("\u{f07c} {t}"), 64 | FeedItemInfo::Item(t, c, s) => { 65 | if let Ok(unread) = library.data.get_unread_feed(c, s) { 66 | if unread > 0 { 67 | format!(" \u{f09e} {t} ({unread})") 68 | } else { 69 | format!(" \u{f09e} {t}") 70 | } 71 | } else { 72 | error!("Couldn't get unread feed entries for '{}'", t); 73 | format!(" \u{f09e} {t}") 74 | } 75 | } 76 | FeedItemInfo::Separator => "".to_string(), 77 | FeedItemInfo::ReadLater => { 78 | if let Ok(count) = library.get_read_later_feed_entries() { 79 | format!("\u{f02d} Read Later ({})", count.len()) 80 | } else { 81 | "\u{f02d} Read Later".to_string() 82 | } 83 | } 84 | }; 85 | 86 | ListItem::new(title.clone()) 87 | }) 88 | .collect() 89 | } 90 | 91 | pub fn get_selected(&self) -> Option<&FeedItemInfo> { 92 | if !self.treeitems.is_empty() { 93 | let idx = self.listatate.selected().unwrap_or(0); 94 | let clamped = idx.min(self.treeitems.len().saturating_sub(1)); 95 | Some(&self.treeitems[clamped]) 96 | } else { 97 | None 98 | } 99 | } 100 | 101 | pub fn select_next(&mut self) { 102 | if self.treeitems.is_empty() { 103 | return; 104 | } 105 | 106 | let selected = self.listatate.selected().unwrap_or(0); 107 | if selected < self.treeitems.len().saturating_sub(1) { 108 | self.listatate.select_next(); 109 | 110 | if self.is_selected_separator() { 111 | self.select_next(); 112 | } 113 | } 114 | } 115 | 116 | pub fn select_previous(&mut self) { 117 | if self.treeitems.is_empty() { 118 | return; 119 | } 120 | 121 | let selected = self.listatate.selected().unwrap_or(0); 122 | if selected >= self.treeitems.len() { 123 | self.listatate 124 | .select(Some(self.treeitems.len().saturating_sub(1))); 125 | } 126 | 127 | let selected = self.listatate.selected().unwrap_or(0); 128 | 129 | if selected > 0 { 130 | self.listatate.select_previous(); 131 | if self.is_selected_separator() { 132 | self.select_previous(); 133 | } 134 | } 135 | } 136 | 137 | pub fn select_first(&mut self) { 138 | if self.treeitems.is_empty() { 139 | return; 140 | } 141 | 142 | self.listatate.select_first(); 143 | } 144 | 145 | pub fn select_last(&mut self) { 146 | if self.treeitems.is_empty() { 147 | return; 148 | } 149 | 150 | self.listatate 151 | .select(Some(self.treeitems.len().saturating_sub(1))); 152 | } 153 | 154 | fn is_selected_separator(&self) -> bool { 155 | if let Some(index) = self.listatate.selected() { 156 | index < self.treeitems.len() && matches!(self.treeitems[index], FeedItemInfo::Separator) 157 | } else { 158 | false 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/ui/screens/themedialog.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::rc::Rc; 3 | 4 | use color_eyre::eyre::Result; 5 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 6 | use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; 7 | use ratatui::style::{Color, Style}; 8 | use ratatui::widgets::{ 9 | Block, List, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, 10 | }; 11 | 12 | use crate::app::AppWorkStatus; 13 | 14 | use crate::core::library::feedlibrary::FeedLibrary; 15 | use crate::core::ui::appscreen::{AppScreen, AppScreenEvent}; 16 | use crate::core::ui::dialog::Dialog; 17 | use crate::ui::states::themestate::ThemeState; 18 | 19 | pub struct ThemeDialog { 20 | library: Rc>, 21 | state: ThemeState, 22 | } 23 | 24 | impl ThemeDialog { 25 | pub fn new(library: Rc>) -> Self { 26 | Self { 27 | library, 28 | state: ThemeState::default(), 29 | } 30 | } 31 | } 32 | 33 | impl Dialog for ThemeDialog { 34 | fn get_size(&self) -> ratatui::prelude::Rect { 35 | Rect::new(50, 20, 0, 0) 36 | } 37 | 38 | fn as_screen(&self) -> &dyn AppScreen { 39 | self 40 | } 41 | 42 | fn as_screen_mut(&mut self) -> &mut dyn AppScreen { 43 | self 44 | } 45 | } 46 | 47 | impl AppScreen for ThemeDialog { 48 | fn start(&mut self) {} 49 | 50 | fn quit(&mut self) {} 51 | 52 | fn pause(&mut self) {} 53 | 54 | fn unpause(&mut self) {} 55 | 56 | fn render(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) { 57 | let theme = { 58 | let library = self.library.borrow(); 59 | library.settings.get_theme().unwrap().clone() 60 | }; 61 | 62 | let contentlayout = Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]) 63 | .split(area.inner(Margin::new(2, 1))); 64 | 65 | let title = Paragraph::new(self.get_title()) 66 | .style(Style::new().fg(Color::from_u32(theme.base[0x8]))) 67 | .alignment(Alignment::Center) 68 | .wrap(Wrap { trim: true }); 69 | 70 | // List 71 | let chunks = Layout::horizontal([Constraint::Fill(1), Constraint::Length(1)]) 72 | .split(contentlayout[1]); 73 | 74 | self.state.update(&self.library.borrow()); 75 | 76 | let themelist = List::new(self.state.get_items()) 77 | .block( 78 | Block::default() 79 | .style( 80 | Style::default() 81 | .fg(Color::from_u32(theme.base[0x5])) 82 | .bg(Color::from_u32(theme.base[0x1])), 83 | ) 84 | .padding(Padding::new(1, 1, 1, 1)), 85 | ) 86 | .highlight_style( 87 | Style::default() 88 | .fg(Color::from_u32(theme.base[0x2])) 89 | .bg(Color::from_u32(theme.base[0x8])), 90 | ); 91 | 92 | frame.render_widget(title, contentlayout[0]); 93 | frame.render_stateful_widget(themelist, chunks[0], &mut self.state.state.clone()); 94 | 95 | // Scrollbar 96 | let mut scrollbarstate = 97 | ScrollbarState::new(self.state.scroll_max()).position(self.state.scroll()); 98 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight).style( 99 | Style::new() 100 | .fg(Color::from_u32(theme.base[3])) 101 | .bg(Color::from_u32(theme.base[2])), 102 | ); 103 | frame.render_stateful_widget(scrollbar, chunks[1], &mut scrollbarstate); 104 | } 105 | 106 | fn handle_events(&mut self) -> Result { 107 | match event::read()? { 108 | Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key), 109 | Event::Mouse(_) => Ok(AppScreenEvent::None), 110 | Event::Resize(_, _) => Ok(AppScreenEvent::None), 111 | _ => Ok(AppScreenEvent::None), 112 | } 113 | } 114 | 115 | fn handle_keypress(&mut self, key: KeyEvent) -> Result { 116 | match (key.modifiers, key.code) { 117 | (_, KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter) 118 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 119 | Ok(AppScreenEvent::CloseDialog) 120 | } 121 | (_, KeyCode::Down | KeyCode::Char('j')) => { 122 | self.state.select_next(); 123 | let selected = self.state.get_selected(); 124 | self.library.borrow_mut().settings.appearance.theme = selected.unwrap(); 125 | self.library.borrow_mut().settings.appearance.save()?; 126 | Ok(AppScreenEvent::None) 127 | } 128 | (_, KeyCode::Up | KeyCode::Char('k')) => { 129 | self.state.select_previous(); 130 | let selected = self.state.get_selected(); 131 | self.library.borrow_mut().settings.appearance.theme = selected.unwrap(); 132 | self.library.borrow_mut().settings.appearance.save()?; 133 | Ok(AppScreenEvent::None) 134 | } 135 | _ => Ok(AppScreenEvent::None), 136 | } 137 | } 138 | 139 | fn get_work_status(&self) -> AppWorkStatus { 140 | AppWorkStatus::None 141 | } 142 | 143 | fn get_title(&self) -> String { 144 | String::from("Theme") 145 | } 146 | 147 | fn get_instructions(&self) -> String { 148 | String::from("j/k: select theme | Esc/q: close url") 149 | } 150 | 151 | fn get_full_instructions(&self) -> String { 152 | self.get_instructions() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/ui/states/feedentrystate.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use ratatui::{ 4 | style::{Color, Style, Stylize}, 5 | text::{Line, Span, Text}, 6 | widgets::{ListItem, ListState}, 7 | }; 8 | use tracing::error; 9 | 10 | use crate::{ 11 | core::{ 12 | feed::feedentry::FeedEntry, 13 | library::{feedlibrary::FeedLibrary, settings::theme::Theme}, 14 | }, 15 | ui::states::feedtreestate::{FeedItemInfo, FeedTreeState}, 16 | }; 17 | 18 | pub struct FeedEntryState { 19 | pub entries: Vec, 20 | pub listatate: ListState, 21 | pub previous_selected: String, 22 | pub library: Option>>, 23 | theme: Theme, 24 | } 25 | 26 | impl Default for FeedEntryState { 27 | fn default() -> Self { 28 | Self::new() 29 | } 30 | } 31 | 32 | impl FeedEntryState { 33 | pub fn new() -> Self { 34 | Self { 35 | entries: vec![], 36 | listatate: ListState::default().with_selected(Some(0)), 37 | previous_selected: String::new(), 38 | library: None, 39 | theme: Theme::default(), 40 | } 41 | } 42 | 43 | pub fn update(&mut self, library: &mut FeedLibrary, treestate: &FeedTreeState) { 44 | let prev = self.previous_selected.to_string(); 45 | self.theme = library.settings.get_theme().unwrap().clone(); 46 | 47 | self.entries = match treestate.get_selected() { 48 | Some(FeedItemInfo::Category(t)) => { 49 | self.previous_selected = t.to_string(); 50 | match library.get_feed_entries_by_category(t) { 51 | Ok(entries) => entries, 52 | Err(e) => { 53 | error!("Error getting feed entries by category: {:?}", e); 54 | vec![] 55 | } 56 | } 57 | } 58 | Some(FeedItemInfo::Item(_, _, s)) => { 59 | self.previous_selected = s.to_string(); 60 | match library.get_feed_entries_by_item_slug(s) { 61 | Ok(entries) => entries, 62 | Err(e) => { 63 | error!("Error getting feed entries by item slug: {:?}", e); 64 | vec![] 65 | } 66 | } 67 | } 68 | Some(FeedItemInfo::ReadLater) => { 69 | self.previous_selected = "read_later".to_string(); 70 | match library.get_read_later_feed_entries() { 71 | Ok(entries) => entries, 72 | Err(e) => { 73 | error!("Error getting Read Later entries: {:?}", e); 74 | vec![] 75 | } 76 | } 77 | } 78 | _ => vec![], 79 | }; 80 | 81 | if prev != self.previous_selected { 82 | self.listatate.select_first(); 83 | } 84 | } 85 | 86 | pub fn get_items(&self) -> Vec> { 87 | self.entries 88 | .iter() 89 | .map(|entry| { 90 | let mut item_content_lines: Vec = Vec::new(); 91 | 92 | item_content_lines.push(Line::from("")); 93 | 94 | let read_later_icon = 95 | if self.is_in_read_later(entry.filepath.to_str().unwrap_or_default()) { 96 | " \u{f02d}" // read later icon 97 | } else { 98 | "" 99 | }; 100 | 101 | // Title 102 | if !entry.seen { 103 | item_content_lines.push(Line::from(Span::styled( 104 | format!(" \u{f1ea} {}{} \u{e3e3}", entry.title, read_later_icon), 105 | Style::default() 106 | .bold() 107 | .fg(Color::from_u32(self.theme.base[9])), 108 | ))); 109 | } else { 110 | item_content_lines.push(Line::from(Span::styled( 111 | format!(" \u{f1ea} {}{}", entry.title, read_later_icon), 112 | Style::default() 113 | .bold() 114 | .fg(Color::from_u32(self.theme.base[6])), 115 | ))); 116 | }; 117 | 118 | // Date 119 | item_content_lines.push(Line::from(Span::styled( 120 | format!( 121 | " \u{f0520} {} | \u{f09e} {}", 122 | entry.date.with_timezone(&chrono::Local).format("%Y-%m-%d"), 123 | entry.author 124 | ), 125 | Style::default().fg(Color::from_u32(self.theme.base[5])), 126 | ))); 127 | 128 | // Description 129 | item_content_lines.push(Line::from(Span::styled( 130 | format!(" {}...", entry.description), 131 | Style::default().fg(Color::from_u32(self.theme.base[4])), 132 | ))); 133 | 134 | item_content_lines.push(Line::from("")); 135 | 136 | let item_text = Text::from(item_content_lines); 137 | ListItem::new(item_text) 138 | }) 139 | .collect() 140 | } 141 | 142 | pub fn get_selected(&self) -> Option { 143 | match self.listatate.selected() { 144 | None => None, 145 | Some(selected) => { 146 | if selected < self.entries.len() { 147 | Some(self.entries[selected].clone()) 148 | } else { 149 | None 150 | } 151 | } 152 | } 153 | } 154 | 155 | pub fn set_current_read(&mut self) { 156 | if let Some(selected) = self.listatate.selected() 157 | && selected < self.entries.len() 158 | { 159 | self.entries[selected].seen = true; 160 | } 161 | } 162 | 163 | pub fn select_next(&mut self) { 164 | if self.entries.is_empty() { 165 | return; 166 | } 167 | 168 | if self.listatate.selected().unwrap_or(0) < self.entries.len().saturating_sub(1) { 169 | self.listatate.select_next(); 170 | } 171 | } 172 | 173 | pub fn select_previous(&mut self) { 174 | if self.entries.is_empty() { 175 | return; 176 | } 177 | 178 | if self.listatate.selected().unwrap_or(0) > 0 { 179 | self.listatate.select_previous(); 180 | } 181 | } 182 | 183 | pub fn select_first(&mut self) { 184 | if self.entries.is_empty() { 185 | return; 186 | } 187 | 188 | self.listatate.select_first(); 189 | } 190 | 191 | pub fn select_last(&mut self) { 192 | if self.entries.is_empty() { 193 | return; 194 | } 195 | 196 | self.listatate 197 | .select(Some(self.entries.len().saturating_sub(1))); 198 | } 199 | 200 | pub fn scroll_max(&self) -> usize { 201 | self.entries.len().saturating_sub(1) 202 | } 203 | 204 | pub fn scroll(&self) -> usize { 205 | self.listatate.selected().unwrap_or(0) 206 | } 207 | 208 | fn is_in_read_later(&self, file_path: &str) -> bool { 209 | if let Some(library) = &self.library { 210 | library.borrow_mut().is_in_read_later(file_path) 211 | } else { 212 | false 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | use clap::{Error, Parser, Subcommand}; 4 | use tracing::{error, info}; 5 | 6 | use crate::core::defs; 7 | use crate::core::library::data::config::Config; 8 | use crate::core::library::data::opml; 9 | use crate::core::library::feeditem::FeedItem; 10 | use crate::core::library::feedlibrary::FeedLibrary; 11 | 12 | use std::path::Path; 13 | 14 | #[derive(Parser)] 15 | #[command(name = "bulletty")] 16 | #[command(version, about = "Your TUI feed reader", long_about = None)] 17 | pub struct Cli { 18 | #[command(subcommand)] 19 | pub command: Option, 20 | } 21 | 22 | #[derive(Subcommand)] 23 | pub enum Commands { 24 | /// List all feeds and categories 25 | List, 26 | /// Add new feed 27 | Add { 28 | /// The ATOM/RSS feed URL 29 | url: String, 30 | #[arg()] 31 | /// The category to add under, if none is passed, it will be added to General 32 | category: Option, 33 | }, 34 | /// Update all feeds 35 | Update, 36 | /// Delete a feed 37 | Delete { 38 | /// The feed identifier (can be url, title or slug) 39 | ident: String, 40 | }, 41 | /// Show important directories 42 | Dirs, 43 | /// Import a list of feed sources through OPML 44 | Import { 45 | /// The filepath of the OPML file 46 | opml_file: String, 47 | }, 48 | /// Export all your sources to an OPML file 49 | Export { 50 | /// The filepath of the OPML file 51 | opml_file: String, 52 | }, 53 | } 54 | 55 | pub fn run_main_cli(cli: Cli) -> color_eyre::Result<()> { 56 | info!("Initializing CLI"); 57 | 58 | match &cli.command { 59 | Some(Commands::List) => command_list(&cli), 60 | Some(Commands::Add { url, category }) => command_add(&cli, url, category), 61 | Some(Commands::Update) => command_update(&cli), 62 | Some(Commands::Delete { ident }) => command_delete(&cli, ident), 63 | Some(Commands::Dirs) => command_dirs(&cli), 64 | Some(Commands::Import { opml_file }) => command_import(&cli, opml_file), 65 | Some(Commands::Export { opml_file }) => command_export(&cli, opml_file), 66 | None => Ok(()), 67 | } 68 | } 69 | 70 | fn command_list(_cli: &Cli) -> color_eyre::Result<()> { 71 | let library = FeedLibrary::new(); 72 | 73 | println!("Feeds Registered\n\n"); 74 | for category in library.feedcategories.iter() { 75 | println!("{}", category.title); 76 | for feed in category.feeds.iter().as_ref() { 77 | println!("\t-> {}: {}", feed.title, feed.slug); 78 | } 79 | println!(); 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | fn command_add(_cli: &Cli, url: &str, category: &Option) -> color_eyre::Result<()> { 86 | let mut library = FeedLibrary::new(); 87 | match library.add_feed_from_url(url, category) { 88 | Ok(feed) => { 89 | info!("Feed added: {}", feed.title); 90 | println!("Feed added: {}", feed.title); 91 | } 92 | Err(err) => { 93 | error!("{err}"); 94 | println!("{err}"); 95 | } 96 | } 97 | 98 | Ok(()) 99 | } 100 | 101 | fn command_update(_cli: &Cli) -> color_eyre::Result<()> { 102 | let library = FeedLibrary::new(); 103 | 104 | for category in library.feedcategories.iter() { 105 | for feed in category.feeds.iter() { 106 | info!("Updating {}", feed.title); 107 | println!("Updating {}", feed.title); 108 | library 109 | .data 110 | .update_feed_entries(&category.title, feed, None)?; 111 | } 112 | } 113 | 114 | Ok(()) 115 | } 116 | 117 | fn confirm_delete(title: &str) -> Result { 118 | print!("Are you sure you want to delete '{title}'? That can't be reverted. [y/N] "); 119 | io::stdout().flush()?; 120 | 121 | let mut choice = String::new(); 122 | io::stdin().read_line(&mut choice)?; 123 | 124 | let normalized_input = choice.trim().to_lowercase(); 125 | Ok(matches!(normalized_input.as_str(), "y" | "yes")) 126 | } 127 | 128 | fn command_delete(_cli: &Cli, ident: &str) -> color_eyre::Result<()> { 129 | let library = FeedLibrary::new(); 130 | 131 | let matches: Vec<&FeedItem> = library.get_matching_feeds(ident); 132 | let matches_len = matches.len(); 133 | 134 | match matches_len { 135 | 0 => { 136 | info!("No matching feeds exist"); 137 | println!("No matching feeds exist"); 138 | } 139 | 1 => { 140 | let matched = matches[0]; 141 | if confirm_delete(&matched.title)? { 142 | library.delete_feed(&matched.slug, &matched.category)?; 143 | info!("Feed deleted: {}", &matched.title); 144 | println!("Feed deleted: {}", &matched.title); 145 | } else { 146 | info!("Feed was not deleted: {}", &matched.title); 147 | println!("Feed was not deleted: {}", &matched.title); 148 | } 149 | } 150 | _ => { 151 | println!("There were {} feeds found with that identifier:", { 152 | matches_len 153 | }); 154 | let iter = matches.iter().enumerate(); 155 | for (i, feed) in iter { 156 | println!("\t-> {}) {}/{}", i + 1, &feed.category, &feed.title); 157 | } 158 | print!("Which one would you like to delete? "); 159 | io::stdout().flush()?; 160 | 161 | let mut choice = String::new(); 162 | io::stdin().read_line(&mut choice)?; 163 | 164 | let normalized_input = choice.trim(); 165 | 166 | match normalized_input.parse::() { 167 | Ok(ind) => { 168 | if ind >= 1 && ind <= matches_len { 169 | let title = 170 | format!("{}/{}", &matches[ind - 1].category, &matches[ind - 1].title); 171 | 172 | if confirm_delete(&title)? { 173 | library 174 | .delete_feed(&matches[ind - 1].slug, &matches[ind - 1].category)?; 175 | info!("Feed deleted: {}", &matches[ind - 1].title); 176 | println!("Feed deleted: {}", &matches[ind - 1].title); 177 | } else { 178 | info!("Feed was not deleted: {}", &title); 179 | println!("Feed was not deleted: {}", &title); 180 | } 181 | } else { 182 | info!("Invalid input received: {ind}"); 183 | println!("Invalid input received: {ind}"); 184 | } 185 | } 186 | Err(_) => { 187 | info!("Invalid input received: {normalized_input}"); 188 | println!("Invalid input received: {normalized_input}"); 189 | } 190 | } 191 | } 192 | } 193 | 194 | Ok(()) 195 | } 196 | 197 | fn command_dirs(_cli: &Cli) -> color_eyre::Result<()> { 198 | let config = Config::new(); 199 | let library_path = config.datapath; 200 | 201 | let logs_path = Path::new(&dirs::state_dir().unwrap()).join(defs::LOG_DIR); 202 | 203 | println!("bulletty directories"); 204 | println!("\t-> Library: {}", library_path.to_string_lossy()); 205 | println!("\t-> Logs: {}", logs_path.to_string_lossy()); 206 | 207 | Ok(()) 208 | } 209 | 210 | fn command_import(_cli: &Cli, opml_file: &str) -> color_eyre::Result<()> { 211 | println!("Importing feeds"); 212 | let mut library = FeedLibrary::new(); 213 | let opml_feeds = opml::get_opml_feeds(opml_file)?; 214 | 215 | for feed in opml_feeds { 216 | match library.add_feed_from_url(&feed.url, &feed.category) { 217 | Ok(feed) => { 218 | info!("Feed added: {}", feed.title); 219 | println!("Feed added: {}", feed.title); 220 | } 221 | Err(err) => { 222 | error!("{err}"); 223 | println!("{err}"); 224 | } 225 | } 226 | } 227 | 228 | Ok(()) 229 | } 230 | 231 | fn command_export(_cli: &Cli, opml_file: &str) -> color_eyre::Result<()> { 232 | let library = FeedLibrary::new(); 233 | 234 | opml::save_opml(&library.feedcategories, opml_file)?; 235 | 236 | Ok(()) 237 | } 238 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, collections::VecDeque, rc::Rc}; 2 | 3 | use color_eyre::{Result, eyre}; 4 | use ratatui::{ 5 | DefaultTerminal, 6 | layout::{Constraint, Flex, Layout, Margin, Rect}, 7 | style::{Color, Style}, 8 | widgets::{Block, Clear, Gauge, Paragraph}, 9 | }; 10 | 11 | use crate::{ 12 | core::{ 13 | library::feedlibrary::FeedLibrary, 14 | ui::{ 15 | appscreen::{AppScreen, AppScreenEvent}, 16 | dialog::Dialog, 17 | }, 18 | }, 19 | ui::screens::mainscreen::MainScreen, 20 | }; 21 | 22 | pub enum AppWorkStatus { 23 | None, 24 | Working(f32, String), 25 | } 26 | 27 | impl AppWorkStatus { 28 | pub fn is_none(&self) -> bool { 29 | matches!(self, AppWorkStatus::None) 30 | } 31 | } 32 | 33 | pub struct App { 34 | running: bool, 35 | library: Rc>, 36 | current_state: Option>, 37 | states_queue: VecDeque>, 38 | dialog_queue: VecDeque>, 39 | } 40 | 41 | impl Default for App { 42 | fn default() -> Self { 43 | Self::new() 44 | } 45 | } 46 | 47 | impl App { 48 | pub fn new() -> Self { 49 | Self { 50 | library: Rc::new(RefCell::new(FeedLibrary::new())), 51 | 52 | running: true, 53 | current_state: None, 54 | states_queue: VecDeque::>::new(), 55 | dialog_queue: VecDeque::>::new(), 56 | } 57 | } 58 | 59 | pub fn init(&mut self, mut state: Box) { 60 | state.start(); 61 | self.current_state = Some(state); 62 | } 63 | 64 | pub fn initmain(&mut self) { 65 | self.init(Box::new(MainScreen::new(self.library.clone()))); 66 | } 67 | 68 | pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { 69 | while self.running { 70 | let theme = { 71 | let library = self.library.borrow(); 72 | library.settings.get_theme().unwrap().clone() 73 | }; 74 | 75 | let work_status = self.get_work_status(); 76 | if let Some(state) = self.current_state.as_mut() { 77 | terminal.draw(|frame| { 78 | let mainlayout = 79 | Layout::vertical([Constraint::Percentage(99), Constraint::Min(3)]) 80 | .margin(1) 81 | .split(frame.area()); 82 | 83 | state.render(frame, mainlayout[0]); 84 | 85 | // Bottom status line 86 | let statusline = Layout::horizontal([ 87 | Constraint::Max(25), 88 | Constraint::Fill(1), 89 | Constraint::Max(90), 90 | ]) 91 | .margin(1) 92 | .split(mainlayout[1]); 93 | 94 | let background = 95 | Block::default().style(Style::default().bg(Color::from_u32(theme.base[0]))); 96 | frame.render_widget(background, mainlayout[1]); 97 | 98 | let title = if let Some(dialog) = self.dialog_queue.front() { 99 | dialog.as_screen().get_title() 100 | } else { 101 | state.get_title() 102 | }; 103 | 104 | let status_text = Paragraph::new(format!("\u{f0fb1} bulletty | {title}")) 105 | .style(Style::default().fg(Color::from_u32(theme.base[0x6]))); 106 | 107 | frame.render_widget(status_text, statusline[0]); 108 | 109 | let instructions = if let Some(dialog) = self.dialog_queue.front() { 110 | dialog.as_screen().get_instructions() 111 | } else { 112 | state.get_instructions() 113 | }; 114 | 115 | let instructions_text = Paragraph::new(instructions.to_string()) 116 | .style(Style::default().fg(Color::from_u32(theme.base[3]))) 117 | .alignment(ratatui::layout::Alignment::Right); 118 | 119 | frame.render_widget(instructions_text, statusline[2]); 120 | 121 | // work status 122 | if let AppWorkStatus::Working(percentage, description) = work_status { 123 | let gauge = Gauge::default() 124 | .gauge_style( 125 | Style::default() 126 | .fg(Color::from_u32(theme.base[0x9])) 127 | .bg(Color::from_u32(theme.base[0x2])), 128 | ) 129 | .percent((percentage * 100.0).round() as u16) 130 | .label(&description); 131 | frame.render_widget(gauge, statusline[1]); 132 | } 133 | 134 | // After drawing the state, needs to check if there's a dialog 135 | if let Some(dialog) = self.dialog_queue.get_mut(0) { 136 | let overlay = Block::default().style( 137 | Style::default() 138 | .bg(Color::from_u32(theme.base[2])) 139 | .fg(Color::from_u32(theme.base[4])), 140 | ); 141 | frame.render_widget(overlay, mainlayout[0]); 142 | 143 | let border = 144 | Block::new().style(Style::new().bg(Color::from_u32(theme.base[1]))); 145 | let block = 146 | Block::new().style(Style::new().bg(Color::from_u32(theme.base[0]))); 147 | 148 | let area = popup_area(mainlayout[0], dialog.get_size()); 149 | let inner_area = area.inner(Margin::new(2, 1)); 150 | 151 | frame.render_widget(Clear, area); 152 | frame.render_widget(border, area); 153 | frame.render_widget(block, inner_area); 154 | 155 | dialog.as_screen_mut().render(frame, inner_area); 156 | } 157 | })?; 158 | 159 | // Checking the dialog or the state events 160 | let event = if let Some(dialog) = self.dialog_queue.get_mut(0) { 161 | dialog.as_screen_mut().handle_events()? 162 | } else { 163 | state.handle_events()? 164 | }; 165 | 166 | match event { 167 | AppScreenEvent::None => {} 168 | 169 | AppScreenEvent::ChangeState(app_state) => { 170 | self.change_state(app_state); 171 | } 172 | AppScreenEvent::ExitState => { 173 | self.exit_state(); 174 | } 175 | 176 | AppScreenEvent::OpenDialog(app_state) => { 177 | self.open_dialog(app_state); 178 | } 179 | AppScreenEvent::CloseDialog => { 180 | self.close_current_dialog(); 181 | } 182 | 183 | AppScreenEvent::ExitApp => { 184 | self.running = false; 185 | } 186 | } 187 | } else { 188 | self.running = false; 189 | return Err(eyre::eyre!("No current AppState")); 190 | } 191 | } 192 | 193 | if let Some(state) = self.current_state.as_mut() { 194 | state.quit(); 195 | } 196 | 197 | Ok(()) 198 | } 199 | 200 | fn change_state(&mut self, mut new_state: Box) { 201 | if let Some(mut state) = self.current_state.take() { 202 | state.pause(); 203 | self.states_queue.push_back(state); 204 | } 205 | 206 | new_state.start(); 207 | self.current_state = Some(new_state); 208 | } 209 | 210 | fn exit_state(&mut self) { 211 | if let Some(mut state) = self.current_state.take() { 212 | state.quit(); 213 | } 214 | 215 | if !self.states_queue.is_empty() { 216 | if let Some(mut state) = self.states_queue.pop_back() { 217 | state.unpause(); 218 | self.current_state = Some(state); 219 | } else { 220 | self.running = false; 221 | } 222 | } else { 223 | self.running = false; 224 | } 225 | } 226 | 227 | fn get_work_status(&self) -> AppWorkStatus { 228 | if let Some(state) = self.current_state.as_ref() { 229 | let status = state.get_work_status(); 230 | if !status.is_none() { 231 | return status; 232 | } 233 | } 234 | 235 | self.states_queue 236 | .iter() 237 | .map(|state| state.get_work_status()) 238 | .find(|state| !state.is_none()) 239 | .unwrap_or(AppWorkStatus::None) 240 | } 241 | 242 | fn open_dialog(&mut self, mut dialog_state: Box) { 243 | if let Some(state) = self.current_state.as_mut() { 244 | state.pause(); 245 | } 246 | 247 | dialog_state.as_screen_mut().start(); 248 | self.dialog_queue.push_back(dialog_state); 249 | } 250 | 251 | fn close_current_dialog(&mut self) { 252 | if let Some(mut state) = self.dialog_queue.pop_back() { 253 | state.as_screen_mut().quit(); 254 | } 255 | } 256 | } 257 | 258 | fn popup_area(area: Rect, size: Rect) -> Rect { 259 | let mut vertical = Layout::vertical([Constraint::Percentage(size.height)]).flex(Flex::Center); 260 | let mut horizontal = 261 | Layout::horizontal([Constraint::Percentage(size.width)]).flex(Flex::Center); 262 | 263 | if size.x + size.y > 0 { 264 | vertical = Layout::vertical([Constraint::Length(size.y)]).flex(Flex::Center); 265 | horizontal = Layout::horizontal([Constraint::Length(size.x)]).flex(Flex::Center); 266 | } 267 | 268 | let [area] = vertical.areas(area); 269 | let [area] = horizontal.areas(area); 270 | 271 | area 272 | } 273 | -------------------------------------------------------------------------------- /src/ui/screens/readerscreen.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use color_eyre::Result; 4 | use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; 5 | use ratatui::layout::{Alignment, Constraint, Layout}; 6 | use ratatui::style::{Color, Style}; 7 | use ratatui::widgets::{ 8 | Block, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, 9 | }; 10 | use tracing::error; 11 | use unicode_width::UnicodeWidthStr; 12 | 13 | use crate::app::AppWorkStatus; 14 | use crate::core::{ 15 | feed::feedentry::FeedEntry, 16 | library::feedlibrary::FeedLibrary, 17 | ui::appscreen::{AppScreen, AppScreenEvent}, 18 | }; 19 | use crate::ui::screens::urldialog::UrlDialog; 20 | 21 | use super::helpdialog::HelpDialog; 22 | 23 | pub struct ReaderScreen { 24 | library: Rc>, 25 | entries: Vec, 26 | current_index: usize, 27 | scroll: usize, 28 | scrollmax: usize, 29 | } 30 | 31 | impl ReaderScreen { 32 | pub fn new( 33 | library: Rc>, 34 | entries: Vec, 35 | current_index: usize, 36 | ) -> ReaderScreen { 37 | ReaderScreen { 38 | library, 39 | entries, 40 | current_index, 41 | scroll: 0, 42 | scrollmax: 1, 43 | } 44 | } 45 | 46 | pub fn scrollup(&mut self) { 47 | if self.scroll > 0 { 48 | self.scroll -= 1; 49 | } 50 | } 51 | 52 | pub fn scrolldown(&mut self) { 53 | self.scroll = std::cmp::min(self.scroll + 1, self.scrollmax); 54 | } 55 | 56 | pub fn next_entry(&mut self) { 57 | if self.current_index < self.entries.len().saturating_sub(1) { 58 | self.current_index += 1; 59 | self.scroll = 0; 60 | self.library 61 | .borrow_mut() 62 | .data 63 | .set_entry_seen(&self.entries[self.current_index]); 64 | } 65 | } 66 | 67 | pub fn previous_entry(&mut self) { 68 | if self.current_index > 0 { 69 | self.current_index -= 1; 70 | self.scroll = 0; 71 | self.library 72 | .borrow_mut() 73 | .data 74 | .set_entry_seen(&self.entries[self.current_index]); 75 | } 76 | } 77 | 78 | fn open_external_url(&self, url: &str) -> Result { 79 | match open::that(url) { 80 | Ok(_) => Ok(AppScreenEvent::None), 81 | Err(_) => { 82 | error!("Couldn't invoke system browser"); 83 | Ok(AppScreenEvent::OpenDialog(Box::new(UrlDialog::new( 84 | url.to_string(), 85 | )))) 86 | } 87 | } 88 | } 89 | 90 | fn increase_reader_width(&mut self) -> color_eyre::Result<()> { 91 | let mut l = self.library.borrow_mut(); 92 | l.settings.appearance.reader_width = l 93 | .settings 94 | .appearance 95 | .reader_width 96 | .saturating_add(2) 97 | .min(100); 98 | l.settings.appearance.save() 99 | } 100 | 101 | fn decrease_reader_width(&mut self) -> color_eyre::Result<()> { 102 | let mut l = self.library.borrow_mut(); 103 | l.settings.appearance.reader_width = l 104 | .settings 105 | .appearance 106 | .reader_width 107 | .saturating_sub(2) 108 | .min(100); 109 | l.settings.appearance.save() 110 | } 111 | } 112 | 113 | impl AppScreen for ReaderScreen { 114 | fn start(&mut self) {} 115 | 116 | fn render(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) { 117 | let theme = { 118 | let library = self.library.borrow(); 119 | library.settings.get_theme().unwrap().clone() 120 | }; 121 | 122 | let block = Block::default() 123 | .style(Style::default().bg(Color::from_u32(theme.base[1]))) 124 | .padding(Padding::new(3, 3, 3, 3)); 125 | 126 | frame.render_widget(block, area); 127 | 128 | let width = self.library.borrow().settings.appearance.reader_width; 129 | 130 | let sizelayout = Layout::horizontal([ 131 | Constraint::Min(1), 132 | Constraint::Percentage(width), 133 | Constraint::Max(3), 134 | Constraint::Fill(1), 135 | ]) 136 | .margin(2) 137 | .split(area); 138 | 139 | let contentlayout = Layout::vertical([ 140 | Constraint::Length(1), // Title 141 | Constraint::Length(1), // Date 142 | Constraint::Length(2), // URL 143 | Constraint::Fill(1), // Content 144 | ]) 145 | .split(sizelayout[1]); 146 | 147 | let current_entry = &self.entries[self.current_index]; 148 | 149 | // Title 150 | let title = Paragraph::new(current_entry.title.as_str()) 151 | .style(Style::new().fg(Color::from_u32(theme.base[0x8]))) 152 | .alignment(Alignment::Center) 153 | .wrap(Wrap { trim: true }); 154 | 155 | frame.render_widget(title, contentlayout[0]); 156 | 157 | // Date 158 | let date = Paragraph::new(format!( 159 | "\u{f0520} {} | \u{f09e} {}", 160 | current_entry 161 | .date 162 | .with_timezone(&chrono::Local) 163 | .format("%Y-%m-%d"), 164 | current_entry.author 165 | )) 166 | .style(Style::new().fg(Color::from_u32(theme.base[3]))) 167 | .alignment(Alignment::Center) 168 | .wrap(Wrap { trim: true }); 169 | 170 | frame.render_widget(date, contentlayout[1]); 171 | 172 | // URL 173 | let date = Paragraph::new(current_entry.url.to_string()) 174 | .style(Style::new().fg(Color::from_u32(theme.base[0xd]))) 175 | .alignment(Alignment::Center) 176 | .wrap(Wrap { trim: true }); 177 | 178 | frame.render_widget(date, contentlayout[2]); 179 | 180 | // Content 181 | let text = tui_markdown::from_str(¤t_entry.text); 182 | let textheight = text.height() as usize; 183 | 184 | // This is a workaround to get more or less the amount of wrapped lines, to be used on the 185 | // scrollbar 186 | let mut wrapped_lines = 0; 187 | for line in text.lines.iter() { 188 | let content: String = line 189 | .spans 190 | .iter() 191 | .map(|span| span.content.to_string()) 192 | .collect(); 193 | let line_width = UnicodeWidthStr::width(content.as_str()); 194 | let wrapped = line_width.div_ceil(contentlayout[3].width as usize); 195 | wrapped_lines += wrapped - wrapped.min(1); 196 | } 197 | 198 | let scrollheight = textheight + (wrapped_lines as f32 * 1.06) as usize + 4; 199 | self.scrollmax = scrollheight - (contentlayout[3].height as usize).min(scrollheight); 200 | 201 | // Content Paragraph component 202 | let paragraph = Paragraph::new(text) 203 | .scroll((self.scroll as u16, 0)) 204 | .alignment(Alignment::Left) 205 | .wrap(Wrap { trim: true }); 206 | 207 | frame.render_widget(paragraph, contentlayout[3]); 208 | 209 | // Scrollbar 210 | let mut scrollbarstate = ScrollbarState::new(self.scrollmax).position(self.scroll); 211 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight).style( 212 | Style::new() 213 | .fg(Color::from_u32(theme.base[3])) 214 | .bg(Color::from_u32(theme.base[1])), 215 | ); 216 | frame.render_stateful_widget(scrollbar, sizelayout[2], &mut scrollbarstate); 217 | } 218 | 219 | fn handle_events(&mut self) -> Result { 220 | match event::read()? { 221 | Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key), 222 | Event::Mouse(_) => Ok(AppScreenEvent::None), 223 | Event::Resize(_, _) => Ok(AppScreenEvent::None), 224 | _ => Ok(AppScreenEvent::None), 225 | } 226 | } 227 | 228 | fn handle_keypress( 229 | &mut self, 230 | key: crossterm::event::KeyEvent, 231 | ) -> color_eyre::eyre::Result { 232 | match (key.modifiers, key.code) { 233 | (_, KeyCode::Esc | KeyCode::Char('q')) 234 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 235 | Ok(AppScreenEvent::ExitState) 236 | } 237 | (_, KeyCode::Down | KeyCode::Char('j')) => { 238 | self.scrolldown(); 239 | Ok(AppScreenEvent::None) 240 | } 241 | (_, KeyCode::Up | KeyCode::Char('k')) => { 242 | self.scrollup(); 243 | Ok(AppScreenEvent::None) 244 | } 245 | (_, KeyCode::Home | KeyCode::Char('g')) => { 246 | self.scroll = 0; 247 | Ok(AppScreenEvent::None) 248 | } 249 | (_, KeyCode::End | KeyCode::Char('G')) => { 250 | self.scroll = self.scrollmax; 251 | Ok(AppScreenEvent::None) 252 | } 253 | (_, KeyCode::Char('o')) => { 254 | self.open_external_url(&self.entries[self.current_index].url) 255 | } 256 | (_, KeyCode::Char('n')) => { 257 | self.next_entry(); 258 | Ok(AppScreenEvent::None) 259 | } 260 | (_, KeyCode::Char('p')) => { 261 | self.previous_entry(); 262 | Ok(AppScreenEvent::None) 263 | } 264 | (_, KeyCode::Char('>')) => { 265 | self.increase_reader_width()?; 266 | Ok(AppScreenEvent::None) 267 | } 268 | (_, KeyCode::Char('<')) => { 269 | self.decrease_reader_width()?; 270 | Ok(AppScreenEvent::None) 271 | } 272 | (_, KeyCode::Char('?')) => Ok(AppScreenEvent::OpenDialog(Box::new(HelpDialog::new( 273 | self.get_full_instructions(), 274 | )))), 275 | _ => Ok(AppScreenEvent::None), 276 | } 277 | } 278 | 279 | fn pause(&mut self) {} 280 | 281 | fn unpause(&mut self) {} 282 | 283 | fn quit(&mut self) {} 284 | 285 | fn get_title(&self) -> String { 286 | String::from("Reader") 287 | } 288 | 289 | fn get_instructions(&self) -> String { 290 | String::from("?: Help | j/k/↓/↑: scroll | n/p: next/prev | o: open | Esc/q: leave") 291 | } 292 | 293 | fn get_work_status(&self) -> AppWorkStatus { 294 | AppWorkStatus::None 295 | } 296 | 297 | fn get_full_instructions(&self) -> String { 298 | String::from( 299 | "j/k/↓/↑: scroll\ng/G: go to beginning or end of file\n: change reader width\n\n n/p: next/previous entry\no: open externally\n\nEsc/q: leave", 300 | ) 301 | } 302 | } 303 | 304 | #[cfg(test)] 305 | mod tests { 306 | use super::*; 307 | use crate::core::library::feedlibrary::FeedLibrary; 308 | 309 | fn create_test_entries() -> Vec { 310 | vec![ 311 | FeedEntry { 312 | title: "Entry 1".to_string(), 313 | ..Default::default() 314 | }, 315 | FeedEntry { 316 | title: "Entry 2".to_string(), 317 | ..Default::default() 318 | }, 319 | FeedEntry { 320 | title: "Entry 3".to_string(), 321 | ..Default::default() 322 | }, 323 | ] 324 | } 325 | 326 | #[test] 327 | fn test_navigation() { 328 | let (library, _temp_dir) = FeedLibrary::new_for_test(); 329 | let entries = create_test_entries(); 330 | let mut reader_screen = ReaderScreen::new(Rc::new(RefCell::new(library)), entries, 0); 331 | 332 | // Test next_entry 333 | assert_eq!(reader_screen.current_index, 0); 334 | reader_screen.next_entry(); 335 | assert_eq!(reader_screen.current_index, 1); 336 | reader_screen.next_entry(); 337 | assert_eq!(reader_screen.current_index, 2); 338 | // Should not go past the last entry 339 | reader_screen.next_entry(); 340 | assert_eq!(reader_screen.current_index, 2); 341 | 342 | // Test previous_entry 343 | reader_screen.previous_entry(); 344 | assert_eq!(reader_screen.current_index, 1); 345 | reader_screen.previous_entry(); 346 | assert_eq!(reader_screen.current_index, 0); 347 | // Should not go before the first entry 348 | reader_screen.previous_entry(); 349 | assert_eq!(reader_screen.current_index, 0); 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/core/library/feedlibrary.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::eyre; 2 | use fuzzt::algorithms::normalized_levenshtein; 3 | use tracing::error; 4 | 5 | use crate::{ 6 | app::AppWorkStatus, 7 | core::{ 8 | defs, 9 | feed::{self, feedentry::FeedEntry}, 10 | library::{ 11 | data::{config::Config, librarydata::LibraryData}, 12 | feedcategory::FeedCategory, 13 | feeditem::FeedItem, 14 | settings::usersettings::UserSettings, 15 | updater::Updater, 16 | }, 17 | }, 18 | }; 19 | 20 | #[cfg(test)] 21 | use tempfile::TempDir; 22 | 23 | pub struct FeedLibrary { 24 | pub feedcategories: Vec, 25 | pub data: LibraryData, 26 | pub updater: Option, 27 | pub settings: UserSettings, 28 | } 29 | 30 | impl Default for FeedLibrary { 31 | fn default() -> Self { 32 | Self::new() 33 | } 34 | } 35 | 36 | impl FeedLibrary { 37 | pub fn new() -> Self { 38 | let config_obj = Config::new(); 39 | let data_obj = LibraryData::new(config_obj.datapath.as_ref()); 40 | 41 | let categories = match data_obj.generate_categories_tree() { 42 | Ok(c) => c, 43 | Err(e) => { 44 | error!("{}", e); 45 | std::process::exit(1); 46 | } 47 | }; 48 | 49 | Self { 50 | feedcategories: categories, 51 | data: data_obj, 52 | updater: None, 53 | settings: UserSettings::new(&config_obj.datapath).unwrap(), 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | pub fn new_for_test() -> (Self, TempDir) { 59 | let (data_obj, temp_dir) = LibraryData::new_for_test(); 60 | let categories = data_obj.generate_categories_tree().unwrap(); 61 | ( 62 | Self { 63 | feedcategories: categories, 64 | data: data_obj, 65 | updater: None, 66 | settings: UserSettings::new(temp_dir.path()).unwrap(), 67 | }, 68 | temp_dir, 69 | ) 70 | } 71 | 72 | pub fn add_feed_from_url( 73 | &mut self, 74 | url: &str, 75 | category: &Option, 76 | ) -> color_eyre::Result { 77 | let (mut feed, text) = feed::feedparser::get_feed_with_data(url)?; 78 | 79 | feed.category = category 80 | .clone() 81 | .unwrap_or_else(|| String::from(defs::DATA_CATEGORY_DEFAULT)); 82 | 83 | self.add_feed(feed, Some(text)) 84 | } 85 | 86 | pub fn add_feed( 87 | &mut self, 88 | feed: FeedItem, 89 | text: Option, 90 | ) -> color_eyre::Result { 91 | // check if feed already in library 92 | if self.data.feed_exists(&feed.slug, &feed.category) { 93 | return Err(eyre!("Feed {:?} already exists", feed.title)); 94 | } 95 | 96 | // then create 97 | self.data.feed_create(&feed)?; 98 | 99 | // then update 100 | // but let's only update the text is present. because of tests. maybve not the best 101 | // approach, but... 102 | if text.is_some() { 103 | self.data.update_feed_entries(&feed.category, &feed, text)?; 104 | } 105 | 106 | Ok(feed) 107 | } 108 | 109 | pub fn delete_feed(&self, slug: &str, category: &str) -> color_eyre::Result<()> { 110 | self.data.delete_feed(slug, category) 111 | } 112 | 113 | pub fn get_feed_entries_by_category( 114 | &self, 115 | categorytitle: &str, 116 | ) -> color_eyre::Result> { 117 | let mut entries = vec![]; 118 | 119 | for category in self.feedcategories.iter() { 120 | if category.title == categorytitle { 121 | for feed in category.feeds.iter() { 122 | entries.extend(self.data.load_feed_entries(category, feed)?); 123 | } 124 | } 125 | } 126 | 127 | entries.sort_by(|a, b| b.date.cmp(&a.date)); 128 | Ok(entries) 129 | } 130 | 131 | pub fn get_feed_entries_by_item_slug(&self, slug: &str) -> color_eyre::Result> { 132 | for category in self.feedcategories.iter() { 133 | for feed in category.feeds.iter() { 134 | if feed.slug == slug { 135 | let mut entries = self.data.load_feed_entries(category, feed)?; 136 | 137 | entries.sort_by(|a, b| b.date.cmp(&a.date)); 138 | return Ok(entries); 139 | } 140 | } 141 | } 142 | 143 | Ok(vec![]) 144 | } 145 | 146 | pub fn start_updater(&mut self) { 147 | self.updater = Some(Updater::new(self.feedcategories.clone())); 148 | } 149 | 150 | pub fn update(&mut self) { 151 | if let Some(updater) = self.updater.as_ref() 152 | && updater.finished.load(std::sync::atomic::Ordering::Relaxed) 153 | { 154 | self.updater = None; 155 | } 156 | } 157 | 158 | pub fn get_update_status(&self) -> AppWorkStatus { 159 | if let Some(updater) = self.updater.as_ref() { 160 | let total: f32 = self 161 | .feedcategories 162 | .iter() 163 | .map(|cat| cat.feeds.len() as f32) 164 | .sum(); 165 | 166 | AppWorkStatus::Working( 167 | 1.0_f32.min( 168 | updater 169 | .total_completed 170 | .load(std::sync::atomic::Ordering::Relaxed) as f32 171 | / total, 172 | ), 173 | updater.last_completed.lock().unwrap().to_string(), 174 | ) 175 | } else { 176 | AppWorkStatus::None 177 | } 178 | } 179 | 180 | pub fn get_matching_feeds(&self, ident: &str) -> Vec<&FeedItem> { 181 | let mut matching_vec: Vec<&FeedItem> = Vec::new(); 182 | 183 | // Check for matching feeds and push to vec 184 | for category in self.feedcategories.iter() { 185 | for feed in category.feeds.iter() { 186 | let slug_score = normalized_levenshtein(&feed.slug, ident); 187 | let title_score = normalized_levenshtein(&feed.title, ident); 188 | let url_score = normalized_levenshtein(&feed.feed_url, ident); 189 | let max_score = slug_score.max(title_score).max(url_score); 190 | 191 | if max_score > 0.6 { 192 | matching_vec.push(feed); 193 | } 194 | } 195 | } 196 | 197 | matching_vec 198 | } 199 | 200 | pub fn add_to_read_later(&mut self, entry: &FeedEntry) -> color_eyre::Result<()> { 201 | self.data.add_to_read_later(entry) 202 | } 203 | 204 | pub fn remove_from_read_later(&mut self, file_path: &str) -> color_eyre::Result<()> { 205 | self.data.remove_from_read_later(file_path) 206 | } 207 | 208 | pub fn has_read_later_entries(&mut self) -> bool { 209 | match self.get_read_later_feed_entries() { 210 | Ok(entries) => !entries.is_empty(), 211 | Err(_) => false, 212 | } 213 | } 214 | 215 | pub fn is_in_read_later(&mut self, file_path: &str) -> bool { 216 | self.data 217 | .is_in_read_later(file_path) 218 | .map_err(|e| { 219 | error!("{e}"); 220 | false 221 | }) 222 | .unwrap() 223 | } 224 | 225 | pub fn get_read_later_feed_entries(&mut self) -> color_eyre::Result> { 226 | self.data.get_read_later_feed_entries() 227 | } 228 | } 229 | 230 | #[cfg(test)] 231 | mod tests { 232 | use crate::core::library::feedlibrary::FeedLibrary; 233 | 234 | #[test] 235 | fn test_add_and_delete_feed() { 236 | // 1. Create a FeedLibrary that uses a temporary, in-memory database 237 | let (mut library, _temp_dir) = FeedLibrary::new_for_test(); 238 | 239 | // 2. Create a dummy FeedItem to add 240 | let feed_to_add = crate::core::library::feeditem::FeedItem { 241 | title: "My Test Feed".to_string(), 242 | slug: "my-test-feed".to_string(), 243 | category: "testing".to_string(), 244 | ..Default::default() 245 | }; 246 | 247 | // 3. Add the feed to the library and verify 248 | assert!(library.add_feed(feed_to_add.clone(), None).is_ok()); 249 | assert!(library.data.feed_exists("my-test-feed", "testing")); 250 | 251 | // 4. Delete the feed and verify 252 | assert!( 253 | library 254 | .delete_feed(&feed_to_add.slug, &feed_to_add.category) 255 | .is_ok() 256 | ); 257 | assert!(!library.data.feed_exists("my-test-feed", "testing")); 258 | } 259 | 260 | fn setup_test_library_for_matches() -> FeedLibrary { 261 | let (mut library, _temp_dir) = FeedLibrary::new_for_test(); 262 | 263 | let feed1 = crate::core::library::feeditem::FeedItem { 264 | title: "My Test Feed".to_string(), 265 | slug: "my-test-feed".to_string(), 266 | feed_url: "https://mytestfeed/rss".to_string(), 267 | category: "testing".to_string(), 268 | ..Default::default() 269 | }; 270 | 271 | let feed2 = crate::core::library::feeditem::FeedItem { 272 | title: "New sports feed".to_string(), 273 | slug: "new-sports-feed".to_string(), 274 | feed_url: "https://sportsfeed/rss".to_string(), 275 | category: "sports".to_string(), 276 | ..Default::default() 277 | }; 278 | 279 | let feed3 = crate::core::library::feeditem::FeedItem { 280 | title: "TechCrunch".to_string(), 281 | slug: "techcrunch".to_string(), 282 | feed_url: "https://techcrunch/feed".to_string(), 283 | category: "tech".to_string(), 284 | ..Default::default() 285 | }; 286 | 287 | assert!(library.add_feed(feed1.clone(), None).is_ok()); 288 | assert!(library.add_feed(feed2.clone(), None).is_ok()); 289 | assert!(library.add_feed(feed3.clone(), None).is_ok()); 290 | assert!(library.data.feed_exists("techcrunch", "tech")); 291 | assert!(library.data.feed_exists("new-sports-feed", "sports")); 292 | assert!(library.data.feed_exists("my-test-feed", "testing")); 293 | 294 | library.feedcategories = library.data.generate_categories_tree().unwrap(); 295 | 296 | library 297 | } 298 | 299 | #[test] 300 | fn test_exact_slug_match() { 301 | let library = setup_test_library_for_matches(); 302 | let ident = "my-test-feed"; 303 | 304 | let matches = library.get_matching_feeds(ident); 305 | assert_eq!( 306 | matches.len(), 307 | 1, 308 | "Should find exactly one match for exact slug." 309 | ); 310 | assert_eq!(matches[0].slug, ident); 311 | } 312 | 313 | #[test] 314 | fn test_typo_in_title() { 315 | let library = setup_test_library_for_matches(); 316 | let ident = "new spotfed"; 317 | 318 | let matches = library.get_matching_feeds(ident); 319 | assert_ne!(matches.len(), 0, "Should not be empty for the typo title"); 320 | assert_eq!(matches[0].title, "New sports feed"); 321 | } 322 | 323 | #[test] 324 | fn test_url_match() { 325 | let library = setup_test_library_for_matches(); 326 | let ident = "http:/techrunch.com/fed"; 327 | 328 | let matches = library.get_matching_feeds(ident); 329 | assert_eq!( 330 | matches.len(), 331 | 1, 332 | "Should find exactly one match for the url" 333 | ); 334 | assert_eq!(matches[0].feed_url, "https://techcrunch/feed"); 335 | } 336 | 337 | #[test] 338 | fn test_low_score_no_match() { 339 | let library = setup_test_library_for_matches(); 340 | let ident = "mytest"; 341 | 342 | let matches = library.get_matching_feeds(ident); 343 | assert_eq!(matches.len(), 0); 344 | } 345 | 346 | #[test] 347 | fn test_multiple_matches() { 348 | let (mut library, _temp_dir) = FeedLibrary::new_for_test(); 349 | 350 | let feed1 = crate::core::library::feeditem::FeedItem { 351 | title: "TechCrunch".to_string(), 352 | slug: "techcrunch".to_string(), 353 | feed_url: "https://techcrunch/feed".to_string(), 354 | category: "General".to_string(), 355 | ..Default::default() 356 | }; 357 | 358 | let feed2 = crate::core::library::feeditem::FeedItem { 359 | title: "TechCrunch".to_string(), 360 | slug: "techcrunch".to_string(), 361 | feed_url: "https://techcrunch/feed".to_string(), 362 | category: "tech".to_string(), 363 | ..Default::default() 364 | }; 365 | 366 | assert!(library.add_feed(feed1.clone(), None).is_ok()); 367 | assert!(library.add_feed(feed2.clone(), None).is_ok()); 368 | library.feedcategories = library.data.generate_categories_tree().unwrap(); 369 | 370 | let ident = "techcrunch"; 371 | let matches = library.get_matching_feeds(ident); 372 | 373 | assert!(library.data.feed_exists("techcrunch", "tech")); 374 | assert!(library.data.feed_exists("techcrunch", "General")); 375 | 376 | assert_eq!(matches.len(), 2); 377 | assert_eq!( 378 | matches[0].title, matches[1].title, 379 | "Titles should be equal since both are same feeds." 380 | ); 381 | assert_ne!( 382 | matches[0].category, matches[1].category, 383 | "Category should be different for both the feeds." 384 | ); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/ui/screens/mainscreen.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use color_eyre::Result; 4 | use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; 5 | use ratatui::{ 6 | layout::{Constraint, Layout, Rect}, 7 | style::{Color, Style, Stylize}, 8 | widgets::{Block, List, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState}, 9 | }; 10 | use tracing::error; 11 | 12 | use crate::{ 13 | app::AppWorkStatus, 14 | core::{ 15 | feed::feedentry::FeedEntry, 16 | library::feedlibrary::FeedLibrary, 17 | ui::appscreen::{AppScreen, AppScreenEvent}, 18 | }, 19 | ui::{ 20 | screens::{readerscreen::ReaderScreen, themedialog::ThemeDialog, urldialog::UrlDialog}, 21 | states::{ 22 | feedentrystate::FeedEntryState, 23 | feedtreestate::{FeedItemInfo, FeedTreeState}, 24 | }, 25 | }, 26 | }; 27 | 28 | use super::helpdialog::HelpDialog; 29 | 30 | #[derive(PartialEq, Eq)] 31 | enum MainInputState { 32 | Menu, 33 | Content, 34 | } 35 | 36 | pub struct MainScreen { 37 | library: Rc>, 38 | feedtreestate: FeedTreeState, 39 | feedentrystate: FeedEntryState, 40 | inputstate: MainInputState, 41 | } 42 | 43 | impl MainScreen { 44 | pub fn new(library: Rc>) -> Self { 45 | Self { 46 | library, 47 | feedtreestate: FeedTreeState::new(), 48 | feedentrystate: FeedEntryState::new(), 49 | inputstate: MainInputState::Menu, 50 | } 51 | } 52 | 53 | fn set_all_read(&self) { 54 | let entries = match self.feedtreestate.get_selected() { 55 | Some(FeedItemInfo::Category(t)) => { 56 | match self.library.borrow().get_feed_entries_by_category(t) { 57 | Ok(entries) => entries, 58 | Err(e) => { 59 | error!("Error getting feed entries by category: {:?}", e); 60 | vec![] 61 | } 62 | } 63 | } 64 | Some(FeedItemInfo::Item(_, _, s)) => { 65 | match self.library.borrow().get_feed_entries_by_item_slug(s) { 66 | Ok(entries) => entries, 67 | Err(e) => { 68 | error!("Error getting feed entries by item slug: {:?}", e); 69 | vec![] 70 | } 71 | } 72 | } 73 | Some(FeedItemInfo::ReadLater) => { 74 | match self.library.borrow_mut().get_read_later_feed_entries() { 75 | Ok(entries) => entries, 76 | Err(e) => { 77 | error!("Error getting Read Later entries: {:?}", e); 78 | vec![] 79 | } 80 | } 81 | } 82 | _ => vec![], 83 | }; 84 | 85 | for entry in entries.iter() { 86 | self.library.borrow_mut().data.set_entry_seen(entry); 87 | } 88 | } 89 | 90 | fn open_external_url(&self, url: &str) -> Result { 91 | match open::that(url) { 92 | Ok(_) => Ok(AppScreenEvent::None), 93 | Err(_) => { 94 | error!("Couldn't invoke system browser"); 95 | Ok(AppScreenEvent::OpenDialog(Box::new(UrlDialog::new( 96 | url.to_string(), 97 | )))) 98 | } 99 | } 100 | } 101 | 102 | fn open_theme_selector(&self) -> Result { 103 | Ok(AppScreenEvent::OpenDialog(Box::new(ThemeDialog::new( 104 | self.library.clone(), 105 | )))) 106 | } 107 | 108 | fn toggle_read_later(&mut self, entry: &FeedEntry) { 109 | let file_path = entry.filepath.to_str().unwrap_or_default(); 110 | 111 | if self.library.borrow_mut().is_in_read_later(file_path) { 112 | if let Err(e) = self.library.borrow_mut().remove_from_read_later(file_path) { 113 | error!("Failed to remove from read later: {:?}", e); 114 | } 115 | } else if let Err(e) = self.library.borrow_mut().add_to_read_later(entry) { 116 | error!("Failed to add entry to read later: {:?}", e); 117 | } 118 | } 119 | 120 | fn increase_tree_width(&mut self) -> color_eyre::Result<()> { 121 | let mut l = self.library.borrow_mut(); 122 | l.settings.appearance.main_screen_tree_width = l 123 | .settings 124 | .appearance 125 | .main_screen_tree_width 126 | .saturating_add(2) 127 | .min(100); 128 | l.settings.appearance.save() 129 | } 130 | 131 | fn decrease_tree_width(&mut self) -> color_eyre::Result<()> { 132 | let mut l = self.library.borrow_mut(); 133 | l.settings.appearance.main_screen_tree_width = l 134 | .settings 135 | .appearance 136 | .main_screen_tree_width 137 | .saturating_sub(2) 138 | .min(100); 139 | l.settings.appearance.save() 140 | } 141 | } 142 | 143 | impl AppScreen for MainScreen { 144 | fn start(&mut self) { 145 | self.library.borrow_mut().start_updater(); 146 | } 147 | 148 | fn quit(&mut self) {} 149 | 150 | fn pause(&mut self) {} 151 | 152 | fn unpause(&mut self) {} 153 | 154 | fn render(&mut self, frame: &mut ratatui::Frame, area: Rect) { 155 | self.library.borrow_mut().update(); 156 | 157 | let theme = { 158 | let library = self.library.borrow(); 159 | library.settings.get_theme().unwrap().clone() 160 | }; 161 | 162 | let treewidth = self 163 | .library 164 | .borrow() 165 | .settings 166 | .appearance 167 | .main_screen_tree_width; 168 | 169 | let chunks = Layout::horizontal([ 170 | Constraint::Min(treewidth), 171 | Constraint::Percentage(85), 172 | Constraint::Length(1), 173 | ]) 174 | .split(area); 175 | 176 | // Feed tree 177 | self.feedtreestate.update(&mut self.library.borrow_mut()); 178 | 179 | let (treestyle, treeselectionstyle) = if self.inputstate == MainInputState::Menu { 180 | ( 181 | Block::default() 182 | .style(Style::default().fg(Color::from_u32(theme.base[5]))) 183 | .bg(Color::from_u32(theme.base[1])) 184 | .padding(Padding::new(2, 2, 2, 2)), 185 | Style::default() 186 | .fg(Color::from_u32(theme.base[0x2])) 187 | .bg(Color::from_u32(theme.base[0x8])), 188 | ) 189 | } else { 190 | ( 191 | Block::default() 192 | .style(Style::default().fg(Color::from_u32(theme.base[4]))) 193 | .bg(Color::from_u32(theme.base[1])) 194 | .padding(Padding::new(2, 2, 2, 2)), 195 | Style::default() 196 | .fg(Color::from_u32(theme.base[5])) 197 | .bg(Color::from_u32(theme.base[2])), 198 | ) 199 | }; 200 | 201 | let treelist = List::new(self.feedtreestate.get_items(&mut self.library.borrow_mut())) 202 | .block(treestyle) 203 | .highlight_style(treeselectionstyle); 204 | 205 | let mut treestate = self.feedtreestate.listatate.clone(); 206 | frame.render_stateful_widget(treelist, chunks[0], &mut treestate); 207 | 208 | // The feed entries 209 | self.feedentrystate.library = Some(self.library.clone()); 210 | self.feedentrystate 211 | .update(&mut self.library.borrow_mut(), &self.feedtreestate); 212 | 213 | let mut entryliststate = self.feedentrystate.listatate.clone(); 214 | 215 | let entryselectionstyle = if self.inputstate == MainInputState::Content { 216 | Style::default() 217 | .fg(Color::from_u32(theme.base[0x2])) 218 | .bg(Color::from_u32(theme.base[0x8])) 219 | } else { 220 | Style::default().bg(Color::from_u32(theme.base[2])) 221 | }; 222 | 223 | let list_widget = List::new(self.feedentrystate.get_items()) 224 | .block( 225 | Block::default() 226 | .style(Style::default().bg(Color::from_u32(theme.base[2]))) 227 | .padding(Padding::new(2, 2, 1, 1)), 228 | ) 229 | .highlight_style(entryselectionstyle); 230 | 231 | frame.render_stateful_widget(list_widget, chunks[1], &mut entryliststate); 232 | 233 | // Scrollbar 234 | let mut scrollbarstate = ScrollbarState::new(self.feedentrystate.scroll_max()) 235 | .position(self.feedentrystate.scroll()); 236 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight).style( 237 | Style::new() 238 | .fg(Color::from_u32(theme.base[3])) 239 | .bg(Color::from_u32(theme.base[2])), 240 | ); 241 | frame.render_stateful_widget(scrollbar, chunks[2], &mut scrollbarstate); 242 | } 243 | 244 | fn handle_events(&mut self) -> Result { 245 | match event::read()? { 246 | Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_keypress(key), 247 | Event::Mouse(_) => Ok(AppScreenEvent::None), 248 | Event::Resize(_, _) => Ok(AppScreenEvent::None), 249 | _ => Ok(AppScreenEvent::None), 250 | } 251 | } 252 | 253 | fn handle_keypress(&mut self, key: crossterm::event::KeyEvent) -> Result { 254 | match self.inputstate { 255 | MainInputState::Menu => match (key.modifiers, key.code) { 256 | (_, KeyCode::Esc | KeyCode::Char('q')) 257 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 258 | Ok(AppScreenEvent::ExitApp) 259 | } 260 | (_, KeyCode::Down | KeyCode::Char('j')) => { 261 | self.feedtreestate.select_next(); 262 | Ok(AppScreenEvent::None) 263 | } 264 | (_, KeyCode::Up | KeyCode::Char('k')) => { 265 | self.feedtreestate.select_previous(); 266 | Ok(AppScreenEvent::None) 267 | } 268 | (_, KeyCode::Home | KeyCode::Char('g')) => { 269 | self.feedtreestate.select_first(); 270 | Ok(AppScreenEvent::None) 271 | } 272 | (_, KeyCode::End | KeyCode::Char('G')) => { 273 | self.feedtreestate.select_last(); 274 | Ok(AppScreenEvent::None) 275 | } 276 | (_, KeyCode::Right | KeyCode::Enter | KeyCode::Tab | KeyCode::Char('l')) => { 277 | self.inputstate = MainInputState::Content; 278 | Ok(AppScreenEvent::None) 279 | } 280 | (_, KeyCode::Char('R')) => { 281 | self.set_all_read(); 282 | Ok(AppScreenEvent::None) 283 | } 284 | (_, KeyCode::Char('>')) => { 285 | self.increase_tree_width()?; 286 | Ok(AppScreenEvent::None) 287 | } 288 | (_, KeyCode::Char('<')) => { 289 | self.decrease_tree_width()?; 290 | Ok(AppScreenEvent::None) 291 | } 292 | (_, KeyCode::Char('t')) => self.open_theme_selector(), 293 | (_, KeyCode::Char('?')) => Ok(AppScreenEvent::OpenDialog(Box::new( 294 | HelpDialog::new(self.get_full_instructions()), 295 | ))), 296 | _ => Ok(AppScreenEvent::None), 297 | }, 298 | MainInputState::Content => match (key.modifiers, key.code) { 299 | (_, KeyCode::Char('q')) 300 | | (KeyModifiers::CONTROL, KeyCode::Char('c') | KeyCode::Char('C')) => { 301 | Ok(AppScreenEvent::ExitApp) 302 | } 303 | (_, KeyCode::Down | KeyCode::Char('j')) => { 304 | self.feedentrystate.select_next(); 305 | Ok(AppScreenEvent::None) 306 | } 307 | (_, KeyCode::Up | KeyCode::Char('k')) => { 308 | self.feedentrystate.select_previous(); 309 | Ok(AppScreenEvent::None) 310 | } 311 | (_, KeyCode::Home | KeyCode::Char('g')) => { 312 | self.feedentrystate.select_first(); 313 | Ok(AppScreenEvent::None) 314 | } 315 | (_, KeyCode::End | KeyCode::Char('G')) => { 316 | self.feedentrystate.select_last(); 317 | Ok(AppScreenEvent::None) 318 | } 319 | (_, KeyCode::Esc) => { 320 | self.inputstate = MainInputState::Menu; 321 | Ok(AppScreenEvent::None) 322 | } 323 | (_, KeyCode::Left | KeyCode::Char('h')) => { 324 | self.inputstate = MainInputState::Menu; 325 | Ok(AppScreenEvent::None) 326 | } 327 | (_, KeyCode::Enter) => { 328 | if let Some(entry) = self.feedentrystate.get_selected() { 329 | self.library.borrow_mut().data.set_entry_seen(&entry); 330 | self.feedentrystate.set_current_read(); 331 | 332 | Ok(AppScreenEvent::ChangeState(Box::new(ReaderScreen::new( 333 | self.library.clone(), 334 | self.feedentrystate.entries.clone(), 335 | self.feedentrystate.listatate.selected().unwrap_or(0), 336 | )))) 337 | } else { 338 | Ok(AppScreenEvent::None) 339 | } 340 | } 341 | (_, KeyCode::Char('r')) => { 342 | if let Some(entry) = self.feedentrystate.get_selected() { 343 | self.library.borrow_mut().data.toggle_entry_seen(&entry); 344 | } 345 | Ok(AppScreenEvent::None) 346 | } 347 | (_, KeyCode::Char('R')) => { 348 | self.set_all_read(); 349 | Ok(AppScreenEvent::None) 350 | } 351 | (_, KeyCode::Char('>')) => { 352 | self.increase_tree_width()?; 353 | Ok(AppScreenEvent::None) 354 | } 355 | (_, KeyCode::Char('<')) => { 356 | self.decrease_tree_width()?; 357 | Ok(AppScreenEvent::None) 358 | } 359 | (_, KeyCode::Char('o')) => { 360 | if let Some(entry) = self.feedentrystate.get_selected() { 361 | self.library.borrow_mut().data.set_entry_seen(&entry); 362 | self.open_external_url(&entry.url) 363 | } else { 364 | Ok(AppScreenEvent::None) 365 | } 366 | } 367 | (_, KeyCode::Char('L')) => { 368 | if let Some(entry) = self.feedentrystate.get_selected() { 369 | self.toggle_read_later(&entry); 370 | } 371 | 372 | Ok(AppScreenEvent::None) 373 | } 374 | (_, KeyCode::Char('t')) => self.open_theme_selector(), 375 | (_, KeyCode::Char('?')) => Ok(AppScreenEvent::OpenDialog(Box::new( 376 | HelpDialog::new(self.get_full_instructions()), 377 | ))), 378 | _ => Ok(AppScreenEvent::None), 379 | }, 380 | } 381 | } 382 | 383 | fn get_title(&self) -> String { 384 | String::from("Main") 385 | } 386 | 387 | fn get_instructions(&self) -> String { 388 | if self.inputstate == MainInputState::Menu { 389 | String::from("?: Help | j/k/↓/↑: move | Enter: select | Esc: quit") 390 | } else { 391 | String::from( 392 | "?: Help | j/k/↓/↑: move | o: open | L: add/remove read later | Enter: read | Esc: back", 393 | ) 394 | } 395 | } 396 | 397 | fn get_work_status(&self) -> AppWorkStatus { 398 | self.library.borrow().get_update_status() 399 | } 400 | 401 | fn get_full_instructions(&self) -> String { 402 | String::from( 403 | "j/k/↓/↑: move selection\ng/G/Home/End: beginning and end of the list\n: change reader width\n\no: open link externally\nL: add/remove read later\nEnter: select category or read entry\n\nr: toggle item read state\nR: mark all of the items as read\n\nEsc/q: back from entries or quit", 404 | ) 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /src/core/library/data/librarydata.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use std::{ 3 | fs::{self, OpenOptions}, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use chrono::{Duration, Utc}; 8 | use color_eyre::eyre::eyre; 9 | use slug::slugify; 10 | use tracing::{error, info}; 11 | 12 | use crate::core::feed::feedentry::FeedEntry; 13 | use crate::core::feed::feedparser; 14 | use crate::core::library::feedcategory::FeedCategory; 15 | use crate::{ 16 | core::defs::{self, DATA_CATEGORIES_DIR, DATA_FEED, DATA_READ_LATER}, 17 | core::library::feeditem::FeedItem, 18 | }; 19 | use serde::{Deserialize, Serialize}; 20 | 21 | #[cfg(test)] 22 | use tempfile::TempDir; 23 | 24 | #[derive(Default, Debug, Serialize, Deserialize)] 25 | pub struct ReadLaterData { 26 | pub read_later: Vec, 27 | #[serde(skip_serializing, skip_deserializing)] 28 | pub loaded: bool, 29 | } 30 | 31 | pub struct LibraryData { 32 | pub path: PathBuf, 33 | pub read_later: ReadLaterData, 34 | } 35 | 36 | impl LibraryData { 37 | pub fn new(datapath: &Path) -> LibraryData { 38 | load_or_create(datapath); 39 | LibraryData { 40 | path: PathBuf::from(datapath), 41 | read_later: ReadLaterData::default(), 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | pub fn new_for_test() -> (LibraryData, TempDir) { 47 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); 48 | let path = temp_dir.path().to_path_buf(); 49 | load_or_create(&path); 50 | ( 51 | LibraryData { 52 | path, 53 | read_later: ReadLaterData::default(), 54 | }, 55 | temp_dir, 56 | ) 57 | } 58 | 59 | pub fn feed_exists(&self, slug: &str, category: &str) -> bool { 60 | let feeddata = self 61 | .path 62 | .join(DATA_CATEGORIES_DIR) 63 | .join(category) 64 | .join(slug) 65 | .join(DATA_FEED); 66 | feeddata.exists() 67 | } 68 | pub fn delete_feed(&self, slug: &str, category: &str) -> color_eyre::Result<()> { 69 | let feed_dir = self 70 | .path 71 | .join(DATA_CATEGORIES_DIR) 72 | .join(category) 73 | .join(slug); 74 | 75 | if feed_dir.exists() { 76 | fs::remove_dir_all(&feed_dir).map_err(|e| { 77 | eyre!( 78 | "Failed to delete feed directory {}: {}", 79 | feed_dir.display(), 80 | e 81 | ) 82 | }) 83 | } else { 84 | Ok(()) // Nothing to delete 85 | } 86 | } 87 | 88 | pub fn feed_create(&self, feed: &FeedItem) -> color_eyre::Result<()> { 89 | let feedir = self 90 | .path 91 | .join(DATA_CATEGORIES_DIR) 92 | .join(&feed.category) 93 | .join(&feed.slug); 94 | fs::create_dir_all(&feedir)?; 95 | 96 | let feeddata = feedir.join(DATA_FEED); 97 | let toml_str = 98 | toml::to_string(feed).map_err(|e| eyre!("Failed to serialize feed: {}", e))?; 99 | 100 | let mut file = OpenOptions::new() 101 | .write(true) 102 | .create(true) 103 | .truncate(true) 104 | .open(&feeddata) 105 | .map_err(|e| eyre!("Couldn't open file {}: {}", feeddata.display(), e))?; 106 | 107 | file.write_all(toml_str.as_bytes()) 108 | .map_err(|e| eyre!("Failed to write file {}: {}", feeddata.display(), e)) 109 | } 110 | 111 | pub fn generate_categories_tree(&self) -> color_eyre::Result> { 112 | let mut categories: Vec = Vec::new(); 113 | let catpath = self.path.join(DATA_CATEGORIES_DIR); 114 | 115 | for entry in fs::read_dir(catpath)? { 116 | let path = entry?.path(); 117 | if path.is_dir() 118 | && let Some(name) = path.file_name().and_then(|n| n.to_str()) 119 | { 120 | let cat = FeedCategory { 121 | title: String::from(name), 122 | feeds: self.load_feeds_from_category(name, path.as_path())?, 123 | }; 124 | 125 | categories.push(cat); 126 | } 127 | } 128 | 129 | Ok(categories) 130 | } 131 | 132 | pub fn load_feeds_from_category( 133 | &self, 134 | category_name: &str, 135 | category: &Path, 136 | ) -> color_eyre::Result> { 137 | let mut feeds = Vec::new(); 138 | 139 | for entry in fs::read_dir(category)? { 140 | let path = entry?.path(); 141 | if path.is_dir() { 142 | let feedpath = path.join(defs::DATA_FEED); 143 | 144 | if let Ok(file) = std::fs::read_to_string(&feedpath) { 145 | let mut feed: FeedItem = match toml::from_str(&file) { 146 | Ok(f) => f, 147 | Err(e) => { 148 | return Err(eyre!("Error: feed file can't be parsed: {}", e)); 149 | } 150 | }; 151 | 152 | feed.category = category_name.to_string(); 153 | feeds.push(feed); 154 | } 155 | } 156 | } 157 | 158 | Ok(feeds) 159 | } 160 | 161 | pub fn update_feed_entries( 162 | &self, 163 | category: &str, 164 | feed: &FeedItem, 165 | feedxml: Option, 166 | ) -> color_eyre::Result<()> { 167 | // TODO: hard coding 5 minutes for now 168 | if Utc::now().signed_duration_since(feed.lastupdated) < Duration::minutes(5) { 169 | return Ok(()); 170 | } 171 | 172 | let mut feedentries = if let Some(txt) = feedxml { 173 | feedparser::get_feed_entries_doc(&txt, &feed.author) 174 | } else { 175 | feedparser::get_feed_entries(feed) 176 | }?; 177 | 178 | feedentries.iter_mut().for_each(|e| { 179 | let entrypath = self 180 | .path 181 | .join(defs::DATA_CATEGORIES_DIR) 182 | .join(category) 183 | .join(&feed.slug); 184 | 185 | let item_slug = { 186 | let base_path = entrypath.to_string_lossy(); 187 | let max_slug_len = 250usize.saturating_sub(base_path.len() + 1); 188 | let slug = slugify(&e.title); 189 | let slug_cut = &slug[..slug.len().min(max_slug_len)]; 190 | slug_cut.to_string() 191 | }; 192 | 193 | e.filepath = entrypath.join(format!("{item_slug}.md")); 194 | }); 195 | 196 | self.update_entries(feed, feedentries) 197 | } 198 | 199 | fn update_entries(&self, feed: &FeedItem, entries: Vec) -> color_eyre::Result<()> { 200 | for entry in entries.iter().as_ref() { 201 | // if it exists, it means the entry has been setup already 202 | if !entry.filepath.exists() { 203 | let mut file = match OpenOptions::new() 204 | .write(true) 205 | .create_new(true) 206 | .open(&entry.filepath) 207 | { 208 | Ok(file) => file, 209 | Err(error) => { 210 | error!( 211 | "Error creating file '{}': {}", 212 | entry.filepath.display(), 213 | error 214 | ); 215 | 216 | break; 217 | } 218 | }; 219 | 220 | let mut entryclone = (*entry).clone(); 221 | entryclone.text = String::new(); 222 | 223 | let entrytext = format!( 224 | "---\n{}---\n{}", 225 | toml::to_string(&entryclone).unwrap_or(String::new()), 226 | &entry.text 227 | ); 228 | 229 | file.write_all(&entrytext.into_bytes())?; 230 | } 231 | } 232 | 233 | let mut feed = feed.clone(); 234 | feed.lastupdated = Utc::now(); 235 | self.feed_create(&feed)?; 236 | 237 | Ok(()) 238 | } 239 | 240 | pub fn save_feed_entry(&self, entry: &FeedEntry) -> color_eyre::Result<()> { 241 | info!("Saving {:?}", entry.filepath); 242 | 243 | let mut file = match OpenOptions::new() 244 | .write(true) 245 | .create(true) 246 | .truncate(true) 247 | .open(&entry.filepath) 248 | { 249 | Ok(file) => file, 250 | Err(error) => { 251 | return Err(eyre!( 252 | "Error creating file '{}': {}", 253 | entry.filepath.display(), 254 | error 255 | )); 256 | } 257 | }; 258 | 259 | let mut entryclone = (*entry).clone(); 260 | entryclone.text = String::new(); 261 | 262 | let entrytext = format!( 263 | "---\n{}---\n{}", 264 | toml::to_string(&entryclone).unwrap_or_default(), 265 | &entry.text, 266 | ); 267 | 268 | file.write_all(&entrytext.into_bytes())?; 269 | 270 | Ok(()) 271 | } 272 | 273 | pub fn load_feed_entries( 274 | &self, 275 | category: &FeedCategory, 276 | item: &FeedItem, 277 | ) -> color_eyre::Result> { 278 | let mut entries = vec![]; 279 | 280 | let feedir = self 281 | .path 282 | .join(DATA_CATEGORIES_DIR) 283 | .join(&category.title) 284 | .join(&item.slug); 285 | 286 | for entry in fs::read_dir(&feedir)? { 287 | let entry = entry?; 288 | let path = entry.path(); 289 | if path.is_file() { 290 | let contents = std::fs::read_to_string(&path)?; 291 | if let Ok(entry) = self.parse_feed_entry(&contents, &path) { 292 | entries.push(entry); 293 | } 294 | } 295 | } 296 | 297 | Ok(entries) 298 | } 299 | 300 | // TODO: this needs to be cached and only updated every now and then, since it's beeing pretty 301 | // intensive now 302 | pub fn get_unread_feed(&self, category: &str, feed_slug: &str) -> color_eyre::Result { 303 | let mut unread: u16 = 0; 304 | 305 | let feedir = self 306 | .path 307 | .join(DATA_CATEGORIES_DIR) 308 | .join(category) 309 | .join(feed_slug); 310 | 311 | for entry in fs::read_dir(feedir)? { 312 | let entry = entry?; 313 | let path = entry.path(); 314 | if path.is_file() { 315 | let contents = std::fs::read_to_string(&path)?; 316 | if let Ok(entry) = self.parse_feed_entry(&contents, &path) 317 | && !entry.seen 318 | { 319 | unread += 1; 320 | } 321 | } 322 | } 323 | 324 | Ok(unread) 325 | } 326 | 327 | fn parse_feed_entry(&self, contents: &str, path: &Path) -> color_eyre::Result { 328 | let parts: Vec<&str> = contents.split("---").collect(); 329 | if parts.len() < 3 { 330 | return Err(eyre!("Invalid feed entry format")); 331 | } 332 | let mut entry: FeedEntry = toml::from_str(parts[1])?; 333 | entry.filepath = path.to_path_buf(); 334 | entry.text = parts[2..].join("---"); 335 | Ok(entry) 336 | } 337 | 338 | pub fn set_entry_seen(&self, entry: &FeedEntry) { 339 | if !entry.seen { 340 | let mut entry = entry.clone(); 341 | entry.seen = true; 342 | if let Err(e) = self.save_feed_entry(&entry) { 343 | error!("Couldn't set entry seen: {:?}", e); 344 | } 345 | } 346 | } 347 | 348 | pub fn toggle_entry_seen(&self, entry: &FeedEntry) { 349 | let mut entry = entry.clone(); 350 | entry.seen = !entry.seen; 351 | if let Err(e) = self.save_feed_entry(&entry) { 352 | error!("Couldn't toggle entry seen: {:?}", e); 353 | } 354 | } 355 | 356 | pub fn add_to_read_later(&mut self, entry: &FeedEntry) -> color_eyre::Result<()> { 357 | self.ensure_read_later()?; 358 | 359 | let rel_path = 360 | self.absolute_path_to_relative_path(entry.filepath.to_str().unwrap_or_default()); 361 | 362 | if rel_path.is_empty() { 363 | return Ok(()); 364 | } 365 | 366 | // check if entry already exits 367 | if self.read_later.read_later.iter().any(|p| p == &rel_path) { 368 | return Ok(()); 369 | } 370 | 371 | self.read_later.read_later.push(rel_path); 372 | self.save_read_later(&self.read_later)?; 373 | 374 | Ok(()) 375 | } 376 | 377 | pub fn remove_from_read_later(&mut self, file_path: &str) -> color_eyre::Result<()> { 378 | self.ensure_read_later()?; 379 | 380 | let rel_path = self.absolute_path_to_relative_path(file_path); 381 | 382 | self.read_later.read_later.retain(|p| p != &rel_path); 383 | self.save_read_later(&self.read_later)?; 384 | 385 | Ok(()) 386 | } 387 | 388 | pub fn get_read_later_feed_entries(&mut self) -> color_eyre::Result> { 389 | let read_later_list = self.load_read_later()?; 390 | let mut feed_entries: Vec = Vec::new(); 391 | 392 | for rel in read_later_list.read_later { 393 | let full_path = self.path.join(DATA_CATEGORIES_DIR).join(rel); 394 | if let Ok(contents) = std::fs::read_to_string(&full_path) 395 | && let Ok(fe) = self.parse_feed_entry(&contents, &full_path) 396 | { 397 | feed_entries.push(fe); 398 | } 399 | } 400 | Ok(feed_entries) 401 | } 402 | 403 | pub fn is_in_read_later(&mut self, file_path: &str) -> color_eyre::Result { 404 | self.ensure_read_later()?; 405 | 406 | let rel_path = self.absolute_path_to_relative_path(file_path); 407 | Ok(self.read_later.read_later.iter().any(|p| p == &rel_path)) 408 | } 409 | 410 | fn ensure_read_later(&mut self) -> color_eyre::Result<()> { 411 | if !self.read_later.loaded { 412 | self.read_later = self.load_read_later()?; 413 | } 414 | 415 | Ok(()) 416 | } 417 | 418 | fn load_read_later(&mut self) -> color_eyre::Result { 419 | let read_later_path = self.path.join(DATA_READ_LATER); 420 | if !read_later_path.exists() { 421 | return Ok(ReadLaterData::default()); 422 | } 423 | 424 | let contents = std::fs::read_to_string(&read_later_path)?; 425 | let mut read_later: ReadLaterData = toml::from_str(&contents) 426 | .map_err(|e| eyre!("Failed to parse read later data: {}", e))?; 427 | 428 | // Cleanup: drop non-existent entries 429 | let original_len = read_later.read_later.len(); 430 | read_later.read_later.retain(|rel| { 431 | let full_path = self.path.join(DATA_CATEGORIES_DIR).join(rel); 432 | full_path.exists() 433 | }); 434 | 435 | read_later.loaded = true; 436 | 437 | if read_later.read_later.len() < original_len { 438 | let _ = self.save_read_later(&read_later); 439 | } 440 | 441 | Ok(read_later) 442 | } 443 | 444 | fn save_read_later(&self, read_later_list: &ReadLaterData) -> color_eyre::Result<()> { 445 | let read_later_path = self.path.join(DATA_READ_LATER); 446 | let toml_str = toml::to_string(read_later_list) 447 | .map_err(|e| eyre!("Failed to serialize read later data: {}", e))?; 448 | 449 | let mut file = OpenOptions::new() 450 | .write(true) 451 | .create(true) 452 | .truncate(true) 453 | .open(&read_later_path) 454 | .map_err(|e| { 455 | eyre!( 456 | "Couldn't open read later file {}: {}", 457 | read_later_path.display(), 458 | e 459 | ) 460 | })?; 461 | 462 | file.write_all(toml_str.as_bytes()).map_err(|e| { 463 | eyre!( 464 | "Failed to write read later file {}: {}", 465 | read_later_path.display(), 466 | e 467 | ) 468 | }) 469 | } 470 | 471 | fn absolute_path_to_relative_path(&self, file_path: &str) -> String { 472 | let path = Path::new(file_path); 473 | 474 | let prefix = self.path.join(DATA_CATEGORIES_DIR); 475 | 476 | if let Ok(rel_path) = path.strip_prefix(&prefix) { 477 | rel_path.to_str().unwrap_or_default().to_string() 478 | } else { 479 | String::new() 480 | } 481 | } 482 | } 483 | 484 | pub fn load_or_create(path: &Path) { 485 | let datapath = Path::new(path); 486 | std::fs::create_dir_all(datapath).expect("Error: Failed to create datapath directory"); 487 | std::fs::create_dir_all(datapath.join(defs::DATA_CATEGORIES_DIR)) 488 | .expect("Error: Failed to create datapath directory"); 489 | } 490 | -------------------------------------------------------------------------------- /src/core/library/settings/themedata.rs: -------------------------------------------------------------------------------- 1 | // This is a generated file. Check `build.rs` 2 | use crate::core::library::settings::theme::Theme; 3 | 4 | use std::collections::HashMap; 5 | pub fn get_themes() -> HashMap { 6 | let mut m = HashMap::new(); 7 | m.insert( 8 | "Porple".to_string(), 9 | Theme { 10 | scheme: "Porple".to_string(), 11 | author: "Niek den Breeje (https://github.com/AuditeMarlow)".to_string(), 12 | base00: "292c36".to_string(), 13 | base01: "333344".to_string(), 14 | base02: "474160".to_string(), 15 | base03: "65568a".to_string(), 16 | base04: "b8b8b8".to_string(), 17 | base05: "d8d8d8".to_string(), 18 | base06: "e8e8e8".to_string(), 19 | base07: "f8f8f8".to_string(), 20 | base08: "f84547".to_string(), 21 | base09: "d28e5d".to_string(), 22 | base0A: "efa16b".to_string(), 23 | base0B: "95c76f".to_string(), 24 | base0C: "64878f".to_string(), 25 | base0D: "8485ce".to_string(), 26 | base0E: "b74989".to_string(), 27 | base0F: "986841".to_string(), 28 | base: [ 29 | 2698294, 3355460, 4669792, 6641290, 12105912, 14211288, 15263976, 16316664, 30 | 16270663, 13799005, 15704427, 9815919, 6588303, 8685006, 12011913, 9988161, 31 | ], 32 | }, 33 | ); 34 | m.insert( 35 | "OneDark".to_string(), 36 | Theme { 37 | scheme: "OneDark".to_string(), 38 | author: "Lalit Magant (http://github.com/tilal6991)".to_string(), 39 | base00: "282c34".to_string(), 40 | base01: "353b45".to_string(), 41 | base02: "3e4451".to_string(), 42 | base03: "545862".to_string(), 43 | base04: "565c64".to_string(), 44 | base05: "abb2bf".to_string(), 45 | base06: "b6bdca".to_string(), 46 | base07: "c8ccd4".to_string(), 47 | base08: "e06c75".to_string(), 48 | base09: "d19a66".to_string(), 49 | base0A: "e5c07b".to_string(), 50 | base0B: "98c379".to_string(), 51 | base0C: "56b6c2".to_string(), 52 | base0D: "61afef".to_string(), 53 | base0E: "c678dd".to_string(), 54 | base0F: "be5046".to_string(), 55 | base: [ 56 | 2632756, 3488581, 4080721, 5527650, 5659748, 11252415, 11976138, 13159636, 57 | 14707829, 13736550, 15057019, 10011513, 5682882, 6402031, 13007069, 12472390, 58 | ], 59 | }, 60 | ); 61 | m.insert( 62 | "bulletty".to_string(), 63 | Theme { 64 | scheme: "bulletty".to_string(), 65 | author: "Bruno Croci".to_string(), 66 | base00: "1c1c1c".to_string(), 67 | base01: "262626".to_string(), 68 | base02: "3a3a3a".to_string(), 69 | base03: "4d4d4d".to_string(), 70 | base04: "707070".to_string(), 71 | base05: "a0a0a0".to_string(), 72 | base06: "b9b9b9".to_string(), 73 | base07: "999999".to_string(), 74 | base08: "be5b5b".to_string(), 75 | base09: "86ad80".to_string(), 76 | base0A: "5f9341".to_string(), 77 | base0B: "479b5a".to_string(), 78 | base0C: "3d997d".to_string(), 79 | base0D: "4a5f74".to_string(), 80 | base0E: "5980b6".to_string(), 81 | base0F: "b16557".to_string(), 82 | base: [ 83 | 1842204, 2500134, 3815994, 5066061, 7368816, 10526880, 12171705, 10066329, 84 | 12475227, 8826240, 6263617, 4692826, 4036989, 4874100, 5865654, 11625815, 85 | ], 86 | }, 87 | ); 88 | m.insert( 89 | "Sakura".to_string(), 90 | Theme { 91 | scheme: "Sakura".to_string(), 92 | author: "Misterio77 (http://github.com/Misterio77)".to_string(), 93 | base00: "feedf3".to_string(), 94 | base01: "f8e2e7".to_string(), 95 | base02: "e0ccd1".to_string(), 96 | base03: "755f64".to_string(), 97 | base04: "665055".to_string(), 98 | base05: "564448".to_string(), 99 | base06: "42383a".to_string(), 100 | base07: "33292b".to_string(), 101 | base08: "df2d52".to_string(), 102 | base09: "f6661e".to_string(), 103 | base0A: "c29461".to_string(), 104 | base0B: "2e916d".to_string(), 105 | base0C: "1d8991".to_string(), 106 | base0D: "006e93".to_string(), 107 | base0E: "5e2180".to_string(), 108 | base0F: "ba0d35".to_string(), 109 | base: [ 110 | 16707059, 16311015, 14732497, 7692132, 6705237, 5653576, 4339770, 3352875, 111 | 14626130, 16147998, 12751969, 3051885, 1935761, 28307, 6168960, 12193077, 112 | ], 113 | }, 114 | ); 115 | m.insert( 116 | "Rosé Pine".to_string(), 117 | Theme { 118 | scheme: "Rosé Pine".to_string(), 119 | author: "Emilia Dunfelt ".to_string(), 120 | base00: "191724".to_string(), 121 | base01: "1f1d2e".to_string(), 122 | base02: "26233a".to_string(), 123 | base03: "6e6a86".to_string(), 124 | base04: "908caa".to_string(), 125 | base05: "e0def4".to_string(), 126 | base06: "e0def4".to_string(), 127 | base07: "524f67".to_string(), 128 | base08: "eb6f92".to_string(), 129 | base09: "f6c177".to_string(), 130 | base0A: "ebbcba".to_string(), 131 | base0B: "31748f".to_string(), 132 | base0C: "9ccfd8".to_string(), 133 | base0D: "c4a7e7".to_string(), 134 | base0E: "f6c177".to_string(), 135 | base0F: "524f67".to_string(), 136 | base: [ 137 | 1644324, 2039086, 2499386, 7236230, 9473194, 14737140, 14737140, 5394279, 15429522, 138 | 16171383, 15449274, 3241103, 10276824, 12888039, 16171383, 5394279, 139 | ], 140 | }, 141 | ); 142 | m.insert( 143 | "One Light".to_string(), 144 | Theme { 145 | scheme: "One Light".to_string(), 146 | author: "Daniel Pfeifer (http://github.com/purpleKarrot)".to_string(), 147 | base00: "fafafa".to_string(), 148 | base01: "f0f0f1".to_string(), 149 | base02: "e5e5e6".to_string(), 150 | base03: "a0a1a7".to_string(), 151 | base04: "696c77".to_string(), 152 | base05: "383a42".to_string(), 153 | base06: "202227".to_string(), 154 | base07: "090a0b".to_string(), 155 | base08: "ca1243".to_string(), 156 | base09: "d75f00".to_string(), 157 | base0A: "c18401".to_string(), 158 | base0B: "50a14f".to_string(), 159 | base0C: "0184bc".to_string(), 160 | base0D: "4078f2".to_string(), 161 | base0E: "a626a4".to_string(), 162 | base0F: "986801".to_string(), 163 | base: [ 164 | 16448250, 15790321, 15066598, 10527143, 6909047, 3684930, 2105895, 592395, 165 | 13242947, 14114560, 12682241, 5284175, 99516, 4225266, 10888868, 9988097, 166 | ], 167 | }, 168 | ); 169 | m.insert( 170 | "Decaf".to_string(), 171 | Theme { 172 | scheme: "Decaf".to_string(), 173 | author: "Alex Mirrington (https://github.com/alexmirrington)".to_string(), 174 | base00: "2d2d2d".to_string(), 175 | base01: "393939".to_string(), 176 | base02: "515151".to_string(), 177 | base03: "777777".to_string(), 178 | base04: "b4b7b4".to_string(), 179 | base05: "cccccc".to_string(), 180 | base06: "e0e0e0".to_string(), 181 | base07: "ffffff".to_string(), 182 | base08: "ff7f7b".to_string(), 183 | base09: "ffbf70".to_string(), 184 | base0A: "ffd67c".to_string(), 185 | base0B: "beda78".to_string(), 186 | base0C: "bed6ff".to_string(), 187 | base0D: "90bee1".to_string(), 188 | base0E: "efb3f7".to_string(), 189 | base0F: "ff93b3".to_string(), 190 | base: [ 191 | 2960685, 3750201, 5329233, 7829367, 11843508, 13421772, 14737632, 16777215, 192 | 16744315, 16760688, 16766588, 12507768, 12506879, 9486049, 15709175, 16749491, 193 | ], 194 | }, 195 | ); 196 | m.insert( 197 | "Silk Dark".to_string(), 198 | Theme { 199 | scheme: "Silk Dark".to_string(), 200 | author: "Gabriel Fontes (https://github.com/Misterio77)".to_string(), 201 | base00: "0e3c46".to_string(), 202 | base01: "1D494E".to_string(), 203 | base02: "2A5054".to_string(), 204 | base03: "587073".to_string(), 205 | base04: "9DC8CD".to_string(), 206 | base05: "C7DBDD".to_string(), 207 | base06: "CBF2F7".to_string(), 208 | base07: "D2FAFF".to_string(), 209 | base08: "fb6953".to_string(), 210 | base09: "fcab74".to_string(), 211 | base0A: "fce380".to_string(), 212 | base0B: "73d8ad".to_string(), 213 | base0C: "3fb2b9".to_string(), 214 | base0D: "46bddd".to_string(), 215 | base0E: "756b8a".to_string(), 216 | base0F: "9b647b".to_string(), 217 | base: [ 218 | 932934, 1919310, 2773076, 5795955, 10340557, 13097949, 13366007, 13826815, 219 | 16476499, 16558964, 16573312, 7592109, 4174521, 4636125, 7695242, 10183803, 220 | ], 221 | }, 222 | ); 223 | m.insert( 224 | "Sagelight".to_string(), 225 | Theme { 226 | scheme: "Sagelight".to_string(), 227 | author: "Carter Veldhuizen".to_string(), 228 | base00: "f8f8f8".to_string(), 229 | base01: "e8e8e8".to_string(), 230 | base02: "d8d8d8".to_string(), 231 | base03: "b8b8b8".to_string(), 232 | base04: "585858".to_string(), 233 | base05: "383838".to_string(), 234 | base06: "282828".to_string(), 235 | base07: "181818".to_string(), 236 | base08: "fa8480".to_string(), 237 | base09: "ffaa61".to_string(), 238 | base0A: "ffdc61".to_string(), 239 | base0B: "a0d2c8".to_string(), 240 | base0C: "a2d6f5".to_string(), 241 | base0D: "a0a7d2".to_string(), 242 | base0E: "c8a0d2".to_string(), 243 | base0F: "d2b2a0".to_string(), 244 | base: [ 245 | 16316664, 15263976, 14211288, 12105912, 5789784, 3684408, 2631720, 1579032, 246 | 16417920, 16755297, 16768097, 10539720, 10671861, 10528722, 13148370, 13808288, 247 | ], 248 | }, 249 | ); 250 | m.insert( 251 | "Woodland".to_string(), 252 | Theme { 253 | scheme: "Woodland".to_string(), 254 | author: "Jay Cornwall (https://jcornwall.com)".to_string(), 255 | base00: "231e18".to_string(), 256 | base01: "302b25".to_string(), 257 | base02: "48413a".to_string(), 258 | base03: "9d8b70".to_string(), 259 | base04: "b4a490".to_string(), 260 | base05: "cabcb1".to_string(), 261 | base06: "d7c8bc".to_string(), 262 | base07: "e4d4c8".to_string(), 263 | base08: "d35c5c".to_string(), 264 | base09: "ca7f32".to_string(), 265 | base0A: "e0ac16".to_string(), 266 | base0B: "b7ba53".to_string(), 267 | base0C: "6eb958".to_string(), 268 | base0D: "88a4d3".to_string(), 269 | base0E: "bb90e2".to_string(), 270 | base0F: "b49368".to_string(), 271 | base: [ 272 | 2301464, 3156773, 4735290, 10324848, 11838608, 13286577, 14141628, 14996680, 273 | 13851740, 13270834, 14724118, 12040787, 7256408, 8955091, 12292322, 11834216, 274 | ], 275 | }, 276 | ); 277 | m.insert( 278 | "Twilight".to_string(), 279 | Theme { 280 | scheme: "Twilight".to_string(), 281 | author: "David Hart (https://github.com/hartbit)".to_string(), 282 | base00: "1e1e1e".to_string(), 283 | base01: "323537".to_string(), 284 | base02: "464b50".to_string(), 285 | base03: "5f5a60".to_string(), 286 | base04: "838184".to_string(), 287 | base05: "a7a7a7".to_string(), 288 | base06: "c3c3c3".to_string(), 289 | base07: "ffffff".to_string(), 290 | base08: "cf6a4c".to_string(), 291 | base09: "cda869".to_string(), 292 | base0A: "f9ee98".to_string(), 293 | base0B: "8f9d6a".to_string(), 294 | base0C: "afc4db".to_string(), 295 | base0D: "7587a6".to_string(), 296 | base0E: "9b859d".to_string(), 297 | base0F: "9b703f".to_string(), 298 | base: [ 299 | 1973790, 3290423, 4606800, 6249056, 8618372, 10987431, 12829635, 16777215, 300 | 13593164, 13477993, 16379544, 9411946, 11519195, 7702438, 10192285, 10186815, 301 | ], 302 | }, 303 | ); 304 | m.insert( 305 | "Vice Dark".to_string(), 306 | Theme { 307 | scheme: "Vice Dark".to_string(), 308 | author: "Thomas Leon Highbaugh".to_string(), 309 | base00: "181818".to_string(), 310 | base01: "222222".to_string(), 311 | base02: "323232".to_string(), 312 | base03: "3f3f3f".to_string(), 313 | base04: "666666".to_string(), 314 | base05: "818181".to_string(), 315 | base06: "c6c6c6".to_string(), 316 | base07: "e9e9e9".to_string(), 317 | base08: "ff29a8".to_string(), 318 | base09: "85ffe0".to_string(), 319 | base0A: "f0ffaa".to_string(), 320 | base0B: "0badff".to_string(), 321 | base0C: "8265ff".to_string(), 322 | base0D: "00eaff".to_string(), 323 | base0E: "00f6d9".to_string(), 324 | base0F: "ff3d81".to_string(), 325 | base: [ 326 | 1579032, 2236962, 3289650, 4144959, 6710886, 8487297, 13027014, 15329769, 16722344, 327 | 8781792, 15794090, 765439, 8545791, 60159, 63193, 16727425, 328 | ], 329 | }, 330 | ); 331 | m.insert( 332 | "summercamp".to_string(), 333 | Theme { 334 | scheme: "summercamp".to_string(), 335 | author: "zoe firi (zoefiri.github.io)".to_string(), 336 | base00: "1c1810".to_string(), 337 | base01: "2a261c".to_string(), 338 | base02: "3a3527".to_string(), 339 | base03: "504b38".to_string(), 340 | base04: "5f5b45".to_string(), 341 | base05: "736e55".to_string(), 342 | base06: "bab696".to_string(), 343 | base07: "f8f5de".to_string(), 344 | base08: "e35142".to_string(), 345 | base09: "fba11b".to_string(), 346 | base0A: "f2ff27".to_string(), 347 | base0B: "5ceb5a".to_string(), 348 | base0C: "5aebbc".to_string(), 349 | base0D: "489bf0".to_string(), 350 | base0E: "FF8080".to_string(), 351 | base0F: "F69BE7".to_string(), 352 | base: [ 353 | 1841168, 2762268, 3814695, 5262136, 6249285, 7564885, 12236438, 16315870, 14897474, 354 | 16490779, 15925031, 6089562, 5958588, 4758512, 16744576, 16161767, 355 | ], 356 | }, 357 | ); 358 | m.insert( 359 | "Zenburn".to_string(), 360 | Theme { 361 | scheme: "Zenburn".to_string(), 362 | author: "elnawe".to_string(), 363 | base00: "383838".to_string(), 364 | base01: "404040".to_string(), 365 | base02: "606060".to_string(), 366 | base03: "6f6f6f".to_string(), 367 | base04: "808080".to_string(), 368 | base05: "dcdccc".to_string(), 369 | base06: "c0c0c0".to_string(), 370 | base07: "ffffff".to_string(), 371 | base08: "dca3a3".to_string(), 372 | base09: "dfaf8f".to_string(), 373 | base0A: "e0cf9f".to_string(), 374 | base0B: "5f7f5f".to_string(), 375 | base0C: "93e0e3".to_string(), 376 | base0D: "7cb8bb".to_string(), 377 | base0E: "dc8cc3".to_string(), 378 | base0F: "000000".to_string(), 379 | base: [ 380 | 3684408, 4210752, 6316128, 7303023, 8421504, 14474444, 12632256, 16777215, 381 | 14459811, 14659471, 14733215, 6258527, 9691363, 8173755, 14453955, 0, 382 | ], 383 | }, 384 | ); 385 | m.insert( 386 | "Nord".to_string(), 387 | Theme { 388 | scheme: "Nord".to_string(), 389 | author: "arcticicestudio".to_string(), 390 | base00: "2E3440".to_string(), 391 | base01: "3B4252".to_string(), 392 | base02: "434C5E".to_string(), 393 | base03: "4C566A".to_string(), 394 | base04: "D8DEE9".to_string(), 395 | base05: "E5E9F0".to_string(), 396 | base06: "ECEFF4".to_string(), 397 | base07: "8FBCBB".to_string(), 398 | base08: "88C0D0".to_string(), 399 | base09: "81A1C1".to_string(), 400 | base0A: "5E81AC".to_string(), 401 | base0B: "BF616A".to_string(), 402 | base0C: "D08770".to_string(), 403 | base0D: "EBCB8B".to_string(), 404 | base0E: "A3BE8C".to_string(), 405 | base0F: "B48EAD".to_string(), 406 | base: [ 407 | 3028032, 3883602, 4410462, 5002858, 14212841, 15067632, 15527924, 9419963, 8962256, 408 | 8495553, 6193580, 12542314, 13666160, 15453067, 10731148, 11833005, 409 | ], 410 | }, 411 | ); 412 | m.insert( 413 | "Black Metal".to_string(), 414 | Theme { 415 | scheme: "Black Metal".to_string(), 416 | author: "metalelf0 (https://github.com/metalelf0)".to_string(), 417 | base00: "000000".to_string(), 418 | base01: "121212".to_string(), 419 | base02: "222222".to_string(), 420 | base03: "333333".to_string(), 421 | base04: "999999".to_string(), 422 | base05: "c1c1c1".to_string(), 423 | base06: "999999".to_string(), 424 | base07: "c1c1c1".to_string(), 425 | base08: "5f8787".to_string(), 426 | base09: "aaaaaa".to_string(), 427 | base0A: "a06666".to_string(), 428 | base0B: "dd9999".to_string(), 429 | base0C: "aaaaaa".to_string(), 430 | base0D: "888888".to_string(), 431 | base0E: "999999".to_string(), 432 | base0F: "444444".to_string(), 433 | base: [ 434 | 0, 1184274, 2236962, 3355443, 10066329, 12698049, 10066329, 12698049, 6260615, 435 | 11184810, 10511974, 14522777, 11184810, 8947848, 10066329, 4473924, 436 | ], 437 | }, 438 | ); 439 | m 440 | } 441 | -------------------------------------------------------------------------------- /src/core/feed/feedparser.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; 4 | use color_eyre::eyre::eyre; 5 | use html2md::parse_html; 6 | use regex::Regex; 7 | use reqwest::blocking::Client; 8 | use roxmltree::Node; 9 | use slug::slugify; 10 | use tracing::error; 11 | 12 | use crate::core::{feed::feedentry::FeedEntry, library::feeditem::FeedItem}; 13 | 14 | pub fn get_feed_with_data(url: &str) -> color_eyre::Result<(FeedItem, String)> { 15 | let client = Client::builder() 16 | .user_agent(format!("bulletty/{}", env!("CARGO_PKG_VERSION"))) 17 | .build()?; 18 | 19 | let response = client.get(url).send()?; 20 | 21 | if !response.status().is_success() { 22 | return Err(eyre!( 23 | "Request to \"{}\" returned status code {:?}", 24 | url, 25 | response.status() 26 | )); 27 | } 28 | 29 | let body = response.text()?; 30 | Ok((parse(&body, url)?, body)) 31 | } 32 | 33 | pub fn get_feed(url: &str) -> color_eyre::Result { 34 | let (feeditem, _) = get_feed_with_data(url)?; 35 | Ok(feeditem) 36 | } 37 | 38 | fn parse(doc: &str, feed_url: &str) -> color_eyre::Result { 39 | let mut feed = FeedItem::default(); 40 | 41 | let doc = roxmltree::Document::parse(doc)?; 42 | let feed_tag = doc.root(); 43 | 44 | feed.title = feed_tag 45 | .descendants() 46 | .find(|t| t.tag_name().name() == "title") 47 | .and_then(|t| t.text().map(|s| s.trim())) 48 | .unwrap_or("") 49 | .to_string(); 50 | 51 | feed.description = feed_tag 52 | .descendants() 53 | .find(|t| t.tag_name().name() == "description" || t.tag_name().name() == "subtitle") 54 | .and_then(|t| t.text()) 55 | .unwrap_or(&feed.title) 56 | .to_string(); 57 | 58 | feed.url = feed_tag 59 | .descendants() 60 | .find(|t| t.tag_name().name() == "link") 61 | .and_then(|t| { 62 | if t.text().is_none() { 63 | t.attribute("href") 64 | } else { 65 | t.text() 66 | } 67 | }) 68 | .unwrap_or(feed_url) 69 | .to_string(); 70 | 71 | feed.feed_url = feed_url.to_string(); 72 | 73 | if let Some(author_tag) = feed_tag 74 | .descendants() 75 | .find(|t| t.tag_name().name() == "author") 76 | { 77 | if let Some(nametag) = author_tag 78 | .descendants() 79 | .find(|t| t.tag_name().name() == "name") 80 | .and_then(|t| t.text()) 81 | { 82 | feed.author = String::from(nametag); 83 | } else if let Some(text) = author_tag.text() { 84 | feed.author = String::from(text); 85 | } else { 86 | feed.author = feed.title.to_string(); 87 | } 88 | } else { 89 | feed.author = feed.title.to_string(); 90 | } 91 | 92 | feed.slug = slugify(&feed.title); 93 | 94 | Ok(feed) 95 | } 96 | 97 | pub fn get_feed_entries(feed: &FeedItem) -> color_eyre::Result> { 98 | let client = Client::builder() 99 | .user_agent(format!("bulletty/{}", env!("CARGO_PKG_VERSION"))) 100 | .build()?; 101 | 102 | let response = client.get(&feed.feed_url).send()?; 103 | 104 | if !response.status().is_success() { 105 | return Err(eyre!( 106 | "Request to \"{}\" returned status code {:?}", 107 | feed.feed_url, 108 | response.status() 109 | )); 110 | } 111 | 112 | let body = response.text()?; 113 | get_feed_entries_doc(&body, &feed.author) 114 | } 115 | 116 | pub fn get_feed_entries_doc( 117 | doctxt: &str, 118 | defaultauthor: &str, 119 | ) -> color_eyre::Result> { 120 | let doc = roxmltree::Document::parse(doctxt)?; 121 | 122 | let feed_tag = doc.root(); 123 | 124 | let mut feedentries = Vec::::new(); 125 | 126 | for entry in feed_tag 127 | .descendants() 128 | .filter(|t| t.tag_name().name() == "item" || t.tag_name().name() == "entry") 129 | { 130 | let (desc, content) = get_description_content(&entry); 131 | 132 | // date extraction 133 | let datestr = entry 134 | .descendants() 135 | .find(|t| { 136 | t.tag_name().name() == "published" 137 | || t.tag_name().name() == "updated" 138 | || t.tag_name().name() == "date" 139 | || t.tag_name().name() == "pubDate" 140 | }) 141 | .and_then(|t| t.text()) 142 | .unwrap_or("1990-09-19") 143 | .to_string(); 144 | 145 | // author extraction 146 | let entryauthor: String = if let Some(author_tag) = entry 147 | .descendants() 148 | .find(|t| t.tag_name().name() == "author" || t.tag_name().name() == "creator") 149 | { 150 | if let Some(nametag) = author_tag 151 | .descendants() 152 | .find(|t| t.tag_name().name() == "name") 153 | .and_then(|t| t.text()) 154 | { 155 | String::from(nametag) 156 | } else if let Some(text) = author_tag.text() { 157 | String::from(text) 158 | } else { 159 | defaultauthor.to_string() 160 | } 161 | } else { 162 | defaultauthor.to_string() 163 | }; 164 | 165 | // url extraction 166 | let entryurl = entry 167 | .descendants() 168 | .find(|t| t.tag_name().name() == "id" || t.tag_name().name() == "link") 169 | .and_then(|t| { 170 | if t.text().is_none() { 171 | t.attribute("href") 172 | } else { 173 | t.text() 174 | } 175 | }) 176 | .unwrap_or("NOURL") 177 | .to_string(); 178 | 179 | // feed creation 180 | let fe = FeedEntry { 181 | title: entry 182 | .descendants() 183 | .find(|t| t.tag_name().name() == "title") 184 | .and_then(|t| t.text()) 185 | .unwrap_or("NOTITLE") 186 | .to_string(), 187 | author: entryauthor, 188 | url: entryurl, 189 | text: content, 190 | date: parse_date(&datestr) 191 | .map_err(|err| error!("{:?}", err)) 192 | .unwrap_or_default(), 193 | description: desc, 194 | lastupdated: Utc::now(), 195 | seen: false, 196 | filepath: PathBuf::default(), 197 | }; 198 | 199 | feedentries.push(fe); 200 | } 201 | 202 | Ok(feedentries) 203 | } 204 | 205 | fn parse_date(date_str: &str) -> color_eyre::Result> { 206 | // Attempt to parse as RFC3339 (e.g., "2024-01-01T12:00:00Z" or "2024-01-01T12:00:00+01:00") 207 | if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) { 208 | return Ok(dt.with_timezone(&Utc)); 209 | } 210 | 211 | // Attempt to parse as RFC2822 (e.g., "Mon, 01 Jan 2024 12:00:00 +0000") 212 | if let Ok(dt) = DateTime::parse_from_rfc2822(date_str) { 213 | return Ok(dt.with_timezone(&Utc)); 214 | } 215 | 216 | // Attempt to parse a NaiveDateTime with no offset (e.g., "2024-01-01 12:00:00") 217 | let format_naive_datetime = "%Y-%m-%d %H:%M:%S"; 218 | if let Ok(naive) = NaiveDateTime::parse_from_str(date_str, format_naive_datetime) { 219 | return Ok(DateTime::::from_naive_utc_and_offset(naive, Utc)); 220 | } 221 | 222 | // Attempt to parse a NaiveDate (e.g., "2024-01-01") and set time to midnight UTC 223 | let format_naive_date = "%Y-%m-%d"; 224 | if let Ok(naive_date) = NaiveDate::parse_from_str(date_str, format_naive_date) 225 | && let Some(naive_datetime) = naive_date.and_hms_opt(0, 0, 0) 226 | { 227 | Ok(DateTime::::from_naive_utc_and_offset( 228 | naive_datetime, 229 | Utc, 230 | )) 231 | } else { 232 | Err(eyre!("Couldn't parse date: {:?}", date_str)) 233 | } 234 | } 235 | 236 | fn get_description_content(entry: &Node) -> (String, String) { 237 | let content = entry 238 | .descendants() 239 | .find(|t| t.tag_name().name() == "content" || t.tag_name().name() == "encoded") 240 | .and_then(|t| t.text()); 241 | 242 | let description = entry 243 | .descendants() 244 | .find(|t| t.tag_name().name() == "description" || t.tag_name().name() == "summary") 245 | .and_then(|t| t.text()); 246 | 247 | let content_text = match content.as_ref() { 248 | Some(text) => parse_html(text), 249 | None => match description.as_ref() { 250 | Some(desc) => parse_html(desc), 251 | None => String::new(), 252 | }, 253 | }; 254 | 255 | let description_text = match description { 256 | Some(text) => parse_html(text) 257 | .replace("\n", "") 258 | .chars() 259 | .take(280) 260 | .collect::(), 261 | None => content_text 262 | .replace("\n", "") 263 | .chars() 264 | .take(280) 265 | .collect::(), 266 | }; 267 | 268 | (strip_markdown_tags(&description_text), content_text) 269 | } 270 | 271 | fn strip_markdown_tags(input: &str) -> String { 272 | let patterns = [ 273 | r"\*\*(.*?)\*\*", // bold ** 274 | r"\*(.*?)\*", // italic * 275 | r"`(.*?)`", // inline code 276 | r"~~(.*?)~~", // strikethrough 277 | r"#+\s*", // headings 278 | r"!\[(.*?)\]\(.*?\)", // images 279 | r"\[(.*?)\]\(.*?\)", // links 280 | r">+\s*", // blockquotes 281 | r"[-*_=]{3,}", // horizontal rules 282 | r"`{3}.*?`{3}", // code blocks 283 | ]; 284 | let mut result = input.to_string(); 285 | for pat in patterns.iter() { 286 | let re = Regex::new(pat).unwrap(); 287 | result = re.replace_all(&result, "$1").to_string(); 288 | } 289 | result 290 | } 291 | 292 | #[cfg(test)] 293 | mod tests { 294 | use chrono::TimeZone; 295 | 296 | use super::*; 297 | 298 | #[test] 299 | fn test_strip_markdown_tags() { 300 | let input = "**bold** *italic* `code` ~~strike~~ [link](url) ![image](url) # heading > blockquote\n---\n"; 301 | let expected = "bold italic code strike link image heading blockquote\n\n"; 302 | assert_eq!(strip_markdown_tags(input), expected); 303 | } 304 | 305 | #[test] 306 | fn test_parse_date_various_formats() { 307 | let datetime_strings = [ 308 | "2024-01-01T12:00:00Z", // RFC3339 UTC 309 | "2024-01-01T13:00:00+01:00", // RFC3339 with offset 310 | "2024-02-29 09:00:00", // Naive datetime 311 | "2023-11-20", // Naive date 312 | "Mon, 01 Jan 2024 12:00:00 +0000", // RFC2822 313 | "Invalid Date String", // Invalid format 314 | ]; 315 | 316 | let expected = [ 317 | Some( 318 | DateTime::parse_from_rfc3339("2024-01-01T12:00:00+00:00") 319 | .unwrap() 320 | .with_timezone(&Utc), 321 | ), 322 | Some( 323 | DateTime::parse_from_rfc3339("2024-01-01T12:00:00+00:00") 324 | .unwrap() 325 | .with_timezone(&Utc), 326 | ), // 13:00+01:00 == 12:00Z 327 | Some(Utc.with_ymd_and_hms(2024, 2, 29, 9, 0, 0).unwrap()), 328 | Some(Utc.with_ymd_and_hms(2023, 11, 20, 0, 0, 0).unwrap()), 329 | Some(Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap()), 330 | None, 331 | ]; 332 | 333 | for (input, expected_str) in datetime_strings.iter().zip(expected.iter()) { 334 | let result = parse_date(input); 335 | match expected_str { 336 | Some(exp) => match result { 337 | Ok(ref dt) => assert_eq!(dt, exp, "Failed on input: {input}"), 338 | Err(e) => panic!("Expected Ok for input: {input} - Error: {e}"), 339 | }, 340 | None => assert!(result.is_err(), "Expected error for input: {input}"), 341 | } 342 | } 343 | } 344 | 345 | #[test] 346 | fn parses_rss2_channel_fields() { 347 | let xml = r#" 348 | 349 | 350 | Example RSS 351 | https://example.com/ 352 | RSS description 353 | Alice 354 | 355 | Item 1 356 | https://example.com/item1 357 | Item 1 description 358 | alice@example.com (Alice) 359 | 360 | 361 | "#; 362 | 363 | let feed = parse(xml, "NOURL").expect("failed to parse RSS 2.0"); 364 | assert_eq!(feed.title, "Example RSS"); 365 | assert_eq!(feed.description, "RSS description"); 366 | assert_eq!(feed.url, "https://example.com/"); 367 | assert!(feed.author.contains("Alice")); 368 | } 369 | 370 | #[test] 371 | fn parses_atom_feed_fields() { 372 | let xml = r#" 373 | 374 | Example Atom 375 | Atom description 376 | 377 | 378 | Bob 379 | 380 | urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 381 | 2003-12-13T18:30:02Z 382 | "#; 383 | 384 | let feed = parse(xml, "NOURL").expect("failed to parse Atom"); 385 | assert_eq!(feed.title, "Example Atom"); 386 | assert_eq!(feed.description, "Atom description"); 387 | assert_eq!(feed.url, "https://example.org/"); 388 | assert_eq!(feed.author, "Bob"); 389 | } 390 | 391 | #[test] 392 | fn rss_missing_link_uses_default_url() { 393 | let xml = r#" 394 | 395 | 396 | No Link RSS 397 | No link here 398 | Carol 399 | 400 | "#; 401 | 402 | let feed = parse(xml, "NOURL").expect("failed to parse RSS without link"); 403 | assert_eq!(feed.title, "No Link RSS"); 404 | assert_eq!(feed.description, "No link here"); 405 | assert_eq!(feed.url, "NOURL"); 406 | assert!(feed.author.contains("Carol")); 407 | } 408 | 409 | #[test] 410 | fn rss_missing_author_uses_feed_title() { 411 | let xml = r#" 412 | 413 | 414 | No Author RSS 415 | No author here 416 | 417 | "#; 418 | 419 | let feed = parse(xml, "NOURL").expect("failed to parse RSS without author"); 420 | assert_eq!(feed.title, "No Author RSS"); 421 | assert_eq!(feed.description, "No author here"); 422 | assert_eq!(feed.author, "No Author RSS"); 423 | } 424 | 425 | #[test] 426 | fn get_feed_entries_doc_parses_rss_items_variants() { 427 | let xml = r#" 428 | 429 | 430 | Example RSS 431 | https://example.com/ 432 | RSS description 433 | Carol 434 | 435 | Item A 436 | https://example.com/a 437 | Item A description 438 | Mon, 01 Jan 2024 12:00:00 +0000 439 | Item A content 440 | 441 | 442 | Item B 443 | https://example.com/b 444 | 2024-03-10T09:30:00Z 445 | Item B description 446 | 447 | 448 | "#; 449 | 450 | let entries = get_feed_entries_doc(xml, "Carol").expect("failed to parse RSS entries"); 451 | assert_eq!(entries.len(), 2); 452 | 453 | // Item A: prefers content:encoded for text, description for description, channel-level author 454 | let a = &entries[0]; 455 | assert_eq!(a.title, "Item A"); 456 | assert_eq!(a.url, "https://example.com/a"); 457 | assert_eq!(a.author, "Carol"); 458 | assert_eq!(a.text, "Item A content"); 459 | assert_eq!(a.description, "Item A description"); 460 | let expected_a_date = parse_date("Mon, 01 Jan 2024 12:00:00 +0000").unwrap(); 461 | assert_eq!(a.date, expected_a_date); 462 | 463 | // Item B: no content:encoded, uses description for both text and description, dc:date supported 464 | let b = &entries[1]; 465 | assert_eq!(b.title, "Item B"); 466 | assert_eq!(b.url, "https://example.com/b"); 467 | assert_eq!(b.author, "Carol"); 468 | assert_eq!(b.text, "Item B description"); 469 | assert_eq!(b.description, "Item B description"); 470 | let expected_b_date = DateTime::parse_from_rfc3339("2024-03-10T09:30:00Z") 471 | .unwrap() 472 | .with_timezone(&Utc); 473 | assert_eq!(b.date, expected_b_date); 474 | } 475 | 476 | #[test] 477 | fn get_feed_entries_doc_parses_atom_entries_variants() { 478 | let xml = r#" 479 | 480 | Example Atom 481 | 482 | 483 | Bob 484 | 485 | urn:uuid:feedid 486 | 2024-01-01T00:00:00Z 487 | 488 | Entry 1 489 | https://example.org/e1 490 | Summary 1 491 | Entry 1 content 492 | 2024-02-01T10:00:00Z 493 | 494 | 495 | Entry 2 496 | https://example.org/e2 497 | Entry 2 content 498 | 2024-02-05T11:30:00Z 499 | 500 | Alice 501 | 502 | 503 | 504 | Entry 3 505 | 506 | https://example.org/e3 507 | Entry 3 content 508 | 2024-02-05T11:30:00Z 509 | 510 | Alice 511 | 512 | 513 | "#; 514 | 515 | let entries = get_feed_entries_doc(xml, "Bob").expect("failed to parse Atom entries"); 516 | assert_eq!(entries.len(), 3); 517 | 518 | // Entry 1: uses summary for description, content for text, published for date, id for URL, feed-level author 519 | let e1 = &entries[0]; 520 | assert_eq!(e1.title, "Entry 1"); 521 | assert_eq!(e1.url, "https://example.org/e1"); 522 | assert_eq!(e1.author, "Bob"); 523 | assert_eq!(e1.text, "Entry 1 content"); 524 | assert_eq!(e1.description, "Summary 1"); 525 | let expected_e1_date = DateTime::parse_from_rfc3339("2024-02-01T10:00:00Z") 526 | .unwrap() 527 | .with_timezone(&Utc); 528 | assert_eq!(e1.date, expected_e1_date); 529 | 530 | // Entry 2: no summary -> description falls back to content, updated for date, id for URL 531 | let e2 = &entries[1]; 532 | assert_eq!(e2.title, "Entry 2"); 533 | assert_eq!(e2.url, "https://example.org/e2"); 534 | assert_eq!(e2.author, "Alice"); 535 | assert_eq!(e2.text, "Entry 2 content"); 536 | assert_eq!(e2.description, "Entry 2 content"); 537 | let expected_e2_date = DateTime::parse_from_rfc3339("2024-02-05T11:30:00Z") 538 | .unwrap() 539 | .with_timezone(&Utc); 540 | assert_eq!(e2.date, expected_e2_date); 541 | 542 | // Entry 3: both link tags 543 | let e3 = &entries[2]; 544 | assert_eq!(e3.title, "Entry 3"); 545 | assert_eq!(e3.url, "https://example.org/e3"); 546 | assert_eq!(e3.author, "Alice"); 547 | assert_eq!(e3.text, "Entry 3 content"); 548 | assert_eq!(e3.description, "Entry 3 content"); 549 | let expected_e3_date = DateTime::parse_from_rfc3339("2024-02-05T11:30:00Z") 550 | .unwrap() 551 | .with_timezone(&Utc); 552 | assert_eq!(e3.date, expected_e3_date); 553 | } 554 | 555 | #[test] 556 | fn get_feed_entries_doc_parses_atom_entry_level_author_overrides_feed() { 557 | let xml = r#" 558 | 559 | Example Atom 560 | 561 | 562 | Feed Author 563 | 564 | urn:uuid:feedid 565 | 2024-01-01T00:00:00Z 566 | 567 | 568 | Entry Has Own Author 569 | https://example.org/own 570 | 571 | Alice 572 | 573 | Own author content 574 | 2024-02-01T10:00:00Z 575 | 576 | 577 | 578 | Entry Falls Back To Feed Author 579 | https://example.org/fallback 580 | No entry author here 581 | 2024-02-05T11:30:00Z 582 | 583 | "#; 584 | 585 | let entries = get_feed_entries_doc(xml, "Feed Author") 586 | .expect("failed to parse Atom entries with entry-level authors"); 587 | assert_eq!(entries.len(), 2); 588 | 589 | let e1 = &entries[0]; 590 | assert_eq!(e1.title, "Entry Has Own Author"); 591 | assert_eq!(e1.url, "https://example.org/own"); 592 | assert_eq!(e1.author, "Alice"); // entry-level author should override feed-level author 593 | 594 | let e2 = &entries[1]; 595 | assert_eq!(e2.title, "Entry Falls Back To Feed Author"); 596 | assert_eq!(e2.url, "https://example.org/fallback"); 597 | assert_eq!(e2.author, "Feed Author"); // falls back to feed-level author 598 | } 599 | 600 | #[test] 601 | fn get_feed_entries_doc_parses_rss_item_level_author_overrides_channel() { 602 | let xml = r#" 603 | 604 | 605 | Example RSS 606 | https://example.com/ 607 | RSS description 608 | Channel Author 609 | 610 | Item With Author 611 | https://example.com/with-author 612 | Has its own author 613 | Alice 614 | Mon, 01 Jan 2024 12:00:00 +0000 615 | 616 | 617 | Item With DC Creator 618 | https://example.com/with-dc-creator 619 | Has dc:creator 620 | Dave 621 | 2024-02-01T10:00:00Z 622 | 623 | 624 | "#; 625 | 626 | let entries = get_feed_entries_doc(xml, "Channel Author") 627 | .expect("failed to parse RSS entries with entry-level authors"); 628 | assert_eq!(entries.len(), 2); 629 | 630 | let a = &entries[0]; 631 | assert_eq!(a.title, "Item With Author"); 632 | assert_eq!(a.url, "https://example.com/with-author"); 633 | assert_eq!(a.author, "Alice"); // item-level should override channel author 634 | 635 | let b = &entries[1]; 636 | assert_eq!(b.title, "Item With DC Creator"); 637 | assert_eq!(b.url, "https://example.com/with-dc-creator"); 638 | assert_eq!(b.author, "Dave"); // entry-level should override channel author 639 | } 640 | } 641 | --------------------------------------------------------------------------------