nandi/gleanpublic Fork
⑂ 2b72cef
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
nandithebull committed 2026-09-21T20:05:05-07:00 Browse files
2b72cef parent: 775d793
modified spike/nim/glean.nim +20 -5
@@ -11,11 +11,15 @@
1111 ## extraction -- are the same modules the larger port built and tested.
1212
1313 import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os,
14- strformat, strutils, uri]
14+ strformat, strutils, times, uri]
1515 import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
1616 scraper, sqlite]
1717
1818 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+
1923 ## Everything is stored per-user underneath because the schema came from a
2024 ## multi-user server. One constant user keeps that plumbing satisfied
2125 ## without inventing accounts.
@@ -44,8 +48,16 @@ proc toJson(a: Article): JsonNode =
4448 "url": a.url,
4549 "author": a.author,
4650 "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: ""),
4961 "is_read": a.isRead,
5062 }
5163
@@ -111,7 +123,10 @@ proc handle(r: Reader, req: Request) {.async.} =
111123 case req.reqMethod
112124 of HttpGet:
113125 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":
115130 await jsonResponse(req, Http200, %*{"ok": true})
116131
117132 of "feeds":
@@ -238,7 +253,7 @@ proc main() {.async.} =
238253 let reader = Reader(db: db)
239254
240255 echo &"glean reading from {dbPath}"
241- echo &"listening on http://127.0.0.1:{port}"
256+ echo &"open http://127.0.0.1:{port}"
242257
243258 var server = newAsyncHttpServer()
244259 # 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 const18 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 a23 ## Everything is stored per-user underneath because the schema came from a
20 ## multi-user server. One constant user keeps that plumbing satisfied24 ## 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.reqMethod123 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, not259 # 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 =
259259
260260 # --- entry point -----------------------------------------------------------
261261
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+
262280 proc extractArticle*(body: string): string =
263281 ## Pull the readable article out of a page.
264282 ##
@@ -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>