├── .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 | 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 | 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 | 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 |  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 |  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 |  131 |  132 |  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"