From 968014a75f2fb8788e353e934164a034c2c4e3bf Mon Sep 17 00:00:00 2001 From: Felix Steindorff <86103886+FelixSteindorff@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:27:31 +0200 Subject: [PATCH] Add wxPython GUI with hotkeys --- README.md | 59 ++++++++++++++++++- requirements.txt | 6 ++ streamline.py | 139 +++++++++++++++++++++++++++++++++++++++++++ streamline_gui.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 requirements.txt create mode 100644 streamline.py create mode 100644 streamline_gui.py diff --git a/README.md b/README.md index 5f63b01..794ea65 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ # Streamline -a small rss client written in python + +Streamline is a minimal, accessible RSS reader written in Python. It can +parse local feed files or retrieve items from a Fever API compatible +server. The output is kept simple so that it works well with assistive +technologies such as screen readers. + +## Requirements + +``` +pip install -r requirements.txt +``` + +## Usage + +Read a local feed file: + +``` +python streamline.py local path/to/feed.xml +``` + +Interactive browsing: + +``` +python streamline.py local path/to/feed.xml --interactive +``` + +Fetch items from a Fever API endpoint: + +``` +python streamline.py fever --url https://example.com/fever --api-key YOUR_KEY +``` + +Add `--interactive` to use hotkeys for navigation when fetching from Fever. + +The client prints the title, link and a short summary of each entry. In +interactive mode you can navigate with these hotkeys: + +- **n**: next entry +- **p**: previous entry +- **o**: open the entry link in the default browser +- **q**: quit the viewer + +## Graphical interface + +For a tree view of your feeds, run the WxPython GUI: + +``` +python streamline_gui.py +``` + +Hotkeys inside the GUI: + +- **Tab**: switch between feed tree and article text +- **Ctrl+I**: import feeds from an OPML or text file +- **Ctrl+E**: export your feed list +- **Ctrl+T**: fetch and display the full article text + +The GUI stores subscribed feeds in `feeds.json`. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68f710b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +feedparser +requests +readchar + +wxPython +readability-lxml diff --git a/streamline.py b/streamline.py new file mode 100644 index 0000000..e9db335 --- /dev/null +++ b/streamline.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Minimal accessible RSS client with optional Fever API support.""" + +import argparse +import sys + +import feedparser +import requests +import webbrowser + +import readchar + + +def print_entry(entry, count=None, total=None): + """Print a single feed entry.""" + if count is not None and total is not None: + print(f"Entry {count}/{total}") + title = entry.get('title') or 'No title' + print(f"- {title}") + link = entry.get('link') + if link: + print(f" {link}") + summary = entry.get('summary') or entry.get('content', '') + if summary: + text = summary + if len(text) > 200: + text = text[:200] + '...' + print(f" {text}") + print() + + +def interactive_viewer(entries): + """Interactively browse entries using hotkeys.""" + if not entries: + print("No entries found.") + return + + def help_message(): + print("Hotkeys: [n]ext [p]revious [o]pen link [q]uit [?] help") + + index = 0 + total = len(entries) + help_message() + while True: + print_entry(entries[index], index + 1, total) + key = readchar.readchar() + if key == "n": + index = (index + 1) % total + elif key == "p": + index = (index - 1) % total + elif key == "o": + link = entries[index].get("link") + if link: + webbrowser.open(link) + elif key == "q": + break + elif key in ("?", "h"): + help_message() + + +def print_entries(entries): + """Print feed entries in a simple, accessible format.""" + for entry in entries: + title = entry.get('title') or 'No title' + print(f"- {title}") + link = entry.get('link') + if link: + print(f" {link}") + summary = entry.get('summary') or entry.get('content', '') + if summary: + # print first 200 chars for brevity + text = summary + if len(text) > 200: + text = text[:200] + '...' + print(f" {text}") + print() + + +def read_local(feed_path: str, interactive: bool = False) -> None: + """Read and display a local RSS/Atom feed.""" + feed = feedparser.parse(feed_path) + title = feed.feed.get('title', 'Untitled feed') + print(title) + print('=' * len(title)) + if interactive: + interactive_viewer(feed.entries) + else: + print_entries(feed.entries) + + +def read_fever(url: str, api_key: str, interactive: bool = False) -> None: + """Fetch unread items from a Fever API endpoint.""" + response = requests.post(url, data={'api_key': api_key, 'items': ''}) + if not response.ok: + sys.stderr.write('Failed to fetch items from Fever API.\n') + return + data = response.json() + items = data.get('items', []) + entries = [ + { + 'title': item.get('title'), + 'link': item.get('url'), + 'summary': item.get('html') + } + for item in items + ] + if interactive: + interactive_viewer(entries) + else: + print_entries(entries) + + +def main(argv=None) -> None: + parser = argparse.ArgumentParser(description='Accessible RSS client') + subparsers = parser.add_subparsers(dest='command') + + parser_local = subparsers.add_parser('local', help='Read a local feed file') + parser_local.add_argument('file', help='Path to the feed XML file') + parser_local.add_argument('-i', '--interactive', action='store_true', + help='Browse entries interactively') + + parser_fever = subparsers.add_parser('fever', help='Fetch items via Fever API') + parser_fever.add_argument('--url', required=True, help='Fever API endpoint') + parser_fever.add_argument('--api-key', required=True, help='Fever API key') + parser_fever.add_argument('-i', '--interactive', action='store_true', + help='Browse entries interactively') + + args = parser.parse_args(argv) + + if args.command == 'local': + read_local(args.file, args.interactive) + elif args.command == 'fever': + read_fever(args.url, args.api_key, args.interactive) + else: + parser.print_help() + + +if __name__ == '__main__': + main() diff --git a/streamline_gui.py b/streamline_gui.py new file mode 100644 index 0000000..113d5f9 --- /dev/null +++ b/streamline_gui.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Accessible GUI RSS reader using wxPython.""" + +import json +import os +import re +import feedparser +import requests +import wx + +try: + from readability import Document + import lxml.html +except Exception: + Document = None + +FEED_FILE = 'feeds.json' + + +def load_feeds(): + if os.path.exists(FEED_FILE): + with open(FEED_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + return [] + + +def save_feeds(feeds): + with open(FEED_FILE, 'w', encoding='utf-8') as f: + json.dump(feeds, f, indent=2) + + +class MainFrame(wx.Frame): + def __init__(self): + super().__init__(None, title="Streamline RSS") + self.feeds = load_feeds() + self.build_ui() + self.Bind(wx.EVT_CHAR_HOOK, self.on_hotkey) + + def build_ui(self): + panel = wx.Panel(self) + hbox = wx.BoxSizer(wx.HORIZONTAL) + self.tree = wx.TreeCtrl(panel) + self.text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY) + hbox.Add(self.tree, 1, wx.EXPAND) + hbox.Add(self.text, 2, wx.EXPAND) + panel.SetSizer(hbox) + self.populate_tree() + self.tree.Bind(wx.EVT_TREE_ITEM_ACTIVATED, self.on_activate) + + def populate_tree(self): + self.tree.DeleteAllItems() + root = self.tree.AddRoot("Feeds") + for url in self.feeds: + fitem = self.tree.AppendItem(root, url) + feed = feedparser.parse(url) + for entry in feed.entries: + child = self.tree.AppendItem(fitem, entry.get('title', 'No title')) + self.tree.SetItemData(child, entry) + self.tree.Expand(root) + + def on_activate(self, event): + item = event.GetItem() + data = self.tree.GetItemData(item) + if isinstance(data, dict): + summary = data.get('summary') or data.get('content', '') + self.text.SetValue(summary or 'No content available.') + self.text.SetFocus() + + def on_hotkey(self, event): + code = event.GetKeyCode() + if code == wx.WXK_TAB: + if self.tree.HasFocus(): + self.text.SetFocus() + else: + self.tree.SetFocus() + elif code == ord('I') and event.ControlDown(): + self.import_feeds() + elif code == ord('E') and event.ControlDown(): + self.export_feeds() + elif code == ord('T') and event.ControlDown(): + self.load_full_text() + else: + event.Skip() + + def import_feeds(self): + with wx.FileDialog(self, "Import feeds", wildcard="OPML or text files (*.opml;*.txt)|*.opml;*.txt", + style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + path = dlg.GetPath() + feeds = [] + if path.endswith('.opml'): + content = open(path, encoding='utf-8').read() + feeds.extend(re.findall(r'xmlUrl="([^"]+)"', content)) + else: + with open(path, encoding='utf-8') as f: + for line in f: + url = line.strip() + if url: + feeds.append(url) + for url in feeds: + if url not in self.feeds: + self.feeds.append(url) + save_feeds(self.feeds) + self.populate_tree() + + def export_feeds(self): + with wx.FileDialog(self, "Export feeds", wildcard="Text files (*.txt)|*.txt", + style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as dlg: + if dlg.ShowModal() == wx.ID_CANCEL: + return + path = dlg.GetPath() + with open(path, 'w', encoding='utf-8') as f: + for url in self.feeds: + f.write(url + '\n') + + def load_full_text(self): + item = self.tree.GetSelection() + data = self.tree.GetItemData(item) + if not isinstance(data, dict): + return + link = data.get('link') + if not link: + return + try: + resp = requests.get(link, timeout=10) + text = resp.text + if Document: + doc = Document(text) + html = doc.summary() + root = lxml.html.fromstring(html) + text = root.text_content() + self.text.SetValue(text) + except Exception as exc: + self.text.SetValue(f"Failed to load article: {exc}") + + +class App(wx.App): + def OnInit(self): + frame = MainFrame() + frame.Show() + return True + + +if __name__ == '__main__': + app = App() + app.MainLoop()