├── .github ├── screenshot.png ├── screenshot.svg ├── screenshot_add_feed.png ├── screenshot_add_feed.svg ├── screenshot_confirmation.png ├── screenshot_confirmation.svg ├── screenshot_edit_feed.png ├── screenshot_edit_feed.svg ├── screenshot_in_app_reader.png ├── screenshot_in_app_reader.svg ├── screenshot_save_for_later.png ├── screenshot_save_for_later.svg └── workflows │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── src └── lazyfeed │ ├── __init__.py │ ├── app.py │ ├── config_template.toml │ ├── db.py │ ├── decorators.py │ ├── feeds.py │ ├── global.tcss │ ├── http_client.py │ ├── main.py │ ├── messages.py │ ├── models.py │ ├── settings.py │ ├── utils.py │ └── widgets │ ├── __init__.py │ ├── custom_header.py │ ├── helpable.py │ ├── item_screen.py │ ├── item_table.py │ ├── modals │ ├── __init__.py │ ├── add_feed_modal.py │ ├── confirm_action_modal.py │ ├── edit_feed_modal.py │ └── help_modal.py │ ├── rss_feed_tree.py │ └── validators.py ├── tests ├── __init__.py └── test_validators.py └── uv.lock /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot.png -------------------------------------------------------------------------------- /.github/screenshot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | lazyfeed 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ↪ lazyfeedv0.5.0 125 |  feeds ─────────────── items ──────────────────────────────────────────────────────────────────────────────────────────── 126 | 25 37signals DevSurely the Mars rover needed integrated tests! (Maybe not?) (https://blog.thecodewhisperer.com/p 127 | 73 </> htmx - high powThe Ruby 19x Web Servers Booklet (https://oldmoe.blog/2009/10/07/the-ruby-19x-web-servers-bookle 128 | 18 A List Apart: The FPython N00b (https://gcollazo.com/python-n00b)                                                   129 | 10 Adam Johnson   What your tests don't need to know will hurt you (https://blog.thecodewhisperer.com/permalink/wh 130 | 10 Adrian RoselliJavaScript has “Good Parts”!? Yes, it does. (https://www.mattlayman.com/blog/2010/javascript-goo 131 | 57 Alex Molas BlogNot Just Slow: Integration Tests are a Vortex of Doom (https://blog.thecodewhisperer.com/permali 132 | 55 Andrew Quinn's TILsPretty Perl and maintainability (https://www.mattlayman.com/blog/2010/pretty-perl/)              133 | 10 Anton ZhiyanovUsing Integration Tests Mindfully: A Case Study (https://blog.thecodewhisperer.com/permalink/usi 134 | 50 Anže’s BlogJava, Eclipse, and Maven altogether (https://www.mattlayman.com/blog/2010/java-eclipse-maven/)   135 | 20 Ars Technica - All "Integration Tests are a Scam" is a Scam (https://blog.thecodewhisperer.com/permalink/integratio 136 | 40 Articles on SmashinRSpec, have_tag(), Spec::Matcher and Nokogiri (https://blog.thecodewhisperer.com/permalink/rspec 137 | 100 AsteriskAmazing and Free Software (https://cassidoo.co/post/free-software/)                              138 | 10 Awesome Python WeekThe T in Often (https://cassidoo.co/post/the-t-in-often/)                                        139 | 261 CSS In Real LifeHow to test the hard stuff (https://pyvideo.org/boston-python-meetup/boston-python-meetup--how-t 140 | 191 Cassidy WilliamsTesting: Where do I start? (https://pyvideo.org/boston-python-meetup/boston-python-meetup--testi 141 | 12 Chris CoyierNew website and server (https://www.mattlayman.com/blog/2010/new-website/)                       142 | ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 3592  143 |  o open  O open in browser  m mark as read  s save  a pending  l saved  ^c quit  ? help  R refresh  144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /.github/screenshot_add_feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot_add_feed.png -------------------------------------------------------------------------------- /.github/screenshot_confirmation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot_confirmation.png -------------------------------------------------------------------------------- /.github/screenshot_edit_feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot_edit_feed.png -------------------------------------------------------------------------------- /.github/screenshot_in_app_reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot_in_app_reader.png -------------------------------------------------------------------------------- /.github/screenshot_in_app_reader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | lazyfeed 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |  article ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 120 | 121 | 09 Jan 2013 122 | 123 | 124 | Game Jams: The most fun you can have while programming 125 | 126 | Programming is a lot of fun. It’s an awesome feeling when your code compiles without errors, the app passes all the  127 | tests, and runs without any (noticeable) bugs. It’s an awesome feeling even if you are working on a boring enterprise  128 | app. You made the primitive machine under your keyboard do something. If that isn’t amazing I don’t know what is. 129 | 130 | Now take the satisfaction you get when you produce working code and multiply it by 100. This is how awesome it feels  131 | when you are making a game. 132 | 133 | 🖼   134 | 135 | 136 | 137 | ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 138 |  ^c go back  s save  O open in browser  ? help  139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /.github/screenshot_save_for_later.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/.github/screenshot_save_for_later.png -------------------------------------------------------------------------------- /.github/screenshot_save_for_later.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | lazyfeed 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | ↪ lazyfeedv0.5.0 125 |  feeds ─────────────── items ──────────────────────────────────────────────────────────────────────────────────────────── 126 | 25 37signals Dev  About (https://www.mattlayman.com/about/)                                                         127 | 73 </> htmx - high pow  The Ruby 19x Web Servers Booklet (https://oldmoe.blog/2009/10/07/the-ruby-19x-web-servers-booklet 128 | 18 A List Apart: The F  Use Your Singletons Wisely: Ten Years Later (https://blog.thecodewhisperer.com/permalink/use-your 129 | 10 Adam Johnson  Sharing your PythonAnywhere consoles with anyone (https://blog.pythonanywhere.com/11/)            130 | 10 Adrian Roselli 131 | 57 Alex Molas Blog 132 | 55 Andrew Quinn's TILs 133 | 10 Anton Zhiyanov 134 | 50 Anže’s Blog 135 | 20 Ars Technica - All  136 | 40 Articles on Smashin 137 | 100 Asterisk 138 | 10 Awesome Python Week 139 | 261 CSS In Real Life 140 | 191 Cassidy Williams 141 | 12 Chris Coyier 142 | ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 4  143 |  o open  O open in browser  m mark as read  s save  a pending  l saved  ^c quit  ? help  R refresh  144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: ["published"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | pypi-publish: 13 | name: Upload release to PyPI 14 | runs-on: ubuntu-latest 15 | 16 | environment: 17 | name: release 18 | url: https://pypi.org/project/lazyfeed/ 19 | 20 | permissions: 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v3 28 | with: 29 | enable-cache: true 30 | 31 | - name: Set up Python 32 | run: uv python install 3.13 33 | 34 | - name: Build 35 | run: uv build 36 | 37 | - name: Publish package distributions to PyPI 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Data ### 2 | *.csv 3 | *.dat 4 | *.efx 5 | *.gbr 6 | *.key 7 | *.pps 8 | *.ppt 9 | *.pptx 10 | *.sdf 11 | *.tax2010 12 | *.vcf 13 | *.xml 14 | *.opml 15 | 16 | ### Database ### 17 | *.accdb 18 | *.db 19 | *.dbf 20 | *.mdb 21 | *.pdb 22 | *.sqlite3 23 | *.db-shm 24 | *.db-wal 25 | 26 | ### Python ### 27 | # Byte-compiled / optimized / DLL files 28 | __pycache__/ 29 | *.py[cod] 30 | *$py.class 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | build/ 38 | develop-eggs/ 39 | dist/ 40 | downloads/ 41 | eggs/ 42 | .eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | wheels/ 49 | share/python-wheels/ 50 | *.egg-info/ 51 | .installed.cfg 52 | *.egg 53 | MANIFEST 54 | 55 | # PyInstaller 56 | # Usually these files are written by a python script from a template 57 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 58 | *.manifest 59 | *.spec 60 | 61 | # Installer logs 62 | pip-log.txt 63 | pip-delete-this-directory.txt 64 | 65 | # Unit test / coverage reports 66 | htmlcov/ 67 | .tox/ 68 | .nox/ 69 | .coverage 70 | .coverage.* 71 | .cache 72 | nosetests.xml 73 | coverage.xml 74 | *.cover 75 | *.py,cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | cover/ 79 | 80 | # Translations 81 | *.mo 82 | *.pot 83 | 84 | # Django stuff: 85 | *.log 86 | local_settings.py 87 | db.sqlite3 88 | db.sqlite3-journal 89 | 90 | # Flask stuff: 91 | instance/ 92 | .webassets-cache 93 | 94 | # Scrapy stuff: 95 | .scrapy 96 | 97 | # Sphinx documentation 98 | docs/_build/ 99 | 100 | # PyBuilder 101 | .pybuilder/ 102 | target/ 103 | 104 | # Jupyter Notebook 105 | .ipynb_checkpoints 106 | 107 | # IPython 108 | profile_default/ 109 | ipython_config.py 110 | 111 | # pyenv 112 | # For a library or package, you might want to ignore these files since the code is 113 | # intended to run in multiple environments; otherwise, check them in: 114 | # .python-version 115 | 116 | # pipenv 117 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 118 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 119 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 120 | # install all needed dependencies. 121 | #Pipfile.lock 122 | 123 | # poetry 124 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 125 | # This is especially recommended for binary packages to ensure reproducibility, and is more 126 | # commonly ignored for libraries. 127 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 128 | #poetry.lock 129 | 130 | # pdm 131 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 132 | #pdm.lock 133 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 134 | # in version control. 135 | # https://pdm.fming.dev/#use-with-ide 136 | .pdm.toml 137 | 138 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 139 | __pypackages__/ 140 | 141 | # Celery stuff 142 | celerybeat-schedule 143 | celerybeat.pid 144 | 145 | # SageMath parsed files 146 | *.sage.py 147 | 148 | # Environments 149 | .env 150 | .venv 151 | env/ 152 | venv/ 153 | ENV/ 154 | env.bak/ 155 | venv.bak/ 156 | 157 | # Spyder project settings 158 | .spyderproject 159 | .spyproject 160 | 161 | # Rope project settings 162 | .ropeproject 163 | 164 | # mkdocs documentation 165 | /site 166 | 167 | # mypy 168 | .mypy_cache/ 169 | .dmypy.json 170 | dmypy.json 171 | 172 | # Pyre type checker 173 | .pyre/ 174 | 175 | # pytype static type analyzer 176 | .pytype/ 177 | 178 | # Cython debug symbols 179 | cython_debug/ 180 | 181 | # PyCharm 182 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 183 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 184 | # and can be added to the global gitignore or merged into this file. For a more nuclear 185 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 186 | #.idea/ 187 | 188 | ### Python Patch ### 189 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 190 | poetry.toml 191 | 192 | # ruff 193 | .ruff_cache/ 194 | 195 | # LSP config files 196 | pyrightconfig.json 197 | 198 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.4 4 | hooks: 5 | - id: ruff 6 | args: [--fix] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 dnlzrgz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | find . -type f -name "*.py[co]" -delete 3 | find . -type d -name "__pycache__" -delete 4 | 5 | lint: 6 | pre-commit run --all-files 7 | 8 | update: 9 | uv lock --upgrade 10 | uv sync 11 | 12 | console: 13 | textual console 14 | 15 | dev: 16 | uv run textual run --dev src/lazyfeed/main.py 17 | 18 | test: 19 | pytest -v 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazyfeed 2 | 3 |

4 | A fast, modern, and simple RSS/Atom feed reader for the terminal written in pure Python. 5 |

6 | 7 | ![Loaded screenshot](./.github/screenshot.png) 8 | 9 | ## Features 10 | 11 | - Support for RSS/Atom feeds. 12 | - Import from and export feeds. 13 | - Save for later. 14 | - Vim-like keybindings for navigation. 15 | - Theming. 16 | - "In-app" reading support. 17 | - Configuration options. 18 | 19 | ## Core dependencies 20 | 21 | - [textual](https://www.textualize.io/). 22 | - [aiohttp](https://docs.aiohttp.org/en/stable/index.html). 23 | - [feedparser](https://feedparser.readthedocs.io/en/latest/basic.html). 24 | - [sqlalchemy](https://www.sqlalchemy.org/). 25 | 26 | ## Motivation 27 | 28 | For quite some time, I have wanted to build an RSS reader for myself. While I appreciated some existing solutions, I often felt that they were missing key features or included unnecessary ones. I wanted a simple, fast, and elegant way to stay updated with my favorite feeds without the hassle of limits, ads, or cumbersome configuration files. 29 | 30 | ## Coming up 31 | 32 | - Full-text search support. 33 | - Categories. 34 | - Better in-app reading experience. 35 | - Customizable keybindings. 36 | 37 | ## Installation 38 | 39 | The recommended way is by using [uv](https://docs.astral.sh/uv/guides/tools/): 40 | 41 | ```bash 42 | uv tool install --python 3.13 lazyfeed 43 | ``` 44 | 45 | Now you just need to import your feeds from an OPML file like this: 46 | 47 | ```bash 48 | lazyfeed < ~/Downloads/feeds.opml 49 | ``` 50 | 51 | Or, after starting `lazyfeed`, adding your favorite feeds one by one: 52 | 53 | ![Add feed](./.github/screenshot_add_feed.png) 54 | 55 | ## Import and export 56 | 57 | As you can see importing your RSS feeds is pretty simple and exporting them is as simple as just doing: 58 | 59 | ```bash 60 | lazyfeed > ~/Downloads/feeds.opml 61 | ``` 62 | 63 | > At the moment only OPML is supported. 64 | 65 | ## Configuration 66 | 67 | The configuration file for `lazyfeed` is located at `$XSG_CONFIG_HOME/lazyfeed/config.toml`. This file is generated automatically the first time you run `lazyfeed` and will look something like this: 68 | 69 | ```toml 70 | # Welcome! This is the configuration file for lazyfeed. 71 | 72 | # Available themes include: 73 | # - "dracula" 74 | # - "textual-dark" 75 | # - "textual-light" 76 | # - "nord" 77 | # - "gruvbox" 78 | # - "catppuccin-mocha" 79 | # - "textual-ansi" 80 | # - "tokyo-night" 81 | # - "monokai" 82 | # - "flexoki" 83 | # - "catppuccin-latte" 84 | # - "solarized-light" 85 | theme = "dracula" 86 | 87 | # If set to true, all items will be marked as read when quitting the application. 88 | auto_read = false 89 | 90 | # If set to true, items will be fetched at start. 91 | auto_load = false 92 | 93 | # If set to false, items will be marked as read without asking for confirmation. 94 | confirm_before_read = true 95 | 96 | # Specifies by which attribute the items will be sorted. 97 | sort_by = "published_at" # "title", "is_read", "published_at" 98 | 99 | # Specifies the sort order. 100 | sort_order = "ascending" # "descending", "ascending" 101 | 102 | [client] 103 | # Maximum times (in seconds) to wait for all request operations. 104 | timeout = 300 105 | 106 | # Timeout for establishing a connection. 107 | connect_timeout = 10 108 | 109 | [client.headers] 110 | # This section defines the HTTP headers that will be sent with 111 | # each request. 112 | # User-Agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" 113 | # Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" 114 | # Accept-Language = "en-US,en;q=0.6" 115 | # Accept-Encoding = "gzip,deflate,br,zstd" 116 | ``` 117 | 118 | > The folder that holds the configuration file as well as the SQLite database is determined by the `get_app_dir` utility provided by `click`. You can read more about it [here](https://click.palletsprojects.com/en/stable/api/#click.get_app_dir). 119 | 120 | ## Usage 121 | 122 | To start using `lazyfeed` you just need to run: 123 | 124 | ```bash 125 | lazyfeed 126 | ``` 127 | 128 | ## Some screenshots 129 | 130 | ![Confirmation](./.github/screenshot_confirmation.png) 131 | ![In-app reader](./.github/screenshot_in_app_reader.png) 132 | ![Saved for later](./.github/screenshot_save_for_later.png) 133 | 134 | > The theme used for the screenshots is `dracula`. 135 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "lazyfeed" 3 | version = "0.5.9" 4 | description = "lazyfeed is a fast and simple terminal base RSS/Atom reader built using textual." 5 | authors = [{ name = "dnlzrgz", email = "contact@dnlzrgz.com" }] 6 | license = "MIT" 7 | readme = "README.md" 8 | requires-python = ">=3.12" 9 | dependencies = [ 10 | "feedparser>=6.0.11", 11 | "rich>=13.8.1", 12 | "textual>=0.79.1", 13 | "sqlalchemy>=2.0.34", 14 | "pydantic-settings>=2.5.2", 15 | "aiohttp[speedups]>=3.10.5", 16 | "markdownify>=0.14.1", 17 | "selectolax>=0.3.27", 18 | "click>=8.1.8", 19 | ] 20 | 21 | [project.urls] 22 | homepage = "https://dnlzrgz.com/projects/lazyfeed/" 23 | source = "https://github.com/dnlzrgz/lazyfeed" 24 | issues = "https://github.com/dnlzrgz/lazyfeed/issues" 25 | releases = "https://github.com/dnlzrgz/lazyfeed/releases" 26 | 27 | [project.scripts] 28 | lazyfeed = "lazyfeed:main.main" 29 | 30 | [build-system] 31 | requires = ["hatchling"] 32 | build-backend = "hatchling.build" 33 | 34 | [tool.uv] 35 | dev-dependencies = [ 36 | "commitizen>=3.29.0", 37 | "ruff>=0.6.4", 38 | "textual-dev>=1.6.1", 39 | "pytest-aiohttp>=1.0.5", 40 | "pytest-asyncio>=0.24.0", 41 | "pytest>=8.3.3", 42 | "pre-commit>=4.0.1", 43 | ] 44 | 45 | [tool.pytest.ini_options] 46 | asyncio_mode = "auto" 47 | -------------------------------------------------------------------------------- /src/lazyfeed/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from functools import wraps 3 | 4 | 5 | def rollback_session( 6 | error_message: str = "", 7 | severity: str = "error", 8 | callback: Callable | None = None, 9 | ): 10 | def decorator(func): 11 | @wraps(func) 12 | async def wrapper(self, *args, **kwargs): 13 | try: 14 | return await func(self, *args, **kwargs) 15 | except Exception as e: 16 | self.session.rollback() 17 | message = ( 18 | f"{error_message}: {e}" 19 | if error_message 20 | else f"something went wrong: {e}" 21 | ) 22 | self.notify(message, severity=severity) 23 | finally: 24 | if callback: 25 | callback(self) 26 | 27 | return wrapper 28 | 29 | return decorator 30 | -------------------------------------------------------------------------------- /src/lazyfeed/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import date 3 | from sqlalchemy import create_engine, delete, exists, func, select, update 4 | from sqlalchemy.exc import IntegrityError 5 | from sqlalchemy.orm import sessionmaker 6 | from textual import on, work 7 | from textual.app import App, ComposeResult 8 | from textual.binding import Binding 9 | from textual.reactive import var 10 | from textual.widget import Widget 11 | from textual.widgets import Footer 12 | from textual.worker import Worker, WorkerState 13 | from lazyfeed.db import init_db 14 | from lazyfeed.decorators import fetch_guard, rollback_session 15 | from lazyfeed.feeds import fetch_content, fetch_entries, fetch_feed 16 | from lazyfeed.http_client import http_client_session 17 | from lazyfeed.models import Feed, Item 18 | from lazyfeed.settings import APP_NAME, DB_URL, Settings 19 | from lazyfeed.widgets import ( 20 | CustomHeader, 21 | ItemTable, 22 | RSSFeedTree, 23 | ItemScreen, 24 | ) 25 | from lazyfeed.widgets.modals import ( 26 | AddFeedModal, 27 | EditFeedModal, 28 | ConfirmActionModal, 29 | HelpModal, 30 | ) 31 | import lazyfeed.messages as messages 32 | 33 | 34 | class LazyFeedApp(App): 35 | """ 36 | A simple and modern RSS feed reader for the terminal. 37 | """ 38 | 39 | TITLE = APP_NAME 40 | ENABLE_COMMAND_PALETTE = False 41 | CSS_PATH = "global.tcss" 42 | 43 | BINDINGS = [ 44 | Binding("ctrl+c,escape,q", "quit", "quit"), 45 | Binding("?,f1", "help", "help"), 46 | Binding("R", "refresh", "refresh"), 47 | ] 48 | 49 | is_fetching: var[bool] = var(False) 50 | show_read: var[bool] = var(False) 51 | 52 | def __init__(self, settings: Settings): 53 | super().__init__() 54 | 55 | self.settings = settings 56 | self.theme = self.settings.theme 57 | 58 | sort_column = getattr(Item, self.settings.sort_by, Item.published_at) 59 | self.sort_order = sort_column.desc() 60 | if self.settings.sort_order == "ascending": 61 | self.sort_order = sort_column.asc() 62 | 63 | engine = create_engine(f"sqlite:///{DB_URL}") 64 | init_db(engine) 65 | 66 | Session = sessionmaker(bind=engine) 67 | self.session = Session() 68 | 69 | def compose(self) -> ComposeResult: 70 | yield CustomHeader( 71 | title=f"↪ {APP_NAME}", 72 | subtitle=f"v{self.settings.version}", 73 | ) 74 | yield RSSFeedTree(label="*") 75 | yield ItemTable() 76 | yield Footer() 77 | 78 | async def on_mount(self) -> None: 79 | self.item_table = self.query_one(ItemTable) 80 | self.rss_feed_tree = self.query_one(RSSFeedTree) 81 | 82 | self.item_table.focus() 83 | 84 | await self.sync_feeds() 85 | await self.sync_items() 86 | 87 | if self.settings.auto_load: 88 | self.fetch_items() 89 | 90 | def action_help(self) -> None: 91 | widget = self.focused 92 | if not widget: 93 | self.notify("first you have to focus a widget", severity="warning") 94 | return 95 | 96 | self.push_screen(HelpModal(widget=widget)) 97 | 98 | @rollback_session() 99 | async def action_quit(self) -> None: 100 | async def callback(response: bool | None = False) -> None: 101 | if response: 102 | self.session.close() 103 | self.exit(return_code=0) 104 | 105 | if self.is_fetching: 106 | self.push_screen( 107 | ConfirmActionModal( 108 | border_title="quit", 109 | message="are you sure you want to quit while a data fetching is in progress?", 110 | action_name="quit", 111 | ), 112 | callback, 113 | ) 114 | else: 115 | if self.settings.auto_read: 116 | stmt = update(Item).where(Item.is_read.is_(False)).values(is_read=True) 117 | self.session.execute(stmt) 118 | self.session.commit() 119 | 120 | self.session.close() 121 | self.exit(return_code=0) 122 | 123 | @fetch_guard 124 | async def action_refresh(self) -> None: 125 | self.fetch_items() 126 | 127 | def toggle_widget_loading(self, widget: Widget, loading: bool = False) -> None: 128 | widget.loading = loading 129 | 130 | @on(messages.AddFeed) 131 | @fetch_guard 132 | @rollback_session("something went wrong while saving new feed") 133 | async def add_feed(self) -> None: 134 | async def callback(response: dict | None = None) -> None: 135 | if not response: 136 | return 137 | 138 | title = response.get("title", "") 139 | url = response.get("url") 140 | assert url 141 | 142 | async with http_client_session(self.settings) as client_session: 143 | stmt = select(exists().where(Feed.url == url)) 144 | feed_in_db = self.session.execute(stmt).scalar() 145 | if feed_in_db: 146 | self.notify("feed already exists", severity="error") 147 | return 148 | 149 | feed = await fetch_feed(client_session, url, title) 150 | self.session.add(feed) 151 | self.session.commit() 152 | 153 | self.notify("new feed added") 154 | await self.sync_feeds() 155 | 156 | self.push_screen(AddFeedModal(), callback) 157 | 158 | @on(messages.EditFeed) 159 | @fetch_guard 160 | @rollback_session("something went wrong while updating feed") 161 | async def update_feed(self, message: messages.EditFeed) -> None: 162 | stmt = select(Feed).where(Feed.id == message.id) 163 | feed_in_db = self.session.execute(stmt).scalar() 164 | if not feed_in_db: 165 | self.notify("feed not found", severity="error") 166 | return 167 | 168 | async def callback(response: dict | None = None) -> None: 169 | if not response: 170 | return 171 | 172 | title = response.get("title", "") 173 | url = response.get("url") 174 | assert url 175 | if not title: 176 | async with http_client_session(self.settings) as client_session: 177 | feed = await fetch_feed(client_session, url, title) 178 | title = feed.title 179 | 180 | feed_in_db.title = title 181 | feed_in_db.url = url 182 | self.session.commit() 183 | 184 | self.notify("feed updated") 185 | 186 | await self.sync_feeds() 187 | 188 | self.push_screen(EditFeedModal(feed_in_db.url, feed_in_db.title), callback) 189 | 190 | @on(messages.DeleteFeed) 191 | @fetch_guard 192 | @rollback_session("something went wrong while removing feed") 193 | async def delete_feed(self, message: messages.DeleteFeed) -> None: 194 | stmt = select(Feed).where(Feed.id == message.id) 195 | feed_in_db = self.session.execute(stmt).scalar() 196 | if not feed_in_db: 197 | self.notify("feed not found", severity="error") 198 | return 199 | 200 | async def callback(response: bool | None = False) -> None: 201 | if not response: 202 | return 203 | 204 | stmt = delete(Feed).where(Feed.id == feed_in_db.id) 205 | self.session.execute(stmt) 206 | self.session.commit() 207 | 208 | self.notify(f'feed "{feed_in_db.title}" removed') 209 | 210 | await self.sync_feeds() 211 | await self.sync_items() 212 | 213 | self.push_screen( 214 | ConfirmActionModal( 215 | border_title="remove feed", 216 | message=f'are you sure you want to remove "{feed_in_db.title}"?', 217 | action_name="remove", 218 | ), 219 | callback, 220 | ) 221 | 222 | @on(messages.FilterByFeed) 223 | @fetch_guard 224 | @rollback_session("something went wrong while getting items from feed") 225 | async def filter_by_feed(self, message: messages.FilterByFeed) -> None: 226 | self.show_read = True 227 | self.item_table.border_title = "items/by feed" 228 | 229 | stmt = ( 230 | select(Item).where(Item.feed_id.is_(message.id)).order_by(self.sort_order) 231 | ) 232 | results = self.session.execute(stmt).scalars().all() 233 | 234 | self.item_table.mount_items(results) 235 | self.item_table.focus() 236 | 237 | @on(messages.MarkAsRead) 238 | @rollback_session("something went wrong while updating item") 239 | async def mark_item_as_read(self, message: messages.MarkAsRead) -> None: 240 | item_id = message.item_id 241 | 242 | stmt = select(Item).where(Item.id == item_id) 243 | result = self.session.execute(stmt).scalar() 244 | if not result: 245 | self.notify("something went wrong while getting the item", severity="error") 246 | return 247 | 248 | stmt = update(Item).where(Item.id == item_id).values(is_read=True) 249 | self.session.execute(stmt) 250 | self.session.commit() 251 | 252 | self.session.refresh(result) 253 | 254 | if self.show_read: 255 | self.item_table.update_item(f"{item_id}", result) 256 | else: 257 | self.item_table.remove_row(row_key=f"{item_id}") 258 | 259 | self.item_table.border_subtitle = f"{self.item_table.row_count}" 260 | 261 | stmt = select(Feed).where(Feed.id == result.feed_id) 262 | result = self.session.execute(stmt).scalar() 263 | if result: 264 | stmt = select( 265 | func.coalesce(func.count(Item.id).filter(Item.is_read.is_(False)), 0) 266 | ).where(Item.feed_id == result.id) 267 | pending_posts = self.session.execute(stmt).scalar() 268 | 269 | self.rss_feed_tree.update_feed((result.id, pending_posts, result.title)) 270 | 271 | @on(messages.MarkAllAsRead) 272 | @fetch_guard 273 | @rollback_session("something went wrong while updating items") 274 | async def mark_all_items_as_read(self) -> None: 275 | async def callback(response: bool | None = False) -> None: 276 | if not response: 277 | return 278 | 279 | stmt = update(Item).where(Item.is_read.is_(False)).values(is_read=True) 280 | self.session.execute(stmt) 281 | self.session.commit() 282 | self.notify("all items marked as read") 283 | 284 | await self.sync_feeds() 285 | await self.sync_items() 286 | 287 | if self.settings.confirm_before_read: 288 | self.push_screen( 289 | ConfirmActionModal( 290 | border_title="mark all as read", 291 | message="are you sure that you want to mark all items as read?", 292 | action_name="confirm", 293 | ), 294 | callback, 295 | ) 296 | else: 297 | await callback(True) 298 | 299 | @on(messages.MarkAsPending) 300 | @rollback_session("something went wrong while updating item") 301 | async def mark_item_as_pending(self, message: messages.MarkAsPending) -> None: 302 | item_id = message.item_id 303 | 304 | stmt = select(Item).where(Item.id == item_id) 305 | result = self.session.execute(stmt).scalar() 306 | if not result: 307 | self.notify("something went wrong while getting the item", severity="error") 308 | return 309 | 310 | stmt = update(Item).where(Item.id == item_id).values(is_read=False) 311 | self.session.execute(stmt) 312 | self.session.commit() 313 | 314 | self.session.refresh(result) 315 | 316 | if self.show_read: 317 | self.item_table.update_item(f"{item_id}", result) 318 | else: 319 | self.item_table.remove_row(row_key=f"{item_id}") 320 | 321 | self.item_table.border_subtitle = f"{self.item_table.row_count}" 322 | 323 | stmt = select(Feed).where(Feed.id == result.feed_id) 324 | result = self.session.execute(stmt).scalar() 325 | if result: 326 | stmt = select( 327 | func.coalesce(func.count(Item.id).filter(Item.is_read.is_(False)), 0) 328 | ).where(Item.feed_id == result.id) 329 | pending_posts = self.session.execute(stmt).scalar() 330 | 331 | self.rss_feed_tree.update_feed((result.id, pending_posts, result.title)) 332 | 333 | @on(messages.ShowAll) 334 | @fetch_guard 335 | @rollback_session( 336 | error_message="something went wrong while getting items", 337 | callback=lambda self: self.toggle_widget_loading(self.item_table), 338 | ) 339 | async def show_all_items(self) -> None: 340 | self.show_read = True 341 | self.item_table.border_title = "items/all" 342 | 343 | stmt = select(Item).order_by(self.sort_order) 344 | results = self.session.execute(stmt).scalars().all() 345 | self.item_table.mount_items(results) 346 | 347 | @on(messages.ShowPending) 348 | @fetch_guard 349 | async def show_pending_items(self) -> None: 350 | self.show_read = False 351 | self.item_table.border_title = "items/pending" 352 | await self.sync_items() 353 | 354 | @on(messages.Open) 355 | @fetch_guard 356 | @rollback_session("something went wrong while updating item") 357 | async def open_item(self, message: messages.Open) -> None: 358 | item_id = message.item_id 359 | 360 | stmt = select(Item).where(Item.id == item_id) 361 | result = self.session.execute(stmt).scalar() 362 | if result: 363 | self.push_screen(ItemScreen(result)) 364 | self.post_message(messages.MarkAsRead(item_id)) 365 | 366 | @on(messages.OpenInBrowser) 367 | @fetch_guard 368 | @rollback_session("something went wrong while updating item") 369 | async def open_in_browser(self, message: messages.OpenInBrowser) -> None: 370 | item_id = message.item_id 371 | 372 | stmt = select(Item).where(Item.id == item_id) 373 | result = self.session.execute(stmt).scalar() 374 | if result: 375 | self.open_url(result.url) 376 | self.post_message(messages.MarkAsRead(item_id)) 377 | else: 378 | self.notify("item not found", severity="error") 379 | 380 | @on(messages.SaveForLater) 381 | @fetch_guard 382 | @rollback_session("something went wrong while updating items") 383 | async def save_for_later(self, message: messages.SaveForLater) -> None: 384 | item_id = message.item_id 385 | 386 | stmt = select(Item).where(Item.id == item_id) 387 | result = self.session.execute(stmt).scalar() 388 | if result: 389 | stmt = ( 390 | update(Item) 391 | .where(Item.id == item_id) 392 | .values(is_saved=not result.is_saved) 393 | ) 394 | self.session.execute(stmt) 395 | self.session.commit() 396 | 397 | self.session.refresh(result) 398 | self.item_table.update_item(f"{item_id}", result) 399 | 400 | @on(messages.ShowSavedForLater) 401 | @fetch_guard 402 | @rollback_session( 403 | error_message="something went wrong while getting items", 404 | callback=lambda self: self.toggle_widget_loading(self.item_table), 405 | ) 406 | async def load_saved_for_later(self) -> None: 407 | self.show_read = True 408 | self.item_table.border_title = "items/saved" 409 | 410 | stmt = select(Item).where(Item.is_saved.is_(True)).order_by(self.sort_order) 411 | results = self.session.execute(stmt).scalars().all() 412 | self.item_table.mount_items(results) 413 | 414 | @on(messages.ShowToday) 415 | @fetch_guard 416 | @rollback_session( 417 | error_message="something went wrong while getting items", 418 | callback=lambda self: self.toggle_widget_loading(self.item_table), 419 | ) 420 | async def load_today_items(self) -> None: 421 | self.show_read = True 422 | self.item_table.border_title = "items/today" 423 | 424 | today = date.today() 425 | 426 | stmt = ( 427 | select(Item) 428 | .where(func.date(Item.published_at) == today) 429 | .order_by(self.sort_order) 430 | ) 431 | results = self.session.execute(stmt).scalars().all() 432 | self.item_table.mount_items(results) 433 | 434 | @rollback_session( 435 | error_message="something went wrong while getting feeds", 436 | callback=lambda self: self.toggle_widget_loading(self.rss_feed_tree), 437 | ) 438 | async def sync_feeds(self) -> None: 439 | stmt = ( 440 | select( 441 | Feed.id, 442 | func.coalesce( 443 | func.count(Item.id).filter(Item.is_read.is_(False)), 0 444 | ).label("pending_posts"), 445 | Feed.title, 446 | ) 447 | .outerjoin(Item) 448 | .group_by(Feed.id, Feed.title) 449 | .order_by(Feed.title.asc()) 450 | ) 451 | results = self.session.execute(stmt).all() 452 | self.rss_feed_tree.mount_feeds(results) 453 | 454 | @rollback_session( 455 | error_message="something went wrong while getting items", 456 | callback=lambda self: self.toggle_widget_loading(self.item_table), 457 | ) 458 | async def sync_items(self) -> None: 459 | stmt = select(Item) 460 | if not self.show_read: 461 | stmt = stmt.where(Item.is_read.is_(False)) 462 | 463 | stmt = stmt.order_by(self.sort_order) 464 | results = self.session.execute(stmt).scalars().all() 465 | self.item_table.mount_items(results) 466 | 467 | @work(exclusive=True) 468 | async def fetch_items(self) -> None: 469 | async with http_client_session(self.settings) as client_session: 470 | feeds = self.session.query(Feed).all() 471 | n_feeds = len(feeds) 472 | 473 | for i, feed in enumerate(feeds): 474 | self.item_table.border_title = f"loading... {i + 1}/{n_feeds}" 475 | 476 | tasks = [] 477 | try: 478 | entries, etag = await fetch_entries( 479 | client_session, feed.url, feed.etag 480 | ) 481 | if not entries: 482 | continue 483 | 484 | feed.etag = etag 485 | for entry in entries: 486 | stmt = select(Item).where(Item.url == entry.link) 487 | result = self.session.execute(stmt).scalar() 488 | if result: 489 | continue 490 | 491 | tasks.append(fetch_content(client_session, entry, feed.id)) 492 | except (RuntimeError, Exception) as e: 493 | self.notify( 494 | f'something went wrong when parsing feed "{feed.title}": {e}' 495 | ) 496 | 497 | results = await asyncio.gather(*tasks, return_exceptions=True) 498 | successful_items = [ 499 | result for result in results if not isinstance(result, Exception) 500 | ] 501 | unique_items = {item.url: item for item in successful_items} 502 | try: 503 | self.session.add_all(list(unique_items.values())) 504 | self.session.commit() 505 | except (IntegrityError, Exception) as e: 506 | self.session.rollback() 507 | self.notify(f"something went wrong while saving items: {e}") 508 | 509 | @on(Worker.StateChanged) 510 | async def on_fetch_items_state(self, event: Worker.StateChanged) -> None: 511 | if event.state == WorkerState.PENDING or event.state == WorkerState.RUNNING: 512 | self.is_fetching = True 513 | self.toggle_widget_loading(self.item_table, True) 514 | else: 515 | self.is_fetching = False 516 | self.item_table.border_title = "items" 517 | 518 | await self.sync_items() 519 | await self.sync_feeds() 520 | -------------------------------------------------------------------------------- /src/lazyfeed/config_template.toml: -------------------------------------------------------------------------------- 1 | # Welcome! This is the configuration file for lazyfeed. 2 | 3 | # Available themes include: 4 | # - "dracula" 5 | # - "textual-dark" 6 | # - "textual-light" 7 | # - "nord" 8 | # - "gruvbox" 9 | # - "catppuccin-mocha" 10 | # - "textual-ansi" 11 | # - "tokyo-night" 12 | # - "monokai" 13 | # - "flexoki" 14 | # - "catppuccin-latte" 15 | # - "solarized-light" 16 | theme = "dracula" 17 | 18 | # If set to true, all items will be marked as read when quitting the application. 19 | auto_read = false 20 | 21 | # If set to true, items will be fetched at start. 22 | auto_load = false 23 | 24 | # If set to false, items will be marked as read without asking for confirmation. 25 | confirm_before_read = true 26 | 27 | # Specifies by which attribute the items will be sorted. 28 | sort_by = "published_at" # "title", "is_read", "published_at" 29 | 30 | # Specifies the sort order. 31 | sort_order = "ascending" # "descending", "ascending" 32 | 33 | [client] 34 | # Maximum times (in seconds) to wait for all request operations. 35 | timeout = 300 36 | 37 | # Timeout for establishing a connection. 38 | connect_timeout = 10 39 | 40 | [client.headers] 41 | # This section defines the HTTP headers that will be sent with 42 | # each request. 43 | # User-Agent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" 44 | # Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" 45 | # Accept-Language = "en-US,en;q=0.6" 46 | # Accept-Encoding = "gzip,deflate,br,zstd" 47 | -------------------------------------------------------------------------------- /src/lazyfeed/db.py: -------------------------------------------------------------------------------- 1 | from lazyfeed.models import Base 2 | 3 | 4 | def init_db(engine) -> None: 5 | """ 6 | Initialize database by creating all tables defined in the 7 | ORM models. 8 | """ 9 | 10 | Base.metadata.create_all(engine) 11 | -------------------------------------------------------------------------------- /src/lazyfeed/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from functools import wraps 3 | from textual.widgets.data_table import RowDoesNotExist, CellDoesNotExist 4 | 5 | 6 | def fetch_guard(func: Callable) -> Callable: 7 | """ 8 | Decorator to prevent multiple fetch request at the same time and 9 | avoid executing certain actions while a fetch is in progress. 10 | """ 11 | 12 | @wraps(func) 13 | async def wrapper(self, *args, **kwargs): 14 | if self.is_fetching: 15 | self.notify( 16 | "a data refresh is in progress... please wait until it finishes", 17 | severity="warning", 18 | ) 19 | return 20 | 21 | return await func(self, *args, **kwargs) 22 | 23 | return wrapper 24 | 25 | 26 | def rollback_session( 27 | error_message: str = "", 28 | severity: str = "error", 29 | callback: Callable | None = None, 30 | ) -> Callable: 31 | """ 32 | Decorator to handle exceptions and perform a rollback if needed. It also 33 | notifies the user with an error message and, if specified, executes a callback 34 | function at the end. 35 | """ 36 | 37 | def decorator(func): 38 | @wraps(func) 39 | async def wrapper(self, *args, **kwargs): 40 | try: 41 | return await func(self, *args, **kwargs) 42 | except (RowDoesNotExist, CellDoesNotExist): 43 | pass 44 | except Exception as e: 45 | self.session.rollback() 46 | message = ( 47 | f"{error_message}: {e}" 48 | if error_message 49 | else f"something went wrong: {e}" 50 | ) 51 | self.notify(message, severity=severity) 52 | finally: 53 | if callback: 54 | callback(self) 55 | 56 | return wrapper 57 | 58 | return decorator 59 | -------------------------------------------------------------------------------- /src/lazyfeed/feeds.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import aiohttp 3 | import feedparser 4 | from selectolax.parser import HTMLParser 5 | from markdownify import markdownify as md 6 | from lazyfeed.models import Feed, Item 7 | 8 | 9 | def clean_html(html: str) -> str | None: 10 | """ 11 | Removes unwanted content from the given HTML. 12 | 13 | Args: 14 | html (str): The HTML content to clean. 15 | 16 | Returns: 17 | str | None: The cleaned HTML content, or None in the case of error. 18 | """ 19 | 20 | tree = HTMLParser(html) 21 | tags = [ 22 | "canvas", 23 | "footer", 24 | "head", 25 | "header", 26 | "iframe", 27 | "nav", 28 | "script", 29 | "style", 30 | "noscript", 31 | ] 32 | tree.strip_tags(tags) 33 | return tree.html 34 | 35 | 36 | async def fetch_feed( 37 | client: aiohttp.ClientSession, 38 | url: str, 39 | title: str | None = None, 40 | ) -> Feed: 41 | """ 42 | Fetch and parse an RSS feed from the specified URL. 43 | 44 | Args: 45 | client (aiohttp.ClientSession): The HTTP client session to use for the request. 46 | url (str): The URL of the feed to be fetched. 47 | title (str | None): Optional title for the feed. 48 | 49 | Returns: 50 | Feed: Feed object. 51 | 52 | Raises: 53 | RuntimeError: If the feed cannot be fetched or is badly formatted. 54 | """ 55 | 56 | try: 57 | resp = await client.get(url) 58 | resp.raise_for_status() 59 | except aiohttp.ClientError as e: 60 | raise RuntimeError(f'failed to fetch feed from "{url}": {e}') 61 | 62 | content = await resp.text() 63 | d = feedparser.parse(content) 64 | if d.bozo: 65 | raise RuntimeError(f"feed is badly formatted: {d.bozo_exception}") 66 | 67 | metadata = d["channel"] 68 | feed = Feed( 69 | url=url, 70 | title=title or metadata.get("title"), 71 | site=metadata.get("link"), 72 | description=metadata.get("description", ""), 73 | ) 74 | 75 | return feed 76 | 77 | 78 | async def fetch_entries( 79 | client: aiohttp.ClientSession, 80 | url: str, 81 | etag: str = "", 82 | ) -> tuple[list[dict], str]: 83 | """ 84 | Fetch entries from the specified RSS feed URL. 85 | 86 | Args: 87 | client (aiohttp.ClientSession): The HTTP client session to use for the request. 88 | url (str): The URL of the feed to fetch entries from. 89 | etag (str): Optional ETag header to check if the feed has been updates since the last time. 90 | 91 | Returns: 92 | tuple[list[dict], str]: A tuple containing a list of entries and the ETag from the response. 93 | 94 | Raises: 95 | RuntimeError: If the feed cannot be fetched or is badly formatted. 96 | """ 97 | 98 | headers = {} 99 | if etag: 100 | headers["If-None-Match"] = etag 101 | 102 | try: 103 | resp = await client.get(url, headers=headers) 104 | resp.raise_for_status() 105 | except aiohttp.ClientError as e: 106 | raise RuntimeError(f'failed to fetch items from "{url}": {e}') 107 | 108 | if resp.status == 304: 109 | return [], etag 110 | 111 | content = await resp.text() 112 | d = feedparser.parse(content) 113 | if d.bozo: 114 | raise RuntimeError(f"feed is badly formatted: {d.bozo_exception}") 115 | 116 | return d.get("entries", []), resp.headers.get("Etag", "") 117 | 118 | 119 | async def fetch_content( 120 | client: aiohttp.ClientSession, 121 | entry_data: dict, 122 | feed_id: int, 123 | ) -> Item | None: 124 | """ 125 | Fetch and parse the content of a specific entry. 126 | 127 | Args: 128 | client (aiohttp.ClientSession): The HTTP client session to use for the request. 129 | entry_data (dict): The data of the entry to fetch content for. 130 | feed_id (int): The ID of the feed associated with the entry. 131 | 132 | Returns: 133 | Item | None: An Item object containing the entry's content, or None if the entry is invalid or something went wrong while parsing. 134 | 135 | Raises: 136 | RuntimeError: If the content cannot be fetched. 137 | """ 138 | 139 | url = entry_data.get("link") 140 | title = entry_data.get("title", "") 141 | author = entry_data.get("author", "") 142 | description = entry_data.get("description", "") 143 | published_parsed = entry_data.get("published_parsed") 144 | 145 | assert url 146 | 147 | try: 148 | resp = await client.get(url) 149 | resp.raise_for_status() 150 | except aiohttp.ClientError as e: 151 | raise RuntimeError(f'failed to fetch contents from "{url}": {e}') 152 | 153 | raw_content = await resp.text() 154 | md_content = md(clean_html(raw_content)) 155 | 156 | published_at = None 157 | if published_parsed: 158 | published_at = datetime(*published_parsed[:6]) 159 | 160 | return Item( 161 | title=title, 162 | url=url, 163 | author=author, 164 | description=description, 165 | raw_content=raw_content, 166 | content=md_content, 167 | feed_id=feed_id, 168 | published_at=published_at, 169 | ) 170 | -------------------------------------------------------------------------------- /src/lazyfeed/global.tcss: -------------------------------------------------------------------------------- 1 | * { 2 | scrollbar-background-active: $surface-darken-1; 3 | scrollbar-background-hover: $surface-darken-1; 4 | scrollbar-background: $surface-darken-1; 5 | scrollbar-color-active: $primary; 6 | scrollbar-color-hover: $primary 80%; 7 | scrollbar-color: $surface-lighten-1 60%; 8 | scrollbar-size-vertical: 1; 9 | scrollbar-size-horizontal: 0; 10 | 11 | &:focus { 12 | scrollbar-color: $primary 55%; 13 | } 14 | } 15 | 16 | Screen { 17 | grid-columns: 1fr 4fr; 18 | grid-rows: auto 1fr; 19 | grid-size: 2 2; 20 | layout: grid; 21 | } 22 | 23 | CustomHeader { 24 | color: $primary; 25 | column-span: 2; 26 | height: 2; 27 | 28 | .header__subtitle { 29 | color: $warning; 30 | margin-left: 1; 31 | } 32 | } 33 | 34 | RSSFeedTree, 35 | ItemTable { 36 | background: $background; 37 | border: round $primary; 38 | opacity: 80%; 39 | height: 1fr; 40 | width: 1fr; 41 | 42 | &:focus { 43 | opacity: 100%; 44 | } 45 | } 46 | 47 | Footer { 48 | background: $background; 49 | } 50 | 51 | ModalScreen { 52 | align: center middle; 53 | background: $background 60%; 54 | layout: vertical; 55 | 56 | .modal-body { 57 | border: round $primary; 58 | margin: 1 0; 59 | max-height: 20; 60 | min-height: 5; 61 | width: 40; 62 | } 63 | 64 | .modal-body--help { 65 | min-width: 40; 66 | max-width: 80; 67 | } 68 | 69 | .modal-body--confirm { 70 | grid-columns: 1fr; 71 | grid-gutter: 1; 72 | grid-rows: 1fr auto; 73 | grid-size: 1 2; 74 | layout: grid; 75 | 76 | Static { 77 | content-align: center middle; 78 | padding: 1 0; 79 | text-align: center; 80 | } 81 | } 82 | 83 | .help-table__label { 84 | margin-bottom: 1; 85 | } 86 | 87 | .help-description { 88 | height: 0; 89 | } 90 | 91 | .inputs { 92 | grid-columns: 1fr; 93 | grid-gutter: 1; 94 | grid-rows: auto; 95 | grid-size: 1 2; 96 | layout: grid; 97 | } 98 | 99 | Input { 100 | border: none; 101 | height: 1; 102 | padding: 0 1; 103 | 104 | &.-invalid { 105 | padding-left: 0; 106 | border-left: outer $error; 107 | } 108 | 109 | &:focus { 110 | background: $surface-darken-1; 111 | border-left: outer $primary; 112 | padding-left: 0; 113 | } 114 | } 115 | 116 | Button { 117 | border: none; 118 | color: $background; 119 | height: 1; 120 | padding: 0 1; 121 | width: 100%; 122 | } 123 | } 124 | 125 | MarkdownViewer { 126 | border: round $primary; 127 | column-span: 2; 128 | height: 1fr; 129 | padding: 1; 130 | row-span: 2; 131 | } 132 | -------------------------------------------------------------------------------- /src/lazyfeed/http_client.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | from contextlib import asynccontextmanager 3 | from lazyfeed.settings import Settings 4 | 5 | 6 | @asynccontextmanager 7 | async def http_client_session(settings: Settings): 8 | """ 9 | Asynchronous context manager for creating an HTTP client session using 10 | aiohttp.ClientSession. It configures the session with specified timeouts 11 | and headers from the provided settings, and the session is automatically 12 | closed upon exiting the context. 13 | """ 14 | 15 | client_timeout = aiohttp.ClientTimeout( 16 | total=settings.http_client.timeout, 17 | connect=settings.http_client.connect_timeout, 18 | ) 19 | 20 | async with aiohttp.ClientSession( 21 | timeout=client_timeout, 22 | headers=settings.http_client.headers, 23 | ) as session: 24 | yield session 25 | -------------------------------------------------------------------------------- /src/lazyfeed/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from sqlalchemy import select 4 | from sqlalchemy.orm import Session 5 | from lazyfeed.app import LazyFeedApp 6 | from lazyfeed.feeds import fetch_feed 7 | from lazyfeed.http_client import http_client_session 8 | from lazyfeed.models import Feed 9 | from lazyfeed.settings import Settings 10 | from lazyfeed.utils import export_opml, import_opml, console 11 | 12 | 13 | async def fetch_new_feeds( 14 | settings: Settings, 15 | session: Session, 16 | feeds: set[str], 17 | ) -> None: 18 | """ 19 | Fetch and store new RSS feeds. 20 | """ 21 | 22 | async with http_client_session(settings) as client_session: 23 | tasks = [fetch_feed(client_session, feed) for feed in feeds] 24 | results = await asyncio.gather(*tasks, return_exceptions=True) 25 | 26 | for result in results: 27 | if isinstance(result, Exception): 28 | console.print(f"❌ something went wrong fetching feed: {result}") 29 | continue 30 | 31 | try: 32 | session.add(result) 33 | session.commit() 34 | console.print(f'✅ added "{result.url}"') 35 | except Exception as e: 36 | session.rollback() 37 | console.print( 38 | f"❌ something went wrong while saving feeds to the database: {e}" 39 | ) 40 | 41 | 42 | def main(): 43 | settings = Settings() 44 | app = LazyFeedApp(settings) 45 | session = app.session 46 | 47 | if not sys.stdin.isatty(): 48 | with console.status( 49 | "[green]importing feeds from file... please, wait a moment", 50 | spinner="earth", 51 | ) as status: 52 | opml_content = sys.stdin.read() 53 | feeds_in_file = import_opml(opml_content) 54 | 55 | console.print("✅ file read correctly") 56 | 57 | stmt = select(Feed.url) 58 | results = session.execute(stmt).scalars().all() 59 | new_feeds = {feed for feed in feeds_in_file if feed not in results} 60 | if not new_feeds: 61 | console.print("✅ all feeds had been already added") 62 | return 63 | 64 | status.update(f"[green]fetching {len(new_feeds)} new feeds...[/]") 65 | asyncio.run(fetch_new_feeds(settings, session, new_feeds)) 66 | return 67 | 68 | if not sys.stdout.isatty(): 69 | stmt = select(Feed) 70 | results = session.execute(stmt).scalars().all() 71 | output = export_opml(results) 72 | sys.stdout.write(output) 73 | return 74 | 75 | app.run() 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /src/lazyfeed/messages.py: -------------------------------------------------------------------------------- 1 | from textual.message import Message 2 | 3 | 4 | class AddFeed(Message): 5 | """Message to add a new RSS feed.""" 6 | 7 | pass 8 | 9 | 10 | class EditFeed(Message): 11 | """Message to edit an existing RSS feed.""" 12 | 13 | def __init__(self, id: int) -> None: 14 | self.id = id 15 | super().__init__() 16 | 17 | 18 | class DeleteFeed(Message): 19 | """Message to delete a specified RSS feed.""" 20 | 21 | def __init__(self, id: int) -> None: 22 | self.id = id 23 | super().__init__() 24 | 25 | 26 | class FilterByFeed(Message): 27 | """Message to filter by the specified RSS feed.""" 28 | 29 | def __init__(self, id: int) -> None: 30 | self.id = id 31 | super().__init__() 32 | 33 | 34 | class MarkAsRead(Message): 35 | """Message to mark an item as 'read'.""" 36 | 37 | def __init__(self, item_id: int) -> None: 38 | self.item_id = item_id 39 | super().__init__() 40 | 41 | 42 | class MarkAllAsRead(Message): 43 | """Message to mark all items as 'read'.""" 44 | 45 | pass 46 | 47 | 48 | class MarkAsPending(Message): 49 | """Message to mark an item as 'unread' or 'pending'.""" 50 | 51 | def __init__(self, item_id: int) -> None: 52 | self.item_id = item_id 53 | super().__init__() 54 | 55 | 56 | class Open(Message): 57 | """Message to open item's content.""" 58 | 59 | def __init__(self, item_id: int) -> None: 60 | self.item_id = item_id 61 | super().__init__() 62 | 63 | 64 | class OpenInBrowser(Message): 65 | """Message to open an item in the browser.""" 66 | 67 | def __init__(self, item_id: int) -> None: 68 | self.item_id = item_id 69 | super().__init__() 70 | 71 | 72 | class SaveForLater(Message): 73 | """Message to save an item for later.""" 74 | 75 | def __init__(self, item_id: int) -> None: 76 | self.item_id = item_id 77 | super().__init__() 78 | 79 | 80 | class ShowPending(Message): 81 | """Message to list all items.""" 82 | 83 | pass 84 | 85 | 86 | class ShowAll(Message): 87 | """Message to list all pending items.""" 88 | 89 | pass 90 | 91 | 92 | class ShowSavedForLater(Message): 93 | """Message to list all saved for later items.""" 94 | 95 | pass 96 | 97 | 98 | class ShowToday(Message): 99 | """Message to list all items published at today's date.""" 100 | 101 | pass 102 | -------------------------------------------------------------------------------- /src/lazyfeed/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List 3 | from sqlalchemy import ForeignKey, Boolean, Text, func 4 | from sqlalchemy.orm import ( 5 | DeclarativeBase, 6 | Mapped, 7 | mapped_column, 8 | relationship, 9 | ) 10 | 11 | 12 | class Base(DeclarativeBase): 13 | pass 14 | 15 | 16 | class Feed(Base): 17 | __tablename__ = "feed" 18 | 19 | id: Mapped[int] = mapped_column(primary_key=True) 20 | url: Mapped[str] = mapped_column(unique=True) 21 | site: Mapped[str] = mapped_column(nullable=True) 22 | title: Mapped[str] 23 | description: Mapped[str] = mapped_column(nullable=True) 24 | 25 | items: Mapped[List["Item"]] = relationship( 26 | back_populates="feed", 27 | cascade="all, delete", 28 | ) 29 | 30 | etag: Mapped[str] = mapped_column(nullable=True) 31 | created_at: Mapped[datetime] = mapped_column(default=func.now()) 32 | last_updated_at: Mapped[datetime] = mapped_column( 33 | default=func.now(), 34 | onupdate=func.now(), 35 | ) 36 | 37 | def __repr__(self) -> str: 38 | return f"" 39 | 40 | 41 | class Item(Base): 42 | __tablename__ = "item" 43 | 44 | id: Mapped[int] = mapped_column(primary_key=True) 45 | title: Mapped[str] = mapped_column(nullable=True) 46 | url: Mapped[str] = mapped_column(unique=True) 47 | author: Mapped[str] = mapped_column(nullable=True) 48 | description: Mapped[str] = mapped_column(nullable=True) 49 | 50 | is_read: Mapped[bool] = mapped_column(Boolean(), default=False) 51 | is_saved: Mapped[bool] = mapped_column(Boolean(), default=False) 52 | 53 | feed_id: Mapped[int] = mapped_column(ForeignKey("feed.id")) 54 | feed: Mapped[Feed] = relationship(back_populates="items") 55 | 56 | raw_content: Mapped[str] = mapped_column(Text(), nullable=True) 57 | content: Mapped[str] = mapped_column(Text(), nullable=True) 58 | 59 | published_at: Mapped[datetime] = mapped_column(default=func.now()) 60 | last_updated_at: Mapped[datetime] = mapped_column( 61 | default=func.now(), 62 | onupdate=func.now(), 63 | ) 64 | 65 | def __repr__(self) -> str: 66 | return f"" 67 | -------------------------------------------------------------------------------- /src/lazyfeed/settings.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import click 3 | from importlib.metadata import version 4 | from pathlib import Path 5 | from typing import Type, Tuple 6 | from pydantic import BaseModel, Field 7 | from pydantic_settings import ( 8 | BaseSettings, 9 | PydanticBaseSettingsSource, 10 | SettingsConfigDict, 11 | TomlConfigSettingsSource, 12 | ) 13 | 14 | APP_NAME = "lazyfeed" 15 | APP_DIR = Path(click.get_app_dir(app_name=APP_NAME)) 16 | DB_URL: Path = APP_DIR / f"{APP_NAME}.db" 17 | CONFIG_FILE_PATH = APP_DIR / "config.toml" 18 | TEMPLATE_FILE_PATH = Path(__file__).parent / "config_template.toml" 19 | 20 | 21 | class ClientSettings(BaseModel): 22 | timeout: int = 300 23 | connect_timeout: int = 10 24 | headers: dict = {} 25 | 26 | 27 | class Settings(BaseSettings): 28 | name: str = APP_NAME 29 | version: str = version(APP_NAME) 30 | 31 | http_client: ClientSettings = Field(default_factory=ClientSettings) 32 | 33 | theme: str = "dracula" 34 | 35 | auto_read: bool = False 36 | auto_load: bool = False 37 | confirm_before_read: bool = True 38 | sort_by: str = "published_at" 39 | sort_order: str = "ascending" 40 | 41 | model_config = SettingsConfigDict( 42 | env_nested_delimiter="__", 43 | toml_file=f"{APP_DIR / 'config.toml'}", 44 | validate_default=True, 45 | ) 46 | 47 | @classmethod 48 | def settings_customise_sources( 49 | cls, 50 | settings_cls: Type[BaseSettings], 51 | init_settings: PydanticBaseSettingsSource, 52 | env_settings: PydanticBaseSettingsSource, 53 | dotenv_settings: PydanticBaseSettingsSource, 54 | file_secret_settings: PydanticBaseSettingsSource, 55 | ) -> Tuple[PydanticBaseSettingsSource, ...]: 56 | APP_DIR.mkdir(parents=True, exist_ok=True) 57 | 58 | if not CONFIG_FILE_PATH.exists(): 59 | shutil.copy(TEMPLATE_FILE_PATH, CONFIG_FILE_PATH) 60 | 61 | return ( 62 | env_settings, 63 | TomlConfigSettingsSource(settings_cls), 64 | ) 65 | 66 | 67 | if __name__ == "__main__": 68 | settings = Settings() 69 | print(settings.model_dump()) 70 | -------------------------------------------------------------------------------- /src/lazyfeed/utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import xml.etree.ElementTree as ET 3 | from rich.console import Console 4 | from lazyfeed.models import Feed 5 | from lazyfeed.settings import APP_NAME 6 | 7 | console = Console(emoji=True) 8 | 9 | 10 | def export_opml(feeds: list[Feed]): 11 | """ 12 | Export a list of RSS feeds into an OPML formatted string. 13 | """ 14 | 15 | opml = ET.Element("opml", version="1.0") 16 | 17 | head = ET.SubElement(opml, "head") 18 | title = ET.SubElement(head, "title") 19 | title.text = f"RSS feeds from {APP_NAME}" 20 | body = ET.SubElement(opml, "body") 21 | 22 | for feed in feeds: 23 | ET.SubElement( 24 | body, 25 | "outline", 26 | text=feed.title, 27 | type="rss", 28 | xmlUrl=feed.url, 29 | ) 30 | 31 | tree = ET.ElementTree(opml) 32 | output_buffer = io.BytesIO() 33 | 34 | tree.write(output_buffer, encoding="utf-8", xml_declaration=True) 35 | 36 | opml_output = output_buffer.getvalue().decode("utf-8") 37 | output_buffer.close() 38 | 39 | return opml_output 40 | 41 | 42 | def import_opml(input: str) -> list[str]: 43 | """ 44 | Import RSS feeds from an OPML formatted string. 45 | """ 46 | 47 | feeds = [] 48 | 49 | root = ET.fromstring(input) 50 | for outline in root.findall(".//outline"): 51 | xml_url = outline.get("xmlUrl") 52 | if xml_url: 53 | feeds.append(xml_url) 54 | 55 | return feeds 56 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from lazyfeed.widgets.custom_header import CustomHeader 2 | from lazyfeed.widgets.item_table import ItemTable 3 | from lazyfeed.widgets.item_screen import ItemScreen 4 | from lazyfeed.widgets.rss_feed_tree import RSSFeedTree 5 | 6 | __all__ = [ 7 | "CustomHeader", 8 | "ItemTable", 9 | "ItemScreen", 10 | "RSSFeedTree", 11 | ] 12 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/custom_header.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Horizontal 3 | from textual.widgets import Label, Static 4 | 5 | 6 | class CustomHeader(Static): 7 | """ 8 | Custom header widget displaying a title and subtitle. 9 | """ 10 | 11 | def __init__(self, title: str, subtitle: str) -> None: 12 | self.title = title 13 | self.subtitle = subtitle 14 | super().__init__() 15 | 16 | def compose(self) -> ComposeResult: 17 | yield Horizontal( 18 | Label(self.title, classes="header__title"), 19 | Label(self.subtitle, classes="header__subtitle"), 20 | ) 21 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/helpable.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Protocol, runtime_checkable 3 | 4 | 5 | @dataclass 6 | class HelpData: 7 | """ 8 | Data related to a widget to be displayed in the help modal. 9 | """ 10 | 11 | # Title of the widget. 12 | title: str = field(default="") 13 | 14 | # Description in markdown format. 15 | description: str = field(default="") 16 | 17 | 18 | @runtime_checkable 19 | class Helpable(Protocol): 20 | """ 21 | Protocol for widgets that contain information required by the 22 | help modal. 23 | """ 24 | 25 | help: HelpData 26 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/item_screen.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.binding import Binding 3 | from textual.screen import Screen 4 | from textual.widgets import MarkdownViewer, Footer 5 | from lazyfeed.messages import OpenInBrowser, SaveForLater 6 | from lazyfeed.models import Item 7 | 8 | 9 | class ItemScreen(Screen): 10 | """ 11 | Screen for displaying the content of a single item in markdown format. 12 | """ 13 | 14 | BINDINGS = [ 15 | Binding("R", "none", "none", show=False, priority=True), 16 | Binding("ctrl+c,escape,q,o", "app.pop_screen", "go back", priority=True), 17 | Binding("s", "save_for_later", "save", priority=True), 18 | Binding("O", "open_in_browser", "open in browser", priority=True), 19 | ] 20 | 21 | def __init__(self, item: Item): 22 | super().__init__() 23 | self.item = item 24 | 25 | def compose(self) -> ComposeResult: 26 | yield MarkdownViewer(self.item.content, show_table_of_contents=False) 27 | yield Footer() 28 | 29 | def on_mount(self) -> None: 30 | self.md_viewer = self.query_one(MarkdownViewer) 31 | self.md_viewer.border_title = "article" 32 | 33 | def action_open_in_browser(self) -> None: 34 | self.post_message(OpenInBrowser(self.item.id)) 35 | 36 | def action_save_for_later(self) -> None: 37 | self.post_message(SaveForLater(self.item.id)) 38 | 39 | def action_none(self) -> None: 40 | pass 41 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/item_table.py: -------------------------------------------------------------------------------- 1 | from textual.binding import Binding 2 | from textual.widgets import DataTable 3 | from textual.widgets.data_table import CellDoesNotExist 4 | from lazyfeed.models import Item 5 | from lazyfeed.messages import ( 6 | MarkAllAsRead, 7 | MarkAsPending, 8 | MarkAsRead, 9 | Open, 10 | OpenInBrowser, 11 | SaveForLater, 12 | ShowPending, 13 | ShowAll, 14 | ShowSavedForLater, 15 | ShowToday, 16 | ) 17 | from lazyfeed.widgets.helpable import HelpData 18 | 19 | 20 | class ItemTable(DataTable): 21 | """ 22 | Custom DataTable widget for displaying and managing a list of items. 23 | """ 24 | 25 | help = HelpData( 26 | title="item table", 27 | description="""\ 28 | Table for managing items from your RSS feeds. You can (`o`)pen an item to read 29 | it in markdown, (`O`)pen it in your default browser, (`m`)ark it as read or (`s`)ave it 30 | for later. 31 | """, 32 | ) 33 | 34 | BINDINGS = [ 35 | Binding("up,k", "cursor_up", "cursor up", show=False), 36 | Binding("down,j", "cursor_down", "cursor down", show=False), 37 | Binding("g", "scroll_top", "cursor to top", show=False), 38 | Binding("G", "scroll_bottom", "cursor to bottom", show=False), 39 | Binding("o", "open", "open"), 40 | Binding("O", "open_in_browser", "open in browser"), 41 | Binding("m", "mark_as_read", "mark as read"), 42 | Binding("M", "mark_all_as_read", "mark all as read", show=False), 43 | Binding("u", "mark_as_pending", "mark as pending (unread)", show=False), 44 | Binding("s", "save_for_later", "save"), 45 | Binding("a", "show_pending", "pending"), 46 | Binding("A", "show_all", "all", show=False), 47 | Binding("l", "show_saved", "saved"), 48 | Binding("t", "show_today", "today", show=False), 49 | ] 50 | 51 | def __init__(self, *args, **kwargs): 52 | super().__init__(cursor_type="row", header_height=0, *args, **kwargs) 53 | 54 | def on_mount(self) -> None: 55 | self.add_column("items", key="items") 56 | self.border_title = "items" 57 | 58 | def action_mark_as_read(self) -> None: 59 | try: 60 | row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) 61 | except CellDoesNotExist: 62 | return 63 | 64 | assert row_key.value 65 | self.post_message(MarkAsRead(int(row_key.value))) 66 | 67 | def action_mark_all_as_read(self) -> None: 68 | self.post_message(MarkAllAsRead()) 69 | 70 | def action_mark_as_pending(self) -> None: 71 | try: 72 | row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) 73 | except CellDoesNotExist: 74 | return 75 | 76 | assert row_key.value 77 | self.post_message(MarkAsPending(int(row_key.value))) 78 | 79 | def action_open(self) -> None: 80 | try: 81 | row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) 82 | except CellDoesNotExist: 83 | return 84 | 85 | assert row_key.value 86 | self.post_message(Open(int(row_key.value))) 87 | 88 | def action_open_in_browser(self) -> None: 89 | try: 90 | row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) 91 | except CellDoesNotExist: 92 | return 93 | 94 | assert row_key.value 95 | self.post_message(OpenInBrowser(int(row_key.value))) 96 | 97 | def action_save_for_later(self) -> None: 98 | try: 99 | row_key, _ = self.coordinate_to_cell_key(self.cursor_coordinate) 100 | except CellDoesNotExist: 101 | return 102 | 103 | assert row_key.value 104 | self.post_message(SaveForLater(int(row_key.value))) 105 | 106 | def action_show_all(self) -> None: 107 | self.post_message(ShowAll()) 108 | 109 | def action_show_pending(self) -> None: 110 | self.post_message(ShowPending()) 111 | 112 | def action_show_saved(self) -> None: 113 | self.post_message(ShowSavedForLater()) 114 | 115 | def action_show_today(self) -> None: 116 | self.post_message(ShowToday()) 117 | 118 | def format_item(self, item: Item) -> str: 119 | saved = "" if item.is_saved else " " 120 | item_title = ( 121 | f"[bold]{item.title}[/]" 122 | if not item.is_read 123 | else f"[bold strike]{item.title}[/]" 124 | ) 125 | url = item.url 126 | 127 | return f"{saved} [bold]{item_title}[/] ([underline italic]{url}[/])" 128 | 129 | def update_item(self, row_key: str, item: Item) -> None: 130 | self.update_cell(row_key, "items", self.format_item(item)) 131 | 132 | def mount_items(self, items: list[Item]) -> None: 133 | self.clear() 134 | for item in items: 135 | self.add_row(self.format_item(item), key=f"{item.id}") 136 | 137 | self.border_subtitle = f"{self.row_count}" 138 | self.refresh() 139 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/modals/__init__.py: -------------------------------------------------------------------------------- 1 | from lazyfeed.widgets.modals.add_feed_modal import AddFeedModal 2 | from lazyfeed.widgets.modals.confirm_action_modal import ConfirmActionModal 3 | from lazyfeed.widgets.modals.edit_feed_modal import EditFeedModal 4 | from lazyfeed.widgets.modals.help_modal import HelpModal 5 | 6 | __all__ = [ 7 | "AddFeedModal", 8 | "ConfirmActionModal", 9 | "EditFeedModal", 10 | "HelpModal", 11 | ] 12 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/modals/add_feed_modal.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import Container, VerticalScroll 4 | from textual.validation import Function 5 | from textual.widgets import Button, Input, Label 6 | from textual.screen import ModalScreen 7 | from lazyfeed.widgets.validators import is_valid_url 8 | 9 | 10 | class AddFeedModal(ModalScreen[dict | None]): 11 | """ 12 | Modal screen for adding a new RSS feed. 13 | """ 14 | 15 | BINDINGS = [ 16 | ("escape,q", "dismiss", "dismiss"), 17 | ] 18 | 19 | def compose(self) -> ComposeResult: 20 | with VerticalScroll(classes="modal-body modal-body--add") as container: 21 | container.border_title = "add new feed" 22 | 23 | yield Container( 24 | Container( 25 | Label("title (optional)"), 26 | Input( 27 | placeholder="title", 28 | classes="input--feed-title", 29 | ), 30 | ), 31 | Container( 32 | Label("url"), 33 | Input( 34 | placeholder="url", 35 | validate_on=["changed", "submitted"], 36 | validators=[ 37 | Function(is_valid_url, "invalid url."), 38 | ], 39 | classes="input--feed-url", 40 | ), 41 | ), 42 | classes="inputs", 43 | ) 44 | yield Button(label="add feed", variant="success", disabled=True) 45 | 46 | def on_mount(self) -> None: 47 | self.input_fields = self.query(Input) 48 | self.button = self.query_one(Button) 49 | 50 | def action_dismiss_overlay(self) -> None: 51 | self.dismiss(None) 52 | 53 | @on(Input.Changed, ".input--feed-url") 54 | def enable_button(self, event: Input.Changed) -> None: 55 | if not event.value or not event.validation_result.is_valid: 56 | self.button.disabled = True 57 | return 58 | 59 | self.button.disabled = False 60 | 61 | @on(Button.Pressed) 62 | def submit_form(self) -> None: 63 | self.dismiss( 64 | { 65 | "title": self.query_one(".input--feed-title").value, 66 | "url": self.query_one(".input--feed-url").value, 67 | } 68 | ) 69 | 70 | @on(Input.Submitted, ".input--feed-url") 71 | async def add_new_feed(self, event: Input.Submitted) -> None: 72 | if not event.validation_result.is_valid: 73 | return 74 | 75 | self.dismiss( 76 | { 77 | "title": self.query_one(".input--feed-title").value, 78 | "url": event.value, 79 | } 80 | ) 81 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/modals/confirm_action_modal.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import VerticalScroll 4 | from textual.widgets import Button, Static 5 | from textual.screen import ModalScreen 6 | 7 | 8 | class ConfirmActionModal(ModalScreen[bool]): 9 | """ 10 | Modal screen with a prompt for confirmation of an action. 11 | """ 12 | 13 | BINDINGS = [ 14 | ("escape,q", "dismiss", "dismiss"), 15 | ("enter,y", "confirm", "confirm"), 16 | ] 17 | 18 | def __init__( 19 | self, border_title: str, message: str, action_name: str, *args, **kwargs 20 | ) -> None: 21 | super().__init__(*args, **kwargs) 22 | self.border_title = border_title 23 | self.message = message 24 | self.action_name = action_name 25 | 26 | def compose(self) -> ComposeResult: 27 | with VerticalScroll(classes="modal-body modal-body--confirm") as container: 28 | container.border_title = self.border_title 29 | 30 | yield Static(self.message) 31 | yield Button(label=self.action_name, variant="error") 32 | 33 | def on_mount(self) -> None: 34 | self.query_one(Button).focus() 35 | 36 | def action_dismiss_overlay(self) -> None: 37 | self.dismiss(False) 38 | 39 | @on(Button.Pressed) 40 | def action_confirm(self) -> None: 41 | self.dismiss(True) 42 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/modals/edit_feed_modal.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import Container, VerticalScroll 4 | from textual.validation import Function 5 | from textual.widgets import Button, Input, Label 6 | from textual.screen import ModalScreen 7 | from lazyfeed.widgets.validators import is_valid_url 8 | 9 | 10 | class EditFeedModal(ModalScreen[dict | None]): 11 | """ 12 | Modal screen for editing an existing RSS feed. 13 | """ 14 | 15 | BINDINGS = [ 16 | ("escape,q", "dismiss", "dismiss"), 17 | ] 18 | 19 | def __init__(self, url: str, title: str, *args, **kwargs) -> None: 20 | super().__init__(*args, **kwargs) 21 | self.url = url 22 | self.title = title 23 | 24 | def compose(self) -> ComposeResult: 25 | with VerticalScroll(classes="modal-body moda-body--edit") as container: 26 | container.border_title = "edit feed" 27 | 28 | yield Container( 29 | Container( 30 | Label("feed title"), 31 | Input( 32 | placeholder="title (optional)", 33 | classes="input--feed-title", 34 | value=self.title, 35 | ), 36 | ), 37 | Container( 38 | Label("url"), 39 | Input( 40 | placeholder="url", 41 | validate_on=["changed", "submitted"], 42 | validators=[ 43 | Function(is_valid_url, "invalid url."), 44 | ], 45 | classes="input--feed-url", 46 | value=self.url, 47 | ), 48 | ), 49 | classes="inputs", 50 | ) 51 | yield Button(label="update", variant="success") 52 | 53 | def on_mount(self) -> None: 54 | self.button = self.query_one(Button) 55 | 56 | def action_dismiss_overlay(self) -> None: 57 | self.dismiss(None) 58 | 59 | @on(Input.Changed, ".input--feed-url") 60 | def enable_button(self, event: Input.Changed) -> None: 61 | if not event.value or not event.validation_result.is_valid: 62 | self.button.disabled = True 63 | return 64 | 65 | self.button.disabled = False 66 | 67 | @on(Button.Pressed) 68 | def submit_form(self) -> None: 69 | self.dismiss( 70 | { 71 | "title": self.query_one(".input--feed-title").value, 72 | "url": self.query_one(".input--feed-url").value, 73 | } 74 | ) 75 | 76 | @on(Input.Submitted, ".input--feed-url") 77 | async def edit_feed(self, event: Input.Submitted) -> None: 78 | if not event.validation_result.is_valid: 79 | return 80 | 81 | self.dismiss( 82 | { 83 | "title": self.query_one(".input--feed-title").value, 84 | "url": event.value, 85 | } 86 | ) 87 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/modals/help_modal.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from textual.app import ComposeResult 3 | from textual.binding import Binding 4 | from textual.containers import VerticalScroll 5 | from textual.screen import ModalScreen 6 | from textual.widget import Widget 7 | from textual.widgets import DataTable, Label, Markdown 8 | from lazyfeed.widgets.helpable import Helpable 9 | 10 | 11 | class HelpModal(ModalScreen[None]): 12 | """ 13 | Modal screen to display help information for a specific widget. 14 | """ 15 | 16 | BINDINGS = [ 17 | ("escape,q", "dismiss", "dismiss"), 18 | ] 19 | 20 | def __init__(self, widget: Widget) -> None: 21 | super().__init__() 22 | self.widget = widget 23 | 24 | def compose(self) -> ComposeResult: 25 | with VerticalScroll(classes="modal-body modal-body--help") as container: 26 | widget = self.widget 27 | if isinstance(widget, Helpable): 28 | help = widget.help 29 | help_title = help.title 30 | help_description = help.description 31 | 32 | if help_title: 33 | container.border_title = help_title 34 | else: 35 | container.border_title = "help" 36 | 37 | if help_description: 38 | help_description = help_title.strip() 39 | with VerticalScroll(classes="help-description"): 40 | yield Markdown(help_description) 41 | 42 | bindings = widget._bindings 43 | keys: list[tuple[str, list[Binding]]] = list( 44 | bindings.key_to_bindings.items(), 45 | ) 46 | 47 | if keys: 48 | yield Label("all keybindings", classes="help-table__label") 49 | 50 | table = DataTable(cursor_type="row", zebra_stripes=True) 51 | table.add_columns("key", "description") 52 | for _, bindings in keys: 53 | table.add_row( 54 | Text( 55 | ", ".join( 56 | binding.key_display 57 | if binding.key_display 58 | else self.app.get_key_display(binding) 59 | for binding in bindings 60 | ), 61 | style="bold", 62 | no_wrap=True, 63 | end="", 64 | ), 65 | bindings[0].description.lower(), 66 | ) 67 | 68 | yield table 69 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/rss_feed_tree.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, List 2 | from rich.text import Text 3 | from textual.binding import Binding 4 | from textual.widgets import Tree 5 | from lazyfeed.messages import AddFeed, DeleteFeed, EditFeed, FilterByFeed 6 | from lazyfeed.widgets.helpable import HelpData 7 | 8 | 9 | class RSSFeedTree(Tree): 10 | """ 11 | Custom Tree that provides functionality to show, add, edit, delete and filter 12 | by feeds. 13 | """ 14 | 15 | help = HelpData( 16 | title="rss feed tree", 17 | description="""\ 18 | A tree for managing RSS feeds. You can (`a`)dd new feeds, (`e`)dit an existing feed, 19 | (`d`)elete feeds, or select one to filter the items (`enter`). 20 | """, 21 | ) 22 | 23 | BINDINGS = [ 24 | Binding("backspace,d,x", "delete", "delete feed"), 25 | Binding("a,n", "add", "add"), 26 | Binding("e", "edit", "edit"), 27 | Binding("enter", "select_feed", "select"), 28 | Binding("up,k", "cursor_up", "cursor up", show=False), 29 | Binding("down,j", "cursor_down", "cursor down", show=False), 30 | Binding("g", "scroll_home", "cursor to top", show=False), 31 | Binding("G", "scroll_end", "cursor to bottom", show=False), 32 | ] 33 | 34 | def on_mount(self) -> None: 35 | self.show_root = False 36 | self.border_title = "feeds" 37 | 38 | css_variables = self.app.get_css_variables() 39 | self.primary = css_variables.get("primary", "blue") 40 | 41 | def action_delete(self) -> None: 42 | if not self.cursor_node or not self.cursor_node.data: 43 | self.notify("no feed selected") 44 | return 45 | 46 | self.post_message(DeleteFeed(self.cursor_node.data["id"])) 47 | 48 | def action_add(self) -> None: 49 | self.post_message(AddFeed()) 50 | 51 | def action_edit(self) -> None: 52 | if not self.cursor_node or not self.cursor_node.data: 53 | self.notify("no feed selected") 54 | return 55 | 56 | self.post_message(EditFeed(id=self.cursor_node.data["id"])) 57 | 58 | def action_select_feed(self) -> None: 59 | if not self.cursor_node or not self.cursor_node.data: 60 | self.notify("no feed selected") 61 | return 62 | 63 | self.post_message(FilterByFeed(id=self.cursor_node.data["id"])) 64 | 65 | def update_feed(self, feed_data: Tuple[int, int, str]) -> None: 66 | feed_id, pending_posts, title = feed_data 67 | for node in self.root.children: 68 | if node.data and node.data["id"] == feed_id: 69 | label = Text() 70 | if pending_posts: 71 | label.append(f"{pending_posts}", style=f"on {self.primary}") 72 | label.append(" ") 73 | 74 | label.append(title) 75 | 76 | node.label = label 77 | self.refresh() 78 | return 79 | 80 | def mount_feeds(self, feeds_data: List[Tuple[int, int, str]]) -> None: 81 | self.clear() 82 | self.guide_depth = 3 83 | self.root.expand() 84 | 85 | for feed in feeds_data: 86 | feed_id, pending_posts, title = feed 87 | label = Text() 88 | if pending_posts: 89 | label.append(f"{pending_posts}", style=f"on {self.primary}") 90 | label.append(" ") 91 | 92 | label.append(title) 93 | 94 | self.root.add_leaf(label=label, data={"id": feed_id}) 95 | 96 | self.cursor_line = 0 97 | -------------------------------------------------------------------------------- /src/lazyfeed/widgets/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # Code extracted from: 4 | # https://github.com/django/django/blob/stable/1.3.x/django/core/validators.py#L45 5 | url_regex = re.compile( 6 | r"^(?:http|ftp)s?://" # http:// or https:// 7 | r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" # domain... 8 | r"localhost|" # localhost... 9 | r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip 10 | r"(?::\d+)?" # optional port 11 | r"(?:/?|[/?]\S+)$", 12 | re.IGNORECASE, 13 | ) 14 | 15 | 16 | def is_valid_url(value: str) -> bool: 17 | """ 18 | Check if the provided string is a valid URL. 19 | 20 | Args: 21 | value (str): The string to be validated. 22 | 23 | Returns: 24 | bool: True if the string is a valid URL, False otherwise. 25 | """ 26 | 27 | return re.match(url_regex, value) is not None 28 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dnlzrgz/lazyfeed/83d9bf3d0be26dd00c563fdf32f91d9c081a979c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from lazyfeed.widgets.validators import is_valid_url 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "url, expected", 7 | [ 8 | ("", False), 9 | ("://example.com", False), 10 | ("ftp://example.com", True), 11 | ("htp://example.com", False), 12 | ("http//example.com", False), 13 | ("http://", False), 14 | ("http://-example.com", False), 15 | ("http://.com", False), 16 | ("http://123.123.123.123:80/resource", True), 17 | ("http://192.168.1.1", True), 18 | ("http://example-.com", False), 19 | ("http://example..com", False), 20 | ("http://example.co.uk", True), 21 | ("http://example.com", True), 22 | ("http://example.com#fragment", False), 23 | ("http://example.com:8080", True), 24 | ("http://example.com:abc", False), 25 | ("http://example.com?query=param", True), 26 | ("http://localhost", True), 27 | ("http:/example.com", False), 28 | ("https://example.com", True), 29 | ("https://example.com/path/to/resource", True), 30 | ("https://subdomain.example.com", True), 31 | ], 32 | ) 33 | def test_is_valid_url(url, expected): 34 | assert is_valid_url(url) == expected 35 | --------------------------------------------------------------------------------