diff options
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | __pycache__/tagrss.cpython-311.pyc | bin | 7735 -> 0 bytes | |||
-rw-r--r-- | pyrightconfig.json | 3 | ||||
-rw-r--r-- | requirements.txt | 5 | ||||
-rwxr-xr-x | serve.py | 69 | ||||
-rw-r--r-- | tagrss.py | 125 | ||||
-rw-r--r-- | views/add_feed.tpl | 1 | ||||
-rw-r--r-- | views/delete.html | 14 | ||||
-rw-r--r-- | views/index.tpl | 8 | ||||
-rw-r--r-- | views/manage_feed.tpl | 56 |
10 files changed, 244 insertions, 40 deletions
@@ -1,2 +1,3 @@ +/__pycache__/ /venv/ -/ignore/
\ No newline at end of file +/ignore/ diff --git a/__pycache__/tagrss.cpython-311.pyc b/__pycache__/tagrss.cpython-311.pyc Binary files differdeleted file mode 100644 index d906911..0000000 --- a/__pycache__/tagrss.cpython-311.pyc +++ /dev/null diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..6d8dc68 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "typeCheckingMode": "basic", +} diff --git a/requirements.txt b/requirements.txt index 425cad0..c32839f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,12 @@ bottle==0.12.25 +certifi==2023.5.7 +charset-normalizer==3.1.0 feedparser==6.0.10 gevent==22.10.2 greenlet==2.0.2 +idna==3.4 +requests==2.31.0 sgmllib3k==1.0.0 +urllib3==2.0.3 zope.event==4.6 zope.interface==6.0 @@ -3,13 +3,12 @@ import gevent.monkey gevent.monkey.patch_all() import bottle -import feedparser import gevent.lock import argparse import os import pathlib -import time +import typing import tagrss @@ -22,7 +21,7 @@ args = parser.parse_args() storage_path: pathlib.Path = pathlib.Path(args.storage_path) tagrss_lock = gevent.lock.RLock() -tagrss_backend = tagrss.TagRss(storage_path=storage_path) +core = tagrss.TagRss(storage_path=storage_path) def parse_space_separated_tags(inp: str) -> list[str]: @@ -44,30 +43,39 @@ def parse_space_separated_tags(inp: str) -> list[str]: tag += c if tag: tags.add(tag) - return tuple(sorted(tags)) + return list(sorted(tags)) + + +def serialise_tags(tags: list[str]) -> str: + result = "" + for i, tag in enumerate(tags): + if i > 0: + result += " " + result += (tag.replace("\\", "\\\\")).replace(" ", "\\ ") + return result @bottle.route("/") def index(): with tagrss_lock: - entries = tagrss_backend.get_entries(limit=100) - return bottle.template("index", entries=entries, tagrss_backend=tagrss_backend) + entries = core.get_entries(limit=100) + return bottle.template("index", entries=entries, core=core) @bottle.get("/add_feed") -def add_feed_ui(): +def add_feed_view(): return bottle.template("add_feed") @bottle.post("/add_feed") def add_feed_effect(): - feed_source = bottle.request.forms.get("feed_source") - tags = parse_space_separated_tags(bottle.request.forms.get("tags")) + feed_source: str = bottle.request.forms.get("feed_source") # type: ignore + tags = parse_space_separated_tags(bottle.request.forms.get("tags")) # type: ignore already_present: bool = False with tagrss_lock: try: - tagrss_backend.add_feed(feed_source=feed_source, tags=tags) + core.add_feed(feed_source=feed_source, tags=tags) except tagrss.FeedAlreadyAddedError: already_present = True # TODO: handle FeedFetchError too @@ -75,13 +83,52 @@ def add_feed_effect(): "add_feed", after_add=True, feed_source=feed_source, - already_present=already_present + already_present=already_present, ) +@bottle.get("/manage_feed") +def manage_feed_view(): + try: + feed_id_raw: str = bottle.request.query["feed"] # type: ignore + feed_id: int = int(feed_id_raw) + except KeyError: + raise bottle.HTTPError(400, "Feed ID not given.") + feed: dict[str, typing.Any] = {} + feed["id"] = feed_id + feed["source"] = core.get_feed_source(feed_id) + feed["title"] = core.get_feed_title(feed_id) + feed["tags"] = core.get_feed_tags(feed_id) + feed["serialised_tags"] = serialise_tags(feed["tags"]) + return bottle.template("manage_feed", feed=feed) + +@bottle.post("/manage_feed") +def manage_feed_effect_update(): + feed_id: int = int(bottle.request.forms["id"]) # type: ignore + feed_source: str = bottle.request.forms["source"] # type: ignore + feed_title: str = bottle.request.forms["title"] # type: ignore + feed_tags: list[str] = parse_space_separated_tags(bottle.request.forms["tags"]) # type: ignore + core.set_feed_source(feed_id, feed_source) + core.set_feed_title(feed_id, feed_title) + core.set_feed_tags(feed_id, feed_tags) + return bottle.redirect(f"/manage_feed?feed={feed_id}") + +@bottle.get("/delete") +def delete_view(): + return bottle.static_file("delete.html", root="views") + + +@bottle.post("/delete") +def delete_effect(): + feed_id: int = int(bottle.request.forms["id"]) # type: ignore + core.delete_feed(feed_id) + return bottle.redirect("/delete") + + @bottle.get("/static/<path:path>") def serve_static(path): return bottle.static_file(path, pathlib.Path(os.getcwd(), "static")) bottle.run(host=args.host, port=args.port, server="gevent") +core.close() @@ -18,24 +18,51 @@ class FeedFetchError(Exception): super().__init__(f"Get {feed_source} returned HTTP {status_code}") +class SqliteMissingForeignKeySupportError(Exception): + pass + + class TagRss: def __init__(self, *, storage_path: str | pathlib.Path): self.connection: sqlite3.Connection = sqlite3.connect(storage_path) + with self.connection: self.connection.executescript( """ -CREATE TABLE IF NOT EXISTS feeds(id INTEGER PRIMARY KEY, source TEXT UNIQUE, title TEXT); -CREATE INDEX IF NOT EXISTS feed_source ON feeds(source); +PRAGMA foreign_keys = ON; -CREATE TABLE IF NOT EXISTS feed_tags(feed_id INTEGER, tag TEXT); -CREATE INDEX IF NOT EXISTS feed_tags_feed_id ON feed_tags(feed_id); +CREATE TABLE IF NOT EXISTS + feeds( + id INTEGER PRIMARY KEY, + source TEXT UNIQUE, + title TEXT + ) STRICT; -CREATE TABLE IF NOT EXISTS entries(id INTEGER PRIMARY KEY, feed_id INTEGER, title TEXT, link TEXT, epoch_published INTEGER, epoch_updated INTEGER, epoch_downloaded INTEGER); -CREATE INDEX IF NOT EXISTS entry_epoch_downloaded ON entries(epoch_downloaded); +CREATE TABLE IF NOT EXISTS + feed_tags( + feed_id INTEGER REFERENCES feeds(id) ON DELETE CASCADE, + tag TEXT + ) STRICT; +CREATE INDEX IF NOT EXISTS idx_feed_tags__feed_id__tag ON feed_tags(feed_id, tag); +CREATE INDEX IF NOT EXISTS idx_feed_tags__tag__feed_id ON feed_tags(tag, feed_id); + +CREATE TABLE IF NOT EXISTS + entries( + id INTEGER PRIMARY KEY, + feed_id INTEGER REFERENCES feeds(id) ON DELETE CASCADE, + title TEXT, + link TEXT, + epoch_published INTEGER, + epoch_updated INTEGER, + epoch_downloaded INTEGER + ) STRICT; +CREATE INDEX IF NOT EXISTS idx_entries__epoch_downloaded ON entries(epoch_downloaded); """ ) + if (1,) not in self.connection.execute("PRAGMA foreign_keys;").fetchmany(1): + raise SqliteMissingForeignKeySupportError - def add_feed(self, *, feed_source: str, tags: tuple[str]): + def add_feed(self, feed_source: str, tags: list[str]) -> None: response = requests.get(feed_source) if response.status_code != requests.codes.ok: raise FeedFetchError(feed_source, response.status_code) @@ -44,7 +71,8 @@ CREATE INDEX IF NOT EXISTS entry_epoch_downloaded ON entries(epoch_downloaded); except KeyError: base: str = feed_source parsed = feedparser.parse( - io.BytesIO(bytes(response.text, encoding="utf-8")), response_headers={"Content-Location": base} + io.BytesIO(bytes(response.text, encoding="utf-8")), + response_headers={"Content-Location": base}, ) with self.connection: feed_title: str = parsed.feed.get("title", "") @@ -52,36 +80,37 @@ CREATE INDEX IF NOT EXISTS entry_epoch_downloaded ON entries(epoch_downloaded); self.connection.execute( "INSERT INTO feeds(source, title) VALUES(?, ?);", (feed_source, feed_title), - ) + ) # Note: ensure no more INSERTs between this and the last_insert_rowid() call except sqlite3.IntegrityError: raise FeedAlreadyAddedError feed_id: int = int( - self.connection.execute( - "SELECT id FROM feeds WHERE source = ?;", (feed_source,) - ).fetchone()[0] + self.connection.execute("SELECT last_insert_rowid();").fetchone()[0] ) self.connection.executemany( - f"INSERT INTO feed_tags(feed_id, tag) VALUES({feed_id}, ?);", tuple(((tag,) for tag in tags)) + "INSERT INTO feed_tags(feed_id, tag) VALUES(?, ?);", + ((feed_id, tag) for tag in tags), ) for entry in reversed(parsed.entries): - link: str = entry.get("link", "") + link: str = entry.get("link", None) + title: str = entry.get("title", None) try: epoch_published: typing.Optional[int] = calendar.timegm( entry.get("published_parsed", None) ) - except ValueError: + except TypeError: epoch_published = None try: epoch_updated: typing.Optional[int] = calendar.timegm( entry.get("updated_parsed", None) ) - except ValueError: + except TypeError: epoch_updated = None self.connection.execute( - "INSERT INTO entries(feed_id, title, link, epoch_published, epoch_updated, epoch_downloaded) VALUES(?, ?, ?, ?, ?, ?);", + "INSERT INTO entries(feed_id, title, link, epoch_published, epoch_updated, epoch_downloaded) \ + VALUES(?, ?, ?, ?, ?, ?);", ( feed_id, - feed_title, + title, link, epoch_published, epoch_updated, @@ -89,15 +118,16 @@ CREATE INDEX IF NOT EXISTS entry_epoch_downloaded ON entries(epoch_downloaded); ), ) - def get_entries(self, *, limit: int): + def get_entries(self, *, limit: int) -> list[dict[str, typing.Any]]: with self.connection: - result = self.connection.execute( - "SELECT feed_id, title, link, epoch_published, epoch_updated FROM entries ORDER BY epoch_downloaded DESC LIMIT ?;", + resp = self.connection.execute( + "SELECT feed_id, title, link, epoch_published, epoch_updated FROM entries \ + ORDER BY epoch_downloaded DESC LIMIT ?;", (limit,), ).fetchall() entries = [] - for entry in result: + for entry in resp: entries.append( { "feed_id": entry[0], @@ -108,6 +138,53 @@ CREATE INDEX IF NOT EXISTS entry_epoch_downloaded ON entries(epoch_downloaded); } ) return entries - def get_feed_tags(self, feed_id: int) -> tuple[str]: + + def get_feed_source(self, feed_id: int) -> str: + with self.connection: + return self.connection.execute( + "SELECT source FROM feeds WHERE id = ?;", (feed_id,) + ).fetchone()[0] + + def get_feed_title(self, feed_id: int) -> str: + with self.connection: + return self.connection.execute( + "SELECT title FROM feeds WHERE id = ?;", (feed_id,) + ).fetchone()[0] + + def get_feed_tags(self, feed_id: int) -> list[str]: + with self.connection: + return [ + t[0] + for t in self.connection.execute( + "SELECT tag FROM feed_tags WHERE feed_id = ?;", (feed_id,) + ).fetchall() + ] + + def set_feed_source(self, feed_id: int, feed_source: str): + with self.connection: + self.connection.execute( + "UPDATE feeds SET source = ? WHERE id = ?;", (feed_source, feed_id) + ) + + def set_feed_title(self, feed_id: int, feed_title: str): + with self.connection: + self.connection.execute( + "UPDATE feeds SET title = ? WHERE id = ?;", (feed_title, feed_id) + ) + + def set_feed_tags(self, feed_id: int, feed_tags: list[str]): + with self.connection: + self.connection.execute("DELETE FROM feed_tags WHERE feed_id = ?;", (feed_id,)) + self.connection.executemany( + "INSERT INTO feed_tags(feed_id, tag) VALUES(?, ?);", + ((feed_id, tag) for tag in feed_tags), + ) + + def delete_feed(self, feed_id: int) -> None: + with self.connection: + self.connection.execute("DELETE FROM feeds WHERE id = ?;", (feed_id,)) + + def close(self) -> None: with self.connection: - return tuple((t[0] for t in self.connection.execute("SELECT tag FROM feed_tags WHERE feed_id = ?;", (feed_id,)).fetchall()))
\ No newline at end of file + self.connection.execute("PRAGMA optimize;") + self.connection.close() diff --git a/views/add_feed.tpl b/views/add_feed.tpl index 3c54ffb..5fc88fb 100644 --- a/views/add_feed.tpl +++ b/views/add_feed.tpl @@ -23,7 +23,6 @@ <input type="text" placeholder="Tags" name="tags"> <span class="hover-help" tabindex="0" title="Space separated. Backslashes escape spaces.">🛈</span> </div> - <br> <input type="submit" value="Add"> </form> </body> diff --git a/views/delete.html b/views/delete.html new file mode 100644 index 0000000..000b733 --- /dev/null +++ b/views/delete.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Feed Deleted | TagRSS</title> + <link href="/static/styles/main.css" rel="stylesheet"> + <meta http-equiv="refresh" content="5; url=/" /> +</head> +<body> + <p>Feed successfully deleted. Redirecting...</p> + <a href="/">home</a> +</body> +</html> diff --git a/views/index.tpl b/views/index.tpl index 6ff4ab4..6ca1518 100644 --- a/views/index.tpl +++ b/views/index.tpl @@ -1,3 +1,6 @@ +<% + import time +%> <!DOCTYPE html> <html lang="en"> <head> @@ -27,7 +30,6 @@ <td>{{i + 1}}</td> <td><a href="{{entry["link"]}}">{{entry["title"]}}</a></td> <% - import time dates = [] if entry.get("epoch_published", None): dates.append(time.strftime("%x %X", time.localtime(entry["epoch_published"]))) @@ -43,7 +45,7 @@ {{", updated ".join(dates)}} </td> <td> - % tags = tagrss_backend.get_feed_tags(entry["feed_id"]) + % tags = core.get_feed_tags(entry["feed_id"]) % for i, tag in enumerate(tags): % if i > 0: {{", "}} @@ -52,7 +54,7 @@ % end </td> <td> - <a href="/manage_feeds?feed={{entry["feed_id"]}}">⚙</a> + <a href="/manage_feed?feed={{entry["feed_id"]}}">⚙</a> </td> </tr> % end diff --git a/views/manage_feed.tpl b/views/manage_feed.tpl new file mode 100644 index 0000000..86a9c5d --- /dev/null +++ b/views/manage_feed.tpl @@ -0,0 +1,56 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Manage Feed | TagRSS</title> + <link href="/static/styles/main.css" rel="stylesheet"> +</head> +<body> + <a href="/">< home</a> + <h1>Manage feed</h1> + <table> + <tr> + <th>Title</th> + <td>{{feed["title"]}}</td> + </tr> + <tr> + <th>Source</th> + <td>{{feed["source"]}}</td> + </tr> + <tr> + <th>Tags</th> + <td> + % tags = feed["tags"] + % for i, tag in enumerate(tags): + % if i > 0: + {{", "}} + % end + <span class="tag">{{tag}}</span> + % end + </td> + </tr> + </table> + <form method="post"> + <input type="number" name="id" value="{{feed['id']}}" style="display: none;"> + <label>Title: + <input type="text" name="title" value="{{feed['title']}}"><br> + </label> + <label>Source: + <input type="text" name="source" value="{{feed['source']}}"><br> + </label> + <div class="side-by-side-help-container"> + <label>Tags: + <input type="text" name="tags" value="{{feed['serialised_tags']}}"> + </label> + <span class="hover-help" tabindex="0" title="Space separated. Backslashes escape spaces.">🛈</span> + </div> + <input type="submit" value="Update" name="update_feed"> + </form> + <hr> + <form method="post" action="/delete"> + <input type="number" name="id" value="{{feed['id']}}" style="display: none;"> + <input type="submit" value="Delete" name="delete_feed"> + </form> +</body> +</html>
\ No newline at end of file |