From 3f9b108482bc187a8280256b0401c5086448fc82 Mon Sep 17 00:00:00 2001 From: dimon Date: Sat, 6 Jun 2026 19:34:53 +0800 Subject: [PATCH] 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) --- .dockerignore | 12 ++ .gitignore | 12 ++ Dockerfile | 32 ++++ README.md | 236 +++++++++++++++++++++++++++++ docker-compose.yml | 16 ++ engine.py | 353 +++++++++++++++++++++++++++++++++++++++++++ main.py | 61 ++++++++ opml.py | 86 +++++++++++ requirements.txt | 6 + static/css/style.css | 159 +++++++++++++++++++ static/js/app.js | 326 +++++++++++++++++++++++++++++++++++++++ static/js/i18n.js | 229 ++++++++++++++++++++++++++++ store.py | 177 ++++++++++++++++++++++ templates/index.html | 184 ++++++++++++++++++++++ webapp.py | 187 +++++++++++++++++++++++ 15 files changed, 2076 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 engine.py create mode 100644 main.py create mode 100644 opml.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 static/js/i18n.js create mode 100644 store.py create mode 100644 templates/index.html create mode 100644 webapp.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff82aef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +data/ +.git/ +.gitignore +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.env +.idea/ +.vscode/ +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80ca021 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +data/ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.env +.idea/ +.vscode/ +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d27a2a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.12-slim + +LABEL org.opencontainers.image.title="rss-bridge-ntfy-web" +LABEL org.opencontainers.image.description="RSS/Atom -> ntfy bridge with a web UI" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DATA_DIR=/data \ + PORT=8080 \ + TZ=UTC + +WORKDIR /app + +# Install dependencies first to leverage layer caching. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Application code. +COPY main.py engine.py store.py webapp.py opml.py ./ +COPY templates ./templates +COPY static ./static + +# Persistent data (settings, feeds, history db, logs) lives here. +RUN mkdir -p /data +VOLUME ["/data"] + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,os; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8080')+'/api/health')" || exit 1 + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c48ea7 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# RSS → ntfy Bridge (с веб-интерфейсом) + +Мост, который следит за RSS/Atom-лентами и отправляет новые записи как push-уведомления +в [ntfy](https://ntfy.sh). В отличие от +[оригинального проекта](https://github.com/nurefexc/rss-bridge-ntfy), где ленты +настраивались через JSON-файлы и переменные окружения, **здесь всё управление +происходит через веб-интерфейс** — добавление лент, топиков, расписаний и +глобальных настроек, запуск синхронизации, просмотр журнала и отправка тестовых +уведомлений. + +Проект упакован в Docker и запускается одной командой. + +--- + +## Возможности + +- 🌐 **Полное управление через браузер** — фиды, топики, настройки, без правки файлов. +- 🔔 **Отправка в ntfy** с заголовком, ссылкой (Click), Markdown, иконкой и картинкой (Attach). +- 🧠 **Дедупликация** через SQLite (WAL) — одна запись не приходит дважды. +- 🌙 **Тихие часы** — пониженный приоритет в заданном интервале (напр. `22-7`). +- 🧹 **Фильтры** include/exclude по регулярным выражениям. +- 🌊 **Флуд-защита** — ступенчатая задержка доставки для записей с низким приоритетом. +- 🖼️ **Картинки и описание** автоматически извлекаются из HTML записи. +- 📜 **Живой журнал** и дашборд со статусом прямо в интерфейсе. +- ▶️ **Кнопки** «Синхронизировать сейчас», «Пауза/Возобновить», «Тестовое уведомление». +- 📥 **Импорт/экспорт OPML** — перенос списка лент между ридерами (с сохранением ntfy-параметров). +- 🌍 **Локализация интерфейса** — переключение языка (RU/EN) прямо в шапке; влияет и на текст уведомлений. +- 💾 **Все данные в одном томе** `./data` (настройки, фиды, история, логи). + +--- + +## Быстрый старт (Docker Compose) — рекомендуется + +Требуется установленный **Docker** и **Docker Compose**. + +```bash +# 1. Перейти в каталог проекта +cd rss-bridge-ntfy + +# 2. Собрать образ и запустить контейнер +docker compose up -d --build + +# 3. Открыть веб-интерфейс +# http://localhost:8080 +``` + +Готово. Откройте **http://localhost:8080**, перейдите на вкладку **«Фиды»** и +добавьте первую ленту. + +Полезные команды: + +```bash +docker compose logs -f # смотреть логи +docker compose restart # перезапустить +docker compose down # остановить и удалить контейнер (данные в ./data сохранятся) +``` + +### Настройка перед запуском (необязательно) + +В файле `docker-compose.yml` можно поменять: + +- **Порт.** `"8080:8080"` → например `"9000:8080"`, тогда интерфейс будет на `:9000`. +- **Часовой пояс.** `TZ: "Europe/Moscow"` — влияет на тихие часы и время в логах. + +> Адрес ntfy-сервера и токен задаются **не здесь, а в самом интерфейсе** +> (вкладка «Настройки»). По умолчанию используется публичный `https://ntfy.sh`. + +--- + +## Запуск через `docker run` (без Compose) + +```bash +docker build -t rss-bridge-ntfy-web . + +docker run -d \ + --name rss-bridge-ntfy \ + -p 8080:8080 \ + -e TZ=Europe/Moscow \ + -v "$(pwd)/data:/data" \ + --restart unless-stopped \ + rss-bridge-ntfy-web +``` + +--- + +## Запуск без Docker (для разработки) + +Требуется **Python 3.11+**. + +```bash +pip install -r requirements.txt +python main.py +# Интерфейс: http://localhost:8080 +``` + +Переменные окружения (необязательно): `PORT` (по умолчанию `8080`), +`HOST` (`0.0.0.0`), `DATA_DIR` (`data`), `TZ` (`UTC`). + +--- + +## Как пользоваться интерфейсом + +Интерфейс состоит из трёх вкладок. + +### 1. Дашборд +- Карточки со статистикой: число фидов, топиков, отправленных уведомлений, записей в истории. +- Состояние движка, время последней и следующей синхронизации. +- **Тестовое уведомление** — введите топик и текст, проверьте доставку в ntfy. +- **Журнал** работы в реальном времени и кнопка очистки истории дедупликации. +- В шапке: **«Синхронизировать»** (запустить цикл немедленно) и **«Пауза/Возобновить»**. + +### 2. Фиды +Кнопка **«+ Добавить фид»** открывает форму. Поля: + +| Поле | Описание | +|------|----------| +| **Название** | Подпись источника в уведомлении. | +| **URL фида*** | Ссылка на RSS/Atom. | +| **Топик ntfy*** | Топик, в который уйдут уведомления (напр. `news`). | +| **Приоритет** | 1–5 (базовый приоритет уведомления). | +| **Иконка (URL)** | Картинка-иконка уведомления (заголовок `Icon`). | +| **Тихие часы** | Интервал вида `22-7`; в это время используется приоритет ниже. | +| **Приоритет в тихие часы** | Какой приоритет применять в тихие часы (по умолчанию 1). | +| **Include regex** | Показывать только записи, совпадающие с выражением. | +| **Exclude regex** | Отбрасывать записи, совпадающие с выражением. | +| **Включён** | Переключатель активности фида. | + +Кнопка **«Проверить фид»** в форме загружает ленту и показывает несколько свежих +заголовков — удобно убедиться, что URL рабочий, ещё до сохранения. + +Каждую ленту в списке можно включить/выключить тумблером, отредактировать (✎) или удалить (🗑). + +**Импорт/экспорт OPML.** В шапке вкладки «Фиды» есть кнопки: +- **Экспорт OPML** — скачивает файл `feeds.opml` со всеми лентами (ntfy-параметры — + топик, приоритет, тихие часы, фильтры — сохраняются в кастомных атрибутах `ntfy*`). +- **Импорт OPML** — загружает `.opml`-файл и добавляет ленты. Дубликаты (совпадение + по URL и топику) пропускаются. У стандартных OPML-файлов из других ридеров топик + берётся из родительской группы (или `rss`, если её нет), затем его можно поправить. + +### Локализация + +Язык интерфейса переключается выпадающим списком **RU/EN** в правом верхнем углу. +Выбор сохраняется в настройках на сервере и влияет также на язык ссылки +«Читать на сайте» / «Read on website» в самих уведомлениях. + +### 3. Настройки (глобальные) + +| Настройка | Описание | +|-----------|----------| +| **ntfy сервер (URL)** | Адрес сервера ntfy (по умолчанию `https://ntfy.sh`; можно указать свой self-hosted). | +| **ntfy токен** | Токен доступа (`Bearer`) для приватных серверов/топиков. Необязательно. | +| **Интервал синхронизации** | Как часто опрашивать ленты, в секундах (по умолчанию 600). | +| **Часовой пояс (IANA)** | Напр. `Europe/Moscow`. Используется для тихих часов. | +| **Лимит новых записей за цикл** | Максимум новых уведомлений на ленту за один проход (по умолчанию 3). | +| **Макс. длина описания** | До скольких символов обрезать текст уведомления. | +| **User-Agent** | Заголовок User-Agent при запросе лент. | +| **Флуд-защита** | Ступенчатые задержки доставки для записей с приоритетом < 4. | +| **Язык (RU/EN)** | Переключается в шапке; влияет на интерфейс и текст уведомлений. | + +После сохранения настроек запускается синхронизация. + +--- + +## Получение уведомлений на телефоне + +1. Установите приложение **ntfy** ([Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [iOS](https://apps.apple.com/app/ntfy/id1625396347)) или откройте веб-клиент https://ntfy.sh/app. +2. Подпишитесь на топик, который вы указали у фида (например `news`). +3. Новые записи из ленты будут приходить push-уведомлениями. + +> Топик в ntfy.sh — это, по сути, публичный канал. Используйте длинное, +> труднодоступное имя топика или собственный сервер ntfy с токеном для приватности. + +--- + +## Где хранятся данные + +Всё лежит в каталоге `./data` (примонтирован как том `/data` в контейнере): + +| Файл | Назначение | +|------|------------| +| `settings.json` | Глобальные настройки. | +| `feeds.json` | Список лент и их параметры. | +| `history.db` | SQLite-база отправленных записей (дедупликация). | +| `bridge.log` | Файл журнала. | + +Резервная копия = копия каталога `data/`. Удаление `history.db` приведёт к +повторной отправке последних записей. + +--- + +## Структура проекта + +``` +rss-bridge-ntfy/ +├── main.py # точка входа: поднимает движок и веб-сервер (waitress) +├── engine.py # ядро: парсинг лент, дедуп, фильтры, отправка в ntfy +├── store.py # потокобезопасное хранилище настроек и фидов (JSON) +├── webapp.py # Flask: REST API + отдача интерфейса +├── opml.py # импорт/экспорт OPML +├── templates/index.html +├── static/css/style.css +├── static/js/app.js +├── static/js/i18n.js # словарь локализации (RU/EN) +├── requirements.txt +├── Dockerfile +├── docker-compose.yml +└── data/ # создаётся при запуске (том с данными) +``` + +--- + +## REST API + +Интерфейс работает поверх простого API — им можно пользоваться и напрямую: + +| Метод | Путь | Назначение | +|-------|------|------------| +| `GET` | `/api/status` | Статус движка и статистика. | +| `POST` | `/api/sync` | Запустить синхронизацию немедленно. | +| `POST` | `/api/engine` | `{"action":"pause"|"resume"}` — пауза/возобновление. | +| `GET` | `/api/logs` | Последние строки журнала. | +| `POST` | `/api/history/clear` | Очистить историю дедупликации. | +| `GET`/`PUT` | `/api/settings` | Получить/обновить глобальные настройки. | +| `GET`/`POST` | `/api/feeds` | Список фидов / создать фид. | +| `PUT`/`DELETE` | `/api/feeds/` | Изменить / удалить фид. | +| `POST` | `/api/feeds/preview` | `{"url":"..."}` — проверить ленту. | +| `GET` | `/api/export/opml` | Скачать все ленты в формате OPML. | +| `POST` | `/api/import/opml` | Загрузить OPML (multipart `file` или тело запроса). | +| `POST` | `/api/test-notify` | `{"topic":"...","message":"..."}` — тест. | + +--- + +## Лицензия + +MIT. Основано на идее проекта +[nurefexc/rss-bridge-ntfy](https://github.com/nurefexc/rss-bridge-ntfy). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e91442b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + rss-bridge-ntfy: + build: . + image: rss-bridge-ntfy-web:latest + container_name: rss-bridge-ntfy + restart: unless-stopped + ports: + - "8080:8080" + environment: + # Часовой пояс контейнера (для тихих часов и логов) + TZ: "Europe/Moscow" + # Порт веб-интерфейса внутри контейнера + PORT: "8080" + volumes: + # Все настройки, фиды, история и логи хранятся здесь + - ./data:/data diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..ebd77c5 --- /dev/null +++ b/engine.py @@ -0,0 +1,353 @@ +"""RSS/Atom -> ntfy bridge engine. + +A single background thread runs sync cycles on an interval. For every enabled +feed it parses entries, de-duplicates them via a SQLite history table, applies +include/exclude filters, computes a (possibly quiet-hours adjusted) priority and +posts a Markdown notification to the feed's ntfy topic. + +The engine exposes status and an in-memory log ring buffer so the web UI can +display what is happening without reading files. +""" + +import hashlib +import logging +import re +import sqlite3 +import threading +import time +from collections import deque +from datetime import datetime, timezone +from urllib.parse import urlparse, urlunparse +from zoneinfo import ZoneInfo + +import feedparser +import requests +from bs4 import BeautifulSoup + +import store as store_mod + +logger = logging.getLogger("bridge") + + +class RingBufferHandler(logging.Handler): + """Keeps the most recent log records in memory for the web UI.""" + + def __init__(self, capacity=300): + super().__init__() + self.buffer = deque(maxlen=capacity) + + def emit(self, record): + self.buffer.append( + { + "time": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "level": record.levelname, + "message": record.getMessage(), + } + ) + + def records(self): + return list(self.buffer) + + +class Engine: + def __init__(self, store: store_mod.Store): + self.store = store + self._stop = threading.Event() + self._wake = threading.Event() + self._force = False + self._thread = None + self._sync_lock = threading.Lock() + + self.ring = RingBufferHandler() + self.ring.setFormatter(logging.Formatter("%(message)s")) + + self.status = { + "running": False, + "syncing": False, + "last_sync": None, + "last_sync_ok": None, + "next_sync": None, + "last_error": None, + "sent_total": 0, + "sent_last_cycle": 0, + } + + self._init_db() + + # ---- lifecycle --------------------------------------------------------- + def start(self): + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, name="engine", daemon=True) + self._thread.start() + self.status["running"] = True + logger.info("Engine started") + + def stop(self): + self._stop.set() + self._wake.set() + if self._thread: + self._thread.join(timeout=10) + self.status["running"] = False + logger.info("Engine stopped") + + def trigger_sync(self): + """Request an immediate sync cycle even if the engine is paused.""" + self._force = True + self._wake.set() + + # ---- main loop --------------------------------------------------------- + def _loop(self): + while not self._stop.is_set(): + settings = self.store.get_settings() + if settings["enabled"] or self._force: + self._force = False + try: + self.sync(settings) + except Exception as exc: # never let the loop die + logger.exception("Sync cycle crashed: %s", exc) + self.status["last_error"] = str(exc) + + interval = max(30, int(settings.get("sync_interval", 600))) + self.status["next_sync"] = datetime.now(timezone.utc).timestamp() + interval + self._wake.wait(timeout=interval) + self._wake.clear() + + # ---- database ---------------------------------------------------------- + def _connect(self): + conn = sqlite3.connect(store_mod.DB_PATH, timeout=30) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + def _init_db(self): + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS seen_entries ( + hash TEXT PRIMARY KEY, + topic TEXT, + created_at TEXT + ) + """ + ) + + def _seen(self, conn, entry_hash): + cur = conn.execute("SELECT 1 FROM seen_entries WHERE hash=?", (entry_hash,)) + return cur.fetchone() is not None + + def _mark_seen(self, conn, entry_hash, topic): + conn.execute( + "INSERT OR IGNORE INTO seen_entries (hash, topic, created_at) VALUES (?,?,?)", + (entry_hash, topic, datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + + def history_count(self): + try: + with self._connect() as conn: + return conn.execute("SELECT COUNT(*) FROM seen_entries").fetchone()[0] + except sqlite3.Error: + return 0 + + def clear_history(self): + with self._connect() as conn: + conn.execute("DELETE FROM seen_entries") + conn.commit() + + # ---- helpers ----------------------------------------------------------- + @staticmethod + def clean_url(url): + """Strip query/params/fragment so tracking params don't change the hash.""" + try: + parts = urlparse(url) + return urlunparse((parts.scheme, parts.netloc, parts.path, "", "", "")) + except ValueError: + return url + + def _entry_hash(self, topic, entry): + ident = entry.get("id") or self.clean_url(entry.get("link", "")) + raw = f"{topic}_{ident}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + def _tz(self, settings): + try: + return ZoneInfo(settings.get("tz", "UTC")) + except Exception: + return timezone.utc + + def get_dynamic_priority(self, feed, settings): + base = int(feed.get("priority", 3)) + quiet = (feed.get("quiet_hours") or "").strip() + if not quiet: + return base + nums = re.findall(r"\d+", quiet) + if len(nums) < 2: + logger.warning("Invalid quiet_hours '%s' for feed %s", quiet, feed.get("name")) + return base + start, end = int(nums[0]) % 24, int(nums[1]) % 24 + hour = datetime.now(self._tz(settings)).hour + if start > end: # overnight window, e.g. 22-7 + is_quiet = hour >= start or hour < end + else: + is_quiet = start <= hour < end + if is_quiet: + return int(feed.get("quiet_priority", 1)) + return base + + @staticmethod + def should_filter(feed, title, summary): + """Return True if the entry must be dropped.""" + text = f"{title} {summary}".lower() + exclude = (feed.get("exclude_regex") or "").strip() + include = (feed.get("include_regex") or "").strip() + try: + if exclude and re.search(exclude, text): + return True + if include and not re.search(include, text): + return True + except re.error as exc: + logger.warning("Bad regex on feed %s: %s", feed.get("name"), exc) + return False + + def clean_html_content(self, html, max_len): + """Return (text, image_url) extracted from an HTML snippet.""" + if not html: + return "", None + soup = BeautifulSoup(html, "lxml") + image = None + img = soup.find("img") + if img and img.get("src"): + image = img["src"] + text = soup.get_text(separator=" ", strip=True) + if len(text) > max_len: + text = text[:max_len].rstrip() + "…" + return text, image + + # ---- ntfy -------------------------------------------------------------- + def send_ntfy(self, settings, topic, title, message, link, + priority, icon=None, attach=None, delay=None): + url = settings["ntfy_url"].rstrip("/") + "/" + topic + headers = { + "User-Agent": settings.get("user_agent", store_mod.DEFAULT_USER_AGENT), + "Title": title.encode("utf-8"), # bytes -> ntfy decodes UTF-8 + "Priority": str(priority), + "Tags": "newspaper", + "Markdown": "yes", + } + if link: + headers["Click"] = link + if settings.get("ntfy_token"): + headers["Authorization"] = f"Bearer {settings['ntfy_token']}" + if icon: + headers["Icon"] = icon + if attach: + headers["Attach"] = attach + if delay: + headers["Delay"] = delay + + resp = requests.post(url, data=message.encode("utf-8"), + headers=headers, timeout=30) + resp.raise_for_status() + return resp + + def send_test(self, topic, title="Test", message="rss-bridge-ntfy test notification"): + settings = self.store.get_settings() + self.send_ntfy(settings, topic, title, message, link=None, priority=3) + + # ---- sync -------------------------------------------------------------- + def sync(self, settings=None): + if not self._sync_lock.acquire(blocking=False): + logger.info("Sync already in progress, skipping trigger") + return + try: + settings = settings or self.store.get_settings() + self.status["syncing"] = True + self.status["last_error"] = None + sent_cycle = 0 + feeds = [f for f in self.store.get_feeds() if f["enabled"] and f["url"] and f["topic"]] + logger.info("Sync started: %d active feed(s)", len(feeds)) + + with self._connect() as conn: + for feed in feeds: + sent_cycle += self._process_feed(conn, settings, feed) + + self.status["sent_last_cycle"] = sent_cycle + self.status["sent_total"] += sent_cycle + self.status["last_sync"] = datetime.now(timezone.utc).isoformat() + self.status["last_sync_ok"] = True + logger.info("Sync finished: %d notification(s) sent", sent_cycle) + except Exception as exc: + self.status["last_sync_ok"] = False + self.status["last_error"] = str(exc) + logger.exception("Sync failed: %s", exc) + finally: + self.status["syncing"] = False + self._sync_lock.release() + + def _process_feed(self, conn, settings, feed): + name = feed.get("name") or feed.get("url") + try: + parsed = feedparser.parse( + feed["url"], agent=settings.get("user_agent", store_mod.DEFAULT_USER_AGENT) + ) + except Exception as exc: + logger.warning("Failed to fetch feed %s: %s", name, exc) + return 0 + + if parsed.bozo and not parsed.entries: + logger.warning("Feed %s returned no usable entries (%s)", name, parsed.get("bozo_exception")) + return 0 + + batch_limit = int(settings.get("batch_limit", 3)) + max_len = int(settings.get("max_desc_length", 250)) + flood = bool(settings.get("flood_protection", True)) + + # New (unseen) entries, oldest first, capped to the batch limit. + new_entries = [] + for entry in parsed.entries: + entry_hash = self._entry_hash(feed["topic"], entry) + if not self._seen(conn, entry_hash): + new_entries.append((entry_hash, entry)) + new_entries = list(reversed(new_entries))[:batch_limit] + + sent = 0 + for index, (entry_hash, entry) in enumerate(new_entries): + title = entry.get("title", "(no title)") + link = entry.get("link", "") + raw_html = "" + if entry.get("content"): + raw_html = entry["content"][0].get("value", "") + raw_html = raw_html or entry.get("summary", "") + description, image = self.clean_html_content(raw_html, max_len) + + if self.should_filter(feed, title, description): + self._mark_seen(conn, entry_hash, feed["topic"]) # drop quietly + continue + + priority = self.get_dynamic_priority(feed, settings) + + # Flood protection: stagger low priority items within the batch. + delay = None + if flood and priority < 4 and index > 0: + delay = f"{index * 5}m" + + read_more = {"ru": "Читать на сайте", "en": "Read on website"}.get( + settings.get("language", "ru"), "Читать на сайте") + body = f"**{feed.get('name', '')}**\n\n{description}" + if link: + body += f"\n\n[{read_more}]({link})" + + try: + self.send_ntfy( + settings, feed["topic"], title, body, link, + priority, icon=feed.get("icon") or None, + attach=image, delay=delay, + ) + self._mark_seen(conn, entry_hash, feed["topic"]) + sent += 1 + logger.info("Sent '%s' -> topic '%s' (priority %s)", title, feed["topic"], priority) + except Exception as exc: + logger.warning("Failed to send '%s' to '%s': %s", title, feed["topic"], exc) + + return sent diff --git a/main.py b/main.py new file mode 100644 index 0000000..278460b --- /dev/null +++ b/main.py @@ -0,0 +1,61 @@ +"""Entry point: wire up the store, the background engine and the web server. + +A single process hosts both the Flask web UI/API and the RSS->ntfy engine +(running in its own daemon thread). Served by waitress so it works the same on +Linux containers and Windows. +""" + +import logging +import os +import signal +import sys + +from waitress import serve + +import store as store_mod +from engine import Engine +from webapp import create_app + + +def setup_logging(engine: Engine): + logger = logging.getLogger("bridge") + logger.setLevel(logging.INFO) + fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S") + + stream = logging.StreamHandler(sys.stdout) + stream.setFormatter(fmt) + logger.addHandler(stream) + + file_handler = logging.FileHandler(store_mod.LOG_PATH, encoding="utf-8") + file_handler.setFormatter(fmt) + logger.addHandler(file_handler) + + logger.addHandler(engine.ring) # in-memory buffer for the web UI + logger.propagate = False + + +def main(): + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", "8080")) + + store = store_mod.Store() + engine = Engine(store) + setup_logging(engine) + + engine.start() + app = create_app(store, engine) + + def shutdown(signum, frame): # noqa: ARG001 + logging.getLogger("bridge").info("Shutting down (signal %s)", signum) + engine.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + logging.getLogger("bridge").info("Web UI on http://%s:%s", host, port) + serve(app, host=host, port=port, threads=8) + + +if __name__ == "__main__": + main() diff --git a/opml.py b/opml.py new file mode 100644 index 0000000..98a0749 --- /dev/null +++ b/opml.py @@ -0,0 +1,86 @@ +"""OPML import/export. + +Feeds are exported as an OPML 2.0 outline grouped by ntfy topic. ntfy-specific +options are stored as custom ``ntfy*`` attributes so an export/import round-trip +preserves everything. Import also accepts plain OPML files from other readers: +feeds without an ``ntfyTopic`` attribute inherit the parent outline's text as the +topic (falling back to ``rss``). +""" + +import xml.etree.ElementTree as ET + + +def _int(value, default): + try: + return int(value) + except (TypeError, ValueError): + return default + + +def feeds_to_opml(feeds): + opml = ET.Element("opml", version="2.0") + head = ET.SubElement(opml, "head") + ET.SubElement(head, "title").text = "rss-bridge-ntfy feeds" + body = ET.SubElement(opml, "body") + + # Group feeds by topic, preserving insertion order. + groups = {} + for feed in feeds: + groups.setdefault(feed.get("topic", "") or "feeds", []).append(feed) + + for topic, items in groups.items(): + group = ET.SubElement(body, "outline", text=topic, title=topic) + for feed in items: + label = feed.get("name") or feed.get("url", "") + ET.SubElement(group, "outline", **{ + "type": "rss", + "text": label, + "title": label, + "xmlUrl": feed.get("url", ""), + "htmlUrl": feed.get("url", ""), + "ntfyTopic": feed.get("topic", "") or "", + "ntfyPriority": str(feed.get("priority", 3)), + "ntfyIcon": feed.get("icon", "") or "", + "ntfyQuietHours": feed.get("quiet_hours", "") or "", + "ntfyQuietPriority": str(feed.get("quiet_priority", 1)), + "ntfyInclude": feed.get("include_regex", "") or "", + "ntfyExclude": feed.get("exclude_regex", "") or "", + "ntfyEnabled": "true" if feed.get("enabled", True) else "false", + }) + + xml = ET.tostring(opml, encoding="unicode") + return '\n' + xml + + +def opml_to_feeds(xml_str): + root = ET.fromstring(xml_str) + body = root.find("body") + if body is None: + return [] + + feeds = [] + + def walk(node, parent_topic): + for outline in node.findall("outline"): + xml_url = outline.get("xmlUrl") or outline.get("xmlurl") + text = outline.get("text") or outline.get("title") or "" + if xml_url: + topic = outline.get("ntfyTopic") or parent_topic or text or "rss" + feeds.append({ + "name": text or xml_url, + "url": xml_url, + "topic": topic, + "priority": _int(outline.get("ntfyPriority"), 3), + "icon": outline.get("ntfyIcon", "") or "", + "quiet_hours": outline.get("ntfyQuietHours", "") or "", + "quiet_priority": _int(outline.get("ntfyQuietPriority"), 1), + "include_regex": outline.get("ntfyInclude", "") or "", + "exclude_regex": outline.get("ntfyExclude", "") or "", + "enabled": (outline.get("ntfyEnabled", "true").lower() != "false"), + }) + # A container outline (no xmlUrl) defines the topic for its children. + child_topic = text if (not xml_url and text) else parent_topic + walk(outline, child_topic) + + walk(body, "") + return feeds diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b8da8a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +feedparser==6.0.11 +requests==2.31.0 +beautifulsoup4==4.12.3 +lxml==5.1.0 +Flask==3.0.3 +waitress==3.0.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..1c731b0 --- /dev/null +++ b/static/css/style.css @@ -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; } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..c5a8e15 --- /dev/null +++ b/static/js/app.js @@ -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) => + ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 `
${time} ${escapeHtml(l.message)}
`; + }).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 = `
${escapeHtml(t("feeds_empty"))}
`; + 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 = [`→ ${escapeHtml(f.topic)}`, + `${t("priority")} ${f.priority}`]; + if (f.quiet_hours) badges.push(`${t("quiet")} ${escapeHtml(f.quiet_hours)}`); + if (f.include_regex) badges.push(`include`); + if (f.exclude_regex) badges.push(`exclude`); + return ` +
+ +
+
${escapeHtml(f.name || t("no_name"))}
+
${escapeHtml(f.url)}
+
${badges.join("")}
+
+
+ + +
+
`; +} + +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 = `${escapeHtml(t("need_url"))}`; 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 = `${escapeHtml(t("err_prefix") + r.error)}`; + return; + } + const items = r.entries.map((it) => `
  • ${escapeHtml(it.title)}
  • `).join(""); + box.innerHTML = `${escapeHtml(t("preview_ok"))} «${escapeHtml(r.title)}», ` + + `${escapeHtml(t("preview_entries"))} ${r.count}
      ${items}
    `; + } catch (err) { box.innerHTML = `${escapeHtml(t("err_prefix") + err.message)}`; } +}); + +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); diff --git a/static/js/i18n.js b/static/js/i18n.js new file mode 100644 index 0000000..418da5c --- /dev/null +++ b/static/js/i18n.js @@ -0,0 +1,229 @@ +"use strict"; + +// Translation dictionary. Add a language by adding a key here and an