moshcode 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/news.mjs ADDED
@@ -0,0 +1,1071 @@
1
+ // `moshcode news` — the headlines, in the pit.
2
+ //
3
+ // The same split as src/crypto.mjs, for the same reasons: argument translation
4
+ // is pure and testable, the network call is injectable, and rendering is a
5
+ // function of the parsed feed. What differs is where the data comes from —
6
+ // there is no advis0r API here, only whatever feeds the operator subscribed to.
7
+ //
8
+ // Subscriptions live in an OPML file (~/.moshcode/news.opml) rather than in a
9
+ // news.json of our own invention. OPML is the interchange format every reader
10
+ // already speaks, so the subscription list can be exported from an existing
11
+ // reader, dropped in, and taken back out again — which is the whole reason to
12
+ // have a file instead of a flag. `/news add` accepts either an OPML document or
13
+ // a single RSS/Atom link and works out which it was given, because "a feed" and
14
+ // "a list of feeds" are the two shapes a URL handed to a news reader can have.
15
+ //
16
+ // The XML is read with targeted regexes rather than a parser. That is a real
17
+ // constraint and it is deliberate: moshcode ships with no runtime dependencies,
18
+ // and feeds are a small, well-trodden subset of XML. It also removes a class of
19
+ // risk outright — DOCTYPE is stripped and entities are decoded from a fixed
20
+ // table, so a hostile feed cannot expand an entity into a file read.
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { acid, ash, amber, bone, danger } from "./ui.mjs";
26
+ import {
27
+ bingNewsSearch,
28
+ defaultFeeds,
29
+ googleNewsSearch,
30
+ OPML_BUNDLES,
31
+ resolveBundle,
32
+ unwrapRedirect,
33
+ } from "./news-sources.mjs";
34
+
35
+ const USAGE = `usage: moshcode news [verb|keyword…] [args…]
36
+
37
+ (no verb) latest headlines across every subscribed feed
38
+ latest the same thing, said out loud
39
+ <keyword…> search the news for a word or phrase
40
+ <url> read one feed without subscribing to it
41
+ list the feeds you are subscribed to
42
+ add <url|file|bundle> subscribe — an RSS/Atom link, an OPML list, or
43
+ a bundle: ${OPML_BUNDLES.map((b) => b.name).join(", ")}
44
+ rm <name|url> unsubscribe
45
+ open <n> open headline <n> from the last listing
46
+ sources the default feeds and the bundles on offer
47
+ export print the subscription list as OPML
48
+
49
+ --json print structured data instead of headlines
50
+ --limit <n> how many headlines to show (default 20)
51
+ --feed <name> only this subscribed feed
52
+ --timeout <sec> per-feed fetch timeout (default 10)
53
+
54
+ \`moshcode rss\` opens the same headlines as a full-screen reader.
55
+
56
+ Feeds live in ~/.moshcode/news.opml — export it to any reader, or point
57
+ MOSHCODE_NEWS_OPML at a list you already keep somewhere else. With no
58
+ subscriptions the defaults are read instead, so \`/news\` works on a fresh
59
+ install; \`/news add\` anything and the defaults step aside.`;
60
+
61
+ export function newsUsage() {
62
+ return USAGE;
63
+ }
64
+
65
+ /** Verb names, in help order. cli-schema's NEWS_VERBS must match (drift test). */
66
+ export const NEWS_VERB_NAMES = ["latest", "search", "list", "add", "rm", "open", "sources", "export"];
67
+
68
+ // The same reasoning as crypto's alias table: the obvious synonym should not be
69
+ // an error. `import` is the word an OPML file invites, and it is the same verb
70
+ // as `add` here precisely because `add` already takes an OPML document.
71
+ const VERB_ALIASES = {
72
+ new: "latest", recent: "latest", top: "latest", headlines: "latest",
73
+ find: "search", q: "search", query: "search", grep: "search",
74
+ feeds: "list", ls: "list", subscriptions: "list",
75
+ sub: "add", subscribe: "add", import: "add", follow: "add",
76
+ remove: "rm", unsub: "rm", unsubscribe: "rm", del: "rm", delete: "rm",
77
+ read: "open", browse: "open", www: "open",
78
+ bundles: "sources", defaults: "sources",
79
+ opml: "export", dump: "export",
80
+ };
81
+
82
+ /** Resolve a first argument to a canonical verb, or null when it is not one. */
83
+ export function resolveVerb(word) {
84
+ const key = String(word ?? "").toLowerCase();
85
+ if (NEWS_VERB_NAMES.includes(key)) return key;
86
+ return VERB_ALIASES[key] ?? null;
87
+ }
88
+
89
+ /** Owner-only, like aliases.json: a subscription list is a reading history. */
90
+ const FILE_MODE = 0o600;
91
+
92
+ /** Enough for a large feed, small enough that one bad URL cannot eat the pit. */
93
+ const MAX_BYTES = 8 * 1024 * 1024;
94
+
95
+ /** How many feeds are in flight at once. Politeness, not throughput. */
96
+ const CONCURRENCY = 6;
97
+
98
+ const DEFAULT_LIMIT = 20;
99
+ const MAX_LIMIT = 200;
100
+ const DEFAULT_TIMEOUT_MS = 10_000;
101
+
102
+ /**
103
+ * Where the subscription list lives. Derived per call so tests can move $HOME,
104
+ * and overridable so an operator can point at a list they already maintain.
105
+ */
106
+ export function opmlFile(env = process.env) {
107
+ const override = String(env.MOSHCODE_NEWS_OPML || "").trim();
108
+ if (override) return path.resolve(override);
109
+ return path.join(os.homedir(), ".moshcode", "news.opml");
110
+ }
111
+
112
+ /** Where the last rendered listing is remembered, so `/news open 3` knows what 3 was. */
113
+ export function cacheFile(env = process.env) {
114
+ return path.join(path.dirname(opmlFile(env)), "news-last.json");
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // XML, the small subset feeds actually use
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const ENTITIES = {
122
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
123
+ ldquo: "“", rdquo: "”", lsquo: "‘", rsquo: "’",
124
+ mdash: "—", ndash: "–", hellip: "…", eacute: "é",
125
+ };
126
+
127
+ /**
128
+ * Decode the entities a feed actually carries. A fixed table plus numeric
129
+ * escapes — never the document's own DOCTYPE entities, which is what keeps a
130
+ * feed from declaring one that expands to the contents of /etc/passwd.
131
+ */
132
+ export function decodeEntities(text) {
133
+ return String(text ?? "").replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
134
+ if (body[0] === "#") {
135
+ const code = body[1] === "x" || body[1] === "X"
136
+ ? Number.parseInt(body.slice(2), 16)
137
+ : Number.parseInt(body.slice(1), 10);
138
+ // Surrogates and out-of-range code points would throw; leave them as text.
139
+ if (!Number.isFinite(code) || code < 1 || code > 0x10ffff) return match;
140
+ if (code >= 0xd800 && code <= 0xdfff) return match;
141
+ return String.fromCodePoint(code);
142
+ }
143
+ const named = ENTITIES[body.toLowerCase()];
144
+ return named === undefined ? match : named;
145
+ });
146
+ }
147
+
148
+ /** Strip the parts of a document that are never content: BOM, comments, DOCTYPE. */
149
+ function scrub(xml) {
150
+ return String(xml ?? "")
151
+ .replace(/^/, "")
152
+ .replace(/<!--[\s\S]*?-->/g, "")
153
+ .replace(/<!DOCTYPE[^>[]*(\[[\s\S]*?\])?[^>]*>/gi, "");
154
+ }
155
+
156
+ /**
157
+ * CDATA out, entities decoded, tags out, whitespace collapsed.
158
+ *
159
+ * Decoding before stripping, not after, and the order is load-bearing: a
160
+ * `<description>` arrives as markup two different ways. CDATA carries real
161
+ * tags, but plenty of publishers — Google News among them — escape the same
162
+ * HTML into `&lt;a href=…&gt;` instead. Strip first and the escaped form sails
163
+ * through untouched, then decoding turns it back into the markup that was
164
+ * supposed to have been removed, and the headline reads as an anchor tag.
165
+ */
166
+ function text(raw) {
167
+ let value = String(raw ?? "").replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
168
+ // Twice, because aggregators escape HTML that was already escaped: a Google
169
+ // News description arrives as `&lt;a…&gt;` for the tags and `&amp;nbsp;` for
170
+ // the spaces between them, so one round leaves a literal `&nbsp;` on screen.
171
+ // Bounded at two — that is every doubling seen in the wild, and looping until
172
+ // a document stops changing is a decompression bomb waiting to happen.
173
+ for (let round = 0; round < 2; round++) {
174
+ value = decodeEntities(value).replace(/<[^>]*>/g, " ");
175
+ }
176
+ return value.replace(/\s+/g, " ").trim();
177
+ }
178
+
179
+ /**
180
+ * The text of the first `<tag>` in a block, namespace prefix optional.
181
+ *
182
+ * Namespace-agnostic because feeds are inconsistent about it in exactly the
183
+ * places that matter: the same publisher's `<title>` and `<dc:title>` mean the
184
+ * same thing. Callers that need one specific namespace pass the prefix in.
185
+ */
186
+ function pick(block, tag) {
187
+ const name = tag.includes(":") ? tag.replace(":", "\\:") : `(?:[a-z0-9]+\\:)?${tag}`;
188
+ const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "i").exec(block);
189
+ return match ? text(match[1]) : "";
190
+ }
191
+
192
+ /** The value of an attribute on a tag, unescaped. Single or double quoted. */
193
+ function attr(tag, name) {
194
+ const match = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i").exec(tag);
195
+ return match ? decodeEntities(match[2] ?? match[3] ?? "") : "";
196
+ }
197
+
198
+ /** Every `<tag …>` opening in a document, in order, as raw strings. */
199
+ function tagsNamed(xml, name) {
200
+ return xml.match(new RegExp(`<${name}(?:\\s[^>]*)?/?>`, "gi")) || [];
201
+ }
202
+
203
+ /** Only http(s) survives. A feed must not talk us into opening file: or data:. */
204
+ export function safeUrl(raw, base = null) {
205
+ const value = String(raw ?? "").trim();
206
+ if (!value) return null;
207
+ let url;
208
+ try { url = base ? new URL(value, base) : new URL(value); }
209
+ catch { return null; }
210
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // OPML — the subscription list
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Every feed in an OPML document, as { name, url, site, category }.
219
+ *
220
+ * Outlines nest: readers use a bare `<outline text="Tech">` as a folder around
221
+ * the feeds inside it. The stack below tracks that, so an imported list keeps
222
+ * the grouping its owner gave it instead of flattening to one pile. Only
223
+ * outlines carrying an `xmlUrl` are feeds; the rest are folders.
224
+ */
225
+ export function parseOpml(xml) {
226
+ const doc = scrub(xml);
227
+ const body = /<body(?:\s[^>]*)?>([\s\S]*)<\/body>/i.exec(doc);
228
+ const source = body ? body[1] : doc;
229
+ const feeds = [];
230
+ const seen = new Set();
231
+ const stack = [];
232
+
233
+ // One pass over every outline tag and every </outline>, in document order, so
234
+ // the folder stack stays in step with the nesting.
235
+ const token = /<outline(?:\s[^>]*)?>|<\/outline\s*>/gi;
236
+ let match;
237
+ while ((match = token.exec(source)) !== null) {
238
+ if (match[0][1] === "/") { stack.pop(); continue; }
239
+ const tag = match[0];
240
+ // Read the slash off the raw tag rather than capturing it: an attribute
241
+ // group greedy enough to hold `text="Tech" xmlUrl="…"` also swallows the
242
+ // trailing `/`, so every self-closing outline reads as a folder that never
243
+ // closes and the category stack grows without bound.
244
+ const selfClosing = /\/\s*>$/.test(tag);
245
+ const xmlUrl = safeUrl(attr(tag, "xmlUrl"));
246
+ const label = attr(tag, "title") || attr(tag, "text") || "";
247
+
248
+ if (!xmlUrl) {
249
+ // A folder. Self-closing folders enclose nothing, so they never nest.
250
+ if (!selfClosing) stack.push(label);
251
+ continue;
252
+ }
253
+ if (!seen.has(xmlUrl)) {
254
+ seen.add(xmlUrl);
255
+ feeds.push({
256
+ name: slugify(label) || hostSlug(xmlUrl),
257
+ title: label || hostOf(xmlUrl),
258
+ url: xmlUrl,
259
+ site: safeUrl(attr(tag, "htmlUrl")) || "",
260
+ category: stack.filter(Boolean).join("/"),
261
+ });
262
+ }
263
+ // A feed outline can still have children in the wild; keep the stack honest.
264
+ if (!selfClosing) stack.push(label);
265
+ }
266
+ return feeds;
267
+ }
268
+
269
+ /** Escape a string for an XML attribute. */
270
+ function xmlAttr(value) {
271
+ return String(value ?? "")
272
+ .replace(/&/g, "&amp;")
273
+ .replace(/</g, "&lt;")
274
+ .replace(/>/g, "&gt;")
275
+ .replace(/"/g, "&quot;");
276
+ }
277
+
278
+ /** Render a subscription list back to OPML, grouped by category. */
279
+ export function buildOpml(feeds, { title = "moshcode news" } = {}) {
280
+ const groups = new Map();
281
+ for (const feed of feeds) {
282
+ const key = feed.category || "";
283
+ if (!groups.has(key)) groups.set(key, []);
284
+ groups.get(key).push(feed);
285
+ }
286
+ const outline = (feed, indent) =>
287
+ `${indent}<outline type="rss" text="${xmlAttr(feed.title || feed.name)}" `
288
+ + `title="${xmlAttr(feed.title || feed.name)}" xmlUrl="${xmlAttr(feed.url)}"`
289
+ + `${feed.site ? ` htmlUrl="${xmlAttr(feed.site)}"` : ""}/>`;
290
+
291
+ const lines = [
292
+ '<?xml version="1.0" encoding="UTF-8"?>',
293
+ '<opml version="2.0">',
294
+ " <head>",
295
+ ` <title>${xmlAttr(title)}</title>`,
296
+ " </head>",
297
+ " <body>",
298
+ ];
299
+ // Ungrouped feeds first, then folders — the order a reader displays them in.
300
+ for (const [category, rows] of [...groups].sort((a, b) => a[0].localeCompare(b[0]))) {
301
+ if (!category) { for (const feed of rows) lines.push(outline(feed, " ")); continue; }
302
+ lines.push(` <outline text="${xmlAttr(category)}" title="${xmlAttr(category)}">`);
303
+ for (const feed of rows) lines.push(outline(feed, " "));
304
+ lines.push(" </outline>");
305
+ }
306
+ lines.push(" </body>", "</opml>", "");
307
+ return lines.join("\n");
308
+ }
309
+
310
+ /** A stable, typeable short name for a feed. */
311
+ export function slugify(label) {
312
+ return String(label ?? "")
313
+ .toLowerCase()
314
+ .replace(/['’]/g, "")
315
+ .replace(/[^a-z0-9]+/g, "-")
316
+ .replace(/^-+|-+$/g, "")
317
+ .slice(0, 32);
318
+ }
319
+
320
+ function hostOf(url) {
321
+ try { return new URL(url).hostname.replace(/^www\./, ""); }
322
+ catch { return String(url); }
323
+ }
324
+
325
+ function hostSlug(url) {
326
+ return slugify(hostOf(url).replace(/\.[a-z]{2,}$/i, "")) || "feed";
327
+ }
328
+
329
+ /** Make `name` unique against `taken` by suffixing -2, -3, … */
330
+ function uniqueName(name, taken) {
331
+ if (!taken.has(name)) return name;
332
+ for (let i = 2; i < 1000; i++) {
333
+ const candidate = `${name}-${i}`;
334
+ if (!taken.has(candidate)) return candidate;
335
+ }
336
+ return `${name}-${taken.size}`;
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // The subscription store
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * The subscribed feeds.
345
+ *
346
+ * A missing or unreadable file reads as "no subscriptions" rather than
347
+ * throwing, the way loadAliases() does: this runs on the way into a command
348
+ * that should still be able to tell you how to fix it.
349
+ */
350
+ export function loadFeeds(env = process.env) {
351
+ let raw;
352
+ try { raw = fs.readFileSync(opmlFile(env), "utf8"); }
353
+ catch { return []; }
354
+ try { return parseOpml(raw); }
355
+ catch { return []; }
356
+ }
357
+
358
+ /**
359
+ * The feeds to read, and whether they are the operator's own.
360
+ *
361
+ * An empty reader is a useless one — `/news` on a fresh install should print
362
+ * the news, not instructions for how to earn the news. So with no
363
+ * subscriptions the defaults stand in, and the flag comes back with them so
364
+ * the UI can say which it is showing rather than quietly implying the operator
365
+ * subscribed to thirteen feeds they have never seen.
366
+ */
367
+ export function readingList(env = process.env) {
368
+ const subscribed = loadFeeds(env);
369
+ if (subscribed.length) return { feeds: subscribed, usingDefaults: false };
370
+ return { feeds: defaultFeeds(), usingDefaults: true };
371
+ }
372
+
373
+ /** Write the list back, creating ~/.moshcode if this is the first feed. */
374
+ export function saveFeeds(feeds, env = process.env) {
375
+ const file = opmlFile(env);
376
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
377
+ fs.writeFileSync(file, buildOpml(feeds), { mode: FILE_MODE });
378
+ // `mode` only applies at creation, so tighten every write — aliases.mjs and
379
+ // the history file do the same for the same reason.
380
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
381
+ }
382
+
383
+ /** Add one feed to a list, naming it uniquely. Returns { feeds, added, existed }. */
384
+ export function withFeed(feeds, candidate) {
385
+ const existing = feeds.find((f) => f.url === candidate.url);
386
+ if (existing) return { feeds, added: existing, existed: true };
387
+ const taken = new Set(feeds.map((f) => f.name));
388
+ const added = { ...candidate, name: uniqueName(candidate.name || hostSlug(candidate.url), taken) };
389
+ return { feeds: [...feeds, added], added, existed: false };
390
+ }
391
+
392
+ /** Find a feed by name, url, or title. */
393
+ export function findFeed(feeds, needle) {
394
+ const wanted = String(needle ?? "").trim().toLowerCase();
395
+ if (!wanted) return null;
396
+ return feeds.find((f) => f.name.toLowerCase() === wanted)
397
+ ?? feeds.find((f) => f.url.toLowerCase() === wanted)
398
+ ?? feeds.find((f) => (f.title || "").toLowerCase() === wanted)
399
+ ?? null;
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Feeds — RSS 2.0, Atom, and RSS 1.0/RDF
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /** Is this document a subscription list rather than a feed? */
407
+ export function looksLikeOpml(xml) {
408
+ return /<opml[\s>]/i.test(scrub(xml));
409
+ }
410
+
411
+ /**
412
+ * The link for an entry.
413
+ *
414
+ * Atom puts it in an attribute and may carry several: `rel="alternate"` (or no
415
+ * rel at all, which means alternate) is the human-readable page, while
416
+ * `rel="self"`, `"replies"` and `"enclosure"` are not what a reader should
417
+ * open. RSS puts it in element text, and some publishers only fill a permalink
418
+ * `<guid>` — hence the third fallback.
419
+ */
420
+ function linkOf(block, base) {
421
+ const links = tagsNamed(block, "(?:[a-z0-9]+\\:)?link");
422
+ const alternate = links.find((tag) => {
423
+ const rel = attr(tag, "rel").toLowerCase();
424
+ return (!rel || rel === "alternate") && attr(tag, "href");
425
+ });
426
+ if (alternate) return safeUrl(attr(alternate, "href"), base);
427
+
428
+ const inline = pick(block, "link");
429
+ if (inline) return safeUrl(inline, base);
430
+
431
+ const guid = /<(?:[a-z0-9]+:)?guid(\s[^>]*)?>([\s\S]*?)<\/(?:[a-z0-9]+:)?guid>/i.exec(block);
432
+ if (guid && !/isPermaLink\s*=\s*["']false["']/i.test(guid[1] || "")) {
433
+ return safeUrl(text(guid[2]), base);
434
+ }
435
+ return null;
436
+ }
437
+
438
+ /** The publication time of an entry as epoch ms, or null when it has none. */
439
+ function dateOf(block) {
440
+ for (const tag of ["pubDate", "published", "updated", "dc:date", "date", "created"]) {
441
+ const raw = pick(block, tag);
442
+ if (!raw) continue;
443
+ const ms = Date.parse(raw);
444
+ if (Number.isFinite(ms)) return ms;
445
+ }
446
+ return null;
447
+ }
448
+
449
+ /**
450
+ * Parse an RSS/Atom/RDF document into { title, site, items }.
451
+ *
452
+ * One code path for all three because, at the level a headline list needs, they
453
+ * genuinely are the same document: a channel with a title and a list of dated,
454
+ * linked entries. Where they disagree — the entry element name, where the link
455
+ * lives, which tag holds the date — the difference is handled at that field
456
+ * rather than by forking the whole parser.
457
+ */
458
+ export function parseFeed(xml, { url = "" } = {}) {
459
+ const doc = scrub(xml);
460
+ const blocks = doc.match(/<(?:[a-z0-9]+:)?(?:item|entry)(?:\s[^>]*)?>[\s\S]*?<\/(?:[a-z0-9]+:)?(?:item|entry)>/gi) || [];
461
+
462
+ // The channel header is whatever precedes the first entry — taking the title
463
+ // from the whole document would pick up an entry's title on a feed whose
464
+ // channel has none.
465
+ const head = blocks.length ? doc.slice(0, doc.indexOf(blocks[0])) : doc;
466
+ const feedTitle = pick(head, "title") || hostOf(url);
467
+ const site = linkOf(head, url) || "";
468
+
469
+ const items = [];
470
+ for (const block of blocks) {
471
+ const title = pick(block, "title");
472
+ const link = linkOf(block, url);
473
+ if (!title && !link) continue; // nothing to show and nothing to open
474
+ items.push({
475
+ title: title || link,
476
+ link,
477
+ date: dateOf(block),
478
+ author: pick(block, "creator") || pick(block, "author") || "",
479
+ summary: clip(pick(block, "description") || pick(block, "summary") || "", 400),
480
+ });
481
+ }
482
+ return { title: feedTitle, site, url, items };
483
+ }
484
+
485
+ function clip(value, max) {
486
+ const s = String(value ?? "");
487
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Fetching
492
+ // ---------------------------------------------------------------------------
493
+
494
+ /**
495
+ * Fetch one document. Returns { ok, body, error }.
496
+ *
497
+ * Size-capped while streaming rather than after: a feed that turns out to be a
498
+ * disk image should cost a few megabytes of transfer, not all of it.
499
+ */
500
+ export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
501
+ const impl = fetchImpl || globalThis.fetch;
502
+ if (typeof impl !== "function") return { ok: false, error: "no fetch available in this runtime" };
503
+ const safe = safeUrl(url);
504
+ if (!safe) return { ok: false, error: `not an http(s) URL: ${url}` };
505
+
506
+ const controller = new AbortController();
507
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
508
+ try {
509
+ const res = await impl(safe, {
510
+ signal: controller.signal,
511
+ redirect: "follow",
512
+ headers: {
513
+ accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.8",
514
+ "user-agent": "moshcode/news (+https://moshcode.sh)",
515
+ },
516
+ });
517
+ if (!res.ok) return { ok: false, status: res.status, error: `${res.status} ${res.statusText || ""}`.trim() };
518
+
519
+ // Prefer the stream so the cap can stop a huge body early; fall back to
520
+ // text() for any fetch implementation (tests included) that has no body.
521
+ if (!res.body || typeof res.body.getReader !== "function") {
522
+ const body = await res.text();
523
+ if (body.length > MAX_BYTES) return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
524
+ return { ok: true, body };
525
+ }
526
+ const reader = res.body.getReader();
527
+ const decoder = new TextDecoder("utf-8");
528
+ let body = "";
529
+ let bytes = 0;
530
+ for (;;) {
531
+ const { done, value } = await reader.read();
532
+ if (done) break;
533
+ bytes += value.byteLength;
534
+ if (bytes > MAX_BYTES) {
535
+ try { await reader.cancel(); } catch { /* already gone */ }
536
+ return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
537
+ }
538
+ body += decoder.decode(value, { stream: true });
539
+ }
540
+ body += decoder.decode();
541
+ return { ok: true, body };
542
+ } catch (e) {
543
+ const aborted = e?.name === "AbortError";
544
+ return { ok: false, error: aborted ? `timed out after ${Math.round(timeoutMs / 1000)}s` : String(e?.message || e) };
545
+ } finally {
546
+ clearTimeout(timer);
547
+ }
548
+ }
549
+
550
+ /** Read a document from a local path, or over the network when it is a URL. */
551
+ export async function readSource(source, opts = {}) {
552
+ const asUrl = safeUrl(source);
553
+ if (asUrl) return fetchDocument(asUrl, opts);
554
+ try { return { ok: true, body: fs.readFileSync(path.resolve(source), "utf8"), local: true }; }
555
+ catch (e) { return { ok: false, error: `can't read ${source}: ${e.message}` }; }
556
+ }
557
+
558
+ /** Run `worker` over `items` with a bounded number in flight. Order is preserved. */
559
+ async function mapLimit(items, limit, worker) {
560
+ const out = new Array(items.length);
561
+ let cursor = 0;
562
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
563
+ for (;;) {
564
+ const index = cursor++;
565
+ if (index >= items.length) return;
566
+ out[index] = await worker(items[index], index);
567
+ }
568
+ });
569
+ await Promise.all(runners);
570
+ return out;
571
+ }
572
+
573
+ /**
574
+ * Fetch every feed and merge them into one dated list.
575
+ *
576
+ * A feed that fails is reported, not fatal: a reader whose whole listing
577
+ * disappears because one publisher is having an outage is not a reader. The
578
+ * failures come back alongside the items so the caller can say which.
579
+ */
580
+ export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
581
+ const results = await mapLimit(feeds, CONCURRENCY, async (feed) => {
582
+ const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs });
583
+ if (!res.ok) return { feed, error: res.error };
584
+ let parsed;
585
+ try { parsed = parseFeed(res.body, { url: feed.url }); }
586
+ catch (e) { return { feed, error: `unreadable feed (${e.message})` }; }
587
+ return { feed, parsed };
588
+ });
589
+
590
+ const items = [];
591
+ const failures = [];
592
+ const seen = new Set();
593
+ for (const result of results) {
594
+ if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; }
595
+ for (const item of result.parsed.items) {
596
+ // Aggregator feeds wrap the publisher's URL in one of their own. Unwrap
597
+ // before deduping, so the same story arriving via Google News and via the
598
+ // publisher's own feed is recognised as one story rather than two.
599
+ const link = item.link ? unwrapRedirect(item.link) : null;
600
+ const key = link || `${result.feed.name}:${item.title}`;
601
+ if (seen.has(key)) continue;
602
+ seen.add(key);
603
+ items.push({ ...item, link, feed: result.feed.name, feedTitle: result.parsed.title || result.feed.title });
604
+ }
605
+ }
606
+ // Newest first, and undated entries last rather than pretending they are old:
607
+ // plenty of feeds omit dates entirely, and sorting them to the bottom keeps
608
+ // them reachable without letting them claim the top of the list.
609
+ items.sort((a, b) => (b.date ?? -Infinity) - (a.date ?? -Infinity));
610
+ return { items, failures };
611
+ }
612
+
613
+ /**
614
+ * The feeds a keyword search reads.
615
+ *
616
+ * Two engines rather than one, which is advis0r's reasoning carried over
617
+ * verbatim: Google has the better index, but its RSS links are interstitials
618
+ * that a reader cannot open into an article, while Bing wraps the real
619
+ * publisher URL in a `url=` parameter that unwrapRedirect decodes. Querying
620
+ * both and deduping on the unwrapped link gets Google's coverage with Bing's
621
+ * openable links wherever the two overlap.
622
+ */
623
+ export function searchFeeds(query) {
624
+ return [
625
+ { name: "google", title: `Google News — ${query}`, url: googleNewsSearch(query), site: "", category: "" },
626
+ { name: "bing", title: `Bing News — ${query}`, url: bingNewsSearch(query), site: "", category: "" },
627
+ ];
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Arguments
632
+ // ---------------------------------------------------------------------------
633
+
634
+ function takeFlag(args, name, { boolean = false } = {}) {
635
+ const out = { value: null, rest: [], missing: false, present: false };
636
+ for (let i = 0; i < args.length; i++) {
637
+ const arg = String(args[i]);
638
+ if (arg === name) {
639
+ out.present = true;
640
+ if (boolean) continue;
641
+ const next = args[i + 1];
642
+ if (next == null || String(next).startsWith("-")) out.missing = true;
643
+ else { out.value = String(next); i++; }
644
+ continue;
645
+ }
646
+ if (!boolean && arg.startsWith(`${name}=`)) {
647
+ out.present = true;
648
+ const value = arg.slice(name.length + 1);
649
+ if (value === "") out.missing = true; else out.value = value;
650
+ continue;
651
+ }
652
+ out.rest.push(arg);
653
+ }
654
+ return out;
655
+ }
656
+
657
+ /**
658
+ * Translate argv into a request. Pure — no network, no filesystem.
659
+ *
660
+ * Returns { verb, target, limit, feed, json, timeoutMs } or { error } / { usage }.
661
+ */
662
+ export function newsArgs(argv = []) {
663
+ const args = (Array.isArray(argv) ? argv : []).map(String);
664
+ if (args.includes("--help") || args.includes("-h") || args.includes("help")) return { usage: true };
665
+
666
+ const json = takeFlag(args, "--json", { boolean: true });
667
+ const limitFlag = takeFlag(json.rest, "--limit");
668
+ if (limitFlag.missing) return { error: "--limit needs a number" };
669
+ const feedFlag = takeFlag(limitFlag.rest, "--feed");
670
+ if (feedFlag.missing) return { error: "--feed needs a feed name" };
671
+ const timeoutFlag = takeFlag(feedFlag.rest, "--timeout");
672
+ if (timeoutFlag.missing) return { error: "--timeout needs a number of seconds" };
673
+
674
+ let limit = DEFAULT_LIMIT;
675
+ if (limitFlag.value != null) {
676
+ const n = Number(limitFlag.value);
677
+ if (!Number.isInteger(n) || n < 1) return { error: `--limit takes a whole number of headlines, got ${JSON.stringify(limitFlag.value)}` };
678
+ limit = Math.min(n, MAX_LIMIT);
679
+ }
680
+
681
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
682
+ if (timeoutFlag.value != null) {
683
+ const secs = Number(timeoutFlag.value);
684
+ if (!Number.isFinite(secs) || secs <= 0 || secs > 120) return { error: `--timeout takes 1-120 seconds, got ${JSON.stringify(timeoutFlag.value)}` };
685
+ timeoutMs = Math.round(secs * 1000);
686
+ }
687
+
688
+ const rest = timeoutFlag.rest.filter((a) => a !== "");
689
+ const unknown = rest.find((a) => a.startsWith("--"));
690
+ if (unknown) return { error: `unknown flag ${unknown}` };
691
+
692
+ const base = { limit, feed: feedFlag.value, json: json.present, timeoutMs };
693
+ const [first, ...tail] = rest;
694
+
695
+ // No argument at all: the headlines. This is the common case and it is why
696
+ // `/news` is worth having as one word.
697
+ if (!first) return { ...base, verb: "headlines", target: null };
698
+
699
+ const verb = resolveVerb(first);
700
+ if (!verb) {
701
+ // Not a verb. Two things it can still be, and the URL check decides which:
702
+ // a feed to read directly — the "or rss link" half of the feature — or a
703
+ // keyword to search for. Everything that is not a URL is a keyword, so
704
+ // `/news tariffs` and `/news openai earnings` both work and neither needs
705
+ // a verb in front of it. That does mean a mistyped verb searches for the
706
+ // typo instead of erroring, which is the right trade: `/news lst` finding
707
+ // nothing is recoverable, and refusing every unrecognised word would make
708
+ // the headline search unreachable without ceremony.
709
+ const url = safeUrl(first);
710
+ if (url) {
711
+ if (tail.length) return { error: "reading one feed takes a single URL" };
712
+ return { ...base, verb: "headlines", target: url, oneOff: true };
713
+ }
714
+ return { ...base, verb: "search", query: [first, ...tail].join(" ") };
715
+ }
716
+
717
+ if (verb === "latest") {
718
+ if (tail.length) return { error: "latest takes no arguments — /news <keyword> to search" };
719
+ return { ...base, verb: "headlines", target: null };
720
+ }
721
+ if (verb === "search") {
722
+ const query = tail.join(" ").trim();
723
+ if (!query) return { error: "usage: moshcode news search <keyword…>" };
724
+ return { ...base, verb, query };
725
+ }
726
+ if (verb === "add") {
727
+ if (!tail.length) return { error: "usage: moshcode news add <url|file>" };
728
+ if (tail.length > 1) return { error: "add takes one URL or file at a time" };
729
+ return { ...base, verb, target: tail[0] };
730
+ }
731
+ if (verb === "rm") {
732
+ if (!tail.length) return { error: "usage: moshcode news rm <name|url>" };
733
+ return { ...base, verb, target: tail.join(" ") };
734
+ }
735
+ if (verb === "open") {
736
+ if (tail.length !== 1) return { error: "usage: moshcode news open <n>" };
737
+ const n = Number(tail[0]);
738
+ if (!Number.isInteger(n) || n < 1) return { error: `open takes a headline number, got ${JSON.stringify(tail[0])}` };
739
+ return { ...base, verb, index: n };
740
+ }
741
+ if (tail.length) return { error: `${verb} takes no arguments` };
742
+ return { ...base, verb, target: null };
743
+ }
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // Rendering
747
+ // ---------------------------------------------------------------------------
748
+
749
+ /** "3h ago" — how long before `now` something was published. */
750
+ export function ago(ms, now = Date.now()) {
751
+ if (!Number.isFinite(ms)) return "";
752
+ const secs = Math.round((now - ms) / 1000);
753
+ if (secs < 0) return "just now";
754
+ if (secs < 90) return `${secs}s ago`;
755
+ const mins = Math.round(secs / 60);
756
+ if (mins < 90) return `${mins}m ago`;
757
+ const hours = Math.round(mins / 60);
758
+ if (hours < 36) return `${hours}h ago`;
759
+ const days = Math.round(hours / 24);
760
+ if (days < 14) return `${days}d ago`;
761
+ const weeks = Math.round(days / 7);
762
+ if (weeks < 9) return `${weeks}w ago`;
763
+ return `${Math.round(days / 30)}mo ago`;
764
+ }
765
+
766
+ /** The headline list. */
767
+ export function renderHeadlines(items, { failures = [], columns, limit = DEFAULT_LIMIT, now = Date.now(), source = "" } = {}) {
768
+ const width = Math.max(48, Math.min(Number(columns) || 88, 100));
769
+ const shown = items.slice(0, limit);
770
+ if (!shown.length) {
771
+ const lines = ["", ` ${ash("nothing came back")}`];
772
+ if (failures.length) lines.push("", ...failureLines(failures));
773
+ else lines.push("", ` ${ash("subscribe with")} ${bone("/news add <url>")}`);
774
+ return lines.join("\n");
775
+ }
776
+
777
+ // The widest index, so "9." and "10." line their titles up.
778
+ const gutter = String(shown.length).length + 1;
779
+ const tails = shown.map((item) => {
780
+ const when = ago(item.date, now);
781
+ return `${item.feed || ""}${when ? ` · ${when}` : ""}`.trim();
782
+ });
783
+ // One column width for every tail, not one per row: the feed name and the age
784
+ // are what make a headline placeable, so they keep their room and the title
785
+ // is what gives — and they line up, which is the whole point of a column.
786
+ const tailWidth = Math.max(0, ...tails.map((t) => t.length));
787
+ const room = Math.max(24, width - gutter - tailWidth - 4);
788
+
789
+ const lines = ["", ` ${ash(source || `${items.length} headline${items.length === 1 ? "" : "s"}`)}`, ""];
790
+ for (const [i, item] of shown.entries()) {
791
+ const n = `${i + 1}.`.padStart(gutter);
792
+ lines.push(` ${acid(n)} ${bone(clip(item.title, room).padEnd(room))} ${ash(tails[i].padStart(tailWidth))}`);
793
+ }
794
+ lines.push("", ` ${ash("open one with")} ${bone("/news open <n>")}`);
795
+ if (failures.length) lines.push("", ...failureLines(failures));
796
+ return lines.join("\n");
797
+ }
798
+
799
+ function failureLines(failures) {
800
+ return [
801
+ ` ${amber(`${failures.length} feed${failures.length === 1 ? "" : "s"} didn't answer`)}`,
802
+ ...failures.map((f) => ` ${ash(`${f.name} — ${f.error}`)}`),
803
+ ];
804
+ }
805
+
806
+ /** The subscription list. */
807
+ export function renderFeeds(feeds, { file = "", usingDefaults = false } = {}) {
808
+ if (!feeds.length) {
809
+ return ["", ` ${ash("no feeds yet")}`, "",
810
+ ` ${ash("add one:")} ${bone("/news add https://example.com/feed.xml")}`,
811
+ ` ${ash("or a list:")} ${bone("/news add ~/subscriptions.opml")}`,
812
+ ` ${ash("or a bundle:")}${bone(" /news add journalists")}`].join("\n");
813
+ }
814
+ const width = Math.max(...feeds.map((f) => f.name.length));
815
+ const header = usingDefaults
816
+ ? `${feeds.length} default feeds · nothing subscribed yet`
817
+ : `${feeds.length} feed${feeds.length === 1 ? "" : "s"}${file ? ` · ${file}` : ""}`;
818
+ const lines = ["", ` ${ash(header)}`, ""];
819
+ let category = null;
820
+ for (const feed of feeds) {
821
+ if ((feed.category || "") !== category) {
822
+ category = feed.category || "";
823
+ if (category) lines.push(` ${ash(category)}`);
824
+ }
825
+ lines.push(` ${acid(feed.name.padEnd(width))} ${bone(clip(feed.title || "", 34).padEnd(36))}${ash(feed.url)}`);
826
+ }
827
+ lines.push("", usingDefaults
828
+ ? ` ${ash("subscribe to your own with")} ${bone("/news add <url|bundle>")} ${ash("· see them with")} ${bone("/news sources")}`
829
+ : ` ${ash("read them with")} ${bone("/news")} ${ash("· one of them with")} ${bone("/news --feed <name>")}`);
830
+ return lines.join("\n");
831
+ }
832
+
833
+ /** What a fresh install reads, and the lists it can pull in by name. */
834
+ export function renderSources() {
835
+ const defaults = defaultFeeds();
836
+ const width = Math.max(...defaults.map((f) => f.name.length));
837
+ const lines = ["", ` ${bone("defaults")} ${ash(`— read when nothing is subscribed (${defaults.length})`)}`, ""];
838
+ let category = null;
839
+ for (const feed of defaults) {
840
+ if ((feed.category || "") !== category) {
841
+ category = feed.category || "";
842
+ if (category) lines.push(` ${ash(category)}`);
843
+ }
844
+ lines.push(` ${acid(feed.name.padEnd(width))} ${ash(clip(feed.title || "", 44))}`);
845
+ }
846
+ lines.push("", ` ${bone("bundles")} ${ash("— public OPML lists, pull one in by name")}`, "");
847
+ const bw = Math.max(...OPML_BUNDLES.map((b) => b.name.length));
848
+ for (const bundle of OPML_BUNDLES) {
849
+ lines.push(` ${acid(bundle.name.padEnd(bw))} ${ash(bundle.description)}`);
850
+ }
851
+ lines.push("", ` ${ash("pull one in with")} ${bone(`/news add ${OPML_BUNDLES[0].name}`)}`);
852
+ return lines.join("\n");
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // The command
857
+ // ---------------------------------------------------------------------------
858
+
859
+ /** Remember what the numbers in the last listing pointed at. Best effort. */
860
+ function rememberListing(items, env) {
861
+ try {
862
+ const file = cacheFile(env);
863
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
864
+ const rows = items.map(({ title, link, feed, date }) => ({ title, link, feed, date }));
865
+ fs.writeFileSync(file, `${JSON.stringify({ at: Date.now(), items: rows }, null, 2)}\n`, { mode: FILE_MODE });
866
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
867
+ } catch { /* a cache that cannot be written must not fail the listing */ }
868
+ }
869
+
870
+ function readListing(env) {
871
+ try {
872
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(env), "utf8"));
873
+ return Array.isArray(parsed?.items) ? parsed.items : [];
874
+ } catch { return []; }
875
+ }
876
+
877
+ /**
878
+ * Run a `news` invocation end to end. Returns a process exit code.
879
+ *
880
+ * `deps` exists so tests drive the whole command — parse, fetch, render — with
881
+ * no network and no stdout, the way cryptoCommand's does.
882
+ */
883
+ export async function newsCommand(argv = [], deps = {}) {
884
+ const {
885
+ out = (s) => console.log(s),
886
+ fail = (s) => console.error(s),
887
+ fetchImpl,
888
+ openUrl,
889
+ columns = process.stdout.columns,
890
+ env = process.env,
891
+ now = Date.now(),
892
+ } = deps;
893
+
894
+ const request = newsArgs(argv);
895
+ if (request.usage) { out(newsUsage()); return 0; }
896
+ if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; }
897
+
898
+ if (request.verb === "list") {
899
+ const { feeds, usingDefaults } = readingList(env);
900
+ if (request.json) { out(JSON.stringify({ file: opmlFile(env), usingDefaults, feeds }, null, 2)); return 0; }
901
+ out(renderFeeds(feeds, { file: opmlFile(env), usingDefaults }));
902
+ return 0;
903
+ }
904
+
905
+ if (request.verb === "sources") {
906
+ if (request.json) { out(JSON.stringify({ defaults: defaultFeeds(), bundles: OPML_BUNDLES }, null, 2)); return 0; }
907
+ out(renderSources());
908
+ return 0;
909
+ }
910
+
911
+ if (request.verb === "export") {
912
+ // The defaults deliberately: exporting an empty file to hand to a reader is
913
+ // not what anyone means by "export my feeds" on a fresh install.
914
+ out(buildOpml(readingList(env).feeds).trimEnd());
915
+ return 0;
916
+ }
917
+
918
+ if (request.verb === "add") return addCommand(request, { out, fail, fetchImpl, env });
919
+ if (request.verb === "rm") return removeCommand(request, { out, fail, env });
920
+
921
+ if (request.verb === "open") {
922
+ const items = readListing(env);
923
+ if (!items.length) { fail(danger("✗ nothing to open — run `/news` first")); return 1; }
924
+ const item = items[request.index - 1];
925
+ if (!item) { fail(danger(`✗ there is no headline ${request.index} — the last listing had ${items.length}`)); return 1; }
926
+ if (!item.link) { fail(danger(`✗ "${clip(item.title, 60)}" has no link`)); return 1; }
927
+ if (request.json) { out(JSON.stringify(item, null, 2)); return 0; }
928
+ const opened = openUrl ? openUrl(item.link) : false;
929
+ out(opened
930
+ ? `${acid("✓ ")}opened ${bone(clip(item.title, 60))}`
931
+ : `${ash("· ")}open this in a browser:\n ${acid(item.link)}`);
932
+ return 0;
933
+ }
934
+
935
+ // Headlines — a keyword search, one URL passed straight in, or the reading list.
936
+ let feeds;
937
+ let source;
938
+ if (request.verb === "search") {
939
+ feeds = searchFeeds(request.query);
940
+ source = `“${request.query}”`;
941
+ } else if (request.oneOff) {
942
+ feeds = [{ name: hostSlug(request.target), title: hostOf(request.target), url: request.target, site: "", category: "" }];
943
+ source = hostOf(request.target);
944
+ } else {
945
+ const list = readingList(env);
946
+ feeds = list.feeds;
947
+ if (list.usingDefaults) source = "default feeds";
948
+ if (request.feed) {
949
+ const one = findFeed(feeds, request.feed);
950
+ if (!one) {
951
+ fail(danger(`✗ no feed named "${request.feed}"`));
952
+ if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`);
953
+ return 1;
954
+ }
955
+ feeds = [one];
956
+ source = one.title || one.name;
957
+ }
958
+ }
959
+
960
+ const { items, failures } = await collectNews(feeds, { fetchImpl, timeoutMs: request.timeoutMs });
961
+ const shown = items.slice(0, request.limit);
962
+
963
+ if (request.json) {
964
+ out(JSON.stringify({ items: shown, failures, feeds: feeds.length }, null, 2));
965
+ } else {
966
+ out(renderHeadlines(items, {
967
+ failures,
968
+ columns,
969
+ limit: request.limit,
970
+ now,
971
+ source: source ? `${source} · ${items.length} headline${items.length === 1 ? "" : "s"}` : "",
972
+ }));
973
+ }
974
+ // Cached even for --json: the numbers a script just read are the numbers
975
+ // `/news open <n>` should resolve.
976
+ if (shown.length) rememberListing(shown, env);
977
+ // Every feed failing is a failed command, not an empty one — a script that
978
+ // branches on the exit status should not read a total outage as "no news".
979
+ return items.length === 0 && failures.length === feeds.length ? 1 : 0;
980
+ }
981
+
982
+ /** `news add <url|file|bundle>` — one feed, or every feed in an OPML list. */
983
+ async function addCommand(request, { out, fail, fetchImpl, env }) {
984
+ // A bundle name resolves to somebody else's OPML list. Checked before the URL
985
+ // and the path so `journalists` is a name rather than a missing file.
986
+ const bundle = resolveBundle(request.target);
987
+ const target = bundle ? bundle.url : request.target;
988
+ if (bundle) out(`${ash("· ")}fetching ${bone(bundle.name)} ${ash(`— ${bundle.description}`)}`);
989
+
990
+ const res = await readSource(target, { fetchImpl, timeoutMs: request.timeoutMs });
991
+ if (!res.ok) { fail(danger(`✗ ${res.error}`)); return 1; }
992
+
993
+ // Subscribing for the first time replaces the defaults rather than merging
994
+ // with them: the defaults are a stand-in, and silently welding thirteen feeds
995
+ // onto the first one somebody chooses is not what `add` means.
996
+ const existing = loadFeeds(env);
997
+ const asUrl = safeUrl(target);
998
+
999
+ if (looksLikeOpml(res.body)) {
1000
+ const incoming = parseOpml(res.body);
1001
+ if (!incoming.length) { fail(danger("✗ that OPML file lists no feeds")); return 1; }
1002
+ let feeds = existing;
1003
+ const added = [];
1004
+ let skipped = 0;
1005
+ for (const feed of incoming) {
1006
+ const result = withFeed(feeds, feed);
1007
+ feeds = result.feeds;
1008
+ if (result.existed) skipped++; else added.push(result.added);
1009
+ }
1010
+ if (!added.length) {
1011
+ out(`${ash("· ")}already subscribed to all ${incoming.length} feed${incoming.length === 1 ? "" : "s"} in that list`);
1012
+ return 0;
1013
+ }
1014
+ try { saveFeeds(feeds, env); }
1015
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
1016
+ if (request.json) { out(JSON.stringify({ added, skipped }, null, 2)); return 0; }
1017
+ out(`${acid("✓ ")}subscribed to ${bone(String(added.length))} feed${added.length === 1 ? "" : "s"}${skipped ? ash(` (${skipped} already there)`) : ""}`);
1018
+ for (const feed of added.slice(0, 10)) out(` ${acid(feed.name.padEnd(18))}${ash(clip(feed.title || feed.url, 56))}`);
1019
+ if (added.length > 10) out(` ${ash(`…and ${added.length - 10} more — /news list`)}`);
1020
+ return 0;
1021
+ }
1022
+
1023
+ // A single feed. It has to be a URL: parsing a local file would subscribe to
1024
+ // a path that only exists on this machine and would never refresh.
1025
+ if (!asUrl) {
1026
+ fail(danger(`✗ ${target} is a feed, not an OPML list — subscribe to it by URL so it can refresh`));
1027
+ return 1;
1028
+ }
1029
+ let parsed;
1030
+ try { parsed = parseFeed(res.body, { url: asUrl }); }
1031
+ catch (e) { fail(danger(`✗ can't read that feed (${e.message})`)); return 1; }
1032
+ if (!parsed.items.length && !parsed.title) {
1033
+ fail(danger(`✗ ${asUrl} doesn't look like an RSS, Atom, or OPML document`));
1034
+ return 1;
1035
+ }
1036
+
1037
+ const candidate = {
1038
+ name: slugify(parsed.title) || hostSlug(asUrl),
1039
+ title: parsed.title || hostOf(asUrl),
1040
+ url: asUrl,
1041
+ site: parsed.site || "",
1042
+ category: "",
1043
+ };
1044
+ const { feeds, added, existed } = withFeed(existing, candidate);
1045
+ if (existed) {
1046
+ out(`${ash("· ")}already subscribed to ${bone(added.name)} ${ash(added.url)}`);
1047
+ return 0;
1048
+ }
1049
+ try { saveFeeds(feeds, env); }
1050
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
1051
+ if (request.json) { out(JSON.stringify({ added, skipped: 0 }, null, 2)); return 0; }
1052
+ out(`${acid("✓ ")}subscribed to ${bone(added.name)} ${ash(`— ${added.title}`)}`);
1053
+ out(` ${ash(`${parsed.items.length} item${parsed.items.length === 1 ? "" : "s"} right now · read them with`)} ${bone(`/news --feed ${added.name}`)}`);
1054
+ return 0;
1055
+ }
1056
+
1057
+ /** `news rm <name|url>` — unsubscribe. */
1058
+ function removeCommand(request, { out, fail, env }) {
1059
+ const feeds = loadFeeds(env);
1060
+ const feed = findFeed(feeds, request.target);
1061
+ if (!feed) {
1062
+ fail(danger(`✗ no feed named "${request.target}"`));
1063
+ if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`);
1064
+ return 1;
1065
+ }
1066
+ try { saveFeeds(feeds.filter((f) => f !== feed), env); }
1067
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
1068
+ if (request.json) { out(JSON.stringify({ removed: feed }, null, 2)); return 0; }
1069
+ out(`${acid("✓ ")}unsubscribed from ${bone(feed.name)} ${ash(feed.url)}`);
1070
+ return 0;
1071
+ }