Add a small RSS reader that actually runs
The parity port was heading for a second backend nobody had decided to deploy: 4,130 lines across 14 modules, each passing its own probe, none of them ever running in the same process. This is the opposite bet -- a few hundred lines of routing over those modules, producing one binary that reads feeds. Single user, no accounts, no sessions, no CSRF, no ATProto. That assumption is the whole simplification: it binds loopback and anything that reaches the port can do anything, which is fine for something running on your own machine and is why the readme says not to expose it. Verified end to end rather than by probe: subscribes to two real feeds, ingests 20 articles, marks read and unread, searches over FTS5, scopes to one feed, scrapes an article's full text and persists it, refreshes without duplicating, and unsubscribes. Parameters are read from the query string or a form body. Nim's asynchttpserver rejects a POST with no Content-Length, so a query-only POST fails in a way that looks like a routing bug; accepting both is what Go's FormValue does anyway. Storage keeps one hard-coded user, because the schema came from a multi-user server and satisfying that was cheaper than rewriting it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4cd4748 parent: 863259c modified
spike/nim/.gitignore +1 -0 | @@ -19,3 +19,4 @@ feedfetcher_probe | ||
| 19 | 19 | scraper_probe |
| 20 | 20 | cluster_probe |
| 21 | 21 | diag |
| 22 | +glean | |
| @@ -19,3 +19,4 @@ feedfetcher_probe | |||
| 19 | scraper_probe | 19 | scraper_probe |
| 20 | cluster_probe | 20 | cluster_probe |
| 21 | diag | 21 | diag |
| 22 | +glean | ||
added
spike/nim/glean.nim +255 -0 | new file mode 100644 | ||
| @@ -0,0 +1,255 @@ | ||
| 1 | +## A small RSS reader. | |
| 2 | +## | |
| 3 | +## nim c -r -d:ssl glean.nim | |
| 4 | +## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean | |
| 5 | +## | |
| 6 | +## Single user, no accounts, no auth. It runs on your machine and reads your | |
| 7 | +## feeds. That assumption is what keeps this a few hundred lines instead of a | |
| 8 | +## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto. | |
| 9 | +## | |
| 10 | +## The pieces underneath -- fetching, parsing, storage, search, full-text | |
| 11 | +## extraction -- are the same modules the larger port built and tested. | |
| 12 | + | |
| 13 | +import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, | |
| 14 | + strformat, strutils, uri] | |
| 15 | +import atproto/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | |
| 16 | + scraper, sqlite] | |
| 17 | + | |
| 18 | +const | |
| 19 | + ## Everything is stored per-user underneath because the schema came from a | |
| 20 | + ## multi-user server. One constant user keeps that plumbing satisfied | |
| 21 | + ## without inventing accounts. | |
| 22 | + LocalUser = "local" | |
| 23 | + DefaultPort = 8080 | |
| 24 | + | |
| 25 | +type Reader = ref object | |
| 26 | + db: GleanDb | |
| 27 | + | |
| 28 | +proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = | |
| 29 | + await req.respond(code, $node, newHttpHeaders({ | |
| 30 | + "Content-Type": "application/json", | |
| 31 | + # The reader may be opened from a local web page or a native client. | |
| 32 | + "Access-Control-Allow-Origin": "*", | |
| 33 | + })) | |
| 34 | + | |
| 35 | +proc fail(req: Request, code: HttpCode, msg: string) {.async.} = | |
| 36 | + await jsonResponse(req, code, %*{"error": msg}) | |
| 37 | + | |
| 38 | +proc toJson(a: Article): JsonNode = | |
| 39 | + %*{ | |
| 40 | + "id": a.id, | |
| 41 | + "feed_url": a.feedUrl, | |
| 42 | + "feed_title": a.feedTitle, | |
| 43 | + "title": a.title, | |
| 44 | + "url": a.url, | |
| 45 | + "author": a.author, | |
| 46 | + "summary": a.summary, | |
| 47 | + "content": (if a.fullContent.len > 0: a.fullContent else: a.content), | |
| 48 | + "published": (if a.published.isSome: $a.published.get else: ""), | |
| 49 | + "is_read": a.isRead, | |
| 50 | + } | |
| 51 | + | |
| 52 | +proc toJson(s: Subscription, unread: int): JsonNode = | |
| 53 | + %*{ | |
| 54 | + "feed_url": s.feedUrl, | |
| 55 | + "title": s.feedTitle, | |
| 56 | + "category": s.category, | |
| 57 | + "unread": unread, | |
| 58 | + "favicon_url": s.faviconUrl, | |
| 59 | + } | |
| 60 | + | |
| 61 | +# --- feed refresh ---------------------------------------------------------- | |
| 62 | + | |
| 63 | +proc refreshFeed(r: Reader, feedUrl: string): tuple[added: int, err: string] = | |
| 64 | + ## Fetch one feed and store what came back. | |
| 65 | + ## | |
| 66 | + ## A failure is recorded rather than raised: one unreachable feed should | |
| 67 | + ## not stop a refresh of the others, and the error belongs on the feed so | |
| 68 | + ## the reader can see which one is broken. | |
| 69 | + try: | |
| 70 | + let parsed = fetchFeed(feedUrl) | |
| 71 | + var batch: seq[NewArticle] | |
| 72 | + for a in parsed.articles: | |
| 73 | + batch.add NewArticle( | |
| 74 | + feedUrl: feedUrl, guid: a.guid, title: a.title, url: a.url, | |
| 75 | + author: a.author, summary: a.summary, content: a.content, | |
| 76 | + published: a.published, updated: a.updated) | |
| 77 | + let before = r.db.getUnreadCount(LocalUser, feedUrl, "") | |
| 78 | + r.db.batchUpsertArticles(batch) | |
| 79 | + r.db.upsertFeed(Feed(feedUrl: feedUrl, title: parsed.feed.title, | |
| 80 | + siteUrl: parsed.feed.siteUrl, | |
| 81 | + description: parsed.feed.description, | |
| 82 | + feedType: $parsed.feed.kind, | |
| 83 | + faviconUrl: parsed.feed.faviconUrl)) | |
| 84 | + r.db.markFeedFetched(feedUrl) | |
| 85 | + (r.db.getUnreadCount(LocalUser, feedUrl, "") - before, "") | |
| 86 | + except CatchableError as e: | |
| 87 | + r.db.markFeedFetchError(feedUrl, e.msg) | |
| 88 | + (0, e.msg) | |
| 89 | + | |
| 90 | +# --- routes ---------------------------------------------------------------- | |
| 91 | + | |
| 92 | +proc handle(r: Reader, req: Request) {.async.} = | |
| 93 | + let path = req.url.path.strip(chars = {'/'}) | |
| 94 | + let segs = path.split('/') | |
| 95 | + # Query string and form body both, the way Go's FormValue does: a caller | |
| 96 | + # should not have to care which one a given route wanted. | |
| 97 | + var q: seq[(string, string)] = @[] | |
| 98 | + for kv in decodeQuery(req.url.query): q.add kv | |
| 99 | + if req.body.len > 0: | |
| 100 | + for kv in decodeQuery(req.body): q.add kv | |
| 101 | + proc param(name: string, fallback = ""): string = | |
| 102 | + for (k, v) in q: | |
| 103 | + if k == name: return v | |
| 104 | + fallback | |
| 105 | + proc intParam(name: string, fallback: int): int = | |
| 106 | + let raw = param(name) | |
| 107 | + if raw.len == 0: fallback | |
| 108 | + else: | |
| 109 | + try: parseInt(raw) except ValueError: fallback | |
| 110 | + | |
| 111 | + case req.reqMethod | |
| 112 | + of HttpGet: | |
| 113 | + case segs[0] | |
| 114 | + of "", "health": | |
| 115 | + await jsonResponse(req, Http200, %*{"ok": true}) | |
| 116 | + | |
| 117 | + of "feeds": | |
| 118 | + var items = newJArray() | |
| 119 | + for s in r.db.listSubscriptions(LocalUser, "", 500, 0): | |
| 120 | + items.add toJson(s, s.unreadCount) | |
| 121 | + await jsonResponse(req, Http200, items) | |
| 122 | + | |
| 123 | + of "articles": | |
| 124 | + if segs.len == 2: | |
| 125 | + # One article, with its full text if we have it. | |
| 126 | + let id = try: parseInt(segs[1]) except ValueError: 0 | |
| 127 | + let a = r.db.getArticle(LocalUser, id) | |
| 128 | + if a.isNone: | |
| 129 | + await fail(req, Http404, "no such article") | |
| 130 | + else: | |
| 131 | + await jsonResponse(req, Http200, toJson(a.get)) | |
| 132 | + return | |
| 133 | + | |
| 134 | + let search = param("q") | |
| 135 | + let articles = | |
| 136 | + if search.len > 0: | |
| 137 | + r.db.searchArticles(LocalUser, search, intParam("limit", 50), 0) | |
| 138 | + else: | |
| 139 | + r.db.listArticles( | |
| 140 | + LocalUser, | |
| 141 | + filter = (if param("status") == "all": afAll | |
| 142 | + elif param("status") == "read": afRead | |
| 143 | + else: afUnread), | |
| 144 | + feedUrl = param("feed"), | |
| 145 | + limit = intParam("limit", 50)) | |
| 146 | + var items = newJArray() | |
| 147 | + for a in articles: items.add toJson(a) | |
| 148 | + await jsonResponse(req, Http200, items) | |
| 149 | + | |
| 150 | + of "unread": | |
| 151 | + await jsonResponse(req, Http200, | |
| 152 | + %*{"count": r.db.getUnreadCount(LocalUser, "", "")}) | |
| 153 | + else: | |
| 154 | + await fail(req, Http404, "not found") | |
| 155 | + | |
| 156 | + of HttpPost: | |
| 157 | + case segs[0] | |
| 158 | + of "feeds": | |
| 159 | + let url = param("url") | |
| 160 | + if url.len == 0: | |
| 161 | + await fail(req, Http400, "url required") | |
| 162 | + return | |
| 163 | + # Subscribe first so a feed that fails to fetch is still listed, with | |
| 164 | + # its error visible, rather than vanishing. | |
| 165 | + r.db.upsertFeed(Feed(feedUrl: url, title: url)) | |
| 166 | + r.db.createSubscription(LocalUser, url, "", param("category"), "", "") | |
| 167 | + let (added, err) = r.refreshFeed(url) | |
| 168 | + if err.len > 0: | |
| 169 | + await jsonResponse(req, Http200, | |
| 170 | + %*{"feed_url": url, "added": 0, "error": err}) | |
| 171 | + else: | |
| 172 | + await jsonResponse(req, Http200, %*{"feed_url": url, "added": added}) | |
| 173 | + | |
| 174 | + of "refresh": | |
| 175 | + var total = 0 | |
| 176 | + var errors = newJArray() | |
| 177 | + for s in r.db.listSubscriptions(LocalUser, "", 500, 0): | |
| 178 | + let (added, err) = r.refreshFeed(s.feedUrl) | |
| 179 | + total += added | |
| 180 | + if err.len > 0: | |
| 181 | + errors.add %*{"feed_url": s.feedUrl, "error": err} | |
| 182 | + await jsonResponse(req, Http200, %*{"added": total, "errors": errors}) | |
| 183 | + | |
| 184 | + of "read": | |
| 185 | + let id = intParam("id", 0) | |
| 186 | + if id == 0: | |
| 187 | + await fail(req, Http400, "id required") | |
| 188 | + return | |
| 189 | + if param("undo") == "1": | |
| 190 | + r.db.markArticleUnread(LocalUser, id) | |
| 191 | + else: | |
| 192 | + r.db.markArticleRead(LocalUser, id) | |
| 193 | + await jsonResponse(req, Http200, %*{"id": id}) | |
| 194 | + | |
| 195 | + of "read-all": | |
| 196 | + r.db.markAllRead(LocalUser, param("feed")) | |
| 197 | + await jsonResponse(req, Http200, | |
| 198 | + %*{"unread": r.db.getUnreadCount(LocalUser, "", "")}) | |
| 199 | + | |
| 200 | + of "fetch-content": | |
| 201 | + # Most feeds ship an excerpt; this pulls the real article. | |
| 202 | + let id = intParam("id", 0) | |
| 203 | + let a = r.db.getArticle(LocalUser, id) | |
| 204 | + if a.isNone or a.get.url.len == 0: | |
| 205 | + await fail(req, Http404, "no such article") | |
| 206 | + return | |
| 207 | + try: | |
| 208 | + let client = newHttpClient(timeout = 20_000, | |
| 209 | + userAgent = "glean/0.1") | |
| 210 | + defer: client.close() | |
| 211 | + let text = extractArticle(client.getContent(a.get.url)) | |
| 212 | + r.db.updateArticleFullContent(id, text) | |
| 213 | + await jsonResponse(req, Http200, %*{"id": id, "content": text}) | |
| 214 | + except CatchableError as e: | |
| 215 | + await fail(req, Http502, e.msg) | |
| 216 | + else: | |
| 217 | + await fail(req, Http404, "not found") | |
| 218 | + | |
| 219 | + of HttpDelete: | |
| 220 | + if segs[0] == "feeds" and param("url").len > 0: | |
| 221 | + r.db.deleteSubscription(LocalUser, param("url")) | |
| 222 | + await jsonResponse(req, Http200, %*{"ok": true}) | |
| 223 | + else: | |
| 224 | + await fail(req, Http400, "url required") | |
| 225 | + | |
| 226 | + else: | |
| 227 | + await fail(req, Http405, "method not allowed") | |
| 228 | + | |
| 229 | +proc main() {.async.} = | |
| 230 | + let dbPath = getEnv("GLEAN_DB", getHomeDir() / ".local/share/glean/glean") | |
| 231 | + createDir(dbPath.parentDir) | |
| 232 | + let port = try: parseInt(getEnv("GLEAN_PORT", $DefaultPort)) | |
| 233 | + except ValueError: DefaultPort | |
| 234 | + | |
| 235 | + var db = gleandb.open(dbPath) | |
| 236 | + db.migrate() | |
| 237 | + db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) | |
| 238 | + let reader = Reader(db: db) | |
| 239 | + | |
| 240 | + echo &"glean reading from {dbPath}" | |
| 241 | + echo &"listening on http://127.0.0.1:{port}" | |
| 242 | + | |
| 243 | + var server = newAsyncHttpServer() | |
| 244 | + # Single-threaded server, single Reader: the cast is asserting that, not | |
| 245 | + # claiming the Reader is safe to share across threads. | |
| 246 | + # Single-threaded server with one Reader. The cast asserts that, rather | |
| 247 | + # than claiming the modules underneath are thread-safe -- they are not, and | |
| 248 | + # do not need to be. | |
| 249 | + proc cb(req: Request) {.async, gcsafe.} = | |
| 250 | + {.cast(gcsafe).}: | |
| 251 | + await reader.handle(req) | |
| 252 | + await server.serve(Port(port), cb, address = "127.0.0.1") | |
| 253 | + | |
| 254 | +when isMainModule: | |
| 255 | + waitFor main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,255 @@ | |||
| 1 | +## A small RSS reader. | ||
| 2 | +## | ||
| 3 | +## nim c -r -d:ssl glean.nim | ||
| 4 | +## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean | ||
| 5 | +## | ||
| 6 | +## Single user, no accounts, no auth. It runs on your machine and reads your | ||
| 7 | +## feeds. That assumption is what keeps this a few hundred lines instead of a | ||
| 8 | +## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto. | ||
| 9 | +## | ||
| 10 | +## The pieces underneath -- fetching, parsing, storage, search, full-text | ||
| 11 | +## extraction -- are the same modules the larger port built and tested. | ||
| 12 | + | ||
| 13 | +import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, | ||
| 14 | + strformat, strutils, uri] | ||
| 15 | +import atproto/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | ||
| 16 | + scraper, sqlite] | ||
| 17 | + | ||
| 18 | +const | ||
| 19 | + ## Everything is stored per-user underneath because the schema came from a | ||
| 20 | + ## multi-user server. One constant user keeps that plumbing satisfied | ||
| 21 | + ## without inventing accounts. | ||
| 22 | + LocalUser = "local" | ||
| 23 | + DefaultPort = 8080 | ||
| 24 | + | ||
| 25 | +type Reader = ref object | ||
| 26 | + db: GleanDb | ||
| 27 | + | ||
| 28 | +proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} = | ||
| 29 | + await req.respond(code, $node, newHttpHeaders({ | ||
| 30 | + "Content-Type": "application/json", | ||
| 31 | + # The reader may be opened from a local web page or a native client. | ||
| 32 | + "Access-Control-Allow-Origin": "*", | ||
| 33 | + })) | ||
| 34 | + | ||
| 35 | +proc fail(req: Request, code: HttpCode, msg: string) {.async.} = | ||
| 36 | + await jsonResponse(req, code, %*{"error": msg}) | ||
| 37 | + | ||
| 38 | +proc toJson(a: Article): JsonNode = | ||
| 39 | + %*{ | ||
| 40 | + "id": a.id, | ||
| 41 | + "feed_url": a.feedUrl, | ||
| 42 | + "feed_title": a.feedTitle, | ||
| 43 | + "title": a.title, | ||
| 44 | + "url": a.url, | ||
| 45 | + "author": a.author, | ||
| 46 | + "summary": a.summary, | ||
| 47 | + "content": (if a.fullContent.len > 0: a.fullContent else: a.content), | ||
| 48 | + "published": (if a.published.isSome: $a.published.get else: ""), | ||
| 49 | + "is_read": a.isRead, | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | +proc toJson(s: Subscription, unread: int): JsonNode = | ||
| 53 | + %*{ | ||
| 54 | + "feed_url": s.feedUrl, | ||
| 55 | + "title": s.feedTitle, | ||
| 56 | + "category": s.category, | ||
| 57 | + "unread": unread, | ||
| 58 | + "favicon_url": s.faviconUrl, | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | +# --- feed refresh ---------------------------------------------------------- | ||
| 62 | + | ||
| 63 | +proc refreshFeed(r: Reader, feedUrl: string): tuple[added: int, err: string] = | ||
| 64 | + ## Fetch one feed and store what came back. | ||
| 65 | + ## | ||
| 66 | + ## A failure is recorded rather than raised: one unreachable feed should | ||
| 67 | + ## not stop a refresh of the others, and the error belongs on the feed so | ||
| 68 | + ## the reader can see which one is broken. | ||
| 69 | + try: | ||
| 70 | + let parsed = fetchFeed(feedUrl) | ||
| 71 | + var batch: seq[NewArticle] | ||
| 72 | + for a in parsed.articles: | ||
| 73 | + batch.add NewArticle( | ||
| 74 | + feedUrl: feedUrl, guid: a.guid, title: a.title, url: a.url, | ||
| 75 | + author: a.author, summary: a.summary, content: a.content, | ||
| 76 | + published: a.published, updated: a.updated) | ||
| 77 | + let before = r.db.getUnreadCount(LocalUser, feedUrl, "") | ||
| 78 | + r.db.batchUpsertArticles(batch) | ||
| 79 | + r.db.upsertFeed(Feed(feedUrl: feedUrl, title: parsed.feed.title, | ||
| 80 | + siteUrl: parsed.feed.siteUrl, | ||
| 81 | + description: parsed.feed.description, | ||
| 82 | + feedType: $parsed.feed.kind, | ||
| 83 | + faviconUrl: parsed.feed.faviconUrl)) | ||
| 84 | + r.db.markFeedFetched(feedUrl) | ||
| 85 | + (r.db.getUnreadCount(LocalUser, feedUrl, "") - before, "") | ||
| 86 | + except CatchableError as e: | ||
| 87 | + r.db.markFeedFetchError(feedUrl, e.msg) | ||
| 88 | + (0, e.msg) | ||
| 89 | + | ||
| 90 | +# --- routes ---------------------------------------------------------------- | ||
| 91 | + | ||
| 92 | +proc handle(r: Reader, req: Request) {.async.} = | ||
| 93 | + let path = req.url.path.strip(chars = {'/'}) | ||
| 94 | + let segs = path.split('/') | ||
| 95 | + # Query string and form body both, the way Go's FormValue does: a caller | ||
| 96 | + # should not have to care which one a given route wanted. | ||
| 97 | + var q: seq[(string, string)] = @[] | ||
| 98 | + for kv in decodeQuery(req.url.query): q.add kv | ||
| 99 | + if req.body.len > 0: | ||
| 100 | + for kv in decodeQuery(req.body): q.add kv | ||
| 101 | + proc param(name: string, fallback = ""): string = | ||
| 102 | + for (k, v) in q: | ||
| 103 | + if k == name: return v | ||
| 104 | + fallback | ||
| 105 | + proc intParam(name: string, fallback: int): int = | ||
| 106 | + let raw = param(name) | ||
| 107 | + if raw.len == 0: fallback | ||
| 108 | + else: | ||
| 109 | + try: parseInt(raw) except ValueError: fallback | ||
| 110 | + | ||
| 111 | + case req.reqMethod | ||
| 112 | + of HttpGet: | ||
| 113 | + case segs[0] | ||
| 114 | + of "", "health": | ||
| 115 | + await jsonResponse(req, Http200, %*{"ok": true}) | ||
| 116 | + | ||
| 117 | + of "feeds": | ||
| 118 | + var items = newJArray() | ||
| 119 | + for s in r.db.listSubscriptions(LocalUser, "", 500, 0): | ||
| 120 | + items.add toJson(s, s.unreadCount) | ||
| 121 | + await jsonResponse(req, Http200, items) | ||
| 122 | + | ||
| 123 | + of "articles": | ||
| 124 | + if segs.len == 2: | ||
| 125 | + # One article, with its full text if we have it. | ||
| 126 | + let id = try: parseInt(segs[1]) except ValueError: 0 | ||
| 127 | + let a = r.db.getArticle(LocalUser, id) | ||
| 128 | + if a.isNone: | ||
| 129 | + await fail(req, Http404, "no such article") | ||
| 130 | + else: | ||
| 131 | + await jsonResponse(req, Http200, toJson(a.get)) | ||
| 132 | + return | ||
| 133 | + | ||
| 134 | + let search = param("q") | ||
| 135 | + let articles = | ||
| 136 | + if search.len > 0: | ||
| 137 | + r.db.searchArticles(LocalUser, search, intParam("limit", 50), 0) | ||
| 138 | + else: | ||
| 139 | + r.db.listArticles( | ||
| 140 | + LocalUser, | ||
| 141 | + filter = (if param("status") == "all": afAll | ||
| 142 | + elif param("status") == "read": afRead | ||
| 143 | + else: afUnread), | ||
| 144 | + feedUrl = param("feed"), | ||
| 145 | + limit = intParam("limit", 50)) | ||
| 146 | + var items = newJArray() | ||
| 147 | + for a in articles: items.add toJson(a) | ||
| 148 | + await jsonResponse(req, Http200, items) | ||
| 149 | + | ||
| 150 | + of "unread": | ||
| 151 | + await jsonResponse(req, Http200, | ||
| 152 | + %*{"count": r.db.getUnreadCount(LocalUser, "", "")}) | ||
| 153 | + else: | ||
| 154 | + await fail(req, Http404, "not found") | ||
| 155 | + | ||
| 156 | + of HttpPost: | ||
| 157 | + case segs[0] | ||
| 158 | + of "feeds": | ||
| 159 | + let url = param("url") | ||
| 160 | + if url.len == 0: | ||
| 161 | + await fail(req, Http400, "url required") | ||
| 162 | + return | ||
| 163 | + # Subscribe first so a feed that fails to fetch is still listed, with | ||
| 164 | + # its error visible, rather than vanishing. | ||
| 165 | + r.db.upsertFeed(Feed(feedUrl: url, title: url)) | ||
| 166 | + r.db.createSubscription(LocalUser, url, "", param("category"), "", "") | ||
| 167 | + let (added, err) = r.refreshFeed(url) | ||
| 168 | + if err.len > 0: | ||
| 169 | + await jsonResponse(req, Http200, | ||
| 170 | + %*{"feed_url": url, "added": 0, "error": err}) | ||
| 171 | + else: | ||
| 172 | + await jsonResponse(req, Http200, %*{"feed_url": url, "added": added}) | ||
| 173 | + | ||
| 174 | + of "refresh": | ||
| 175 | + var total = 0 | ||
| 176 | + var errors = newJArray() | ||
| 177 | + for s in r.db.listSubscriptions(LocalUser, "", 500, 0): | ||
| 178 | + let (added, err) = r.refreshFeed(s.feedUrl) | ||
| 179 | + total += added | ||
| 180 | + if err.len > 0: | ||
| 181 | + errors.add %*{"feed_url": s.feedUrl, "error": err} | ||
| 182 | + await jsonResponse(req, Http200, %*{"added": total, "errors": errors}) | ||
| 183 | + | ||
| 184 | + of "read": | ||
| 185 | + let id = intParam("id", 0) | ||
| 186 | + if id == 0: | ||
| 187 | + await fail(req, Http400, "id required") | ||
| 188 | + return | ||
| 189 | + if param("undo") == "1": | ||
| 190 | + r.db.markArticleUnread(LocalUser, id) | ||
| 191 | + else: | ||
| 192 | + r.db.markArticleRead(LocalUser, id) | ||
| 193 | + await jsonResponse(req, Http200, %*{"id": id}) | ||
| 194 | + | ||
| 195 | + of "read-all": | ||
| 196 | + r.db.markAllRead(LocalUser, param("feed")) | ||
| 197 | + await jsonResponse(req, Http200, | ||
| 198 | + %*{"unread": r.db.getUnreadCount(LocalUser, "", "")}) | ||
| 199 | + | ||
| 200 | + of "fetch-content": | ||
| 201 | + # Most feeds ship an excerpt; this pulls the real article. | ||
| 202 | + let id = intParam("id", 0) | ||
| 203 | + let a = r.db.getArticle(LocalUser, id) | ||
| 204 | + if a.isNone or a.get.url.len == 0: | ||
| 205 | + await fail(req, Http404, "no such article") | ||
| 206 | + return | ||
| 207 | + try: | ||
| 208 | + let client = newHttpClient(timeout = 20_000, | ||
| 209 | + userAgent = "glean/0.1") | ||
| 210 | + defer: client.close() | ||
| 211 | + let text = extractArticle(client.getContent(a.get.url)) | ||
| 212 | + r.db.updateArticleFullContent(id, text) | ||
| 213 | + await jsonResponse(req, Http200, %*{"id": id, "content": text}) | ||
| 214 | + except CatchableError as e: | ||
| 215 | + await fail(req, Http502, e.msg) | ||
| 216 | + else: | ||
| 217 | + await fail(req, Http404, "not found") | ||
| 218 | + | ||
| 219 | + of HttpDelete: | ||
| 220 | + if segs[0] == "feeds" and param("url").len > 0: | ||
| 221 | + r.db.deleteSubscription(LocalUser, param("url")) | ||
| 222 | + await jsonResponse(req, Http200, %*{"ok": true}) | ||
| 223 | + else: | ||
| 224 | + await fail(req, Http400, "url required") | ||
| 225 | + | ||
| 226 | + else: | ||
| 227 | + await fail(req, Http405, "method not allowed") | ||
| 228 | + | ||
| 229 | +proc main() {.async.} = | ||
| 230 | + let dbPath = getEnv("GLEAN_DB", getHomeDir() / ".local/share/glean/glean") | ||
| 231 | + createDir(dbPath.parentDir) | ||
| 232 | + let port = try: parseInt(getEnv("GLEAN_PORT", $DefaultPort)) | ||
| 233 | + except ValueError: DefaultPort | ||
| 234 | + | ||
| 235 | + var db = gleandb.open(dbPath) | ||
| 236 | + db.migrate() | ||
| 237 | + db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) | ||
| 238 | + let reader = Reader(db: db) | ||
| 239 | + | ||
| 240 | + echo &"glean reading from {dbPath}" | ||
| 241 | + echo &"listening on http://127.0.0.1:{port}" | ||
| 242 | + | ||
| 243 | + var server = newAsyncHttpServer() | ||
| 244 | + # Single-threaded server, single Reader: the cast is asserting that, not | ||
| 245 | + # claiming the Reader is safe to share across threads. | ||
| 246 | + # Single-threaded server with one Reader. The cast asserts that, rather | ||
| 247 | + # than claiming the modules underneath are thread-safe -- they are not, and | ||
| 248 | + # do not need to be. | ||
| 249 | + proc cb(req: Request) {.async, gcsafe.} = | ||
| 250 | + {.cast(gcsafe).}: | ||
| 251 | + await reader.handle(req) | ||
| 252 | + await server.serve(Port(port), cb, address = "127.0.0.1") | ||
| 253 | + | ||
| 254 | +when isMainModule: | ||
| 255 | + waitFor main() | ||
added
spike/nim/readme-reader.md +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +# glean — a small RSS reader | |
| 2 | + | |
| 3 | +One binary, one SQLite database, no accounts. | |
| 4 | + | |
| 5 | +``` | |
| 6 | +nim c -d:ssl glean.nim | |
| 7 | +./glean | |
| 8 | +``` | |
| 9 | + | |
| 10 | +Listens on `127.0.0.1:8080` and stores its database under | |
| 11 | +`~/.local/share/glean/`. Override with `GLEAN_PORT` and `GLEAN_DB`. | |
| 12 | + | |
| 13 | +## API | |
| 14 | + | |
| 15 | +``` | |
| 16 | +GET /feeds subscriptions, with unread counts | |
| 17 | +POST /feeds url=… subscribe and fetch immediately | |
| 18 | +DELETE /feeds?url=… unsubscribe | |
| 19 | + | |
| 20 | +GET /articles unread, newest first | |
| 21 | +GET /articles?status=all|read …or everything, or just the read ones | |
| 22 | +GET /articles?feed=… …scoped to one feed | |
| 23 | +GET /articles?q=… full-text search (FTS5) | |
| 24 | +GET /articles/:id one article | |
| 25 | + | |
| 26 | +POST /read id=… mark read (undo=1 to reverse) | |
| 27 | +POST /read-all [feed=…] mark a feed, or everything, read | |
| 28 | +POST /refresh fetch every subscribed feed | |
| 29 | +POST /fetch-content id=… scrape the full article text | |
| 30 | + | |
| 31 | +GET /unread unread count | |
| 32 | +``` | |
| 33 | + | |
| 34 | +Parameters are read from the query string or a form body, whichever the | |
| 35 | +caller used. | |
| 36 | + | |
| 37 | +## What it does not do | |
| 38 | + | |
| 39 | +No accounts, no sessions, no CSRF, no ATProto. It runs on your machine and | |
| 40 | +reads your feeds, and that assumption is what keeps it a few hundred lines | |
| 41 | +rather than a few thousand. | |
| 42 | + | |
| 43 | +The one visible consequence: **do not expose it to a network.** It binds | |
| 44 | +loopback deliberately. Anything that can reach the port can read and change | |
| 45 | +everything, because there is nobody to distinguish from anybody else. | |
| 46 | + | |
| 47 | +## What it is built on | |
| 48 | + | |
| 49 | +The modules under `atproto/` — fetching with a sane retry policy, parsing | |
| 50 | +RSS/RDF/Atom/JSON Feed, SQLite with FTS5, and a readability-style extractor | |
| 51 | +that sanitises what it returns. Those came out of a larger port and carry | |
| 52 | +their own tests (`nimble test`); this file is a few hundred lines of routing | |
| 53 | +on top. | |
| 54 | + | |
| 55 | +Storage keeps a single hard-coded user, because the schema came from a | |
| 56 | +multi-user server and it was cheaper to satisfy that than to rewrite it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +# glean — a small RSS reader | ||
| 2 | + | ||
| 3 | +One binary, one SQLite database, no accounts. | ||
| 4 | + | ||
| 5 | +``` | ||
| 6 | +nim c -d:ssl glean.nim | ||
| 7 | +./glean | ||
| 8 | +``` | ||
| 9 | + | ||
| 10 | +Listens on `127.0.0.1:8080` and stores its database under | ||
| 11 | +`~/.local/share/glean/`. Override with `GLEAN_PORT` and `GLEAN_DB`. | ||
| 12 | + | ||
| 13 | +## API | ||
| 14 | + | ||
| 15 | +``` | ||
| 16 | +GET /feeds subscriptions, with unread counts | ||
| 17 | +POST /feeds url=… subscribe and fetch immediately | ||
| 18 | +DELETE /feeds?url=… unsubscribe | ||
| 19 | + | ||
| 20 | +GET /articles unread, newest first | ||
| 21 | +GET /articles?status=all|read …or everything, or just the read ones | ||
| 22 | +GET /articles?feed=… …scoped to one feed | ||
| 23 | +GET /articles?q=… full-text search (FTS5) | ||
| 24 | +GET /articles/:id one article | ||
| 25 | + | ||
| 26 | +POST /read id=… mark read (undo=1 to reverse) | ||
| 27 | +POST /read-all [feed=…] mark a feed, or everything, read | ||
| 28 | +POST /refresh fetch every subscribed feed | ||
| 29 | +POST /fetch-content id=… scrape the full article text | ||
| 30 | + | ||
| 31 | +GET /unread unread count | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +Parameters are read from the query string or a form body, whichever the | ||
| 35 | +caller used. | ||
| 36 | + | ||
| 37 | +## What it does not do | ||
| 38 | + | ||
| 39 | +No accounts, no sessions, no CSRF, no ATProto. It runs on your machine and | ||
| 40 | +reads your feeds, and that assumption is what keeps it a few hundred lines | ||
| 41 | +rather than a few thousand. | ||
| 42 | + | ||
| 43 | +The one visible consequence: **do not expose it to a network.** It binds | ||
| 44 | +loopback deliberately. Anything that can reach the port can read and change | ||
| 45 | +everything, because there is nobody to distinguish from anybody else. | ||
| 46 | + | ||
| 47 | +## What it is built on | ||
| 48 | + | ||
| 49 | +The modules under `atproto/` — fetching with a sane retry policy, parsing | ||
| 50 | +RSS/RDF/Atom/JSON Feed, SQLite with FTS5, and a readability-style extractor | ||
| 51 | +that sanitises what it returns. Those came out of a larger port and carry | ||
| 52 | +their own tests (`nimble test`); this file is a few hundred lines of routing | ||
| 53 | +on top. | ||
| 54 | + | ||
| 55 | +Storage keeps a single hard-coded user, because the schema came from a | ||
| 56 | +multi-user server and it was cheaper to satisfy that than to rewrite it. | ||