✨ 8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
build-and-push / docker (push) Has been cancelled
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:
+247
-8
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user