RSS/Atom -> ntfy bridge with web UI, OPML import/export and RU/EN localization

Web-managed fork of nurefexc/rss-bridge-ntfy: Flask UI + REST API, background
sync engine (SQLite dedup, quiet hours, filters, flood protection, images),
OPML import/export and switchable interface/notification language.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 19:34:53 +08:00
commit 3f9b108482
15 changed files with 2076 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
:root {
--bg: #0f1419;
--panel: #1a212b;
--panel-2: #232c38;
--border: #2c3543;
--text: #e6edf3;
--muted: #8b97a7;
--accent: #3b82f6;
--accent-2: #2563eb;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.8rem 1.4rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 0.8rem; }
.logo { font-size: 1.8rem; }
.brand h1 { margin: 0; font-size: 1.1rem; }
.brand small { color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 0.6rem; }
.lang-select { width: auto; margin-top: 0; padding: 0.45rem 0.5rem; cursor: pointer; }
.feed-toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.tabs { display: flex; gap: 0.3rem; padding: 0 1.4rem; background: var(--panel); border-bottom: 1px solid var(--border); }
.tab {
background: none; border: none; color: var(--muted);
padding: 0.8rem 1rem; cursor: pointer; font-size: 0.95rem;
border-bottom: 2px solid transparent;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
main { padding: 1.4rem; max-width: 1100px; margin: 0 auto; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.4rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; }
.card-val { font-size: 1.8rem; font-weight: 700; }
.card-lbl { color: var(--muted); margin-top: 0.2rem; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.2rem; }
.panel h2 { margin: 0 0 0.8rem; font-size: 1rem; }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.panel-head h2 { margin: 0; }
.kv { width: 100%; border-collapse: collapse; }
.kv td { padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
.kv td:first-child { color: var(--muted); width: 45%; }
.log {
background: #0b0e13; border: 1px solid var(--border); border-radius: 8px;
padding: 0.6rem; height: 360px; overflow-y: auto;
font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; line-height: 1.5;
}
.log .line { white-space: pre-wrap; word-break: break-word; padding: 1px 0; }
.log .INFO { color: var(--text); }
.log .WARNING { color: var(--yellow); }
.log .ERROR { color: var(--red); }
.log .time { color: var(--muted); }
.btn {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 0.5rem 0.9rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem;
}
.btn:hover { background: #2b3543; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-2); }
.btn-danger { color: #fff; background: var(--red); border-color: var(--red); }
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
.pill { padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
.pill-on { background: rgba(34,197,94,0.15); color: var(--green); }
.pill-off { background: rgba(234,179,8,0.15); color: var(--yellow); }
.pill-muted { background: var(--panel-2); color: var(--muted); }
.row { display: flex; gap: 0.8rem; }
.row > * { flex: 1; }
.row input { width: 100%; }
label { display: block; margin-bottom: 0.8rem; color: var(--muted); font-size: 0.85rem; }
label.check { display: flex; align-items: center; gap: 0.5rem; color: var(--text); }
label.check input { width: auto; }
input, select {
width: 100%; margin-top: 0.3rem; padding: 0.55rem 0.7rem;
background: #0e131a; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 0.9rem;
}
input:focus { outline: none; border-color: var(--accent); }
/* Feeds list */
.feed-row {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 0.9rem 1.1rem; margin-bottom: 0.7rem;
display: flex; align-items: center; gap: 1rem;
}
.feed-main { flex: 1; min-width: 0; }
.feed-name { font-weight: 600; }
.feed-url { color: var(--muted); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-badges { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.3rem; }
.badge { background: var(--panel-2); color: var(--muted); padding: 0.1rem 0.5rem; border-radius: 6px; font-size: 0.72rem; }
.badge.topic { background: rgba(59,130,246,0.18); color: #93c5fd; }
.feed-actions { display: flex; gap: 0.4rem; }
.icon-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
.icon-btn:hover { color: var(--text); }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.switch input { display: none; }
.slider { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: .2s; cursor: pointer; }
.slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; }
.switch input:checked + .slider { background: var(--green); }
.switch input:checked + .slider::before { transform: translateX(18px); }
/* Modal */
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: flex-start; justify-content: center; padding: 3rem 1rem; z-index: 50; overflow-y: auto; }
.modal.hidden { display: none; }
.modal-box { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 560px; padding: 1.4rem; }
.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.modal-head h2 { margin: 0; }
.modal-actions { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.5rem; }
.spacer { flex: 1; }
.preview { margin-top: 1rem; font-size: 0.82rem; color: var(--muted); }
.preview .ok { color: var(--green); }
.preview .err { color: var(--red); }
.preview ul { margin: 0.4rem 0 0; padding-left: 1.1rem; }
.toast {
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
padding: 0.7rem 1.2rem; border-radius: 10px; z-index: 100;
}
.toast.hidden { display: none; }
.toast.err { border-color: var(--red); }
.toast.ok { border-color: var(--green); }
.hidden { display: none; }
.empty { color: var(--muted); text-align: center; padding: 2rem; }
@media (max-width: 760px) {
.cards { grid-template-columns: repeat(2, 1fr); }
.grid-2 { grid-template-columns: 1fr; }
.row { flex-direction: column; }
}
+326
View File
@@ -0,0 +1,326 @@
"use strict";
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...opts,
});
let data = null;
try { data = await res.json(); } catch (_) { /* no body */ }
if (!res.ok) {
const msg = (data && (data.error || data.message)) || res.statusText;
throw new Error(msg);
}
return data;
}
function toast(message, kind = "") {
const el = $("#toast");
el.textContent = message;
el.className = "toast " + kind;
setTimeout(() => el.classList.add("hidden"), 3000);
}
function fmtTime(iso) {
if (!iso) return "—";
try { return new Date(iso).toLocaleString(locale()); }
catch (_) { return iso; }
}
function fmtTs(ts) {
if (!ts) return "—";
return new Date(ts * 1000).toLocaleString(locale());
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
/* ---------------- Language ---------------- */
const langSel = $("#lang-select");
langSel.value = LANG;
langSel.addEventListener("change", async () => {
setLang(langSel.value);
refreshStatus();
refreshLogs();
if ($("#tab-feeds").classList.contains("active")) loadFeeds();
try {
await api("/api/settings", { method: "PUT", body: JSON.stringify({ language: LANG }) });
} catch (_) { /* ignore */ }
});
/* ---------------- Tabs ---------------- */
$$(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
$$(".tab").forEach((t) => t.classList.remove("active"));
$$(".tab-panel").forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
$("#tab-" + tab.dataset.tab).classList.add("active");
if (tab.dataset.tab === "feeds") loadFeeds();
if (tab.dataset.tab === "settings") loadSettings();
});
});
/* ---------------- Status / dashboard ---------------- */
async function refreshStatus() {
try {
const s = await api("/api/status");
$("#stat-feeds").textContent = `${s.feed_active}/${s.feed_total}`;
$("#stat-topics").textContent = s.topics.length;
$("#stat-sent").textContent = s.sent_total;
$("#stat-history").textContent = s.history_count;
const enabled = s.enabled;
const pill = $("#engine-pill");
if (s.syncing) {
pill.textContent = t("pill_syncing"); pill.className = "pill pill-on";
} else if (enabled) {
pill.textContent = t("pill_running"); pill.className = "pill pill-on";
} else {
pill.textContent = t("pill_paused"); pill.className = "pill pill-off";
}
$("#btn-toggle").textContent = enabled ? t("pause") : t("resume");
$("#dash-engine").textContent = s.syncing ? t("st_syncing") : (enabled ? t("st_running") : t("st_paused"));
$("#dash-last").textContent = fmtTime(s.last_sync) +
(s.last_sync_ok === false ? t("err_suffix_sync_failed") : "");
$("#dash-next").textContent = enabled ? fmtTs(s.next_sync) : "—";
$("#dash-interval").textContent = s.sync_interval + " " + t("sec");
$("#dash-error").textContent = s.last_error || "—";
} catch (e) {
$("#engine-pill").textContent = t("no_connection");
}
}
async function refreshLogs() {
try {
const { logs } = await api("/api/logs");
const box = $("#log");
box.innerHTML = logs.map((l) => {
const time = new Date(l.time).toLocaleTimeString(locale());
return `<div class="line ${l.level}"><span class="time">${time}</span> ${escapeHtml(l.message)}</div>`;
}).join("");
} catch (_) { /* ignore */ }
}
/* ---------------- Engine controls ---------------- */
$("#btn-sync").addEventListener("click", async () => {
try { await api("/api/sync", { method: "POST" }); toast(t("sync_started"), "ok"); }
catch (e) { toast(e.message, "err"); }
setTimeout(refreshStatus, 500);
setTimeout(refreshLogs, 1500);
});
$("#btn-toggle").addEventListener("click", async () => {
const s = await api("/api/status");
const action = s.enabled ? "pause" : "resume";
await api("/api/engine", { method: "POST", body: JSON.stringify({ action }) });
refreshStatus();
});
$("#btn-clear-history").addEventListener("click", async () => {
if (!confirm(t("confirm_clear_history"))) return;
await api("/api/history/clear", { method: "POST" });
toast(t("history_cleared"), "ok");
refreshStatus();
});
$("#btn-test").addEventListener("click", async () => {
const topic = $("#test-topic").value.trim();
const message = $("#test-msg").value.trim();
if (!topic) { toast(t("need_topic"), "err"); return; }
try {
await api("/api/test-notify", { method: "POST", body: JSON.stringify({ topic, message }) });
toast(t("notify_sent"), "ok");
} catch (e) { toast(t("err_prefix") + e.message, "err"); }
});
/* ---------------- OPML import / export ---------------- */
$("#btn-export-opml").addEventListener("click", () => {
window.location.href = "/api/export/opml";
});
$("#btn-import-opml").addEventListener("click", () => $("#opml-file").click());
$("#opml-file").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const form = new FormData();
form.append("file", file);
try {
const res = await fetch("/api/import/opml", { method: "POST", body: form });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || res.statusText);
toast(t("import_done").replace("{n}", data.added).replace("{total}", data.total), "ok");
loadFeeds(); refreshStatus();
} catch (err) {
toast(t("err_prefix") + err.message, "err");
} finally {
e.target.value = "";
}
});
/* ---------------- Feeds ---------------- */
async function loadFeeds() {
const { feeds } = await api("/api/feeds");
const list = $("#feeds-list");
if (!feeds.length) {
list.innerHTML = `<div class="empty">${escapeHtml(t("feeds_empty"))}</div>`;
return;
}
list.innerHTML = feeds.map(feedRow).join("");
$$(".feed-toggle").forEach((el) => el.addEventListener("change", onToggleFeed));
$$(".feed-edit").forEach((el) => el.addEventListener("click", onEditFeed));
$$(".feed-del").forEach((el) => el.addEventListener("click", onDeleteFeed));
}
function feedRow(f) {
const badges = [`<span class="badge topic">→ ${escapeHtml(f.topic)}</span>`,
`<span class="badge">${t("priority")} ${f.priority}</span>`];
if (f.quiet_hours) badges.push(`<span class="badge">${t("quiet")} ${escapeHtml(f.quiet_hours)}</span>`);
if (f.include_regex) badges.push(`<span class="badge">include</span>`);
if (f.exclude_regex) badges.push(`<span class="badge">exclude</span>`);
return `
<div class="feed-row">
<label class="switch">
<input type="checkbox" class="feed-toggle" data-id="${f.id}" ${f.enabled ? "checked" : ""}>
<span class="slider"></span>
</label>
<div class="feed-main">
<div class="feed-name">${escapeHtml(f.name || t("no_name"))}</div>
<div class="feed-url">${escapeHtml(f.url)}</div>
<div class="feed-badges">${badges.join("")}</div>
</div>
<div class="feed-actions">
<button class="icon-btn feed-edit" data-id="${f.id}" title="${escapeHtml(t("edit"))}">✎</button>
<button class="icon-btn feed-del" data-id="${f.id}" title="${escapeHtml(t("delete"))}">🗑</button>
</div>
</div>`;
}
async function onToggleFeed(e) {
const id = e.target.dataset.id;
await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify({ enabled: e.target.checked }) });
toast(t("saved"), "ok");
refreshStatus();
}
async function onEditFeed(e) {
const id = e.target.dataset.id;
const { feeds } = await api("/api/feeds");
const feed = feeds.find((f) => f.id === id);
openModal(feed);
}
async function onDeleteFeed(e) {
const id = e.target.dataset.id;
if (!confirm(t("confirm_delete_feed"))) return;
await api(`/api/feeds/${id}`, { method: "DELETE" });
toast(t("feed_deleted"), "ok");
loadFeeds(); refreshStatus();
}
/* ---------------- Feed modal ---------------- */
const modal = $("#modal");
const feedForm = $("#feed-form");
function openModal(feed) {
$("#modal-title").textContent = feed ? t("feed_edit") : t("feed_new");
$("#preview-result").innerHTML = "";
feedForm.reset();
const data = feed || { priority: 3, quiet_priority: 1, enabled: true };
for (const [k, v] of Object.entries(data)) {
const el = feedForm.elements[k];
if (!el) continue;
if (el.type === "checkbox") el.checked = !!v;
else el.value = v ?? "";
}
if (!feed) feedForm.elements["enabled"].checked = true;
modal.classList.remove("hidden");
}
function closeModal() { modal.classList.add("hidden"); }
$("#btn-add-feed").addEventListener("click", () => openModal(null));
$("#modal-close").addEventListener("click", closeModal);
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
feedForm.addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm(feedForm);
const id = payload.id;
delete payload.id;
try {
if (id) await api(`/api/feeds/${id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/api/feeds", { method: "POST", body: JSON.stringify(payload) });
toast(t("feed_saved"), "ok");
closeModal(); loadFeeds(); refreshStatus();
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
});
$("#btn-preview").addEventListener("click", async () => {
const url = feedForm.elements["url"].value.trim();
const box = $("#preview-result");
if (!url) { box.innerHTML = `<span class="err">${escapeHtml(t("need_url"))}</span>`; return; }
box.textContent = t("checking");
try {
const r = await api("/api/feeds/preview", { method: "POST", body: JSON.stringify({ url }) });
if (r.error && !r.entries.length) {
box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + r.error)}</span>`;
return;
}
const items = r.entries.map((it) => `<li>${escapeHtml(it.title)}</li>`).join("");
box.innerHTML = `<span class="ok">${escapeHtml(t("preview_ok"))}</span> «${escapeHtml(r.title)}», ` +
`${escapeHtml(t("preview_entries"))} ${r.count}<ul>${items}</ul>`;
} catch (err) { box.innerHTML = `<span class="err">${escapeHtml(t("err_prefix") + err.message)}</span>`; }
});
function collectForm(form) {
const out = {};
for (const el of form.elements) {
if (!el.name) continue;
if (el.type === "checkbox") out[el.name] = el.checked;
else if (el.type === "number") out[el.name] = el.value === "" ? null : Number(el.value);
else out[el.name] = el.value;
}
return out;
}
/* ---------------- Settings ---------------- */
async function loadSettings() {
const s = await api("/api/settings");
const form = $("#settings-form");
for (const [k, v] of Object.entries(s)) {
const el = form.elements[k];
if (!el) continue;
if (el.type === "checkbox") el.checked = !!v;
else el.value = v ?? "";
}
}
$("#settings-form").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = collectForm(e.target);
try {
await api("/api/settings", { method: "PUT", body: JSON.stringify(payload) });
toast(t("saved"), "ok");
refreshStatus();
} catch (err) { toast(t("err_prefix") + err.message, "err"); }
});
/* ---------------- Init ---------------- */
(async () => {
try {
const s = await api("/api/settings");
if (s.language && s.language !== LANG) {
setLang(s.language);
langSel.value = s.language;
}
} catch (_) { /* ignore */ }
refreshStatus();
refreshLogs();
})();
setInterval(refreshStatus, 5000);
setInterval(refreshLogs, 5000);
+229
View File
@@ -0,0 +1,229 @@
"use strict";
// Translation dictionary. Add a language by adding a key here and an <option>
// to #lang-select in index.html.
const I18N = {
ru: {
subtitle: "веб-панель управления мостом",
sync_now: "⟳ Синхронизировать",
pause: "Пауза",
resume: "Возобновить",
tab_dashboard: "Дашборд",
tab_feeds: "Фиды",
tab_settings: "Настройки",
// dashboard cards
card_feeds: "Фидов (активно)",
card_topics: "Топиков",
card_sent: "Отправлено всего",
card_history: "В истории",
// status panel
status: "Состояние",
engine: "Движок",
last_sync: "Последняя синхронизация",
next_sync: "Следующая",
interval: "Интервал",
error: "Ошибка",
test_notification: "Тестовое уведомление",
ph_topic: "топик (напр. news)",
ph_message: "сообщение",
send: "Отправить",
// log panel
log: "Журнал",
clear_history: "Очистить историю",
// feeds
feeds: "Фиды",
add_feed: "+ Добавить фид",
import_opml: "Импорт OPML",
export_opml: "Экспорт OPML",
feeds_empty: "Фидов пока нет. Нажмите «Добавить фид».",
no_name: "(без названия)",
priority: "приоритет",
quiet: "тихо",
edit: "Изменить",
delete: "Удалить",
// settings
settings_global: "Глобальные настройки",
s_ntfy_url: "ntfy сервер (URL)",
s_ntfy_token: "ntfy токен (необязательно)",
s_interval: "Интервал синхронизации (сек)",
s_tz: "Часовой пояс (IANA)",
s_batch: "Лимит новых записей за цикл",
s_maxdesc: "Макс. длина описания",
s_ua: "User-Agent",
s_flood: "Флуд-защита (задержки для низкого приоритета)",
save: "Сохранить",
// feed modal
feed_new: "Новый фид",
feed_edit: "Изменить фид",
f_name: "Название",
f_url: "URL фида *",
f_topic: "Топик ntfy *",
f_priority: "Приоритет (15)",
f_icon: "Иконка (URL)",
f_quiet_hours: "Тихие часы (напр. 22-7)",
f_quiet_priority: "Приоритет в тихие часы",
f_include: "Include regex (показывать только совпадения)",
f_exclude: "Exclude regex (отбрасывать совпадения)",
f_enabled: "Включён",
check_feed: "Проверить фид",
// dynamic / toasts
sync_started: "Синхронизация запущена",
history_cleared: "История очищена",
need_topic: "Укажите топик",
notify_sent: "Уведомление отправлено",
saved: "Сохранено",
feed_deleted: "Фид удалён",
feed_saved: "Фид сохранён",
confirm_delete_feed: "Удалить этот фид?",
confirm_clear_history: "Очистить историю отправленных записей? Старые записи могут прийти повторно.",
checking: "Проверяю…",
need_url: "Укажите URL",
preview_ok: "OK:",
preview_entries: "записей:",
err_prefix: "Ошибка: ",
no_connection: "нет связи",
st_syncing: "синхронизация…",
st_running: "работает",
st_paused: "на паузе",
pill_syncing: "● синхронизация",
pill_running: "● работает",
pill_paused: "‖ пауза",
sec: "сек",
err_suffix_sync_failed: " (ошибка)",
import_done: "Импорт OPML: добавлено {n} из {total}",
import_choose: "Выберите .opml файл",
},
en: {
subtitle: "web control panel for the bridge",
sync_now: "⟳ Sync now",
pause: "Pause",
resume: "Resume",
tab_dashboard: "Dashboard",
tab_feeds: "Feeds",
tab_settings: "Settings",
card_feeds: "Feeds (active)",
card_topics: "Topics",
card_sent: "Sent total",
card_history: "In history",
status: "Status",
engine: "Engine",
last_sync: "Last sync",
next_sync: "Next",
interval: "Interval",
error: "Error",
test_notification: "Test notification",
ph_topic: "topic (e.g. news)",
ph_message: "message",
send: "Send",
log: "Log",
clear_history: "Clear history",
feeds: "Feeds",
add_feed: "+ Add feed",
import_opml: "Import OPML",
export_opml: "Export OPML",
feeds_empty: "No feeds yet. Click \"Add feed\".",
no_name: "(no name)",
priority: "priority",
quiet: "quiet",
edit: "Edit",
delete: "Delete",
settings_global: "Global settings",
s_ntfy_url: "ntfy server (URL)",
s_ntfy_token: "ntfy token (optional)",
s_interval: "Sync interval (sec)",
s_tz: "Timezone (IANA)",
s_batch: "New items per cycle limit",
s_maxdesc: "Max description length",
s_ua: "User-Agent",
s_flood: "Flood protection (delay low-priority items)",
save: "Save",
feed_new: "New feed",
feed_edit: "Edit feed",
f_name: "Name",
f_url: "Feed URL *",
f_topic: "ntfy topic *",
f_priority: "Priority (15)",
f_icon: "Icon (URL)",
f_quiet_hours: "Quiet hours (e.g. 22-7)",
f_quiet_priority: "Priority during quiet hours",
f_include: "Include regex (only matching items)",
f_exclude: "Exclude regex (drop matching items)",
f_enabled: "Enabled",
check_feed: "Check feed",
sync_started: "Sync started",
history_cleared: "History cleared",
need_topic: "Enter a topic",
notify_sent: "Notification sent",
saved: "Saved",
feed_deleted: "Feed deleted",
feed_saved: "Feed saved",
confirm_delete_feed: "Delete this feed?",
confirm_clear_history: "Clear the history of sent items? Old items may be delivered again.",
checking: "Checking…",
need_url: "Enter a URL",
preview_ok: "OK:",
preview_entries: "items:",
err_prefix: "Error: ",
no_connection: "no connection",
st_syncing: "syncing…",
st_running: "running",
st_paused: "paused",
pill_syncing: "● syncing",
pill_running: "● running",
pill_paused: "‖ paused",
sec: "sec",
err_suffix_sync_failed: " (error)",
import_done: "OPML import: added {n} of {total}",
import_choose: "Choose an .opml file",
},
};
let LANG = localStorage.getItem("lang") || "ru";
function t(key) {
const dict = I18N[LANG] || I18N.ru;
return (key in dict) ? dict[key] : (I18N.ru[key] || key);
}
function locale() {
return LANG === "ru" ? "ru-RU" : "en-US";
}
function applyI18n() {
document.documentElement.lang = LANG;
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll("[data-i18n-ph]").forEach((el) => {
el.placeholder = t(el.dataset.i18nPh);
});
document.querySelectorAll("[data-i18n-title]").forEach((el) => {
el.title = t(el.dataset.i18nTitle);
});
const sel = document.getElementById("lang-select");
if (sel) sel.value = LANG;
}
function setLang(lang) {
LANG = lang;
localStorage.setItem("lang", lang);
applyI18n();
}
// DOM is fully parsed (scripts sit at the end of <body>).
applyI18n();