8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
build-and-push / docker (push) Has been cancelled

- Full article extraction via trafilatura (fetch_full_article)
- Digest mode with configurable period (digest_enabled, digest_period_hours)
- ntfy Actions buttons (Open article, Open feed)
- Notification templates with {title}, {body}, {link}, {source}, {image_url}
- FTS5 full-text search in notification history
- Database backup/restore (download/upload .db)
- HTTP/SOCKS proxy for RSS feed fetching (proxy_url setting)
- Built-in RSS reader tab with categories, unread counts, article detail view
- Auto-category 'Общее' for feeds without a category
- Article storage (Article table) for reader
- DigestEntry model for pending digest entries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dimon
2026-06-03 20:47:46 +08:00
parent f8d2c31658
commit 834092a3ec
13 changed files with 1414 additions and 44 deletions
+247 -8
View File
@@ -4,6 +4,8 @@ const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
let ME = { role: "admin", auth_enabled: false };
let SETTINGS = {};
let CATEGORIES = [];
// ---------- API helper ----------
async function api(method, url, body) {
@@ -105,6 +107,87 @@ async function loadActivity() {
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
}
// ---------- Categories ----------
async function loadCategories() {
try { CATEGORIES = await api("GET", "/api/categories"); } catch (e) { CATEGORIES = []; }
var list = document.getElementById("categories-list");
if (!list) return;
list.innerHTML = "";
var empty = document.getElementById("categories-empty");
if (empty) empty.classList.toggle("hidden", CATEGORIES.length > 0);
CATEGORIES.forEach(function(c) { list.appendChild(categoryCard(c)); });
}
function categoryCard(c) {
var el = document.createElement("div");
el.className = "feed-card";
el.innerHTML = '<div class="feed-top"><span class="dot on"></span>' +
'<div style="flex:1">' +
'<div class="feed-title">' + escapeHtml(c.name) + '</div>' +
'<div class="feed-meta"><span class="chip">' + t("cat.sortOrder") + ': ' + c.sort_order + '</span></div>' +
'</div>' +
'<div class="feed-actions">' +
'<button class="btn ghost small" data-act="edit">✎</button>' +
'<button class="btn danger small" data-act="del">🗑</button>' +
'</div></div>';
el.querySelector('[data-act="edit"]').onclick = function() { openCatModal(c); };
el.querySelector('[data-act="del"]').onclick = function() { deleteCategory(c); };
return el;
}
var catModal = document.getElementById("cat-modal");
var catForm = document.getElementById("cat-form");
function openCatModal(cat) {
catForm.reset();
document.getElementById("cat-modal-title").textContent = cat ? t("cat.editTitle") : t("cat.addTitle");
catForm.id.value = cat ? (cat.id || "") : "";
catForm.name.value = cat ? (cat.name || "") : "";
catForm.sort_order.value = (cat && cat.sort_order != null) ? cat.sort_order : 0;
catModal.classList.remove("hidden");
}
function closeCatModal() { catModal.classList.add("hidden"); }
document.getElementById("add-category").onclick = function() { openCatModal(null); };
document.getElementById("cat-modal-close").onclick = closeCatModal;
document.getElementById("cat-modal-cancel").onclick = closeCatModal;
catModal.addEventListener("click", function(e) { if (e.target === catModal) closeCatModal(); });
catForm.addEventListener("submit", async function(e) {
e.preventDefault();
var payload = {
name: catForm.name.value.trim(),
sort_order: parseInt(catForm.sort_order.value, 10) || 0,
};
var id = catForm.id.value;
try {
if (id) await api("PUT", "/api/categories/" + id, payload);
else await api("POST", "/api/categories", payload);
toast(id ? t("toast.categoryUpdated") : t("toast.categoryAdded"));
closeCatModal();
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
});
async function deleteCategory(c) {
var feedCount = 0;
try {
var feeds = await api("GET", "/api/feeds");
feedCount = feeds.filter(function(f) { return f.category_id === c.id; }).length;
} catch (e) {}
var msg = feedCount
? t("confirm.deleteCategoryFeeds", { name: c.name, n: feedCount })
: t("confirm.deleteCategory", { name: c.name });
if (!confirm(msg)) return;
try {
await api("DELETE", "/api/categories/" + c.id);
toast(t("toast.categoryDeleted"));
await loadCategories();
loadFeeds().catch(function() {});
} catch (err) { toast(err.message, "err"); }
}
// ---------- Feeds ----------
function feedCard(f) {
const el = document.createElement("div");
@@ -119,6 +202,11 @@ function feedCard(f) {
if (f.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</span>`);
if (f.tags) chips.push(`<span class="chip">🏷️ ${escapeHtml(f.tags)}</span>`);
if (f.send_full_content) chips.push('<span class="chip full">📄 full</span>');
if (f.fetch_full_article) chips.push('<span class="chip full">📄 trafilatura</span>');
if (f.digest_enabled) chips.push('<span class="chip">📬 digest ' + f.digest_period_hours + 'h</span>');
var cat = CATEGORIES.find(function(c) { return c.id === f.category_id; });
if (cat) chips.push('<span class="chip cat">🏷️ ' + escapeHtml(cat.name) + '</span>');
const admin = ME.role === "admin";
el.innerHTML = `
@@ -185,12 +273,28 @@ function openModal(feed) {
$("#preview-area").innerHTML = "";
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
feedForm.id.value = feed?.id || "";
const f = feed || { attach_image: true, enabled: true, priority: 3, interval: 0 };
const defaults = {
attach_image: SETTINGS.default_attach_image ?? true,
enabled: true,
priority: SETTINGS.default_priority ?? 3,
interval: SETTINGS.default_interval ?? 0,
tags: SETTINGS.default_tags ?? "",
};
const f = feed || defaults;
for (const el of feedForm.elements) {
if (!el.name || el.name === "id") continue;
if (el.type === "checkbox") el.checked = !!f[el.name];
else if (f[el.name] !== undefined) el.value = f[el.name];
}
// Populate category dropdown
const sel = feedForm.category_id;
if (sel) {
sel.innerHTML = '<option value="">' + t("feed.categoryNone") + '</option>';
CATEGORIES.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + escapeHtml(c.name) + '</option>';
});
if (feed && feed.category_id) sel.value = feed.category_id;
}
modal.classList.remove("hidden");
}
function closeModal() { modal.classList.add("hidden"); }
@@ -240,9 +344,14 @@ feedForm.addEventListener("submit", async e => {
filter_include: feedForm.filter_include.value.trim(),
filter_exclude: feedForm.filter_exclude.value.trim(),
attach_image: feedForm.attach_image.checked,
send_full_content: feedForm.send_full_content.checked,
fetch_full_article: feedForm.fetch_full_article.checked,
digest_enabled: feedForm.digest_enabled.checked,
digest_period_hours: parseInt(feedForm.digest_period_hours.value, 10) || 24,
to_telegram: feedForm.to_telegram.checked,
to_webhook: feedForm.to_webhook.checked,
enabled: feedForm.enabled.checked,
category_id: feedForm.category_id.value || null,
};
const id = feedForm.id.value;
try {
@@ -267,6 +376,25 @@ $("#check-all").onclick = async (e) => {
};
// ---------- OPML ----------
// --- Backup / Restore ---
$("#backup-btn").onclick = function() { location.href = "/api/backup"; };
$("#restore-btn").onclick = function() { document.getElementById("restore-file").click(); };
$("#restore-file").onchange = async function(e) {
var file = e.target.files[0];
if (!file) return;
if (!confirm(t("confirm.restore"))) { e.target.value = ""; return; }
var fd = new FormData();
fd.append("file", file);
try {
var res = await fetch("/api/backup", { method: "POST", body: fd });
var data = await res.json();
if (!res.ok) throw new Error(data.detail || "Error");
toast(t("toast.restored"));
setTimeout(function() { location.reload(); }, 1500);
} catch (err) { toast(err.message, "err"); }
finally { e.target.value = ""; }
};
$("#export-btn").onclick = () => { location.href = "/api/feeds/export"; };
$("#import-btn").onclick = () => $("#opml-file").click();
$("#opml-file").onchange = async (e) => {
@@ -398,11 +526,11 @@ userForm.addEventListener("submit", async e => {
const sForm = $("#settings-form");
async function loadSettings() {
const s = await api("GET", "/api/settings");
SETTINGS = await api("GET", "/api/settings");
for (const el of sForm.elements) {
if (!el.name) continue;
if (el.type === "checkbox") el.checked = !!s[el.name];
else if (s[el.name] !== undefined) el.value = s[el.name];
if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
else if (SETTINGS[el.name] !== undefined) el.value = SETTINGS[el.name];
}
}
@@ -423,6 +551,12 @@ sForm.addEventListener("submit", async e => {
alerts_enabled: sForm.alerts_enabled.checked,
alert_topic: sForm.alert_topic.value.trim(),
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3,
default_priority: parseInt(sForm.default_priority.value, 10) || 3,
default_tags: sForm.default_tags.value.trim(),
default_attach_image: sForm.default_attach_image.checked,
default_interval: parseInt(sForm.default_interval.value, 10) || 0,
notification_template: sForm.notification_template.value,
proxy_url: sForm.proxy_url.value.trim(),
};
try {
await api("PUT", "/api/settings", payload);
@@ -452,6 +586,8 @@ $$(".tab").forEach(tab => tab.addEventListener("click", () => {
tab.classList.add("active");
$(`#tab-${tab.dataset.tab}`).classList.add("active");
if (tab.dataset.tab === "history") loadHistory().catch(() => {});
if (tab.dataset.tab === "reader") loadReader().catch(() => {});
if (tab.dataset.tab === "categories") loadCategories().catch(() => {});
if (tab.dataset.tab === "users") loadUsers().catch(() => {});
}));
@@ -467,6 +603,8 @@ langSelect.onchange = () => {
// Re-render dynamic content in the new language.
loadFeeds().catch(() => {});
if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {});
if ($("#tab-reader").classList.contains("active")) loadReader().catch(() => {});
if ($("#tab-categories").classList.contains("active")) loadCategories().catch(() => {});
if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {});
};
@@ -476,13 +614,114 @@ function renderWhoami() {
}
}
// ---------- Reader ----------
var READER = { cats: [], selectedCat: "all" };
async function loadReaderStats() {
try { READER.cats = await api("GET", "/api/articles/stats"); } catch (e) { READER.cats = []; }
var list = document.getElementById("reader-cat-list");
if (!list) return;
list.innerHTML = '<div class="reader-cat active" data-cat="all"><span>' + t("reader.all") + '</span></div>';
var totalUnread = 0;
READER.cats.forEach(function(c) {
totalUnread += c.unread;
list.innerHTML += '<div class="reader-cat" data-cat="' + c.category_id + '">' +
'<span>' + escapeHtml(c.category_name) + '</span>' +
(c.unread ? '<span class="badge">' + c.unread + '</span>' : '') +
'</div>';
});
var allEl = list.querySelector('[data-cat="all"]');
if (allEl && totalUnread) allEl.innerHTML = '<span>' + t("reader.all") + '</span><span class="badge">' + totalUnread + '</span>';
var cats = list.querySelectorAll('.reader-cat');
cats.forEach(function(el) {
el.onclick = function() {
cats.forEach(function(x) { x.classList.remove("active"); });
el.classList.add("active");
READER.selectedCat = el.dataset.cat;
loadReaderArticles();
};
});
}
async function loadReaderArticles() {
var url = "/api/articles?limit=100";
if (READER.selectedCat !== "all") url += "&category_id=" + READER.selectedCat;
var articles;
try { articles = await api("GET", url); } catch (e) { return; }
var list = document.getElementById("reader-list");
var empty = document.getElementById("reader-empty");
list.innerHTML = "";
if (empty) empty.classList.toggle("hidden", articles.length > 0);
articles.forEach(function(a) {
var el = document.createElement("div");
el.className = "article-row" + (a.is_read ? "" : " unread");
var imgHtml = a.image ? '<img src="' + escapeHtml(a.image) + '" alt="" loading="lazy" style="max-width:80px;max-height:60px;border-radius:6px;flex-shrink:0">' : '';
el.innerHTML = '<span class="article-dot">' + (a.is_read ? '○' : '●') + '</span>' +
'<div class="article-content">' +
'<div class="article-title">' + escapeHtml(a.title) + '</div>' +
'<div class="article-meta">' +
'<span class="chip">' + escapeHtml(a.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(a.created_at) + '</span>' +
'</div>' +
'<div class="article-body-preview">' + escapeHtml(a.body) + '</div>' +
'</div>' +
imgHtml;
el.onclick = async function() {
try {
var full = await api("GET", "/api/articles/" + a.id);
showArticleDetail(full);
} catch (err) { toast(err.message, "err"); }
};
list.appendChild(el);
});
}
function showArticleDetail(article) {
var detail = document.getElementById("reader-detail");
var list = document.getElementById("reader-list");
detail.classList.remove("hidden");
list.classList.add("hidden");
var body = article.full_html || escapeHtml(article.body).replace(/\n/g, '<br>');
detail.innerHTML = '<button class="btn ghost" id="reader-back">← ' + t("reader.back") + '</button>' +
'<h2>' + escapeHtml(article.title) + '</h2>' +
'<div class="article-meta" style="margin:12px 0">' +
'<span class="chip">' + escapeHtml(article.feed_title) + '</span>' +
'<span class="muted">' + fmtDate(article.created_at) + '</span>' +
(article.link ? '<a href="' + escapeHtml(article.link) + '" target="_blank" rel="noopener" class="btn ghost small">' + t("reader.open") + '</a>' : '') +
'</div>' +
'<div class="article-body">' + body + '</div>';
document.getElementById("reader-back").onclick = function() {
detail.classList.add("hidden");
list.classList.remove("hidden");
};
}
async function loadReader() {
await loadReaderStats();
await loadReaderArticles();
}
document.getElementById("reader-mark-all").onclick = async function() {
var url = "/api/articles/read-all";
if (READER.selectedCat !== "all") url += "?category_id=" + READER.selectedCat;
try {
var r = await api("PUT", url);
toast(t("toast.articlesMarked"));
loadReader();
} catch (err) { toast(err.message, "err"); }
};
// ---------- init ----------
async function init() {
applyI18n();
try { ME = await api("GET", "/api/me"); } catch (_) {}
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden"));
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); }
loadFeeds().catch(e => toast(e.message, "err"));
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err"));
if (ME.role !== "admin") $$(".admin-only").forEach(function(el) { el.classList.add("hidden"); });
if (ME.auth_enabled) { document.getElementById("logout-btn").style.display = ""; renderWhoami(); }
if (ME.role === "admin") {
loadCategories().catch(function() {});
loadSettings().catch(function(e) { toast(e.message, "err"); });
}
loadFeeds().catch(function(e) { toast(e.message, "err"); });
}
init();