diff --git a/catalog/desktop/plugin.js b/catalog/desktop/plugin.js index b164bd3..979cd80 100644 --- a/catalog/desktop/plugin.js +++ b/catalog/desktop/plugin.js @@ -222,6 +222,7 @@ function mergeFeed(library, feedId, parsed) { byIdentity.set(article.identity, article); } let added = 0; + const fresh = []; for (const item of parsed.items) { const old = byIdentity.get(item.identity); if (old) { @@ -242,6 +243,7 @@ function mergeFeed(library, feedId, parsed) { library.articles.push(article); byIdentity.set(item.identity, article); added++; + if (article.url) fresh.push(article); } } const unsaved = library.articles.filter((a) => a.feed_id === feedId && !a.is_saved).sort( @@ -251,7 +253,7 @@ function mergeFeed(library, feedId, parsed) { ); const remove = new Set(unsaved.slice(300).map((a) => a.id)); library.articles = library.articles.filter((a) => !remove.has(a.id)); - return { added }; + return { added, fresh: fresh.map((a) => ({ id: a.id, url: a.url })) }; } function parseOpml(content) { if (content.length > 2e6 || / transaction(owner); const write = (change) => transaction(owner, change); const add = (library, input) => { @@ -349,6 +351,13 @@ function createLibrary(owner, fetchFeed2, transaction = transact) { (a) => a.feed_id !== parts[1] || a.is_saved ); }); + if (parts[2] === "reorder" && method === "POST") + return write((library) => { + const order = Array.isArray(body.order) ? body.order : []; + if (order.length !== library.feeds.length || !order.every(id => typeof id === "string" && library.feeds.some(f => f.id === id))) + throw new Error("Order does not match the subscriptions."); + library.feeds.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + }); if (parts[2] === "refresh") { const key = JSON.stringify([owner, parts[1]]); if (feedRefreshes.has(key)) return feedRefreshes.get(key); @@ -357,7 +366,27 @@ function createLibrary(owner, fetchFeed2, transaction = transact) { if (!feed) throw new Error("Subscription not found."); try { const result = await fetchFeed2(feed.url); - return await write((library) => mergeFeed(library, feed.id, result)); + const outcome = await write((library) => mergeFeed(library, feed.id, result)); + if (outcome.added > 0 && Array.isArray(body.captureFull) && body.captureFull.includes(feed.id)) { + let ok = 0; + for (const freshItem of outcome.fresh.slice(0, 10)) { + try { + const fullBody = await captureFn(freshItem.url); + await write((library2) => { + const target = library2.articles.find((a) => a.id === freshItem.id); + if (target && fullBody && fullBody.length > target.body.length) { + target.body = fullBody; + target.captured = true; + } + }); + ok++; + } catch { + // Paywalls, JS-only pages, and bot blocks keep the feed excerpt. + } + } + outcome.captured = ok; + } + return outcome; } catch (error) { await write((library) => { const current = library.feeds.find((f) => f.id === feed.id); @@ -395,6 +424,16 @@ function createLibrary(owner, fetchFeed2, transaction = transact) { for (const key of ["is_saved", "is_read"]) if (typeof body[key] === "boolean") article2[key] = body[key]; }); + if (parts[2] === "capture" && method === "POST") + return write((library2) => { + const article3 = library2.articles.find((a) => a.id === parts[1]); + if (!article3) throw new Error("Article not found."); + if (typeof body.body === "string" && body.body.length > article3.body.length) { + article3.body = body.body.slice(0, 6e4); + article3.captured = true; + article3.actions = article3.actions.map((a) => ({ ...a, stale: true })); + } + }); if (parts[2] === "actions" && method === "POST") return write((library2) => { const article2 = library2.articles.find((a) => a.id === parts[1]); @@ -446,7 +485,8 @@ function readSettings(ctx, owner) { return { autoRefresh: stored.autoRefresh === true, refreshMinutes: Number.isInteger(stored.refreshMinutes) && stored.refreshMinutes >= 1 && stored.refreshMinutes <= 1440 ? stored.refreshMinutes : 15, - markReadOnOpen: stored.markReadOnOpen !== false + markReadOnOpen: stored.markReadOnOpen !== false, + fullCapture: stored.fullCapture === true }; } function currentOwner(host2) { @@ -455,13 +495,19 @@ function currentOwner(host2) { function publishLibraryChange(owner) { window.dispatchEvent(new CustomEvent("hermes-rss-library-changed", { detail: { owner } })); } -async function refreshSubscriptions(library, { feedId = null, shouldContinue = () => true } = {}) { +async function refreshSubscriptions(library, { feedId = null, shouldContinue = () => true, captureFeedIds = [] } = {}) { const feeds = await library("/feeds"); let added = 0, failed = 0; for (const feed of feeds) { if (!shouldContinue()) break; if (feedId && feed.id !== feedId) continue; - try { added += (await library(`/feeds/${feed.id}/refresh`, { method: "POST" })).added; } + try { + const result = await library(`/feeds/${feed.id}/refresh`, { + method: "POST", + body: { captureFull: captureFeedIds === null || captureFeedIds.includes(feed.id) ? [feed.id] : [] } + }); + added += result.added; + } catch { failed++; } } return { added, failed }; @@ -470,7 +516,7 @@ function startAutoRefresh(ctx, host2, options = {}) { const schedule = options.setInterval || setInterval; const unschedule = options.clearInterval || clearInterval; const now = options.now || Date.now; - const makeLibrary = options.makeLibrary || ((owner) => createLibrary(owner, url => fetchFeed(host2, url))); + const makeLibrary = options.makeLibrary || ((owner) => createLibrary(owner, url => fetchFeed(host2, url), transact, null)); const notify = options.notify || publishLibraryChange; const clocks = new Map(); let stopped = false, running = false; @@ -753,6 +799,203 @@ function parseFeed(xml, base) { }); return { title, items }; } +async function captureArticle(host2, rawUrl) { + const route = await currentRoute(host2); + const owner = JSON.stringify([route.connectionId, route.profile]); + const previous = pendingFetches.get(owner) || Promise.resolve(); + const work = previous.catch(() => { + }).then(() => captureArticleNow(host2, rawUrl, route, owner)); + pendingFetches.set(owner, work); + try { + return await work; + } finally { + if (pendingFetches.get(owner) === work) pendingFetches.delete(owner); + } +} +function extractReadable(html) { + const cleaned = html.replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//gi, "").replace(//g, ""); + const articleMatch = /][\s\S]*?<\/article>/i.exec(cleaned); + let scope = articleMatch ? articleMatch[0] : cleaned; + if (!articleMatch) { + const mainMatch = /][\s\S]*?<\/main>/i.exec(cleaned); + if (mainMatch) scope = mainMatch[0]; + } + const template = document.createElement("template"); + template.innerHTML = scope; + template.content.querySelectorAll("script,style,noscript,svg,form,iframe,button,input,select,textarea,nav,aside,footer,header,[aria-hidden=true]").forEach((n) => n.remove()); + const candidates = [...template.content.querySelectorAll("p,li,blockquote,pre,h1,h2,h3,h4")]; + let text = ""; + if (candidates.length >= 3) { + const seen = /* @__PURE__ */ new Set(); + const parts = []; + for (const node of candidates) { + const name = node.localName; + const content = node.textContent.replace(/[^\S\n]+/g, " ").trim(); + if (!content || content.length < 25 && !name.startsWith("h")) continue; + const key = content.slice(0, 80).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + if (name === "p" || name === "blockquote" || name === "pre") + parts.push({ tag: name, text: content }); + else if (name === "li") parts.push({ tag: "li", text: content }); + else parts.push({ tag: `h${Math.min(3, Number(name[1]) || 3)}`, text: content }); + } + text = parts.map((part) => part.tag.startsWith("h") ? `\n\n## ${part.text}\n\n` : part.tag === "li" ? `\u2022 ${part.text}` : part.text).join("\n\n"); + } else { + template.content.querySelectorAll("p,div,li,br,h1,h2,h3,blockquote").forEach((n) => n.append("\n")); + text = template.content.textContent.replace(/[^\S\n]+/g, " ").replace(/\n\s*\n/g, "\n\n").trim(); + } + return text.replace(/\n{3,}/g, "\n\n").trim(); +} +async function captureArticleNow(host2, rawUrl, route, owner) { + const run = async (command, optional) => { + assertOwner(host2, route); + const result = await host2.requestProfile(route, "shell.exec", { command }); + assertOwner(host2, route); + if (result.code !== 0) { + if (optional) return ""; + throw new Error("Capture failed: the gateway needs curl plus gzip and base64 tools."); + } + return result.stdout.trim(); + }; + let family = families.get(owner); + if (!family) { + family = (await run("echo %OS%")) === "Windows_NT" ? "windows" : "posix"; + families.set(owner, family); + } + let directory = caches.get(owner); + if (!directory) { + if (family === "windows") { + const temp = (await run("echo %TEMP%")).replace(/[\\/]+$/, ""); + directory = `${temp}\\hermes-rss.${crypto.randomUUID().replaceAll("-", "").slice(0, 8)}`; + if (!isWindowsCache(directory)) + throw new Error("Could not create a private RSS download cache."); + await run(`mkdir ${cmdQuote(directory)}`); + } else { + directory = await run("mktemp -d /tmp/hermes-rss.XXXXXXXX"); + if (!isPosixCache(directory)) + throw new Error("Could not create a private RSS download cache."); + } + caches.set(owner, directory); + } + const pagePath = family === "windows" ? `${directory}\\page` : `${directory}/page`; + const quote = family === "windows" ? cmdQuote : posixQuote; + const curl = family === "windows" ? "curl.exe" : "curl"; + let url = publicUrl(rawUrl), success = false; + for (let redirect = 0; redirect < 4; redirect++) { + const addresses = await resolvePublicIPv4(run, family, url.hostname); + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + const info = await run( + `${curl} --disable --silent --show-error --noproxy ${quote("*")} --proto ${quote("=http,https")} --connect-timeout 8 --max-time 25 --max-filesize 2000000 --resolve ${quote(`${url.hostname}:${port}:${addresses[0]}`)} --location --header ${quote("Accept: text/html,application/xhtml+xml")} --header ${quote("Accept-Encoding: identity")} --user-agent ${quote("Mozilla/5.0 (compatible; HermesRSS/0.2; reader mode)")} --output ${quote(pagePath)} --write-out ${quote("%{http_code} %{size_download}")} --url ${quote(url.href)}` + ); + const match = /^(\d{3}) ([0-9]+)$/.exec(info); + if (!match) throw new Error("Invalid page download response."); + const code = match[1], size = match[2]; + if (Number(size) > 2e6) throw new Error("Page exceeds 2 MB."); + if (code !== "200") throw new Error(`The page returned HTTP ${code}.`); + success = true; + break; + } + if (!success) throw new Error("The page redirects too many times."); + const packed = await readPackedFeed(run, family, directory, pagePath); + const bytes = Uint8Array.from(atob(packed), (c) => c.charCodeAt(0)); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + const decoded = new Uint8Array(await new Response(stream).arrayBuffer()); + if (decoded.length > 2e6) throw new Error("Page exceeds 2 MB."); + const declared = /]+charset=["']?([\w-]+)/i.exec(new TextDecoder("utf-8").decode(decoded.slice(0, 4096)))?.[1]; + const html = new TextDecoder(declared && !/utf-?8/i.test(declared) ? declared : "utf-8").decode(decoded); + const text = extractReadable(html); + if (!text || text.length < 200) + throw new Error("No readable article text found on the page."); + return plainText(text).slice(0, 6e4); +} +function escapeHtml(value) { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +function renderInline(escaped) { + return escaped + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,;:!?]|$)/g, "$1$2") + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); +} +function bodyToRichHtml(raw) { + const source = String(raw || ""); + const looksLikeHtml = /<\/?(p|div|h[1-6]|ul|ol|li|img|a|blockquote|table|br)\b/i.test(source); + if (looksLikeHtml) { + const template = document.createElement("template"); + template.innerHTML = source; + template.content.querySelectorAll("script,style,noscript,iframe,object,embed,form,button,input,select,textarea,link,meta,svg").forEach((n) => n.remove()); + for (const node of template.content.querySelectorAll("*")) { + for (const attribute of [...node.attributes]) { + const name = attribute.name.toLowerCase(); + const allowed = name === "href" && node.localName === "a" || name === "src" && node.localName === "img" || name === "alt" || name === "title" || name === "colspan" || name === "rowspan"; + if (!allowed || name === "href" && !/^https?:/i.test(attribute.value) || name === "src" && !/^https?:/i.test(attribute.value)) + node.removeAttribute(attribute.name); + } + } + for (const anchor of template.content.querySelectorAll("a[href]")) { + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noreferrer noopener"); + } + for (const image of template.content.querySelectorAll("img")) { + image.setAttribute("loading", "lazy"); + if (!image.getAttribute("alt")) image.setAttribute("alt", ""); + } + return { html: template.innerHTML, isHtml: true }; + } + const lines = source.split(/\n/); + const out = []; + let inList = false, inCode = false, codeBuffer = [], paragraph = []; + const flushParagraph = () => { + if (paragraph.length) { out.push(`

${renderInline(escapeHtml(paragraph.join(" ")))}

`); paragraph = []; } + }; + const closeList = () => { if (inList) { out.push(""); inList = false; } }; + for (const lineRaw of lines) { + const line = lineRaw.replace(/\s+$/, ""); + const trimmed = line.trim(); + if (trimmed.startsWith("```")) { + flushParagraph(); closeList(); + if (inCode) { out.push(`
${escapeHtml(codeBuffer.join("\n"))}
`); codeBuffer = []; inCode = false; } + else inCode = true; + continue; + } + if (inCode) { codeBuffer.push(lineRaw); continue; } + if (!trimmed) { flushParagraph(); closeList(); continue; } + const heading = /^(#{1,4})\s+(.*)$/.exec(trimmed); + if (heading) { + flushParagraph(); closeList(); + const level = Math.min(4, heading[1].length); + out.push(`${renderInline(escapeHtml(heading[2]))}`); + continue; + } + const bullet = /^[-*\u2022+]\s+(.*)$/.exec(trimmed); + if (bullet) { + flushParagraph(); + if (!inList) { out.push("
    "); inList = true; } + out.push(`
  • ${renderInline(escapeHtml(bullet[1]))}
  • `); + continue; + } + const numbered = /^\d+[.)]\s+(.*)$/.exec(trimmed); + if (numbered) { + flushParagraph(); + if (!inList) { out.push('
      '); inList = true; } + out.push(`
    • ${renderInline(escapeHtml(numbered[1]))}
    • `); + continue; + } + if (/^([-_=]\s?)\1{2,}$/.test(trimmed)) { flushParagraph(); closeList(); out.push("
      "); continue; } + if (/^>|^>\s?/.test(trimmed)) { + flushParagraph(); closeList(); + out.push(`
      ${renderInline(escapeHtml(trimmed.replace(/^(>|>)\s?/, "")))}
      `); + continue; + } + paragraph.push(trimmed); + } + if (inCode) out.push(`
      ${escapeHtml(codeBuffer.join("\n"))}
      `); + flushParagraph(); + closeList(); + return { html: out.join("\n"), isHtml: false }; +} // src/styles.mjs var styles = ` @@ -760,8 +1003,11 @@ var styles = ` .hermes-rss *{box-sizing:border-box}.hermes-rss button,.hermes-rss input{font:inherit} .hermes-rss button{cursor:pointer}.hermes-rss button:disabled{opacity:.5;cursor:wait} .hermes-rss button:focus-visible,.hermes-rss input:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px} -.hermes-rss .rss-top{display:flex;justify-content:space-between;align-items:center;padding:24px 28px 20px;border-bottom:1px solid var(--ui-stroke-secondary);gap:16px} +.hermes-rss .rss-top{display:flex;justify-content:space-between;align-items:center;padding:10px 20px;border-bottom:1px solid var(--ui-stroke-secondary);gap:12px} .hermes-rss h1{font-size:24px;letter-spacing:-.8px;font-weight:650;margin:0 0 5px}.hermes-rss h2{font-size:20px;letter-spacing:-.4px;line-height:1.4;margin:0 0 12px} +.hermes-rss .rss-top h1{font-size:15px;letter-spacing:-.2px;margin:0;line-height:1.3} +.hermes-rss .rss-top .rss-tools{gap:6px} +.hermes-rss .rss-top .rss-tools button{padding:4px 10px;font-size:12px;height:26px;min-height:0;line-height:1.2} .hermes-rss p{margin:0;line-height:1.7}.hermes-rss .rss-muted{color:var(--ui-text-secondary)} .hermes-rss .rss-eyebrow{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;font-weight:650;color:var(--ui-text-tertiary);margin-bottom:10px} .hermes-rss .rss-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap} @@ -770,9 +1016,66 @@ var styles = ` .hermes-rss .rss-nav button{display:flex;justify-content:space-between;align-items:center;width:100%;border:0;border-radius:6px;padding:9px 10px;background:transparent;color:var(--ui-text-secondary);text-align:left;margin-bottom:3px;gap:8px} .hermes-rss .rss-nav button[aria-current=true]{color:var(--ui-accent);background:color-mix(in srgb,var(--ui-accent) 10%,transparent)} .hermes-rss .rss-nav .rss-eyebrow{padding:0 10px;margin-top:28px}.hermes-rss .rss-count{font-size:11px;font-variant-numeric:tabular-nums} +.hermes-rss .rss-nav-heading{position:relative;display:flex;align-items:center;padding:0 10px;margin-top:28px;min-height:14px} +.hermes-rss .rss-nav-heading .rss-eyebrow{padding:0;margin:0;letter-spacing:.8px;white-space:nowrap} +.hermes-rss .rss-nav-heading .rss-edit-toggle{position:absolute;right:10px;top:50%;transform:translateY(-50%)} +.hermes-rss .rss-edit-toggle{width:12px;height:12px;padding:0;margin:0;display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;color:var(--ui-text-tertiary)} +.hermes-rss .rss-edit-toggle .codicon{font-size:9px;line-height:1;display:block} +.hermes-rss .rss-edit-toggle[aria-pressed=true]{color:var(--ui-accent)} +.hermes-rss .rss-feed-row{display:flex;align-items:center;gap:2px} +.hermes-rss .rss-feed-row-editing{border-radius:6px} +.hermes-rss .rss-feed-row-dragging{opacity:.45} +.hermes-rss .rss-feed-edit{display:flex;align-items:center;flex-shrink:0} +.hermes-rss .rss-feed-edit button{width:18px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;color:var(--ui-text-tertiary);font-size:12px} +.hermes-rss .rss-grip{cursor:grab} .hermes-rss .rss-feed-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .hermes-rss .rss-list{border-right:1px solid var(--ui-stroke-secondary);display:flex;flex-direction:column;min-height:0} -.hermes-rss .rss-list-head{padding:18px 18px 14px;border-bottom:1px solid var(--ui-stroke-secondary)} +.hermes-rss .rss-list-head{padding:10px 12px;border-bottom:1px solid var(--ui-stroke-secondary);display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.hermes-rss .rss-list-head input{flex:1;min-width:120px;height:26px;padding:4px 8px;font-size:12px} +.hermes-rss .rss-list-head .rss-list-meta{display:flex;align-items:center;gap:6px;white-space:nowrap} +.hermes-rss .rss-list-head .rss-mark-read{padding:4px 8px;font-size:11px;height:26px;min-height:0;line-height:1.2} +.hermes-rss .rss-list-head .rss-filter-chips{margin-top:0} +.hermes-rss .rss-detail{overflow:auto;padding:32px 44px 56px} +.hermes-rss .rss-detail .rss-tools{margin:18px 0} +.hermes-rss .rss-detail-inner{max-width:70ch;margin:0 auto} +.hermes-rss .rss-detail h2{font-size:24px;letter-spacing:-.3px;line-height:1.3;margin:6px 0 14px;font-weight:700} +.hermes-rss .rss-detail .rss-eyebrow{margin-bottom:0} +.hermes-rss .rss-detail .rss-body strong,.hermes-rss .rss-detail .rss-body b{font-weight:650} +.hermes-rss .rss-detail .rss-body li::marker{color:var(--ui-text-tertiary)} +.hermes-rss .rss-article-actions{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:18px 0 0} +.hermes-rss .rss-icon-row{display:inline-flex;align-items:center;gap:2px} +.hermes-rss .rss-icon-btn{width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:5px;background:transparent;color:var(--ui-text-secondary);font-size:14px} +.hermes-rss .rss-icon-btn:hover:not(:disabled){color:var(--foreground);background:var(--chrome-action-hover)} +.hermes-rss .rss-icon-btn:disabled{opacity:.4;cursor:default} +.hermes-rss .rss-body{white-space:pre-wrap;font-size:15.5px;line-height:1.75;overflow-wrap:break-word;color:var(--ui-text-primary,var(--foreground));margin:22px 0 0;letter-spacing:.1px} +.hermes-rss .rss-detail .rss-body p,.hermes-rss .rss-detail .rss-body h1,.hermes-rss .rss-detail .rss-body h2,.hermes-rss .rss-detail .rss-body h3,.hermes-rss .rss-detail .rss-body ul,.hermes-rss .rss-detail .rss-body ol,.hermes-rss .rss-detail .rss-body blockquote{margin:0 0 1.05em} +.hermes-rss .rss-detail .rss-body h1{font-size:1.35em;line-height:1.3} +.hermes-rss .rss-detail .rss-body h2{font-size:1.2em;line-height:1.35} +.hermes-rss .rss-detail .rss-body h3{font-size:1.05em;line-height:1.4} +.hermes-rss .rss-detail .rss-body ul,.hermes-rss .rss-detail .rss-body ol{padding-left:1.4em} +.hermes-rss .rss-detail .rss-body li{margin-bottom:.4em} +.hermes-rss .rss-detail .rss-body blockquote{margin:1em 0;padding:2px 0 2px 14px;border-left:2px solid var(--ui-stroke-secondary);color:var(--ui-text-secondary);font-style:italic} +.hermes-rss .rss-detail .rss-body a{color:var(--ui-accent);text-decoration:none;border-bottom:1px solid color-mix(in srgb,var(--ui-accent) 40%,transparent)} +.hermes-rss .rss-detail .rss-body code{font-size:.88em;background:color-mix(in srgb,var(--ui-text-secondary) 12%,transparent);border-radius:4px;padding:1px 5px} +.hermes-rss .rss-detail .rss-body pre{background:color-mix(in srgb,var(--ui-text-secondary) 8%,transparent);border:1px solid var(--ui-stroke-secondary);border-radius:8px;padding:12px 14px;overflow:auto;white-space:pre-wrap} +.hermes-rss .rss-detail .rss-body pre code{background:transparent;padding:0} +.hermes-rss .rss-detail .rss-body img{max-width:100%;border-radius:8px} +.hermes-rss .rss-detail .rss-body hr{border:0;border-top:1px solid var(--ui-stroke-secondary);margin:1.6em 0} +.hermes-rss .rss-rich table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.92em} +.hermes-rss .rss-rich th,.hermes-rss .rss-rich td{border:1px solid var(--ui-stroke-secondary);padding:6px 10px;text-align:left} +.hermes-rss .rss-rich th{background:color-mix(in srgb,var(--ui-text-secondary) 8%,transparent);font-weight:650} +.hermes-rss .rss-rich h4{font-size:1em;margin:1.2em 0 .5em} +.hermes-rss .rss-rich ul.rss-ol{list-style:decimal;padding-left:1.4em} +.hermes-rss .rss-rich figcaption,.hermes-rss .rss-rich small{color:var(--ui-text-secondary);font-size:.85em} +.hermes-rss .rss-settings-header{font-size:15px;font-weight:700;letter-spacing:-.2px;margin:4px 0 2px;color:var(--ui-text-primary,var(--foreground))} +.hermes-rss .rss-settings-header:not(:first-child){margin-top:14px;padding-top:14px;border-top:1px solid var(--ui-stroke-secondary)} +.hermes-rss .rss-setting-row{display:flex;align-items:center;gap:18px;flex-wrap:wrap} +.hermes-rss .rss-setting-row .rss-setting{margin:0} +.hermes-rss .rss-setting-inline{display:inline-flex;align-items:center;gap:8px} +.hermes-rss .rss-setting-inline input[type=number]{width:74px} +.hermes-rss .rss-tabs-pills{display:inline-flex;gap:14px;margin:0;border:0;padding:0;justify-self:center} +.hermes-rss .rss-tabs-pills button{border:0;background:transparent;border-radius:0;padding:2px 0;font-size:12px;line-height:1.4;color:var(--ui-text-secondary)} +.hermes-rss .rss-tabs-pills button[aria-selected=true]{border-bottom:2px solid var(--ui-accent);background:transparent;color:var(--ui-text-primary,var(--foreground))} .hermes-rss .rss-list-items{overflow:auto;flex:1;padding:8px} .hermes-rss .rss-card{display:block;width:100%;border:1px solid transparent;background:transparent;color:inherit;text-align:left;padding:18px 14px;border-radius:8px;margin-bottom:3px} .hermes-rss .rss-card:hover{background:color-mix(in srgb,var(--ui-text-secondary) 5%,transparent)} @@ -781,12 +1084,10 @@ var styles = ` .hermes-rss .rss-card-read .rss-card-excerpt{color:var(--ui-text-tertiary)} .hermes-rss .rss-card-title{font-size:15px;font-weight:600;line-height:1.45;margin:8px 0}.hermes-rss .rss-card-meta{display:flex;justify-content:space-between;gap:10px;font-size:10px;color:var(--ui-text-tertiary)} .hermes-rss .rss-card-excerpt{font-size:12px;color:var(--ui-text-secondary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} -.hermes-rss .rss-detail{overflow:auto;padding:28px 30px 48px}.hermes-rss .rss-detail .rss-tools{margin:18px 0} .hermes-rss .rss-chip{display:inline-flex;align-items:center;padding:4px 8px;border:1px solid var(--ui-stroke-secondary);border-radius:5px;font-size:10px;color:var(--ui-text-secondary)} .hermes-rss .rss-tabs{display:flex;gap:22px;border-bottom:1px solid var(--ui-stroke-secondary);margin:24px 0} .hermes-rss .rss-tabs button{background:transparent;border:0;border-bottom:2px solid transparent;color:var(--ui-text-secondary);padding:10px 0} .hermes-rss .rss-tabs button[aria-selected=true]{border-bottom-color:var(--ui-accent);color:var(--ui-text-primary,var(--foreground))} -.hermes-rss .rss-body{white-space:pre-wrap;font-size:14px;line-height:1.85;overflow-wrap:anywhere} .hermes-rss .rss-empty{padding:48px 24px;text-align:center;max-width:450px;margin:auto}.hermes-rss .rss-empty-mark{font-size:32px;color:var(--ui-accent);margin-bottom:20px} .hermes-rss .rss-empty h2{font-size:19px}.hermes-rss .rss-empty p{color:var(--ui-text-secondary);margin:10px 0 18px} .hermes-rss .rss-notice{margin:0;padding:10px 24px;border-bottom:1px solid var(--ui-stroke-secondary);background:color-mix(in srgb,var(--ui-accent) 6%,transparent);font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px} @@ -803,8 +1104,10 @@ var styles = ` .hermes-rss .rss-feed-header-error{margin-top:8px;color:var(--ui-danger,var(--ui-text-secondary))} .hermes-rss .rss-settings{padding:18px 28px;border-bottom:1px solid var(--ui-stroke-secondary);display:grid;gap:16px}.hermes-rss .rss-settings h2{font-size:16px;margin:0}.hermes-rss .rss-setting{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.hermes-rss .rss-setting input[type=number]{width:90px}.hermes-rss .rss-setting input[type=checkbox]{accent-color:var(--ui-accent)} .hermes-rss .rss-settings-library{display:grid;gap:12px;padding-top:14px;border-top:1px solid var(--ui-stroke-secondary)} -.hermes-rss .rss-filter-panel{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:18px 28px;border-bottom:1px solid var(--ui-stroke-secondary);max-height:45vh;overflow:auto;flex-shrink:0} -.hermes-rss .rss-filter-column{display:grid;align-content:start;gap:10px;min-width:0}.hermes-rss .rss-filter-column h2{font-size:16px;margin:0}.hermes-rss .rss-filter-column input{min-width:0;max-width:100%} +.hermes-rss .rss-filter-panel{display:grid;grid-template-columns:1fr 1fr;gap:14px 24px;padding:12px 20px;border-bottom:1px solid var(--ui-stroke-secondary);max-height:45vh;overflow:auto;flex-shrink:0} +.hermes-rss .rss-filter-column{display:grid;align-content:start;gap:8px;min-width:0}.hermes-rss .rss-filter-column h2{font-size:12px;letter-spacing:.4px;text-transform:uppercase;color:var(--ui-text-tertiary);margin:0}.hermes-rss .rss-filter-column input,.hermes-rss .rss-filter-column select{min-width:0;max-width:100%;padding:4px 8px;font-size:12px;height:26px} +.hermes-rss .rss-filter-panel button{padding:4px 10px;font-size:12px;height:26px;min-height:0;line-height:1.2} +.hermes-rss .rss-filter-panel .rss-small{line-height:1.45} .hermes-rss select{font:inherit;color:inherit;background:var(--ui-bg-primary,var(--background));border:1px solid var(--ui-stroke-secondary);border-radius:5px;padding:7px;max-width:100%} .hermes-rss .rss-filter-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.hermes-rss .rss-filter-chips button{max-width:100%;white-space:normal;overflow-wrap:anywhere;text-align:left} @media(max-width:760px){.hermes-rss .rss-filter-panel{grid-template-columns:1fr}} @@ -815,7 +1118,7 @@ var styles = ` // src/plugin.jsx import { Fragment, jsx, jsxs } from "react/jsx-runtime"; -var ID = "hermes-rss"; +var ID = "hermes-rss-reader"; var labels = { supported: "Supported by retrieved evidence", conflicting: "Conflicting evidence", @@ -859,7 +1162,7 @@ function Reader({ ctx }) { function ReaderProfile({ ctx, owner }) { const inFlight = useRef(false); const library = useMemo( - () => createLibrary(owner, (url2) => fetchFeed(host, url2)), + () => createLibrary(owner, (url2) => fetchFeed(host, url2), transact, (pageUrl) => captureArticle(host, pageUrl)), [owner] ); const libraryRequest = async (...args) => { @@ -897,6 +1200,11 @@ function ReaderProfile({ ctx, owner }) { const [settings, setSettings] = useState(() => readSettings(ctx, owner)); const [draft, setDraft] = useState(() => readSettings(ctx, owner)); const [feedToRemove, setFeedToRemove] = useState(null); + const [reorderMode, setReorderMode] = useState(false); + const [dragOrder, setDragOrder] = useState(null); + const [draggingId, setDraggingId] = useState(null); + const dragOrderRef = useRef(null); + const dragFeedId = useRef(null); const confirmation = useRef(null); useEffect(() => { if (feedToRemove) confirmation.current?.focus(); }, [feedToRemove]); useEffect(() => { @@ -1004,7 +1312,9 @@ function ReaderProfile({ ctx, owner }) { }; const refreshFeeds = async () => { const result = await refreshSubscriptions(libraryRequest, { - feedId, shouldContinue: () => currentOwner(host) === owner + feedId, + shouldContinue: () => currentOwner(host) === owner, + captureFeedIds: readSettings(ctx, owner).fullCapture ? null : [] }); if (!feedId) ctx.storage.set(`lastRefresh:${owner}`, Date.now()); setNotice(`${result.added} new articles${result.failed ? ` · ${result.failed} feeds could not refresh. Select a feed for details.` : " · Up to date."}`); @@ -1013,6 +1323,53 @@ function ReaderProfile({ ctx, owner }) { const result = await libraryRequest("/articles/read-all", { method: "POST", body: { feed_id: feedId } }); setNotice(`${result.count} article${result.count === 1 ? "" : "s"} marked as read.`); }); + const captureOpen = () => { + const target = article; + if (!target?.url) return; + void act("Capturing full article\u2026", async () => { + const fullBody = await captureArticle(host, target.url); + if (!fullBody || fullBody.length <= target.body.length) return; + await libraryRequest(`/articles/${target.id}/capture`, { method: "POST", body: { body: fullBody } }); + }); + }; + const articleList = articles.data || []; + const selectedIndex = selected ? articleList.findIndex(a => a.id === selected) : -1; + useEffect(() => { + const onKey = (event) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) return; + const list = articleList; + if (!list.length) return; + if (event.key === "j" || event.key === "k") { + const step = event.key === "j" ? 1 : -1; + let next = selectedIndex < 0 ? (step > 0 ? 0 : list.length - 1) : Math.min(list.length - 1, Math.max(0, selectedIndex + step)); + const item = list[next]; + if (item) { + event.preventDefault(); + openArticle(item); + requestAnimationFrame(() => { + const scroller = document.querySelector(".hermes-rss .rss-list-items"); + const card = scroller?.querySelector(`.rss-card[aria-selected="true"]`); + if (scroller && card) { + const top = card.offsetTop - scroller.clientHeight / 2 + card.clientHeight / 2; + scroller.scrollTo({ top, behavior: "smooth" }); + } + }); + } + } else if (event.key === "s" && article) { + event.preventDefault(); + act("Saving…", () => libraryRequest(`/articles/${article.id}`, { + method: "PATCH", body: { is_saved: !article.is_saved } + })); + } else if (event.key === "d" && article && !disabled) { + event.preventDefault(); + start("discuss"); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); const unsubscribe = () => act("Unsubscribing…", async () => { const removed = feedToRemove; await libraryRequest(`/feeds/${removed.id}`, { method: "DELETE" }); @@ -1021,6 +1378,38 @@ function ReaderProfile({ ctx, owner }) { setFeedToRemove(null); setNotice(`Unsubscribed from ${removed.title}. Saved articles and chats were kept.`); }); + const displayedFeeds = (feeds.data || []).map(feed => ({ + feed, + index: dragOrder ? dragOrder.indexOf(feed.id) : (feeds.data || []).indexOf(feed) + })).sort((a, b) => a.index - b.index).map(entry => entry.feed); + const handleDragStart = feed => event => { + dragFeedId.current = feed.id; + setDraggingId(feed.id); + dragOrderRef.current = displayedFeeds.map(f => f.id); + event.dataTransfer.effectAllowed = "move"; + try { event.dataTransfer.setData("text/plain", feed.id); } catch {} + }; + const handleDragOver = () => event => { + if (!dragFeedId.current) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }; + const handleDrop = feed => event => { + event.preventDefault(); + const dragged = dragFeedId.current; + if (!dragged || dragged === feed.id) { setDraggingId(null); dragFeedId.current = null; setDragOrder(null); dragOrderRef.current = null; return; } + const order = (dragOrderRef.current || displayedFeeds.map(f => f.id)).filter(id => id !== dragged); + order.splice(displayedFeeds.findIndex(f => f.id === feed.id), 0, dragged); + setDragOrder(order); + setDraggingId(null); + dragFeedId.current = null; + dragOrderRef.current = null; + act("Reordering\u2026", async () => { + await libraryRequest("/feeds/reorder", { method: "POST", body: { order } }); + refresh(); + setDragOrder(null); + }); + }; const saveSettings = event => { event.preventDefault(); const minutes = Number(draft.refreshMinutes); @@ -1126,11 +1515,9 @@ function ReaderProfile({ ctx, owner }) { return /* @__PURE__ */ jsxs("section", { className: "hermes-rss", "aria-label": "RSS reader", children: [ /* @__PURE__ */ jsx("style", { children: styles }), /* @__PURE__ */ jsxs("header", { className: "rss-top", children: [ - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("div", { className: "rss-eyebrow", children: "Your sources. Your perspective." }), - /* @__PURE__ */ jsx("h1", { children: "RSS" }), - /* @__PURE__ */ jsx("p", { className: "rss-muted", children: "A little less noise. A little more understanding." }) - ] }), + /* @__PURE__ */ jsx("div", { children: + /* @__PURE__ */ jsx("h1", { children: "RSS" }) + }), /* @__PURE__ */ jsxs("div", { className: "rss-tools", children: [ /* @__PURE__ */ jsx( Button, @@ -1186,21 +1573,36 @@ function ReaderProfile({ ctx, owner }) { ] }), settingsOpen && jsxs("div", { className: "rss-settings", children: [ jsxs("form", { className: "rss-stack", "aria-label": "Reader settings", onSubmit: saveSettings, children: [ - jsx("h2", { children: "Reader settings" }), - jsxs("label", { className: "rss-setting", children: [ - jsx("input", { type: "checkbox", checked: draft.autoRefresh, disabled: typeof ctx.onDispose !== "function", onChange: event => setDraft({ ...draft, autoRefresh: event.target.checked }) }), - "Automatically refresh feeds" + jsx("h2", { className: "rss-settings-header", children: "Refreshing" }), + jsxs("div", { className: "rss-setting-row", children: [ + jsx("label", { className: "rss-setting", children: [ + jsx("input", { type: "checkbox", checked: draft.autoRefresh, disabled: typeof ctx.onDispose !== "function", onChange: event => setDraft({ ...draft, autoRefresh: event.target.checked }) }), + "Automatically refresh feeds" + ] }), + jsxs("label", { className: "rss-setting rss-setting-inline", children: [ + "Every", + jsx(Input, { type: "number", min: 1, max: 1440, step: 1, required: true, "aria-label": "Refresh interval in minutes", value: draft.refreshMinutes, onChange: event => setDraft({ ...draft, refreshMinutes: event.target.value }) }), + "minutes" + ] }) ] }), - jsxs("label", { className: "rss-setting", children: ["Every", jsx(Input, { type: "number", min: 1, max: 1440, step: 1, required: true, "aria-label": "Refresh interval in minutes", value: draft.refreshMinutes, onChange: event => setDraft({ ...draft, refreshMinutes: event.target.value }) }), "minutes"] }), jsx("p", { className: "rss-muted rss-small", children: typeof ctx.onDispose === "function" ? "Refreshes the active profile while Hermes is open, even outside RSS. No AI calls run automatically. Settings apply to this profile." : "This Hermes version needs an SDK update for background refresh. Manual refresh still works." }), - jsxs("label", { className: "rss-setting", children: [ + jsx("h2", { className: "rss-settings-header", children: "Reading" }), + jsx("label", { className: "rss-setting", children: [ jsx("input", { type: "checkbox", checked: draft.markReadOnOpen, onChange: event => setDraft({ ...draft, markReadOnOpen: event.target.checked }) }), "Mark articles as read when opened" ] }), - jsxs("div", { className: "rss-tools", children: [jsx(Button, { type: "submit", children: "Save settings" }), jsx(Button, { type: "button", variant: "ghost", onClick: () => setSettingsOpen(false), children: "Cancel" })] }) + jsx("h2", { className: "rss-settings-header", children: "Capturing" }), + jsxs("div", { className: "rss-setting-row", children: [ + jsx("label", { className: "rss-setting", children: [ + jsx("input", { type: "checkbox", checked: draft.fullCapture, onChange: event => setDraft({ ...draft, fullCapture: event.target.checked }) }), + "Capture full articles on refresh" + ] }), + ] }), + jsx("p", { className: "rss-muted rss-small", children: "When on, every new article is fetched from its website and the feed excerpt is replaced with the full text (up to 10 per refresh). Paywalled and script-only pages keep the excerpt. You can always load the full text of the open article from its action row." }), + jsx("div", { className: "rss-tools", children: [jsx(Button, { type: "submit", children: "Save settings" }), jsx(Button, { type: "button", variant: "ghost", onClick: () => setSettingsOpen(false), children: "Cancel" })] }) ] }), jsxs("div", { className: "rss-settings-library", "aria-label": "Library", children: [ - jsx("h2", { children: "Library" }), + jsx("h2", { className: "rss-settings-header", children: "Library" }), jsx("p", { className: "rss-muted rss-small", children: "Import or export subscriptions as OPML. This does not change refresh settings." }), jsxs("div", { className: "rss-tools", children: [ jsx(Button, { type: "button", disabled, onClick: chooseFile, children: "Import OPML" }), @@ -1295,8 +1697,21 @@ function ReaderProfile({ ctx, owner }) { )), searches.length > 0 && jsx("div", { className: "rss-eyebrow", children: "Saved searches" }), searches.map(search => jsx("button", { onClick: () => openSearch(search), title: search.name, children: jsx("span", { className: "rss-feed-name", children: search.name }) }, search.id)), - /* @__PURE__ */ jsx("div", { className: "rss-eyebrow", children: "Subscriptions" }), - (feeds.data || []).map(feed => jsxs("div", { className: "rss-feed-row", children: [ + /* @__PURE__ */ jsxs("div", { className: "rss-nav-heading", children: [ + /* @__PURE__ */ jsx("div", { className: "rss-eyebrow", children: "Subscriptions" }), + jsx("button", { className: "rss-edit-toggle", "aria-pressed": reorderMode, "aria-label": reorderMode ? "Exit edit mode" : "Edit subscriptions", title: reorderMode ? "Exit edit mode" : "Edit subscriptions", onClick: () => setReorderMode(!reorderMode), children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-pencil", "aria-hidden": "true" }) }) + ] }), + (displayedFeeds || []).map((feed) => jsxs("div", { + className: `rss-feed-row${reorderMode ? " rss-feed-row-editing" : ""}${draggingId === feed.id ? " rss-feed-row-dragging" : ""}`, + draggable: reorderMode, + onDragStart: reorderMode ? handleDragStart(feed) : undefined, + onDragOver: reorderMode && draggingId ? handleDragOver() : undefined, + onDrop: reorderMode && draggingId ? handleDrop(feed) : undefined, + onDragEnd: () => { setDraggingId(null); dragFeedId.current = null; }, + children: [ + reorderMode && jsx("span", { className: "rss-feed-edit", children: + jsx("button", { className: "rss-grip", disabled: true, "aria-hidden": "true", tabIndex: -1, title: "Drag to reorder", children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-gripper", "aria-hidden": "true" }) }) + }), jsxs("button", { className: "rss-feed-open", "aria-current": feedId === feed.id, title: `${feed.folder ? feed.folder + " / " : ""}${feed.title}`, onClick: () => selectView("all", feed.id), children: [ @@ -1306,7 +1721,7 @@ function ReaderProfile({ ctx, owner }) { ] }), jsx("span", { className: "rss-count", children: feed.unread || "" }) ] }), - jsx("button", { className: "rss-unsubscribe", disabled, title: "Unsubscribe", "aria-label": `Unsubscribe from ${feed.title}`, onClick: () => setFeedToRemove(feed), children: "×" }) + reorderMode && jsx("button", { className: "rss-unsubscribe rss-unsubscribe-edit", disabled, title: "Unsubscribe", "aria-label": `Unsubscribe from ${feed.title}`, onClick: () => setFeedToRemove(feed), children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-trash", "aria-hidden": "true" }) }) ] }, feed.id)), !feeds.data?.length && /* @__PURE__ */ jsx("p", { className: "rss-muted rss-small", style: { padding: "0 10px" }, children: "Your feeds will appear here." }) ] }), @@ -1325,7 +1740,10 @@ function ReaderProfile({ ctx, owner }) { } } ), - jsxs("div", { className: "rss-filter-chips", "aria-label": "Active filters", children: [ + /* @__PURE__ */ jsx("span", { className: "rss-list-meta", children: + /* @__PURE__ */ jsx("button", { type: "button", className: "rss-mark-read", disabled, onClick: markAllRead, "aria-label": feedId ? "Mark feed as read" : "Mark all as read", title: (feedId ? "Mark feed as read" : "Mark all as read") + " \u00b7 includes hidden articles and articles outside the current search.", children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-check-all", "aria-hidden": "true" }) }) + }), + (query || exclude || feedId || view !== "all" || mutes.length > 0 || showHidden) && jsxs("div", { className: "rss-filter-chips", "aria-label": "Active filters", children: [ query && jsx(Button, { size: "sm", variant: "outline", "aria-label": "Clear search phrase", onClick: () => { setQuery(""); setLimit(100); }, children: `Search: ${query} ×` }), exclude && jsx(Button, { size: "sm", variant: "outline", "aria-label": "Clear excluded phrase", onClick: () => { setExclude(""); setLimit(100); }, children: `Exclude: ${exclude} ×` }), feedId && jsx(Button, { size: "sm", variant: "outline", "aria-label": "Clear feed filter", onClick: () => selectView(view), children: `${chosenFeed?.title || "Removed feed"} ×` }), @@ -1336,13 +1754,6 @@ function ReaderProfile({ ctx, owner }) { ] }), (query || exclude || feedId || view !== "all" || showHidden) && jsx(Button, { size: "sm", variant: "ghost", onClick: resetFilters, children: "Reset filters" }) ] }), - /* @__PURE__ */ jsxs("p", { className: "rss-muted rss-small", style: { marginTop: 10 }, children: [ - chosenFeed?.title || (view === "saved" ? "Saved for later" : view === "unread" ? "Unread articles" : "All articles"), - " ", - "\xB7 ", - articles.data?.length || 0 - ] }), - jsx(Button, { variant: "ghost", size: "sm", disabled: disabled || !(feeds.data || []).some(f => (!feedId || f.id === feedId) && f.unread > 0) && !articles.data?.some(a => !a.is_read), onClick: markAllRead, title: "Includes hidden articles and articles outside the current search.", children: feedId ? "Mark feed as read" : "Mark all as read" }), chosenFeed?.error && /* @__PURE__ */ jsx("p", { role: "status", className: "rss-small rss-feed-header-error", children: chosenFeed.error }) ] }), /* @__PURE__ */ jsxs("div", { className: "rss-list-items", children: [ @@ -1391,102 +1802,147 @@ function ReaderProfile({ ctx, owner }) { articles.data?.length === limit && limit < 500 && /* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: () => setLimit(limit + 100), children: "Load more" }) ] }) ] }), - /* @__PURE__ */ jsx("main", { className: "rss-detail", children: !selected ? /* @__PURE__ */ jsxs(Empty, { title: "Follow your curiosity", children: [ - /* @__PURE__ */ jsx("p", { children: "Pick an article to read, unpack its ideas with Hermes, or look for evidence beyond the headline." }), - /* @__PURE__ */ jsx("div", { className: "rss-note", children: "AI runs only when you ask. Feed refresh uses standard network utilities on the connected gateway. Selected text goes to your configured model. Source checks use your Hermes web tools." }) - ] }) : detail.isPending ? /* @__PURE__ */ jsx(Empty, { title: "Opening article\u2026" }) : detail.error ? /* @__PURE__ */ jsxs(Empty, { title: "Article unavailable", children: [ - /* @__PURE__ */ jsx("p", { children: "It may have been removed." }), - /* @__PURE__ */ jsx(Button, { onClick: () => setSelected(null), children: "Back to articles" }) - ] }) : article && /* @__PURE__ */ jsxs(Fragment, { children: [ - /* @__PURE__ */ jsx( - Button, - { - variant: "ghost", - size: "sm", - onClick: () => setSelected(null), - children: "\u2190 Articles" - } - ), - /* @__PURE__ */ jsxs("div", { className: "rss-eyebrow", style: { marginTop: 24 }, children: [ + /* @__PURE__ */ jsx("main", { className: "rss-detail", children: + !selected ? /* @__PURE__ */ jsx("div", { className: "rss-detail-inner", children: /* @__PURE__ */ jsxs(Empty, { title: "Follow your curiosity", children: [ + /* @__PURE__ */ jsx("p", { children: "Pick an article to read, unpack its ideas with Hermes, or look for evidence beyond the headline." }), + /* @__PURE__ */ jsx("div", { className: "rss-note", children: "AI runs only when you ask. Feed refresh uses standard network utilities on the connected gateway. Selected text goes to your configured model. Source checks use your Hermes web tools." }) + ] }) }) : + detail.isPending ? /* @__PURE__ */ jsx("div", { className: "rss-detail-inner", children: /* @__PURE__ */ jsx(Empty, { title: "Opening article\u2026" }) }) : + detail.error ? /* @__PURE__ */ jsx("div", { className: "rss-detail-inner", children: /* @__PURE__ */ jsxs(Empty, { title: "Article unavailable", children: [ + /* @__PURE__ */ jsx("p", { children: "It may have been removed." }), + /* @__PURE__ */ jsx(Button, { onClick: () => setSelected(null), children: "Back to articles" }) + ] }) }) : + article && /* @__PURE__ */ jsx("div", { className: "rss-detail-inner", children: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "rss-eyebrow", style: { marginTop: 8 }, children: [ article.feed_title, " \xB7 ", date(article.published_at) ] }), - /* @__PURE__ */ jsx("h2", { children: article.title }), - /* @__PURE__ */ jsxs("div", { className: "rss-tools", children: [ - /* @__PURE__ */ jsx("span", { className: "rss-chip", children: "Feed excerpt" }), - /* @__PURE__ */ jsx( - Button, - { - size: "sm", - variant: "ghost", - disabled: !article.url, - onClick: () => act("Opening\u2026", async () => { - if (!await ctx.os.openExternal(article.url)) - throw new Error( - "Could not open the original article." - ); - }), - children: "Open original \u2197" - } - ), - /* @__PURE__ */ jsx( - Button, - { - size: "sm", - variant: "ghost", - disabled: disabled || !article.url, - onClick: () => act("Copying\u2026", async () => { - await navigator.clipboard.writeText(article.url); - setNotice("Article link copied."); - }), - children: "Copy link" - } - ), + /* @__PURE__ */ jsx("h2", { role: article.url ? "link" : undefined, style: article.url ? { cursor: "pointer" } : undefined, title: article.url ? "Open original" : undefined, onClick: article.url ? () => act("Opening\u2026", async () => { + if (!await ctx.os.openExternal(article.url)) + throw new Error( + "Could not open the original article." + ); + }) : undefined, children: article.title }), + article.captured && jsx("span", { className: "rss-chip", style: { marginTop: 8, display: "inline-flex" }, children: "Full text" }), + /* @__PURE__ */ jsxs("div", { className: "rss-tools rss-article-actions", children: [ + /* @__PURE__ */ jsxs("div", { className: "rss-icon-row", children: [ + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled: !article.url, + "aria-label": "Open original", + title: "Open original", + onClick: () => act("Opening\u2026", async () => { + if (!await ctx.os.openExternal(article.url)) + throw new Error( + "Could not open the original article." + ); + }), + children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-link-external", "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled: disabled || !article.url, + "aria-label": "Copy link", + title: "Copy link", + onClick: () => act("Copying\u2026", async () => { + await navigator.clipboard.writeText(article.url); + setNotice("Article link copied."); + }), + children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-copy", "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled, + "aria-label": article.is_saved ? "Saved" : "Save", + title: article.is_saved ? "Saved" : "Save", + onClick: () => act( + "Saving\u2026", + () => libraryRequest(`/articles/${article.id}`, { + method: "PATCH", + body: { is_saved: !article.is_saved } + }) + ), + children: /* @__PURE__ */ jsx("i", { className: `codicon ${article.is_saved ? "codicon-save" : "codicon-save-as"}`, "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled, + "aria-label": article.is_read ? "Mark unread" : "Mark read", + title: article.is_read ? "Mark unread" : "Mark read", + onClick: () => act( + "Updating\u2026", + () => libraryRequest(`/articles/${article.id}`, { + method: "PATCH", + body: { is_read: !article.is_read } + }) + ), + children: /* @__PURE__ */ jsx("i", { className: `codicon ${article.is_read ? "codicon-eye-closed" : "codicon-mail-read"}`, "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled: disabled || !article.url, + "aria-label": article.captured ? "Recapture full article" : "Load full article", + title: article.captured ? "Recapture full article" : "Load full article from the original page", + onClick: captureOpen, + children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-cloud-download", "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "rss-icon-btn", + disabled, + "aria-label": "Check sources", + title: "Check sources", + onClick: () => start("check"), + children: /* @__PURE__ */ jsx("i", { className: "codicon codicon-shield", "aria-hidden": "true" }) + } + ) + ] }), /* @__PURE__ */ jsx( - Button, + "div", { - size: "sm", - variant: "ghost", - disabled, - onClick: () => act( - "Saving\u2026", - () => libraryRequest(`/articles/${article.id}`, { - method: "PATCH", - body: { is_saved: !article.is_saved } - }) - ), - children: article.is_saved ? "\u2605 Saved" : "\u2606 Save" + className: "rss-tabs rss-tabs-pills", + role: "tablist", + "aria-label": "Article content", + children: [ + ["article", "Article"], + ["summary", "Summary"], + ["evidence", "Evidence"] + ].map(([id, label]) => /* @__PURE__ */ jsx( + "button", + { + role: "tab", + "aria-selected": tab === id, + onClick: () => setTab(id), + children: label + }, + id + )) } ), - /* @__PURE__ */ jsx( - Button, - { - size: "sm", - variant: "ghost", - disabled, - onClick: () => act( - "Updating\u2026", - () => libraryRequest(`/articles/${article.id}`, { - method: "PATCH", - body: { is_read: !article.is_read } - }) - ), - children: article.is_read ? "Mark unread" : "Mark read" - } - ) - ] }), - /* @__PURE__ */ jsxs("div", { className: "rss-tools", children: [ - /* @__PURE__ */ jsx(Button, { disabled, onClick: () => start("discuss"), children: "Discuss with Hermes \u2197" }), - /* @__PURE__ */ jsx( - Button, - { - variant: "outline", - disabled, - onClick: () => start("check"), - children: "Check sources" - } - ) + /* @__PURE__ */ jsx(Button, { disabled, onClick: () => start("discuss"), children: "Discuss \u2197" }) ] }), latestChat && /* @__PURE__ */ jsx( Button, @@ -1501,32 +1957,13 @@ function ReaderProfile({ ctx, owner }) { children: "Continue last conversation \u2197" } ), - /* @__PURE__ */ jsx( - "div", - { - className: "rss-tabs", - role: "tablist", - "aria-label": "Article content", - children: [ - ["article", "Article"], - ["summary", "Summary"], - ["evidence", "Evidence"] - ].map(([id, label]) => /* @__PURE__ */ jsx( - "button", - { - role: "tab", - "aria-selected": tab === id, - onClick: () => setTab(id), - children: label - }, - id - )) - } - ), - tab === "article" && /* @__PURE__ */ jsxs("div", { role: "tabpanel", children: [ - /* @__PURE__ */ jsx("p", { className: "rss-body", children: article.body || "This feed contains only a headline. Open the original article to read more." }), - /* @__PURE__ */ jsx("div", { className: "rss-note", children: "This is the text supplied by the feed. It may be an excerpt. Embedded scripts and remote images are not loaded." }) - ] }), + tab === "article" && (() => { + const rich = bodyToRichHtml(article.body || ""); + return /* @__PURE__ */ jsxs("div", { role: "tabpanel", children: [ + rich.html ? /* @__PURE__ */ jsx("div", { className: "rss-body rss-rich", dangerouslySetInnerHTML: { __html: rich.html } }) : /* @__PURE__ */ jsx("p", { className: "rss-body", children: "This feed contains only a headline. Open the original article to read more." }), + /* @__PURE__ */ jsx("div", { className: "rss-note", children: rich.isHtml ? "Rendered from the feed's own HTML. Scripts are stripped and only https links and images survive sanitizing." : "This is the text supplied by the feed. It may be an excerpt. Embedded scripts and remote images are not loaded." }) + ] }); + })(), tab === "summary" && /* @__PURE__ */ jsxs("div", { role: "tabpanel", children: [ summary ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("div", { className: "rss-eyebrow", children: "The short version" }), @@ -1620,14 +2057,15 @@ function ReaderProfile({ ctx, owner }) { } ) ] }) }) - ] }) }) - ] }), - + ] }) }) }) + ] }) ] }); } var plugin_default = { id: ID, - name: "RSS", + name: "RSS Reader", + description: "RSS reader with reader-mode capture, edit-mode subscriptions, and keyboard shortcuts.", + version: "1.0.0", defaultEnabled: true, register(ctx) { if (typeof ctx.onDispose === "function") ctx.onDispose(startAutoRefresh(ctx, host));