Add a web interface
One HTML file, no build step, no dependencies, baked into the binary with staticRead so the reader is still a single file to copy. A two-pane view: feeds and unread counts on the left, articles on the right, the article in place. Add, refresh, search, read, unsubscribe, fetch full text. The interface is the reason for a change underneath it. Scraped article text already went through the scraper's whitelist, but content lifted straight from a feed did not -- and that is markup from a stranger's server heading for a browser. sanitizeFragment now runs every body the API serves through the same whitelist, whatever its source. Verified by planting a script tag, an onerror image and a javascript: link in a stored article: all three are gone by the time the API returns it, and the link survives as its text. Titles and summaries are set with textContent in the page for the same reason; innerHTML is used in exactly one place, on the sanitised body. Also fixes dates. Nim's `$` on a DateTime dumps the struct's fields rather than an ISO string, which reached the browser as "Invalid Date" on every article. Formatted explicitly now. Checked in a browser: list, article, full-text fetch, back, and FTS5 search all work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2b72cef parent: 775d793 modified
spike/nim/glean.nim +20 -5 | @@ -11,11 +11,15 @@ | ||
| 11 | 11 | ## extraction -- are the same modules the larger port built and tested. |
| 12 | 12 | |
| 13 | 13 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, |
| 14 | - strformat, strutils, uri] | |
| 14 | + strformat, strutils, times, uri] | |
| 15 | 15 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, |
| 16 | 16 | scraper, sqlite] |
| 17 | 17 | |
| 18 | 18 | const |
| 19 | + ## Baked in at compile time rather than read from disk, so the reader stays | |
| 20 | + ## one file to copy around. | |
| 21 | + IndexHtml = staticRead("ui.html") | |
| 22 | + | |
| 19 | 23 | ## Everything is stored per-user underneath because the schema came from a |
| 20 | 24 | ## multi-user server. One constant user keeps that plumbing satisfied |
| 21 | 25 | ## without inventing accounts. |
| @@ -44,8 +48,16 @@ proc toJson(a: Article): JsonNode = | ||
| 44 | 48 | "url": a.url, |
| 45 | 49 | "author": a.author, |
| 46 | 50 | "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: ""), | |
| 51 | + # Sanitised on the way out, whichever source it came from. Scraped text | |
| 52 | + # has already been through the whitelist, but feed content has not, and | |
| 53 | + # it is markup from a stranger's server heading for a browser. | |
| 54 | + "content": sanitizeFragment( | |
| 55 | + if a.fullContent.len > 0: a.fullContent else: a.content), | |
| 56 | + # Explicitly ISO 8601. Nim's `$` on a DateTime dumps the struct's fields, | |
| 57 | + # which reaches the browser as "Invalid Date". | |
| 58 | + "published": (if a.published.isSome: | |
| 59 | + a.published.get.format("yyyy-MM-dd'T'HH:mm:ss'Z'") | |
| 60 | + else: ""), | |
| 49 | 61 | "is_read": a.isRead, |
| 50 | 62 | } |
| 51 | 63 | |
| @@ -111,7 +123,10 @@ proc handle(r: Reader, req: Request) {.async.} = | ||
| 111 | 123 | case req.reqMethod |
| 112 | 124 | of HttpGet: |
| 113 | 125 | case segs[0] |
| 114 | - of "", "health": | |
| 126 | + of "": | |
| 127 | + await req.respond(Http200, IndexHtml, | |
| 128 | + newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | |
| 129 | + of "health": | |
| 115 | 130 | await jsonResponse(req, Http200, %*{"ok": true}) |
| 116 | 131 | |
| 117 | 132 | of "feeds": |
| @@ -238,7 +253,7 @@ proc main() {.async.} = | ||
| 238 | 253 | let reader = Reader(db: db) |
| 239 | 254 | |
| 240 | 255 | echo &"glean reading from {dbPath}" |
| 241 | - echo &"listening on http://127.0.0.1:{port}" | |
| 256 | + echo &"open http://127.0.0.1:{port}" | |
| 242 | 257 | |
| 243 | 258 | var server = newAsyncHttpServer() |
| 244 | 259 | # Single-threaded server, single Reader: the cast is asserting that, not |
| @@ -11,11 +11,15 @@ | |||
| 11 | ## extraction -- are the same modules the larger port built and tested. | 11 | ## extraction -- are the same modules the larger port built and tested. |
| 12 | 12 | ||
| 13 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, | 13 | import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os, |
| 14 | - strformat, strutils, uri] | 14 | + strformat, strutils, times, uri] |
| 15 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | 15 | import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, |
| 16 | scraper, sqlite] | 16 | scraper, sqlite] |
| 17 | 17 | ||
| 18 | const | 18 | const |
| 19 | + ## Baked in at compile time rather than read from disk, so the reader stays | ||
| 20 | + ## one file to copy around. | ||
| 21 | + IndexHtml = staticRead("ui.html") | ||
| 22 | + | ||
| 19 | ## Everything is stored per-user underneath because the schema came from a | 23 | ## Everything is stored per-user underneath because the schema came from a |
| 20 | ## multi-user server. One constant user keeps that plumbing satisfied | 24 | ## multi-user server. One constant user keeps that plumbing satisfied |
| 21 | ## without inventing accounts. | 25 | ## without inventing accounts. |
| @@ -44,8 +48,16 @@ proc toJson(a: Article): JsonNode = | |||
| 44 | "url": a.url, | 48 | "url": a.url, |
| 45 | "author": a.author, | 49 | "author": a.author, |
| 46 | "summary": a.summary, | 50 | "summary": a.summary, |
| 47 | - "content": (if a.fullContent.len > 0: a.fullContent else: a.content), | 51 | + # Sanitised on the way out, whichever source it came from. Scraped text |
| 48 | - "published": (if a.published.isSome: $a.published.get else: ""), | 52 | + # has already been through the whitelist, but feed content has not, and |
| 53 | + # it is markup from a stranger's server heading for a browser. | ||
| 54 | + "content": sanitizeFragment( | ||
| 55 | + if a.fullContent.len > 0: a.fullContent else: a.content), | ||
| 56 | + # Explicitly ISO 8601. Nim's `$` on a DateTime dumps the struct's fields, | ||
| 57 | + # which reaches the browser as "Invalid Date". | ||
| 58 | + "published": (if a.published.isSome: | ||
| 59 | + a.published.get.format("yyyy-MM-dd'T'HH:mm:ss'Z'") | ||
| 60 | + else: ""), | ||
| 49 | "is_read": a.isRead, | 61 | "is_read": a.isRead, |
| 50 | } | 62 | } |
| 51 | 63 | ||
| @@ -111,7 +123,10 @@ proc handle(r: Reader, req: Request) {.async.} = | |||
| 111 | case req.reqMethod | 123 | case req.reqMethod |
| 112 | of HttpGet: | 124 | of HttpGet: |
| 113 | case segs[0] | 125 | case segs[0] |
| 114 | - of "", "health": | 126 | + of "": |
| 127 | + await req.respond(Http200, IndexHtml, | ||
| 128 | + newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | ||
| 129 | + of "health": | ||
| 115 | await jsonResponse(req, Http200, %*{"ok": true}) | 130 | await jsonResponse(req, Http200, %*{"ok": true}) |
| 116 | 131 | ||
| 117 | of "feeds": | 132 | of "feeds": |
| @@ -238,7 +253,7 @@ proc main() {.async.} = | |||
| 238 | let reader = Reader(db: db) | 253 | let reader = Reader(db: db) |
| 239 | 254 | ||
| 240 | echo &"glean reading from {dbPath}" | 255 | echo &"glean reading from {dbPath}" |
| 241 | - echo &"listening on http://127.0.0.1:{port}" | 256 | + echo &"open http://127.0.0.1:{port}" |
| 242 | 257 | ||
| 243 | var server = newAsyncHttpServer() | 258 | var server = newAsyncHttpServer() |
| 244 | # Single-threaded server, single Reader: the cast is asserting that, not | 259 | # Single-threaded server, single Reader: the cast is asserting that, not |
modified
spike/nim/reader/scraper.nim +18 -0 | @@ -259,6 +259,24 @@ proc renderNode*(n: XmlNode): string = | ||
| 259 | 259 | |
| 260 | 260 | # --- entry point ----------------------------------------------------------- |
| 261 | 261 | |
| 262 | +proc sanitizeFragment*(html: string): string = | |
| 263 | + ## Run arbitrary HTML through the same whitelist the extractor uses. | |
| 264 | + ## | |
| 265 | + ## Feed content needs this as much as scraped content does, and is easier | |
| 266 | + ## to forget: an entry's `content:encoded` is attacker-controlled markup | |
| 267 | + ## from a stranger's server, and it reaches the reader without ever passing | |
| 268 | + ## through extractArticle. Rendering it raw is a script tag away from | |
| 269 | + ## running in the reader's browser. | |
| 270 | + ## | |
| 271 | + ## Returns "" for input that will not parse, rather than raising: a feed | |
| 272 | + ## with one malformed entry should lose that entry's body, not the article. | |
| 273 | + if html.strip().len == 0: return "" | |
| 274 | + let doc = | |
| 275 | + try: parseHtml(html) | |
| 276 | + except CatchableError: return "" | |
| 277 | + removeUnwanted(doc) | |
| 278 | + renderNode(doc) | |
| 279 | + | |
| 262 | 280 | proc extractArticle*(body: string): string = |
| 263 | 281 | ## Pull the readable article out of a page. |
| 264 | 282 | ## |
| @@ -259,6 +259,24 @@ proc renderNode*(n: XmlNode): string = | |||
| 259 | 259 | ||
| 260 | # --- entry point ----------------------------------------------------------- | 260 | # --- entry point ----------------------------------------------------------- |
| 261 | 261 | ||
| 262 | +proc sanitizeFragment*(html: string): string = | ||
| 263 | + ## Run arbitrary HTML through the same whitelist the extractor uses. | ||
| 264 | + ## | ||
| 265 | + ## Feed content needs this as much as scraped content does, and is easier | ||
| 266 | + ## to forget: an entry's `content:encoded` is attacker-controlled markup | ||
| 267 | + ## from a stranger's server, and it reaches the reader without ever passing | ||
| 268 | + ## through extractArticle. Rendering it raw is a script tag away from | ||
| 269 | + ## running in the reader's browser. | ||
| 270 | + ## | ||
| 271 | + ## Returns "" for input that will not parse, rather than raising: a feed | ||
| 272 | + ## with one malformed entry should lose that entry's body, not the article. | ||
| 273 | + if html.strip().len == 0: return "" | ||
| 274 | + let doc = | ||
| 275 | + try: parseHtml(html) | ||
| 276 | + except CatchableError: return "" | ||
| 277 | + removeUnwanted(doc) | ||
| 278 | + renderNode(doc) | ||
| 279 | + | ||
| 262 | proc extractArticle*(body: string): string = | 280 | proc extractArticle*(body: string): string = |
| 263 | ## Pull the readable article out of a page. | 281 | ## Pull the readable article out of a page. |
| 264 | ## | 282 | ## |
added
spike/nim/ui.html +299 -0 | new file mode 100644 | ||
| @@ -0,0 +1,299 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<meta charset="utf-8"> | |
| 3 | +<meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 4 | +<title>glean</title> | |
| 5 | +<style> | |
| 6 | + :root { | |
| 7 | + --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da; | |
| 8 | + --surface: #fff; --accent: #00754a; --danger: #c82014; | |
| 9 | + } | |
| 10 | + @media (prefers-color-scheme: dark) { | |
| 11 | + :root { | |
| 12 | + --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32; | |
| 13 | + --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e; | |
| 14 | + } | |
| 15 | + } | |
| 16 | + * { box-sizing: border-box; } | |
| 17 | + body { | |
| 18 | + margin: 0; background: var(--bg); color: var(--fg); | |
| 19 | + font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; | |
| 20 | + display: grid; grid-template-columns: 280px 1fr; height: 100vh; | |
| 21 | + } | |
| 22 | + /* The sidebar scrolls independently of the article, so a long read never | |
| 23 | + loses the feed list. */ | |
| 24 | + aside { | |
| 25 | + border-right: 1px solid var(--line); overflow-y: auto; padding: 14px; | |
| 26 | + background: var(--surface); | |
| 27 | + } | |
| 28 | + main { overflow-y: auto; padding: 20px 28px; } | |
| 29 | + h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; } | |
| 30 | + h1 span { color: var(--muted); font-weight: 400; font-size: 13px; } | |
| 31 | + button, input, select { | |
| 32 | + font: inherit; color: inherit; background: var(--bg); | |
| 33 | + border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; | |
| 34 | + } | |
| 35 | + button { cursor: pointer; } | |
| 36 | + button:hover { border-color: var(--accent); } | |
| 37 | + button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } | |
| 38 | + .row { display: flex; gap: 6px; margin-bottom: 10px; } | |
| 39 | + .row input { flex: 1; min-width: 0; } | |
| 40 | + .feed { | |
| 41 | + display: flex; align-items: center; gap: 8px; padding: 6px 8px; | |
| 42 | + border-radius: 6px; cursor: pointer; font-size: 14px; | |
| 43 | + } | |
| 44 | + .feed:hover { background: var(--bg); } | |
| 45 | + .feed.on { background: var(--bg); font-weight: 600; } | |
| 46 | + .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 47 | + .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } | |
| 48 | + .feed .x { color: var(--muted); opacity: 0; } | |
| 49 | + .feed:hover .x { opacity: 1; } | |
| 50 | + .feed .x:hover { color: var(--danger); } | |
| 51 | + .item { | |
| 52 | + padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; | |
| 53 | + } | |
| 54 | + .item.read .title { color: var(--muted); font-weight: 400; } | |
| 55 | + .title { font-weight: 600; margin-bottom: 3px; } | |
| 56 | + .meta { color: var(--muted); font-size: 12.5px; } | |
| 57 | + .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px; | |
| 58 | + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | |
| 59 | + overflow: hidden; } | |
| 60 | + article { max-width: 42em; } | |
| 61 | + article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; } | |
| 62 | + .body { margin-top: 18px; } | |
| 63 | + .body img { max-width: 100%; height: auto; } | |
| 64 | + .body pre { overflow-x: auto; background: var(--surface); padding: 10px; | |
| 65 | + border-radius: 6px; } | |
| 66 | + .body a { color: var(--accent); } | |
| 67 | + .body blockquote { border-left: 3px solid var(--line); margin-left: 0; | |
| 68 | + padding-left: 14px; color: var(--muted); } | |
| 69 | + .empty { color: var(--muted); padding: 30px 0; } | |
| 70 | + .err { color: var(--danger); font-size: 13px; } | |
| 71 | + .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; | |
| 72 | + flex-wrap: wrap; } | |
| 73 | + .bar input[type=search] { flex: 1; min-width: 140px; } | |
| 74 | + @media (max-width: 720px) { | |
| 75 | + body { grid-template-columns: 1fr; } | |
| 76 | + aside { display: none; } | |
| 77 | + aside.show { display: block; } | |
| 78 | + } | |
| 79 | +</style> | |
| 80 | + | |
| 81 | +<aside> | |
| 82 | + <h1>glean <span id="total"></span></h1> | |
| 83 | + <div class="row"> | |
| 84 | + <input id="url" placeholder="feed or site URL" autocomplete="off"> | |
| 85 | + <button id="add" class="primary">+</button> | |
| 86 | + </div> | |
| 87 | + <div class="row"> | |
| 88 | + <button id="refresh">Refresh</button> | |
| 89 | + <button id="allread">Mark read</button> | |
| 90 | + </div> | |
| 91 | + <div id="err" class="err"></div> | |
| 92 | + <div id="feeds"></div> | |
| 93 | +</aside> | |
| 94 | + | |
| 95 | +<main> | |
| 96 | + <div class="bar"> | |
| 97 | + <select id="status"> | |
| 98 | + <option value="unread">Unread</option> | |
| 99 | + <option value="all">All</option> | |
| 100 | + <option value="read">Read</option> | |
| 101 | + </select> | |
| 102 | + <input id="q" type="search" placeholder="Search…"> | |
| 103 | + <button id="back" hidden>← Back</button> | |
| 104 | + </div> | |
| 105 | + <div id="view"></div> | |
| 106 | +</main> | |
| 107 | + | |
| 108 | +<script> | |
| 109 | +const $ = s => document.querySelector(s); | |
| 110 | +const state = { feed: "", status: "unread", q: "", article: null }; | |
| 111 | + | |
| 112 | +const api = async (path, opts) => { | |
| 113 | + const res = await fetch(path, opts); | |
| 114 | + const body = await res.json().catch(() => ({ error: "bad response" })); | |
| 115 | + if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`); | |
| 116 | + return body; | |
| 117 | +}; | |
| 118 | +const post = (path, params) => | |
| 119 | + api(path, { | |
| 120 | + method: "POST", | |
| 121 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| 122 | + body: new URLSearchParams(params || {}), | |
| 123 | + }); | |
| 124 | + | |
| 125 | +const say = msg => { $("#err").textContent = msg || ""; }; | |
| 126 | +const when = iso => { | |
| 127 | + if (!iso) return ""; | |
| 128 | + const d = new Date(iso), mins = (Date.now() - d) / 60000; | |
| 129 | + if (mins < 60) return `${Math.max(1, Math.round(mins))}m`; | |
| 130 | + if (mins < 1440) return `${Math.round(mins / 60)}h`; | |
| 131 | + if (mins < 43200) return `${Math.round(mins / 1440)}d`; | |
| 132 | + return d.toLocaleDateString(); | |
| 133 | +}; | |
| 134 | + | |
| 135 | +async function loadFeeds() { | |
| 136 | + const feeds = await api("/feeds"); | |
| 137 | + const { count } = await api("/unread"); | |
| 138 | + $("#total").textContent = count ? `${count} unread` : "all read"; | |
| 139 | + | |
| 140 | + const el = $("#feeds"); | |
| 141 | + el.innerHTML = ""; | |
| 142 | + el.append(feedRow({ feed_url: "", title: "All feeds", unread: count })); | |
| 143 | + for (const f of feeds) el.append(feedRow(f)); | |
| 144 | +} | |
| 145 | + | |
| 146 | +function feedRow(f) { | |
| 147 | + const div = document.createElement("div"); | |
| 148 | + div.className = "feed" + (state.feed === f.feed_url ? " on" : ""); | |
| 149 | + div.innerHTML = | |
| 150 | + `<span class="name"></span><span class="count"></span>` + | |
| 151 | + (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : ""); | |
| 152 | + // textContent, not innerHTML: a feed title is whatever a stranger's server | |
| 153 | + // put in <title>. | |
| 154 | + div.querySelector(".name").textContent = f.title || f.feed_url; | |
| 155 | + div.querySelector(".count").textContent = f.unread || ""; | |
| 156 | + div.onclick = e => { | |
| 157 | + if (e.target.classList.contains("x")) return; | |
| 158 | + state.feed = f.feed_url; state.article = null; render(); | |
| 159 | + }; | |
| 160 | + const x = div.querySelector(".x"); | |
| 161 | + if (x) x.onclick = async e => { | |
| 162 | + e.stopPropagation(); | |
| 163 | + if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return; | |
| 164 | + await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" }); | |
| 165 | + if (state.feed === f.feed_url) state.feed = ""; | |
| 166 | + render(); | |
| 167 | + }; | |
| 168 | + return div; | |
| 169 | +} | |
| 170 | + | |
| 171 | +async function loadArticles() { | |
| 172 | + const p = new URLSearchParams({ limit: "100" }); | |
| 173 | + if (state.q) p.set("q", state.q); | |
| 174 | + else { | |
| 175 | + p.set("status", state.status); | |
| 176 | + if (state.feed) p.set("feed", state.feed); | |
| 177 | + } | |
| 178 | + return api(`/articles?${p}`); | |
| 179 | +} | |
| 180 | + | |
| 181 | +function listView(items) { | |
| 182 | + const view = $("#view"); | |
| 183 | + view.innerHTML = ""; | |
| 184 | + if (!items.length) { | |
| 185 | + view.innerHTML = `<div class="empty">Nothing here.</div>`; | |
| 186 | + return; | |
| 187 | + } | |
| 188 | + for (const a of items) { | |
| 189 | + const div = document.createElement("div"); | |
| 190 | + div.className = "item" + (a.is_read ? " read" : ""); | |
| 191 | + div.innerHTML = `<div class="title"></div><div class="meta"></div>` + | |
| 192 | + (a.summary ? `<div class="summary"></div>` : ""); | |
| 193 | + div.querySelector(".title").textContent = a.title || "(untitled)"; | |
| 194 | + div.querySelector(".meta").textContent = | |
| 195 | + [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | |
| 196 | + if (a.summary) { | |
| 197 | + // Summaries are HTML in most feeds; show them as the text they read as. | |
| 198 | + const tmp = document.createElement("div"); | |
| 199 | + tmp.innerHTML = a.summary; | |
| 200 | + div.querySelector(".summary").textContent = tmp.textContent; | |
| 201 | + } | |
| 202 | + div.onclick = () => openArticle(a.id); | |
| 203 | + view.append(div); | |
| 204 | + } | |
| 205 | +} | |
| 206 | + | |
| 207 | +async function openArticle(id) { | |
| 208 | + const a = await api(`/articles/${id}`); | |
| 209 | + state.article = a; | |
| 210 | + if (!a.is_read) { await post("/read", { id }); loadFeeds(); } | |
| 211 | + | |
| 212 | + const view = $("#view"); | |
| 213 | + view.innerHTML = `<article> | |
| 214 | + <h2></h2><div class="meta"></div> | |
| 215 | + <div class="bar" style="margin-top:14px"> | |
| 216 | + <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a> | |
| 217 | + <button id="full">Fetch full text</button> | |
| 218 | + <button id="unread">Mark unread</button> | |
| 219 | + </div> | |
| 220 | + <div class="body"></div> | |
| 221 | + </article>`; | |
| 222 | + view.querySelector("h2").textContent = a.title || "(untitled)"; | |
| 223 | + view.querySelector(".meta").textContent = | |
| 224 | + [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | |
| 225 | + // The server sanitises every article body through the same whitelist the | |
| 226 | + // scraper uses, so this is the one place innerHTML is deliberate. | |
| 227 | + view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>"; | |
| 228 | + $("#orig").href = a.url || "#"; | |
| 229 | + $("#back").hidden = false; | |
| 230 | + | |
| 231 | + $("#full").onclick = async e => { | |
| 232 | + e.target.disabled = true; e.target.textContent = "Fetching…"; | |
| 233 | + try { | |
| 234 | + const r = await post("/fetch-content", { id }); | |
| 235 | + view.querySelector(".body").innerHTML = r.content; | |
| 236 | + e.target.textContent = "Fetched"; | |
| 237 | + } catch (err) { | |
| 238 | + e.target.textContent = "Fetch full text"; | |
| 239 | + e.target.disabled = false; | |
| 240 | + say(err.message); | |
| 241 | + } | |
| 242 | + }; | |
| 243 | + $("#unread").onclick = async () => { | |
| 244 | + await post("/read", { id, undo: "1" }); | |
| 245 | + state.article = null; render(); | |
| 246 | + }; | |
| 247 | +} | |
| 248 | + | |
| 249 | +async function render() { | |
| 250 | + say(""); | |
| 251 | + try { | |
| 252 | + await loadFeeds(); | |
| 253 | + if (state.article) return; | |
| 254 | + $("#back").hidden = true; | |
| 255 | + listView(await loadArticles()); | |
| 256 | + } catch (e) { say(e.message); } | |
| 257 | +} | |
| 258 | + | |
| 259 | +$("#add").onclick = async () => { | |
| 260 | + const url = $("#url").value.trim(); | |
| 261 | + if (!url) return; | |
| 262 | + $("#add").disabled = true; | |
| 263 | + try { | |
| 264 | + const r = await post("/feeds", { url }); | |
| 265 | + if (r.error) say(r.error); else $("#url").value = ""; | |
| 266 | + render(); | |
| 267 | + } catch (e) { say(e.message); } | |
| 268 | + finally { $("#add").disabled = false; } | |
| 269 | +}; | |
| 270 | +$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); }; | |
| 271 | + | |
| 272 | +$("#refresh").onclick = async e => { | |
| 273 | + e.target.disabled = true; e.target.textContent = "Refreshing…"; | |
| 274 | + try { | |
| 275 | + const r = await post("/refresh"); | |
| 276 | + if (r.errors?.length) say(`${r.errors.length} feed(s) failed`); | |
| 277 | + state.article = null; render(); | |
| 278 | + } catch (err) { say(err.message); } | |
| 279 | + finally { e.target.disabled = false; e.target.textContent = "Refresh"; } | |
| 280 | +}; | |
| 281 | + | |
| 282 | +$("#allread").onclick = async () => { | |
| 283 | + if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return; | |
| 284 | + await post("/read-all", state.feed ? { feed: state.feed } : {}); | |
| 285 | + state.article = null; render(); | |
| 286 | +}; | |
| 287 | + | |
| 288 | +$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); }; | |
| 289 | +$("#back").onclick = () => { state.article = null; render(); }; | |
| 290 | + | |
| 291 | +let timer; | |
| 292 | +$("#q").oninput = e => { | |
| 293 | + clearTimeout(timer); | |
| 294 | + // Debounced: every keystroke is an FTS5 query otherwise. | |
| 295 | + timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250); | |
| 296 | +}; | |
| 297 | + | |
| 298 | +render(); | |
| 299 | +</script> | |
| new file mode 100644 | |||
| @@ -0,0 +1,299 @@ | |||
| 1 | +<!doctype html> | ||
| 2 | +<meta charset="utf-8"> | ||
| 3 | +<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 4 | +<title>glean</title> | ||
| 5 | +<style> | ||
| 6 | + :root { | ||
| 7 | + --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da; | ||
| 8 | + --surface: #fff; --accent: #00754a; --danger: #c82014; | ||
| 9 | + } | ||
| 10 | + @media (prefers-color-scheme: dark) { | ||
| 11 | + :root { | ||
| 12 | + --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32; | ||
| 13 | + --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e; | ||
| 14 | + } | ||
| 15 | + } | ||
| 16 | + * { box-sizing: border-box; } | ||
| 17 | + body { | ||
| 18 | + margin: 0; background: var(--bg); color: var(--fg); | ||
| 19 | + font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; | ||
| 20 | + display: grid; grid-template-columns: 280px 1fr; height: 100vh; | ||
| 21 | + } | ||
| 22 | + /* The sidebar scrolls independently of the article, so a long read never | ||
| 23 | + loses the feed list. */ | ||
| 24 | + aside { | ||
| 25 | + border-right: 1px solid var(--line); overflow-y: auto; padding: 14px; | ||
| 26 | + background: var(--surface); | ||
| 27 | + } | ||
| 28 | + main { overflow-y: auto; padding: 20px 28px; } | ||
| 29 | + h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; } | ||
| 30 | + h1 span { color: var(--muted); font-weight: 400; font-size: 13px; } | ||
| 31 | + button, input, select { | ||
| 32 | + font: inherit; color: inherit; background: var(--bg); | ||
| 33 | + border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; | ||
| 34 | + } | ||
| 35 | + button { cursor: pointer; } | ||
| 36 | + button:hover { border-color: var(--accent); } | ||
| 37 | + button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } | ||
| 38 | + .row { display: flex; gap: 6px; margin-bottom: 10px; } | ||
| 39 | + .row input { flex: 1; min-width: 0; } | ||
| 40 | + .feed { | ||
| 41 | + display: flex; align-items: center; gap: 8px; padding: 6px 8px; | ||
| 42 | + border-radius: 6px; cursor: pointer; font-size: 14px; | ||
| 43 | + } | ||
| 44 | + .feed:hover { background: var(--bg); } | ||
| 45 | + .feed.on { background: var(--bg); font-weight: 600; } | ||
| 46 | + .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 47 | + .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } | ||
| 48 | + .feed .x { color: var(--muted); opacity: 0; } | ||
| 49 | + .feed:hover .x { opacity: 1; } | ||
| 50 | + .feed .x:hover { color: var(--danger); } | ||
| 51 | + .item { | ||
| 52 | + padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; | ||
| 53 | + } | ||
| 54 | + .item.read .title { color: var(--muted); font-weight: 400; } | ||
| 55 | + .title { font-weight: 600; margin-bottom: 3px; } | ||
| 56 | + .meta { color: var(--muted); font-size: 12.5px; } | ||
| 57 | + .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px; | ||
| 58 | + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | ||
| 59 | + overflow: hidden; } | ||
| 60 | + article { max-width: 42em; } | ||
| 61 | + article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; } | ||
| 62 | + .body { margin-top: 18px; } | ||
| 63 | + .body img { max-width: 100%; height: auto; } | ||
| 64 | + .body pre { overflow-x: auto; background: var(--surface); padding: 10px; | ||
| 65 | + border-radius: 6px; } | ||
| 66 | + .body a { color: var(--accent); } | ||
| 67 | + .body blockquote { border-left: 3px solid var(--line); margin-left: 0; | ||
| 68 | + padding-left: 14px; color: var(--muted); } | ||
| 69 | + .empty { color: var(--muted); padding: 30px 0; } | ||
| 70 | + .err { color: var(--danger); font-size: 13px; } | ||
| 71 | + .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; | ||
| 72 | + flex-wrap: wrap; } | ||
| 73 | + .bar input[type=search] { flex: 1; min-width: 140px; } | ||
| 74 | + @media (max-width: 720px) { | ||
| 75 | + body { grid-template-columns: 1fr; } | ||
| 76 | + aside { display: none; } | ||
| 77 | + aside.show { display: block; } | ||
| 78 | + } | ||
| 79 | +</style> | ||
| 80 | + | ||
| 81 | +<aside> | ||
| 82 | + <h1>glean <span id="total"></span></h1> | ||
| 83 | + <div class="row"> | ||
| 84 | + <input id="url" placeholder="feed or site URL" autocomplete="off"> | ||
| 85 | + <button id="add" class="primary">+</button> | ||
| 86 | + </div> | ||
| 87 | + <div class="row"> | ||
| 88 | + <button id="refresh">Refresh</button> | ||
| 89 | + <button id="allread">Mark read</button> | ||
| 90 | + </div> | ||
| 91 | + <div id="err" class="err"></div> | ||
| 92 | + <div id="feeds"></div> | ||
| 93 | +</aside> | ||
| 94 | + | ||
| 95 | +<main> | ||
| 96 | + <div class="bar"> | ||
| 97 | + <select id="status"> | ||
| 98 | + <option value="unread">Unread</option> | ||
| 99 | + <option value="all">All</option> | ||
| 100 | + <option value="read">Read</option> | ||
| 101 | + </select> | ||
| 102 | + <input id="q" type="search" placeholder="Search…"> | ||
| 103 | + <button id="back" hidden>← Back</button> | ||
| 104 | + </div> | ||
| 105 | + <div id="view"></div> | ||
| 106 | +</main> | ||
| 107 | + | ||
| 108 | +<script> | ||
| 109 | +const $ = s => document.querySelector(s); | ||
| 110 | +const state = { feed: "", status: "unread", q: "", article: null }; | ||
| 111 | + | ||
| 112 | +const api = async (path, opts) => { | ||
| 113 | + const res = await fetch(path, opts); | ||
| 114 | + const body = await res.json().catch(() => ({ error: "bad response" })); | ||
| 115 | + if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`); | ||
| 116 | + return body; | ||
| 117 | +}; | ||
| 118 | +const post = (path, params) => | ||
| 119 | + api(path, { | ||
| 120 | + method: "POST", | ||
| 121 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, | ||
| 122 | + body: new URLSearchParams(params || {}), | ||
| 123 | + }); | ||
| 124 | + | ||
| 125 | +const say = msg => { $("#err").textContent = msg || ""; }; | ||
| 126 | +const when = iso => { | ||
| 127 | + if (!iso) return ""; | ||
| 128 | + const d = new Date(iso), mins = (Date.now() - d) / 60000; | ||
| 129 | + if (mins < 60) return `${Math.max(1, Math.round(mins))}m`; | ||
| 130 | + if (mins < 1440) return `${Math.round(mins / 60)}h`; | ||
| 131 | + if (mins < 43200) return `${Math.round(mins / 1440)}d`; | ||
| 132 | + return d.toLocaleDateString(); | ||
| 133 | +}; | ||
| 134 | + | ||
| 135 | +async function loadFeeds() { | ||
| 136 | + const feeds = await api("/feeds"); | ||
| 137 | + const { count } = await api("/unread"); | ||
| 138 | + $("#total").textContent = count ? `${count} unread` : "all read"; | ||
| 139 | + | ||
| 140 | + const el = $("#feeds"); | ||
| 141 | + el.innerHTML = ""; | ||
| 142 | + el.append(feedRow({ feed_url: "", title: "All feeds", unread: count })); | ||
| 143 | + for (const f of feeds) el.append(feedRow(f)); | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +function feedRow(f) { | ||
| 147 | + const div = document.createElement("div"); | ||
| 148 | + div.className = "feed" + (state.feed === f.feed_url ? " on" : ""); | ||
| 149 | + div.innerHTML = | ||
| 150 | + `<span class="name"></span><span class="count"></span>` + | ||
| 151 | + (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : ""); | ||
| 152 | + // textContent, not innerHTML: a feed title is whatever a stranger's server | ||
| 153 | + // put in <title>. | ||
| 154 | + div.querySelector(".name").textContent = f.title || f.feed_url; | ||
| 155 | + div.querySelector(".count").textContent = f.unread || ""; | ||
| 156 | + div.onclick = e => { | ||
| 157 | + if (e.target.classList.contains("x")) return; | ||
| 158 | + state.feed = f.feed_url; state.article = null; render(); | ||
| 159 | + }; | ||
| 160 | + const x = div.querySelector(".x"); | ||
| 161 | + if (x) x.onclick = async e => { | ||
| 162 | + e.stopPropagation(); | ||
| 163 | + if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return; | ||
| 164 | + await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" }); | ||
| 165 | + if (state.feed === f.feed_url) state.feed = ""; | ||
| 166 | + render(); | ||
| 167 | + }; | ||
| 168 | + return div; | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +async function loadArticles() { | ||
| 172 | + const p = new URLSearchParams({ limit: "100" }); | ||
| 173 | + if (state.q) p.set("q", state.q); | ||
| 174 | + else { | ||
| 175 | + p.set("status", state.status); | ||
| 176 | + if (state.feed) p.set("feed", state.feed); | ||
| 177 | + } | ||
| 178 | + return api(`/articles?${p}`); | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +function listView(items) { | ||
| 182 | + const view = $("#view"); | ||
| 183 | + view.innerHTML = ""; | ||
| 184 | + if (!items.length) { | ||
| 185 | + view.innerHTML = `<div class="empty">Nothing here.</div>`; | ||
| 186 | + return; | ||
| 187 | + } | ||
| 188 | + for (const a of items) { | ||
| 189 | + const div = document.createElement("div"); | ||
| 190 | + div.className = "item" + (a.is_read ? " read" : ""); | ||
| 191 | + div.innerHTML = `<div class="title"></div><div class="meta"></div>` + | ||
| 192 | + (a.summary ? `<div class="summary"></div>` : ""); | ||
| 193 | + div.querySelector(".title").textContent = a.title || "(untitled)"; | ||
| 194 | + div.querySelector(".meta").textContent = | ||
| 195 | + [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | ||
| 196 | + if (a.summary) { | ||
| 197 | + // Summaries are HTML in most feeds; show them as the text they read as. | ||
| 198 | + const tmp = document.createElement("div"); | ||
| 199 | + tmp.innerHTML = a.summary; | ||
| 200 | + div.querySelector(".summary").textContent = tmp.textContent; | ||
| 201 | + } | ||
| 202 | + div.onclick = () => openArticle(a.id); | ||
| 203 | + view.append(div); | ||
| 204 | + } | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +async function openArticle(id) { | ||
| 208 | + const a = await api(`/articles/${id}`); | ||
| 209 | + state.article = a; | ||
| 210 | + if (!a.is_read) { await post("/read", { id }); loadFeeds(); } | ||
| 211 | + | ||
| 212 | + const view = $("#view"); | ||
| 213 | + view.innerHTML = `<article> | ||
| 214 | + <h2></h2><div class="meta"></div> | ||
| 215 | + <div class="bar" style="margin-top:14px"> | ||
| 216 | + <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a> | ||
| 217 | + <button id="full">Fetch full text</button> | ||
| 218 | + <button id="unread">Mark unread</button> | ||
| 219 | + </div> | ||
| 220 | + <div class="body"></div> | ||
| 221 | + </article>`; | ||
| 222 | + view.querySelector("h2").textContent = a.title || "(untitled)"; | ||
| 223 | + view.querySelector(".meta").textContent = | ||
| 224 | + [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | ||
| 225 | + // The server sanitises every article body through the same whitelist the | ||
| 226 | + // scraper uses, so this is the one place innerHTML is deliberate. | ||
| 227 | + view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>"; | ||
| 228 | + $("#orig").href = a.url || "#"; | ||
| 229 | + $("#back").hidden = false; | ||
| 230 | + | ||
| 231 | + $("#full").onclick = async e => { | ||
| 232 | + e.target.disabled = true; e.target.textContent = "Fetching…"; | ||
| 233 | + try { | ||
| 234 | + const r = await post("/fetch-content", { id }); | ||
| 235 | + view.querySelector(".body").innerHTML = r.content; | ||
| 236 | + e.target.textContent = "Fetched"; | ||
| 237 | + } catch (err) { | ||
| 238 | + e.target.textContent = "Fetch full text"; | ||
| 239 | + e.target.disabled = false; | ||
| 240 | + say(err.message); | ||
| 241 | + } | ||
| 242 | + }; | ||
| 243 | + $("#unread").onclick = async () => { | ||
| 244 | + await post("/read", { id, undo: "1" }); | ||
| 245 | + state.article = null; render(); | ||
| 246 | + }; | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +async function render() { | ||
| 250 | + say(""); | ||
| 251 | + try { | ||
| 252 | + await loadFeeds(); | ||
| 253 | + if (state.article) return; | ||
| 254 | + $("#back").hidden = true; | ||
| 255 | + listView(await loadArticles()); | ||
| 256 | + } catch (e) { say(e.message); } | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +$("#add").onclick = async () => { | ||
| 260 | + const url = $("#url").value.trim(); | ||
| 261 | + if (!url) return; | ||
| 262 | + $("#add").disabled = true; | ||
| 263 | + try { | ||
| 264 | + const r = await post("/feeds", { url }); | ||
| 265 | + if (r.error) say(r.error); else $("#url").value = ""; | ||
| 266 | + render(); | ||
| 267 | + } catch (e) { say(e.message); } | ||
| 268 | + finally { $("#add").disabled = false; } | ||
| 269 | +}; | ||
| 270 | +$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); }; | ||
| 271 | + | ||
| 272 | +$("#refresh").onclick = async e => { | ||
| 273 | + e.target.disabled = true; e.target.textContent = "Refreshing…"; | ||
| 274 | + try { | ||
| 275 | + const r = await post("/refresh"); | ||
| 276 | + if (r.errors?.length) say(`${r.errors.length} feed(s) failed`); | ||
| 277 | + state.article = null; render(); | ||
| 278 | + } catch (err) { say(err.message); } | ||
| 279 | + finally { e.target.disabled = false; e.target.textContent = "Refresh"; } | ||
| 280 | +}; | ||
| 281 | + | ||
| 282 | +$("#allread").onclick = async () => { | ||
| 283 | + if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return; | ||
| 284 | + await post("/read-all", state.feed ? { feed: state.feed } : {}); | ||
| 285 | + state.article = null; render(); | ||
| 286 | +}; | ||
| 287 | + | ||
| 288 | +$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); }; | ||
| 289 | +$("#back").onclick = () => { state.article = null; render(); }; | ||
| 290 | + | ||
| 291 | +let timer; | ||
| 292 | +$("#q").oninput = e => { | ||
| 293 | + clearTimeout(timer); | ||
| 294 | + // Debounced: every keystroke is an FTS5 query otherwise. | ||
| 295 | + timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250); | ||
| 296 | +}; | ||
| 297 | + | ||
| 298 | +render(); | ||
| 299 | +</script> | ||