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();
+108
View File
@@ -142,6 +142,60 @@ const I18N = {
"status.sendError": "Ошибка отправки: {msg}",
"status.dash": "—",
"nav.categories": "Категории",
"categories.heading": "Категории",
"categories.add": "+ Добавить категорию",
"categories.empty": "Категорий пока нет.",
"cat.addTitle": "Добавить категорию",
"cat.editTitle": "Редактировать категорию",
"cat.name": "Название *",
"cat.sortOrder": "Порядок сортировки",
"feed.category": "Категория",
"feed.categoryNone": "— без категории —",
"feed.fullContent": "Отправлять полный контент",
"feed.fullContentHint": "Весь текст, все картинки и видео. Для ntfy — Markdown.",
"settings.feedDefaults": "Значения по умолчанию для новых лент",
"confirm.deleteCategory": "Удалить категорию «{name}»?",
"confirm.deleteCategoryFeeds": "Удалить категорию «{name}»? {n} лент будут откреплены.",
"toast.categoryAdded": "Категория добавлена",
"toast.categoryUpdated": "Категория обновлена",
"toast.categoryDeleted": "Категория удалена",
"nav.reader": "Чтение",
"reader.all": "Все",
"reader.markAll": "Отметить все прочитанными",
"reader.back": "← Назад",
"reader.open": "Открыть оригинал →",
"reader.empty": "Статей пока нет. Добавьте ленты, чтобы начать читать.",
"feed.digest": "Дайджест",
"feed.digestEnable": "Накапливать записи (дайджест)",
"feed.digestPeriod": "Период дайджеста (часы)",
"feed.fetchArticle": "Загружать полную статью (trafilatura)",
"feed.fetchArticleHint": "Загружает страницу статьи и извлекает основной текст.",
"settings.template": "Шаблон уведомлений",
"settings.templateHint": "Переменные: {title}, {body}, {link}, {source}, {image_url}",
"settings.proxyUrl": "URL прокси",
"settings.proxyHint": "Например: http://proxy:8080 или socks5://proxy:1080",
"feeds.backup": "💾 Бэкап",
"feeds.restore": "📥 Восстановить",
"confirm.restore": "Восстановление заменит всю текущую базу данных. Продолжить?",
"toast.restored": "База восстановлена. Перезагрузка...",
"toast.articlesMarked": "Все статьи отмечены прочитанными",
"role.admin": "админ",
"role.viewer": "наблюдатель",
"login.subtitle": "Войдите, чтобы продолжить",
@@ -291,6 +345,60 @@ const I18N = {
"status.sendError": "Send error: {msg}",
"status.dash": "—",
"nav.categories": "Categories",
"categories.heading": "Categories",
"categories.add": "+ Add category",
"categories.empty": "No categories yet.",
"cat.addTitle": "Add category",
"cat.editTitle": "Edit category",
"cat.name": "Name *",
"cat.sortOrder": "Sort order",
"feed.category": "Category",
"feed.categoryNone": "— no category —",
"feed.fullContent": "Send full content",
"feed.fullContentHint": "Full text, all images and videos. For ntfy — Markdown.",
"settings.feedDefaults": "Default values for new feeds",
"confirm.deleteCategory": "Delete category «{name}»?",
"confirm.deleteCategoryFeeds": "Delete category «{name}»? {n} feeds will be uncategorized.",
"toast.categoryAdded": "Category added",
"toast.categoryUpdated": "Category updated",
"toast.categoryDeleted": "Category deleted",
"nav.reader": "Reader",
"reader.all": "All",
"reader.markAll": "Mark all read",
"reader.back": "← Back",
"reader.open": "Open original →",
"reader.empty": "No articles yet. Add feeds to start reading.",
"feed.digest": "Digest",
"feed.digestEnable": "Accumulate entries (digest)",
"feed.digestPeriod": "Digest period (hours)",
"feed.fetchArticle": "Fetch full article (trafilatura)",
"feed.fetchArticleHint": "Fetches the article page and extracts main text.",
"settings.template": "Notification template",
"settings.templateHint": "Variables: {title}, {body}, {link}, {source}, {image_url}",
"settings.proxyUrl": "Proxy URL",
"settings.proxyHint": "Example: http://proxy:8080 or socks5://proxy:1080",
"feeds.backup": "💾 Backup",
"feeds.restore": "📥 Restore",
"confirm.restore": "Restore will replace the entire database. Continue?",
"toast.restored": "Database restored. Reloading...",
"toast.articlesMarked": "All articles marked read",
"role.admin": "admin",
"role.viewer": "viewer",
"login.subtitle": "Sign in to continue",
+51
View File
@@ -133,6 +133,8 @@ h1, h2, h3 { margin: 0; font-weight: 600; }
}
.chip.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; }
.chip.cat { background: rgba(168, 85, 247, .18); color: #c9a2f5; }
.chip.full { background: rgba(245, 158, 11, .16); color: #fbbf24; }
.feed-status { font-size: 12.5px; color: var(--muted); }
.feed-status .ok { color: var(--success); }
.feed-status .err { color: var(--danger); }
@@ -316,3 +318,52 @@ details.adv[open] { padding-bottom: 8px; }
.np-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; word-break: break-word; }
.np-body { font-size: 13.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
.ntfy-preview img { max-width: 100%; border-radius: 8px; margin-top: 10px; display: block; }
/* ---------- Reader layout ---------- */
.reader-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
min-height: 60vh;
}
@media (max-width: 700px) {
.reader-layout { grid-template-columns: 1fr; }
}
.reader-sidebar { position: sticky; top: 80px; align-self: start; }
.reader-cats { display: flex; flex-direction: column; gap: 4px; }
.reader-cat {
padding: 8px 14px; border-radius: 8px; cursor: pointer;
font-size: 14px; font-weight: 500; transition: .15s;
display: flex; justify-content: space-between; align-items: center;
}
.reader-cat:hover { background: rgba(255,255,255,.04); }
.reader-cat.active { background: rgba(79,124,255,.16); color: var(--primary); }
.reader-cat .badge {
background: var(--primary); color: #fff; font-size: 11px;
padding: 1px 7px; border-radius: 999px; min-width: 20px; text-align: center;
}
/* Article rows */
.article-row {
display: flex; gap: 12px; padding: 14px 16px;
border-bottom: 1px solid var(--border); cursor: pointer; transition: .1s;
}
.article-row:hover { background: rgba(255,255,255,.03); }
.article-row.unread { border-left: 3px solid var(--primary); }
.article-dot { color: var(--primary); font-size: 12px; flex-shrink: 0; margin-top: 3px; }
.article-content { flex: 1; min-width: 0; }
.article-title { font-weight: 600; font-size: 15px; margin-bottom: 4px; }
.article-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; font-size: 12.5px; }
.article-body-preview {
font-size: 13px; color: var(--muted); white-space: pre-wrap;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* Article detail (full view) */
.article-body {
font-size: 15px; line-height: 1.7; margin-top: 20px;
padding: 20px; background: var(--bg-soft); border-radius: 12px;
white-space: pre-wrap; word-break: break-word;
}
.article-body img { max-width: 100%; border-radius: 8px; margin: 12px 0; }
#reader-back { margin-bottom: 16px; }