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
+200 -9
View File
@@ -2,30 +2,38 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from html import unescape from html import unescape
import httpx
from io import StringIO
import feedparser import feedparser
from sqlmodel import Session, select from sqlmodel import Session, select
from . import delivery from . import delivery
from .database import engine, get_settings from .database import engine, get_settings
from .delivery import Message from .delivery import Message
from .models import Feed, Notification, SeenEntry from .models import Article, DigestEntry, Feed, Notification, SeenEntry
log = logging.getLogger("checker") log = logging.getLogger("checker")
_TAG_RE = re.compile(r"<[^>]+>") _TAG_RE = re.compile(r"<[^>]+>")
_IMG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE) _IMG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
_VIDEO_RE = re.compile(
r'<(?:video|iframe|source|embed)[^>]*src=["\']([^"\']+)["\']', re.IGNORECASE
)
_ENC_VIDEO_RE = re.compile(r'<(?:video|iframe|source|embed)[^>]*>', re.IGNORECASE)
def _strip_html(text: str, limit: int = 1500) -> str: def _strip_html(text: str, limit: int = 1500) -> str:
text = unescape(_TAG_RE.sub(" ", text or "")) text = unescape(_TAG_RE.sub(" ", text or ""))
text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text).strip() text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text).strip()
if len(text) > limit: if limit and len(text) > limit:
text = text[:limit].rsplit(" ", 1)[0] + "" text = text[:limit].rsplit(" ", 1)[0] + ""
return text return text
@@ -57,6 +65,60 @@ def _extract_image(entry) -> str:
return match.group(1) if match else "" return match.group(1) if match else ""
def _extract_all_images(entry) -> list[str]:
"""Extract ALL image URLs from a feed entry (deduplicated, order preserved)."""
urls: list[str] = []
# media_content / media_thumbnail
for key in ("media_content", "media_thumbnail"):
media = entry.get(key)
if media and isinstance(media, list):
for item in media:
url = item.get("url")
if url:
urls.append(url)
# enclosure links with image/ type
for link in entry.get("links", []):
if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("image"):
href = link.get("href", "")
if href:
urls.append(href)
# <img> tags in HTML body
html = entry.get("summary") or entry.get("description") or ""
if not html:
content = entry.get("content")
if content and isinstance(content, list):
html = content[0].get("value", "")
urls.extend(_IMG_RE.findall(html or ""))
# deduplicate preserving order
return list(dict.fromkeys(urls))
def _extract_videos(entry) -> list[str]:
"""Extract video/multimedia URLs from a feed entry."""
urls: list[str] = []
# enclosure links with video/ type
for link in entry.get("links", []):
if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("video"):
href = link.get("href", "")
if href:
urls.append(href)
# <video>, <iframe>, <source>, <embed> tags in HTML body
html = entry.get("summary") or entry.get("description") or ""
if not html:
content = entry.get("content")
if content and isinstance(content, list):
html = content[0].get("value", "")
urls.extend(_VIDEO_RE.findall(html or ""))
return list(dict.fromkeys(urls))
def _passes_filters(feed: Feed, title: str, body: str) -> bool: def _passes_filters(feed: Feed, title: str, body: str) -> bool:
"""Keyword include/exclude check (case-insensitive).""" """Keyword include/exclude check (case-insensitive)."""
haystack = f"{title}\n{body}".lower() haystack = f"{title}\n{body}".lower()
@@ -69,9 +131,43 @@ def _passes_filters(feed: Feed, title: str, body: str) -> bool:
return True return True
def _parse(url: str): def _parse_raw(xml: str):
"""Blocking feedparser call (run in a thread).""" """Blocking feedparser call on XML string (run in a thread)."""
return feedparser.parse(url, agent="rss-ntfy/1.0 (+https://github.com)") return feedparser.parse(StringIO(xml), agent="rss-ntfy/1.0 (+https://github.com)")
async def _fetch_feed(url: str, proxy: str = "") -> str:
"""Download feed XML via httpx (supports proxy)."""
kw = {"timeout": 30}
if proxy.strip():
kw["proxy"] = proxy.strip()
async with httpx.AsyncClient(**kw) as client:
resp = await client.get(
url,
headers={"User-Agent": "rss-ntfy/1.0 (+https://github.com)"},
)
resp.raise_for_status()
return resp.text
def _extract_full_article(url: str) -> tuple[str, str]:
"""Fetch page and extract main article text via trafilatura.
Returns (plain_text, html) or ("", "") on failure.
"""
try:
import trafilatura
downloaded = trafilatura.fetch_url(url)
if downloaded is None:
return "", ""
plain = trafilatura.extract(
downloaded, output_format="txt", with_metadata=False
) or ""
html = trafilatura.extract(
downloaded, output_format="xml", with_metadata=False
) or ""
return plain.strip(), html.strip()
except Exception:
return "", ""
async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict: async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
@@ -79,7 +175,10 @@ async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
Raises ValueError if the feed can't be parsed or has no matching entries. Raises ValueError if the feed can't be parsed or has no matching entries.
""" """
parsed = await asyncio.to_thread(_parse, url) with Session(engine) as s:
proxy = get_settings(s).proxy_url
raw_xml = await _fetch_feed(url, proxy=proxy)
parsed = await asyncio.to_thread(_parse_raw, raw_xml)
if getattr(parsed, "bozo", False) and not parsed.entries: if getattr(parsed, "bozo", False) and not parsed.entries:
raise ValueError(str(getattr(parsed, "bozo_exception", "parse error"))) raise ValueError(str(getattr(parsed, "bozo_exception", "parse error")))
if not parsed.entries: if not parsed.entries:
@@ -104,7 +203,13 @@ async def fetch_preview(url: str, include: str = "", exclude: str = "") -> dict:
async def check_feed(feed: Feed) -> str: async def check_feed(feed: Feed) -> str:
"""Check a single feed, dispatch new entries, log history. Returns status.""" """Check a single feed, dispatch new entries, log history. Returns status."""
parsed = await asyncio.to_thread(_parse, feed.url) # Load settings early for proxy URL
with Session(engine) as _sess:
_settings = get_settings(_sess)
proxy_url = _settings.proxy_url
raw_xml = await _fetch_feed(feed.url, proxy=proxy_url)
parsed = await asyncio.to_thread(_parse_raw, raw_xml)
if getattr(parsed, "bozo", False) and not parsed.entries: if getattr(parsed, "bozo", False) and not parsed.entries:
exc = getattr(parsed, "bozo_exception", "parse error") exc = getattr(parsed, "bozo_exception", "parse error")
@@ -145,7 +250,24 @@ async def check_feed(feed: Feed) -> str:
continue continue
title = entry.get("title", "(без заголовка)") title = entry.get("title", "(без заголовка)")
body = _strip_html(entry.get("summary") or entry.get("description") or "") raw_html = entry.get("summary") or entry.get("description") or ""
link = entry.get("link", "")
full = db_feed.send_full_content
fetch_full = db_feed.fetch_full_article
body = _strip_html(raw_html, limit=0 if full else 1500)
# Trafilatura: extract full article text from the link page
if fetch_full and link and len(body) < 500:
try:
extra_text, extra_html = await asyncio.to_thread(
_extract_full_article, link
)
if extra_text:
body = extra_text
if extra_html:
raw_html = extra_html
except Exception:
pass # siliently fall back to RSS body
if not _passes_filters(db_feed, title, body): if not _passes_filters(db_feed, title, body):
skipped += 1 skipped += 1
@@ -155,9 +277,78 @@ async def check_feed(feed: Feed) -> str:
source=db_feed.title or feed_title, source=db_feed.title or feed_title,
title=title, title=title,
body=body, body=body,
link=entry.get("link", ""), link=link,
image=_extract_image(entry), image=_extract_image(entry),
images=_extract_all_images(entry) if full else [],
full_html=raw_html if full else "",
videos=_extract_videos(entry) if full else [],
full_content=full,
) )
# Store article for RSS reader (always, including first_run entries)
try:
existing_art = session.exec(
select(Article).where(
Article.feed_id == db_feed.id,
Article.link == link,
)
).first()
pub = entry.get("published_parsed")
pub_dt = None
if pub:
try:
pub_dt = datetime(*pub[:6], tzinfo=timezone.utc)
except Exception:
pass
if existing_art:
existing_art.title = title
existing_art.body = body
existing_art.full_html = raw_html
existing_art.image = msg.image
if pub_dt:
existing_art.published_at = pub_dt
session.add(existing_art)
else:
session.add(Article(
feed_id=db_feed.id,
feed_title=db_feed.title or feed_title,
title=title,
body=body,
full_html=raw_html,
link=link,
image=msg.image,
published_at=pub_dt,
))
except Exception:
pass # article storage is best-effort
# Digest mode: store instead of dispatching
if db_feed.digest_enabled:
session.add(DigestEntry(
feed_id=db_feed.id,
title=title,
link=link,
body=body,
image=msg.image,
full_html=raw_html if full else "",
images=json.dumps(msg.images) if full else "[]",
videos=json.dumps(msg.videos) if full else "[]",
full_content=full,
))
# Record as seen but skip dispatch
sent += 1
session.add(
Notification(
feed_id=db_feed.id,
feed_title=msg.source,
title=title,
link=msg.link,
channels="digest",
ok=True,
detail="queued for digest",
)
)
continue
result = await delivery.dispatch(db_feed, settings, msg) result = await delivery.dispatch(db_feed, settings, msg)
session.add( session.add(
+37 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
from . import config from . import config
from .auth import hash_password from .auth import hash_password
from .models import Settings, User from .models import Category, Settings, User
engine = create_engine( engine = create_engine(
config.DATABASE_URL, config.DATABASE_URL,
@@ -18,6 +18,33 @@ engine = create_engine(
) )
def _setup_fts() -> None:
"""Create FTS5 virtual table and triggers for full-text notification search."""
with engine.begin() as conn:
conn.execute(text(
"CREATE VIRTUAL TABLE IF NOT EXISTS notification_fts "
"USING fts5(title, body, content='notification', content_rowid='id')"
))
# Triggers to keep FTS index in sync
for trigger_sql in [
"""CREATE TRIGGER IF NOT EXISTS ntf_ai AFTER INSERT ON notification BEGIN
INSERT INTO notification_fts(rowid, title, body)
VALUES (new.id, new.title, new.feed_title);
END""",
"""CREATE TRIGGER IF NOT EXISTS ntf_ad AFTER DELETE ON notification BEGIN
INSERT INTO notification_fts(notification_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.feed_title);
END""",
"""CREATE TRIGGER IF NOT EXISTS ntf_au AFTER UPDATE ON notification BEGIN
INSERT INTO notification_fts(notification_fts, rowid, title, body)
VALUES ('delete', old.id, old.title, old.feed_title);
INSERT INTO notification_fts(rowid, title, body)
VALUES (new.id, new.title, new.feed_title);
END""",
]:
conn.execute(text(trigger_sql))
def _migrate() -> None: def _migrate() -> None:
"""Add any model columns missing from existing tables (SQLite ALTER ADD). """Add any model columns missing from existing tables (SQLite ALTER ADD).
@@ -60,6 +87,7 @@ def init_db() -> None:
"""Create tables, run migration, ensure settings + admin user exist.""" """Create tables, run migration, ensure settings + admin user exist."""
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_migrate() _migrate()
_setup_fts()
with Session(engine) as session: with Session(engine) as session:
if session.get(Settings, 1) is None: if session.get(Settings, 1) is None:
session.add( session.add(
@@ -83,6 +111,14 @@ def init_db() -> None:
) )
session.commit() session.commit()
# Bootstrap the "Общее" (General) category.
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general is None:
session.add(Category(name="Общее", sort_order=0))
session.commit()
def get_settings(session: Session) -> Settings: def get_settings(session: Session) -> Settings:
settings = session.get(Settings, 1) settings = session.get(Settings, 1)
+63 -3
View File
@@ -24,7 +24,11 @@ class Message:
title: str # entry title title: str # entry title
body: str # plain-text summary body: str # plain-text summary
link: str = "" link: str = ""
image: str = "" # image URL, if any image: str = "" # first image URL, for the Attach header
images: list[str] = field(default_factory=list) # all images (full_content mode)
full_html: str = "" # raw HTML body (full_content mode)
videos: list[str] = field(default_factory=list) # video URLs (full_content mode)
full_content: bool = False
@dataclass @dataclass
@@ -51,7 +55,13 @@ async def _send_telegram(settings: Settings, msg: Message) -> None:
if msg.source: if msg.source:
text = f"📡 <i>{_esc(msg.source)}</i>\n{text}" text = f"📡 <i>{_esc(msg.source)}</i>\n{text}"
if msg.body: if msg.body:
text += f"\n\n{_esc(msg.body[:600])}" limit = 3500 if msg.full_content else 600
text += f"\n\n{_esc(msg.body[:limit])}"
if msg.full_content:
for img_url in msg.images[:5]:
text += f'\n<a href="{_esc(img_url)}">🖼️ image</a>'
for vid_url in msg.videos[:3]:
text += f'\n<a href="{_esc(vid_url)}">🎬 video</a>'
if msg.link: if msg.link:
text += f'\n\n<a href="{_esc(msg.link)}">Открыть →</a>' text += f'\n\n<a href="{_esc(msg.link)}">Открыть →</a>'
@@ -80,6 +90,10 @@ async def _send_webhook(settings: Settings, feed: Feed, msg: Message) -> None:
"link": msg.link, "link": msg.link,
"image": msg.image, "image": msg.image,
} }
if msg.full_content:
payload["images"] = msg.images
payload["videos"] = msg.videos
payload["full_html"] = msg.full_html
async with httpx.AsyncClient(timeout=20) as client: async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(url, json=payload) resp = await client.post(url, json=payload)
resp.raise_for_status() resp.raise_for_status()
@@ -95,6 +109,26 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
server = feed.ntfy_server.strip() or settings.default_ntfy_server server = feed.ntfy_server.strip() or settings.default_ntfy_server
full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title full_title = f"{msg.source}: {msg.title}" if msg.source else msg.title
# Apply notification template to body
template = settings.notification_template or "{title}\n\n{body}\n\n{link}"
try:
formatted_body = template.replace("\\n", "\n").format(
title=msg.title,
body=msg.body,
link=msg.link,
source=msg.source,
image_url=msg.image,
)
except (KeyError, ValueError):
formatted_body = msg.body # fallback on template error
# Build default actions for ntfy
ntfy_actions = []
if msg.link:
ntfy_actions.append({"action": "view", "label": "Открыть статью", "url": msg.link})
if feed.url:
ntfy_actions.append({"action": "view", "label": "Открыть ленту", "url": feed.url})
# Per-feed auth wins; otherwise fall back to the default-server credentials. # Per-feed auth wins; otherwise fall back to the default-server credentials.
has_feed_auth = bool(feed.ntfy_token.strip() or feed.ntfy_username.strip()) has_feed_auth = bool(feed.ntfy_token.strip() or feed.ntfy_username.strip())
token = feed.ntfy_token if has_feed_auth else settings.default_ntfy_token token = feed.ntfy_token if has_feed_auth else settings.default_ntfy_token
@@ -104,11 +138,20 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
# --- ntfy (default channel; requires a topic) --- # --- ntfy (default channel; requires a topic) ---
if feed.ntfy_topic.strip(): if feed.ntfy_topic.strip():
try: try:
if msg.full_content:
# Build markdown body with all images inlined + template applied.
md_body = formatted_body
if msg.images:
for img_url in msg.images:
md_body += f"\n\n![image]({img_url})"
if msg.videos:
for vid_url in msg.videos:
md_body += f"\n\n📹 {vid_url}"
await ntfy.publish( await ntfy.publish(
server=server, server=server,
topic=feed.ntfy_topic, topic=feed.ntfy_topic,
title=full_title, title=full_title,
message=msg.body or "(нет описания)", message=md_body or "(нет описания)",
click=msg.link, click=msg.link,
tags=feed.tags, tags=feed.tags,
priority=feed.priority, priority=feed.priority,
@@ -116,6 +159,23 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
token=token, token=token,
username=username, username=username,
password=password, password=password,
markdown=True,
actions=ntfy_actions,
)
else:
await ntfy.publish(
server=server,
topic=feed.ntfy_topic,
title=full_title,
message=formatted_body or "(нет описания)",
click=msg.link,
tags=feed.tags,
priority=feed.priority,
attach=msg.image if feed.attach_image else "",
token=token,
username=username,
password=password,
actions=ntfy_actions,
) )
result.channels.append("ntfy") result.channels.append("ntfy")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
+383 -5
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile
from fastapi.responses import ( from fastapi.responses import (
FileResponse,
HTMLResponse, HTMLResponse,
JSONResponse, JSONResponse,
PlainTextResponse, PlainTextResponse,
@@ -16,7 +17,7 @@ from fastapi.responses import (
) )
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import Integer from sqlalchemy import Integer, text
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -24,8 +25,8 @@ from . import config, ntfy, opml, scheduler
from .auth import hash_password, verify_password from .auth import hash_password, verify_password
from .checker import check_feed, fetch_preview from .checker import check_feed, fetch_preview
from .database import engine, get_session, get_settings, init_db from .database import engine, get_session, get_settings, init_db
from .models import Feed, Notification, SeenEntry, User from .models import Article, Category, Feed, Notification, SeenEntry, User
from .schemas import FeedIn, PreviewIn, SettingsIn, TestIn, UserIn from .schemas import CategoryIn, FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -160,6 +161,11 @@ def _feed_dict(feed: Feed) -> dict:
"filter_exclude": feed.filter_exclude, "filter_exclude": feed.filter_exclude,
"interval": feed.interval, "interval": feed.interval,
"enabled": feed.enabled, "enabled": feed.enabled,
"send_full_content": feed.send_full_content,
"fetch_full_article": feed.fetch_full_article,
"digest_enabled": feed.digest_enabled,
"digest_period_hours": feed.digest_period_hours,
"category_id": feed.category_id,
"last_checked": feed.last_checked.isoformat() if feed.last_checked else None, "last_checked": feed.last_checked.isoformat() if feed.last_checked else None,
"last_status": feed.last_status, "last_status": feed.last_status,
"error_streak": feed.error_streak, "error_streak": feed.error_streak,
@@ -178,7 +184,14 @@ def create_feed(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_: User = Depends(require_admin), _: User = Depends(require_admin),
): ):
feed = Feed(**data.model_dump()) feed_data = data.model_dump()
if feed_data.get("category_id") is None:
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general:
feed_data["category_id"] = general.id
feed = Feed(**feed_data)
session.add(feed) session.add(feed)
session.commit() session.commit()
session.refresh(feed) session.refresh(feed)
@@ -269,6 +282,79 @@ async def import_feeds(
return {"ok": True, "added": added, "total": len(items)} return {"ok": True, "added": added, "total": len(items)}
# --------------------------------------------------------------------------- #
# API: categories
# --------------------------------------------------------------------------- #
def _category_dict(c: Category) -> dict:
return {"id": c.id, "name": c.name, "sort_order": c.sort_order}
@app.get("/api/categories")
def list_categories(
session: Session = Depends(get_session), _: User = Depends(require_auth)
):
cats = session.exec(select(Category).order_by(Category.sort_order, Category.id)).all()
return [_category_dict(c) for c in cats]
@app.post("/api/categories")
def create_category(
data: CategoryIn,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
if session.exec(select(Category).where(Category.name == data.name)).first():
raise HTTPException(400, "Категория с таким названием уже существует")
cat = Category(**data.model_dump())
session.add(cat)
session.commit()
session.refresh(cat)
return _category_dict(cat)
@app.put("/api/categories/{cat_id}")
def update_category(
cat_id: int,
data: CategoryIn,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
cat = session.get(Category, cat_id)
if cat is None:
raise HTTPException(404, "Категория не найдена")
dup = session.exec(
select(Category).where(Category.name == data.name, Category.id != cat_id)
).first()
if dup:
raise HTTPException(400, "Категория с таким названием уже существует")
cat.name = data.name
cat.sort_order = data.sort_order
session.add(cat)
session.commit()
session.refresh(cat)
return _category_dict(cat)
@app.delete("/api/categories/{cat_id}")
def delete_category(
cat_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_admin),
):
cat = session.get(Category, cat_id)
if cat is None:
raise HTTPException(404, "Категория не найдена")
# Unlink feeds from this category before deleting.
for feed in session.exec(
select(Feed).where(Feed.category_id == cat_id)
).all():
feed.category_id = None
session.add(feed)
session.delete(cat)
session.commit()
return {"ok": True}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# API: history & stats # API: history & stats
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -283,7 +369,20 @@ def history(
limit = min(500, max(1, limit)) limit = min(500, max(1, limit))
query = select(Notification) query = select(Notification)
if q.strip(): if q.strip():
like = f"%{q.strip()}%" qs = q.strip()
# Try FTS5 full-text search first
try:
fts_rows = session.exec(
text("SELECT rowid FROM notification_fts WHERE notification_fts MATCH :q"),
{"q": qs},
).all()
if fts_rows:
matched_ids = [r[0] for r in fts_rows]
query = query.where(Notification.id.in_(matched_ids))
else:
raise ValueError # force fallback
except Exception:
like = f"%{qs}%"
query = query.where( query = query.where(
Notification.title.ilike(like) | Notification.feed_title.ilike(like) Notification.title.ilike(like) | Notification.feed_title.ilike(like)
) )
@@ -376,6 +475,78 @@ async def preview(
raise HTTPException(502, f"Не удалось загрузить ленту: {exc}") raise HTTPException(502, f"Не удалось загрузить ленту: {exc}")
# --------------------------------------------------------------------------- #
# API: backup / restore (admin only)
# --------------------------------------------------------------------------- #
@app.get("/api/backup")
def download_backup(_: User = Depends(require_admin)):
db_path = config.DATA_DIR / "app.db"
if not db_path.exists():
raise HTTPException(404, "База данных не найдена")
now = datetime.now().strftime("%Y-%m-%d")
return FileResponse(
db_path,
media_type="application/octet-stream",
filename=f"rss-ntfy-backup-{now}.db",
)
@app.post("/api/backup")
async def upload_backup(
file: UploadFile,
_: User = Depends(require_admin),
):
import shutil
if not file.filename or not file.filename.endswith(".db"):
raise HTTPException(400, "Ожидается файл .db")
tmp_path = config.DATA_DIR / "restore_tmp.db"
content_bytes = await file.read()
tmp_path.write_bytes(content_bytes)
try:
import sqlite3
conn = sqlite3.connect(str(tmp_path))
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
required = {"feed", "settings", "seenentry"}
missing = required - tables
if missing:
raise HTTPException(
400, f"В файле не хватает таблиц: {', '.join(sorted(missing))}"
)
conn.close()
db_path = config.DATA_DIR / "app.db"
backup_path = (
config.DATA_DIR
/ f"app.db.bak-{datetime.now().strftime('%Y%m%d%H%M%S')}"
)
if db_path.exists():
shutil.copy2(db_path, backup_path)
shutil.copy2(tmp_path, db_path)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(400, f"Невалидный файл: {exc}")
finally:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
scheduler.shutdown()
with Session(engine) as session:
interval = get_settings(session).check_interval
scheduler.start(interval)
return {"ok": True}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# API: settings # API: settings
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -397,6 +568,12 @@ def read_settings(session: Session = Depends(get_session), _: User = Depends(req
"alerts_enabled": s.alerts_enabled, "alerts_enabled": s.alerts_enabled,
"alert_topic": s.alert_topic, "alert_topic": s.alert_topic,
"alert_threshold": s.alert_threshold, "alert_threshold": s.alert_threshold,
"default_priority": s.default_priority,
"notification_template": s.notification_template,
"proxy_url": s.proxy_url,
"default_tags": s.default_tags,
"default_attach_image": s.default_attach_image,
"default_interval": s.default_interval,
} }
@@ -426,6 +603,12 @@ def write_settings(
s.alerts_enabled = data.alerts_enabled s.alerts_enabled = data.alerts_enabled
s.alert_topic = data.alert_topic.strip() s.alert_topic = data.alert_topic.strip()
s.alert_threshold = data.alert_threshold s.alert_threshold = data.alert_threshold
s.default_priority = data.default_priority
s.default_tags = data.default_tags.strip()
s.default_attach_image = data.default_attach_image
s.default_interval = data.default_interval
s.notification_template = data.notification_template
s.proxy_url = data.proxy_url.strip()
session.add(s) session.add(s)
session.commit() session.commit()
@@ -514,6 +697,201 @@ def delete_user(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# API: articles (RSS reader)
# --------------------------------------------------------------------------- #
@app.get("/api/articles")
def list_articles(
category_id: int | None = None,
feed_id: int | None = None,
unread: bool = False,
q: str = "",
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
limit = min(200, max(1, limit))
query = select(Article)
if category_id is not None:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == category_id)
).all()
]
# "Общее" category also includes feeds with NULL category
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general and category_id == general.id:
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
if feed_ids:
query = query.where(Article.feed_id.in_(feed_ids))
else:
return []
if feed_id is not None:
query = query.where(Article.feed_id == feed_id)
if unread:
query = query.where(Article.is_read == False) # noqa: E712
if q.strip():
like = f"%{q.strip()}%"
query = query.where(
Article.title.ilike(like) | Article.body.ilike(like)
)
articles = session.exec(
query.order_by(Article.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return [
{
"id": a.id,
"feed_id": a.feed_id,
"feed_title": a.feed_title,
"title": a.title,
"body": a.body[:300] + ("..." if a.body and len(a.body) > 300 else ""),
"link": a.link,
"image": a.image,
"published_at": a.published_at.isoformat() if a.published_at else None,
"is_read": a.is_read,
"created_at": a.created_at.isoformat(),
}
for a in articles
]
@app.get("/api/articles/stats")
def article_stats(
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
"""Unread article counts per category."""
cats = session.exec(
select(Category).order_by(Category.sort_order, Category.id)
).all()
result = []
for cat in cats:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == cat.id)
).all()
]
if cat.name == "Общее":
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
count = 0
if feed_ids:
count = session.exec(
select(func.count())
.select_from(Article)
.where(
Article.feed_id.in_(feed_ids),
Article.is_read == False, # noqa: E712
)
).one()
result.append(
{"category_id": cat.id, "category_name": cat.name, "unread": count}
)
return result
@app.put("/api/articles/read-all")
def mark_all_read(
category_id: int | None = None,
feed_id: int | None = None,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
query = select(Article).where(Article.is_read == False) # noqa: E712
if category_id is not None:
feed_ids = [
f.id
for f in session.exec(
select(Feed).where(Feed.category_id == category_id)
).all()
]
general = session.exec(
select(Category).where(Category.name == "Общее")
).first()
if general and category_id == general.id:
feeds_no_cat = session.exec(
select(Feed).where(Feed.category_id == None) # noqa: E711
).all()
feed_ids.extend(f.id for f in feeds_no_cat)
if feed_ids:
query = query.where(Article.feed_id.in_(feed_ids))
else:
return {"marked": 0}
if feed_id is not None:
query = query.where(Article.feed_id == feed_id)
articles = session.exec(query).all()
for a in articles:
a.is_read = True
session.add(a)
session.commit()
return {"marked": len(articles)}
@app.get("/api/articles/{article_id}")
def get_article(
article_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
a = session.get(Article, article_id)
if a is None:
raise HTTPException(404, "Статья не найдена")
# Mark as read on view
if not a.is_read:
a.is_read = True
session.add(a)
session.commit()
session.refresh(a)
return {
"id": a.id,
"feed_id": a.feed_id,
"feed_title": a.feed_title,
"title": a.title,
"body": a.body,
"full_html": a.full_html,
"link": a.link,
"image": a.image,
"published_at": a.published_at.isoformat() if a.published_at else None,
"is_read": a.is_read,
"created_at": a.created_at.isoformat(),
}
@app.put("/api/articles/{article_id}/read")
def mark_read(
article_id: int,
session: Session = Depends(get_session),
_: User = Depends(require_auth),
):
a = session.get(Article, article_id)
if a is None:
raise HTTPException(404, "Статья не найдена")
a.is_read = True
session.add(a)
session.commit()
return {"ok": True}
# API: test notification # API: test notification
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@app.post("/api/test") @app.post("/api/test")
+60
View File
@@ -44,6 +44,14 @@ class Feed(SQLModel, table=True):
interval: int = 0 interval: int = 0
enabled: bool = True enabled: bool = True
send_full_content: bool = False # send entire article: all text, images, videos
fetch_full_article: bool = False # trafilatura: extract full page content from link
digest_enabled: bool = False # accumulate entries instead of real-time dispatch
digest_period_hours: int = 24 # how often to send the digest (1=hourly, 24=daily, 168=weekly)
last_digest_at: Optional[datetime] = None
# --- category ---
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
# --- state --- # --- state ---
last_checked: Optional[datetime] = None last_checked: Optional[datetime] = None
@@ -52,6 +60,46 @@ class Feed(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow) created_at: datetime = Field(default_factory=_utcnow)
class Category(SQLModel, table=True):
"""Admin-defined category for organizing feeds."""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
sort_order: int = 0
class Article(SQLModel, table=True):
"""Stored feed entries for the built-in RSS reader."""
id: Optional[int] = Field(default=None, primary_key=True)
feed_id: int = Field(index=True, foreign_key="feed.id")
feed_title: str = ""
title: str = ""
body: str = ""
full_html: str = ""
link: str = ""
image: str = ""
published_at: Optional[datetime] = None
is_read: bool = False
created_at: datetime = Field(default_factory=_utcnow, index=True)
class DigestEntry(SQLModel, table=True):
"""Pending entries for digest-enabled feeds."""
id: Optional[int] = Field(default=None, primary_key=True)
feed_id: int = Field(index=True, foreign_key="feed.id")
title: str = ""
link: str = ""
body: str = ""
image: str = ""
full_html: str = ""
images: str = "" # JSON array
videos: str = "" # JSON array
full_content: bool = False
created_at: datetime = Field(default_factory=_utcnow)
class SeenEntry(SQLModel, table=True): class SeenEntry(SQLModel, table=True):
"""Tracks which feed entries have already been pushed to avoid duplicates.""" """Tracks which feed entries have already been pushed to avoid duplicates."""
@@ -113,3 +161,15 @@ class Settings(SQLModel, table=True):
alerts_enabled: bool = False alerts_enabled: bool = False
alert_topic: str = "" # ntfy topic to notify when a feed keeps failing alert_topic: str = "" # ntfy topic to notify when a feed keeps failing
alert_threshold: int = 3 # consecutive failures before alerting alert_threshold: int = 3 # consecutive failures before alerting
# --- Default values for new feeds (pre-fill the "Add feed" form) ---
default_priority: int = 3
default_tags: str = ""
default_attach_image: bool = True
default_interval: int = 0
# --- Notification template ---
notification_template: str = "**{title}**\n\n{body}\n\n[Открыть \u2192]({link})"
# --- Proxy for RSS fetching ---
proxy_url: str = ""
+13
View File
@@ -36,6 +36,8 @@ async def publish(
token: str = "", token: str = "",
username: str = "", username: str = "",
password: str = "", password: str = "",
markdown: bool = False,
actions: list[dict] | None = None,
) -> None: ) -> None:
"""Send one notification to ntfy. Raises httpx.HTTPStatusError on failure. """Send one notification to ntfy. Raises httpx.HTTPStatusError on failure.
@@ -69,6 +71,17 @@ async def publish(
headers["Attach"] = attach headers["Attach"] = attach
except UnicodeEncodeError: except UnicodeEncodeError:
pass pass
if markdown:
headers["Content-Type"] = "text/markdown"
if actions:
parts = []
for a in actions:
act = a.get("action", "view")
label = a.get("label", "")
url = a.get("url", "")
clear = ", clear=true" if a.get("clear") else ""
parts.append(f"{act}, {label}, {url}{clear}")
headers["Actions"] = "; ".join(parts)
async with httpx.AsyncClient(timeout=20) as client: async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(url, content=message.encode("utf-8"), headers=headers) resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
+89
View File
@@ -7,16 +7,98 @@ import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timezone
from sqlmodel import Session, select
from . import delivery
from .checker import check_all_feeds from .checker import check_all_feeds
from .database import engine, get_settings
from .delivery import Message
from .models import DigestEntry, Feed, Notification
log = logging.getLogger("scheduler") log = logging.getLogger("scheduler")
_scheduler: AsyncIOScheduler | None = None _scheduler: AsyncIOScheduler | None = None
_JOB_ID = "check-feeds" _JOB_ID = "check-feeds"
_JOB_DIGEST = "send-digests"
# Fixed tick; per-feed/global intervals are honoured inside check_all_feeds. # Fixed tick; per-feed/global intervals are honoured inside check_all_feeds.
_TICK_SECONDS = 60 _TICK_SECONDS = 60
async def send_due_digests() -> None:
"""Check each digest-enabled feed and send pending entries."""
now = datetime.now(timezone.utc)
with Session(engine) as session:
settings = get_settings(session)
feeds = session.exec(
select(Feed).where(
Feed.enabled == True, # noqa: E712
Feed.digest_enabled == True, # noqa: E712
)
).all()
for feed in feeds:
period_hours = max(1, feed.digest_period_hours)
if feed.last_digest_at is not None:
last = feed.last_digest_at.replace(tzinfo=timezone.utc) if feed.last_digest_at.tzinfo is None else feed.last_digest_at
if (now - last).total_seconds() < period_hours * 3600:
continue
with Session(engine) as session:
entries = session.exec(
select(DigestEntry)
.where(DigestEntry.feed_id == feed.id)
.order_by(DigestEntry.created_at)
).all()
if not entries:
continue
db_feed = session.get(Feed, feed.id)
stg = get_settings(session)
# Build digest message as Markdown list
lines = [f"# 📡 {db_feed.title or 'Дайджест'}\n"]
for e in entries:
lines.append(f"**[{e.title}]({e.link})**")
if e.body:
preview = e.body[:200].replace("\n", " ")
lines.append(f"> {preview}")
lines.append("")
digest_msg = Message(
source=db_feed.title or "",
title=f"Дайджест ({len(entries)} записей)",
body="\n".join(lines),
link="",
image="",
full_content=True,
)
result = await delivery.dispatch(db_feed, stg, digest_msg)
session.add(
Notification(
feed_id=db_feed.id,
feed_title=digest_msg.source,
title=digest_msg.title,
link="",
channels=",".join(result.channels),
ok=result.ok,
detail=result.detail,
)
)
# Clear sent entries
for e in entries:
session.delete(e)
db_feed.last_digest_at = now
db_feed.last_checked = now
db_feed.last_status = f"digest:{len(entries)}"
session.commit()
def start(interval_minutes: int) -> None: def start(interval_minutes: int) -> None:
global _scheduler global _scheduler
if _scheduler is not None: if _scheduler is not None:
@@ -30,6 +112,13 @@ def start(interval_minutes: int) -> None:
coalesce=True, coalesce=True,
replace_existing=True, replace_existing=True,
) )
_scheduler.add_job(
send_due_digests,
trigger=IntervalTrigger(seconds=300), # every 5 minutes
id=_JOB_DIGEST,
max_instances=1,
coalesce=True,
)
_scheduler.start() _scheduler.start()
log.info("Планировщик запущен (тик 60с), интервал по умолчанию %d мин", interval_minutes) log.info("Планировщик запущен (тик 60с), интервал по умолчанию %d мин", interval_minutes)
+35
View File
@@ -23,6 +23,11 @@ class FeedIn(BaseModel):
filter_exclude: str = "" filter_exclude: str = ""
interval: int = 0 interval: int = 0
enabled: bool = True enabled: bool = True
send_full_content: bool = False
fetch_full_article: bool = False
digest_enabled: bool = False
digest_period_hours: int = 24
category_id: Optional[int] = None
@field_validator("url") @field_validator("url")
@classmethod @classmethod
@@ -63,6 +68,13 @@ class SettingsIn(BaseModel):
alerts_enabled: bool = False alerts_enabled: bool = False
alert_topic: str = "" alert_topic: str = ""
alert_threshold: int = 3 alert_threshold: int = 3
# Default feed values
default_priority: int = 3
default_tags: str = ""
default_attach_image: bool = True
default_interval: int = 0
notification_template: str = "**{title}**\n\n{body}\n\n[Открыть →]({link})"
proxy_url: str = ""
@field_validator("check_interval") @field_validator("check_interval")
@classmethod @classmethod
@@ -74,6 +86,16 @@ class SettingsIn(BaseModel):
def _threshold_min(cls, v: int) -> int: def _threshold_min(cls, v: int) -> int:
return max(1, v) return max(1, v)
@field_validator("default_priority")
@classmethod
def _def_priority_range(cls, v: int) -> int:
return min(5, max(1, v))
@field_validator("default_interval")
@classmethod
def _def_interval_nonneg(cls, v: int) -> int:
return max(0, v)
class TestIn(BaseModel): class TestIn(BaseModel):
server: str = "" server: str = ""
@@ -98,6 +120,19 @@ class PreviewIn(BaseModel):
return v return v
class CategoryIn(BaseModel):
name: str
sort_order: int = 0
@field_validator("name")
@classmethod
def _name_required(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Название категории обязательно")
return v
class UserIn(BaseModel): class UserIn(BaseModel):
username: str username: str
password: str = "" # empty on edit = keep existing password: str = "" # empty on edit = keep existing
+247 -8
View File
@@ -4,6 +4,8 @@ const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
let ME = { role: "admin", auth_enabled: false }; let ME = { role: "admin", auth_enabled: false };
let SETTINGS = {};
let CATEGORIES = [];
// ---------- API helper ---------- // ---------- API helper ----------
async function api(method, url, body) { 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>`; `<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 ---------- // ---------- Feeds ----------
function feedCard(f) { function feedCard(f) {
const el = document.createElement("div"); 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.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</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.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"; const admin = ME.role === "admin";
el.innerHTML = ` el.innerHTML = `
@@ -185,12 +273,28 @@ function openModal(feed) {
$("#preview-area").innerHTML = ""; $("#preview-area").innerHTML = "";
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed"); $("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
feedForm.id.value = feed?.id || ""; 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) { for (const el of feedForm.elements) {
if (!el.name || el.name === "id") continue; if (!el.name || el.name === "id") continue;
if (el.type === "checkbox") el.checked = !!f[el.name]; if (el.type === "checkbox") el.checked = !!f[el.name];
else if (f[el.name] !== undefined) el.value = 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"); modal.classList.remove("hidden");
} }
function closeModal() { modal.classList.add("hidden"); } function closeModal() { modal.classList.add("hidden"); }
@@ -240,9 +344,14 @@ feedForm.addEventListener("submit", async e => {
filter_include: feedForm.filter_include.value.trim(), filter_include: feedForm.filter_include.value.trim(),
filter_exclude: feedForm.filter_exclude.value.trim(), filter_exclude: feedForm.filter_exclude.value.trim(),
attach_image: feedForm.attach_image.checked, 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_telegram: feedForm.to_telegram.checked,
to_webhook: feedForm.to_webhook.checked, to_webhook: feedForm.to_webhook.checked,
enabled: feedForm.enabled.checked, enabled: feedForm.enabled.checked,
category_id: feedForm.category_id.value || null,
}; };
const id = feedForm.id.value; const id = feedForm.id.value;
try { try {
@@ -267,6 +376,25 @@ $("#check-all").onclick = async (e) => {
}; };
// ---------- OPML ---------- // ---------- 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"; }; $("#export-btn").onclick = () => { location.href = "/api/feeds/export"; };
$("#import-btn").onclick = () => $("#opml-file").click(); $("#import-btn").onclick = () => $("#opml-file").click();
$("#opml-file").onchange = async (e) => { $("#opml-file").onchange = async (e) => {
@@ -398,11 +526,11 @@ userForm.addEventListener("submit", async e => {
const sForm = $("#settings-form"); const sForm = $("#settings-form");
async function loadSettings() { async function loadSettings() {
const s = await api("GET", "/api/settings"); SETTINGS = await api("GET", "/api/settings");
for (const el of sForm.elements) { for (const el of sForm.elements) {
if (!el.name) continue; if (!el.name) continue;
if (el.type === "checkbox") el.checked = !!s[el.name]; if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
else if (s[el.name] !== undefined) el.value = s[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, alerts_enabled: sForm.alerts_enabled.checked,
alert_topic: sForm.alert_topic.value.trim(), alert_topic: sForm.alert_topic.value.trim(),
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3, 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 { try {
await api("PUT", "/api/settings", payload); await api("PUT", "/api/settings", payload);
@@ -452,6 +586,8 @@ $$(".tab").forEach(tab => tab.addEventListener("click", () => {
tab.classList.add("active"); tab.classList.add("active");
$(`#tab-${tab.dataset.tab}`).classList.add("active"); $(`#tab-${tab.dataset.tab}`).classList.add("active");
if (tab.dataset.tab === "history") loadHistory().catch(() => {}); 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(() => {}); if (tab.dataset.tab === "users") loadUsers().catch(() => {});
})); }));
@@ -467,6 +603,8 @@ langSelect.onchange = () => {
// Re-render dynamic content in the new language. // Re-render dynamic content in the new language.
loadFeeds().catch(() => {}); loadFeeds().catch(() => {});
if ($("#tab-history").classList.contains("active")) loadHistory().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(() => {}); 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 ---------- // ---------- init ----------
async function init() { async function init() {
applyI18n(); applyI18n();
try { ME = await api("GET", "/api/me"); } catch (_) {} try { ME = await api("GET", "/api/me"); } catch (_) {}
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden")); if (ME.role !== "admin") $$(".admin-only").forEach(function(el) { el.classList.add("hidden"); });
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); } if (ME.auth_enabled) { document.getElementById("logout-btn").style.display = ""; renderWhoami(); }
loadFeeds().catch(e => toast(e.message, "err")); if (ME.role === "admin") {
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err")); loadCategories().catch(function() {});
loadSettings().catch(function(e) { toast(e.message, "err"); });
}
loadFeeds().catch(function(e) { toast(e.message, "err"); });
} }
init(); init();
+108
View File
@@ -142,6 +142,60 @@ const I18N = {
"status.sendError": "Ошибка отправки: {msg}", "status.sendError": "Ошибка отправки: {msg}",
"status.dash": "—", "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.admin": "админ",
"role.viewer": "наблюдатель", "role.viewer": "наблюдатель",
"login.subtitle": "Войдите, чтобы продолжить", "login.subtitle": "Войдите, чтобы продолжить",
@@ -291,6 +345,60 @@ const I18N = {
"status.sendError": "Send error: {msg}", "status.sendError": "Send error: {msg}",
"status.dash": "—", "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.admin": "admin",
"role.viewer": "viewer", "role.viewer": "viewer",
"login.subtitle": "Sign in to continue", "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.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; } .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 { font-size: 12.5px; color: var(--muted); }
.feed-status .ok { color: var(--success); } .feed-status .ok { color: var(--success); }
.feed-status .err { color: var(--danger); } .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-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; } .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; } .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; }
+109
View File
@@ -5,7 +5,9 @@
<div class="brand"><span class="logo">📡</span> RSS&nbsp;&nbsp;ntfy</div> <div class="brand"><span class="logo">📡</span> RSS&nbsp;&nbsp;ntfy</div>
<nav class="tabs"> <nav class="tabs">
<button class="tab active" data-tab="feeds" data-i18n="nav.feeds">Ленты</button> <button class="tab active" data-tab="feeds" data-i18n="nav.feeds">Ленты</button>
<button class="tab" data-tab="reader" data-i18n="nav.reader">Чтение</button>
<button class="tab" data-tab="history" data-i18n="nav.history">История</button> <button class="tab" data-tab="history" data-i18n="nav.history">История</button>
<button class="tab admin-only" data-tab="categories" data-i18n="nav.categories">Категории</button>
<button class="tab admin-only" data-tab="users" data-i18n="nav.users">Пользователи</button> <button class="tab admin-only" data-tab="users" data-i18n="nav.users">Пользователи</button>
<button class="tab admin-only" data-tab="settings" data-i18n="nav.settings">Настройки</button> <button class="tab admin-only" data-tab="settings" data-i18n="nav.settings">Настройки</button>
</nav> </nav>
@@ -39,6 +41,9 @@
<div class="panel-head-actions"> <div class="panel-head-actions">
<button class="btn ghost" id="check-all" data-i18n="feeds.checkAll">↻ Проверить все</button> <button class="btn ghost" id="check-all" data-i18n="feeds.checkAll">↻ Проверить все</button>
<button class="btn ghost admin-only" id="import-btn" data-i18n="feeds.import">⬆ Импорт OPML</button> <button class="btn ghost admin-only" id="import-btn" data-i18n="feeds.import">⬆ Импорт OPML</button>
<button class="btn ghost admin-only" id="backup-btn" data-i18n="feeds.backup">💾 Бэкап</button>
<button class="btn ghost admin-only" id="restore-btn" data-i18n="feeds.restore">📥 Восстановить</button>
<input type="file" id="restore-file" accept=".db" hidden>
<button class="btn ghost" id="export-btn" data-i18n="feeds.export">⬇ Экспорт OPML</button> <button class="btn ghost" id="export-btn" data-i18n="feeds.export">⬇ Экспорт OPML</button>
<button class="btn primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button> <button class="btn primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button>
<input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden> <input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden>
@@ -51,6 +56,29 @@
</div> </div>
</section> </section>
<!-- ===================== READER ===================== -->
<section id="tab-reader" class="tab-panel">
<div class="reader-layout">
<aside class="reader-sidebar">
<div class="reader-cats" id="reader-cat-list">
<div class="reader-cat active" data-cat="all" data-i18n="reader.all">Все</div>
</div>
</aside>
<div class="reader-main">
<div class="panel-head">
<h2 data-i18n="nav.reader">Чтение</h2>
<button class="btn ghost" id="reader-mark-all" data-i18n="reader.markAll">Отметить все прочитанными</button>
</div>
<div id="reader-list"></div>
<div id="reader-detail" class="hidden"></div>
<div id="reader-empty" class="empty hidden">
<div class="empty-icon">📖</div>
<p data-i18n="reader.empty">Статей пока нет. Добавьте ленты, чтобы начать читать.</p>
</div>
</div>
</div>
</section>
<!-- ===================== HISTORY ===================== --> <!-- ===================== HISTORY ===================== -->
<section id="tab-history" class="tab-panel"> <section id="tab-history" class="tab-panel">
<div class="panel-head"> <div class="panel-head">
@@ -72,6 +100,19 @@
</div> </div>
</section> </section>
<!-- ===================== CATEGORIES ===================== -->
<section id="tab-categories" class="tab-panel">
<div class="panel-head">
<h2 data-i18n="categories.heading">Категории</h2>
<button class="btn primary" id="add-category" data-i18n="categories.add">+ Добавить категорию</button>
</div>
<div id="categories-list" class="cards"></div>
<div id="categories-empty" class="empty hidden">
<div class="empty-icon">🏷️</div>
<p data-i18n="categories.empty">Категорий пока нет.</p>
</div>
</section>
<!-- ===================== USERS ===================== --> <!-- ===================== USERS ===================== -->
<section id="tab-users" class="tab-panel"> <section id="tab-users" class="tab-panel">
<div class="panel-head"> <div class="panel-head">
@@ -108,12 +149,39 @@
<button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button> <button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button>
</div> </div>
<h3 data-i18n="settings.check">Проверка</h3>
<label><span data-i18n="settings.proxyUrl">URL прокси</span> <small class="muted" data-i18n="settings.proxyHint"></small>
<input type="text" name="proxy_url" placeholder="http://proxy:8080"></label>
<h3 data-i18n="settings.template">Шаблон уведомлений</h3>
<label><span data-i18n="settings.templateHint">Переменные: {title}, {body}, {link}, {source}, {image_url}</span>
<textarea name="notification_template" rows="3" style="width:100%;margin-top:4px;padding:8px;background:var(--bg-soft);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:monospace;font-size:13px;resize:vertical"></textarea></label>
<h3 data-i18n="settings.check">Проверка</h3> <h3 data-i18n="settings.check">Проверка</h3>
<label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span> <label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span>
<input type="number" name="check_interval" min="1" value="5"> <input type="number" name="check_interval" min="1" value="5">
<small class="muted" data-i18n="settings.intervalHint"></small> <small class="muted" data-i18n="settings.intervalHint"></small>
</label> </label>
<h3 data-i18n="settings.feedDefaults">Значения по умолчанию для новых лент</h3>
<div class="grid-2">
<label><span data-i18n="feed.priority">Приоритет</span>
<select name="default_priority">
<option value="1" data-i18n="feed.p1"></option>
<option value="2" data-i18n="feed.p2"></option>
<option value="3" selected data-i18n="feed.p3"></option>
<option value="4" data-i18n="feed.p4"></option>
<option value="5" data-i18n="feed.p5"></option>
</select>
</label>
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
<input type="number" name="default_interval" min="0" value="0"></label>
</div>
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
<input type="text" name="default_tags" placeholder="newspaper,fire"></label>
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
<input type="checkbox" name="default_attach_image" class="switch" checked></label>
<h3 data-i18n="settings.telegram">Telegram</h3> <h3 data-i18n="settings.telegram">Telegram</h3>
<label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span> <label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span>
<input type="checkbox" name="telegram_enabled" class="switch"></label> <input type="checkbox" name="telegram_enabled" class="switch"></label>
@@ -169,6 +237,11 @@
<label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small> <label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small>
<input type="text" name="title"> <input type="text" name="title">
</label> </label>
<label><span data-i18n="feed.category">Категория</span>
<select name="category_id">
<option value="" data-i18n="feed.categoryNone">— без категории —</option>
</select>
</label>
<div class="grid-2"> <div class="grid-2">
<label><span data-i18n="feed.server">Сервер ntfy</span> <small class="muted" data-i18n="feed.serverHint"></small> <label><span data-i18n="feed.server">Сервер ntfy</span> <small class="muted" data-i18n="feed.serverHint"></small>
<input type="text" name="ntfy_server" placeholder="https://ntfy.sh"></label> <input type="text" name="ntfy_server" placeholder="https://ntfy.sh"></label>
@@ -201,6 +274,15 @@
<label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small> <label><span data-i18n="feed.intervalMin">Интервал, мин</span> <small class="muted" data-i18n="feed.intervalHint"></small>
<input type="number" name="interval" min="0" value="0"></label> <input type="number" name="interval" min="0" value="0"></label>
</div> </div>
<details class="adv">
<summary data-i18n="feed.digest">Дайджест</summary>
<label class="switch-row"><span data-i18n="feed.digestEnable">Накапливать записи (дайджест)</span>
<input type="checkbox" name="digest_enabled" class="switch"></label>
<label><span data-i18n="feed.digestPeriod">Период дайджеста (часы)</span>
<input type="number" name="digest_period_hours" min="1" value="24"></label>
</details>
<label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small> <label><span data-i18n="feed.tags">Теги / эмодзи</span> <small class="muted" data-i18n="feed.commaHint"></small>
<input type="text" name="tags" placeholder="newspaper,fire"></label> <input type="text" name="tags" placeholder="newspaper,fire"></label>
@@ -214,6 +296,12 @@
<div class="switch-grid"> <div class="switch-grid">
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span> <label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
<input type="checkbox" name="attach_image" class="switch" checked></label> <input type="checkbox" name="attach_image" class="switch" checked></label>
<label class="switch-row"><span data-i18n="feed.fetchArticle">Загружать полную статью (trafilatura)</span>
<input type="checkbox" name="fetch_full_article" class="switch"></label>
<small class="muted" data-i18n="feed.fetchArticleHint" style="margin-top:-8px;display:block">Загружает страницу статьи и извлекает основной текст.</small>
<label class="switch-row"><span data-i18n="feed.fullContent">Отправлять полный контент</span>
<input type="checkbox" name="send_full_content" class="switch"></label>
<small class="muted" data-i18n="feed.fullContentHint" style="margin-top:-8px;display:block">Весь текст, все картинки и видео. Для ntfy — Markdown.</small>
<label class="switch-row"><span data-i18n="feed.dupTg">Дублировать в Telegram</span> <label class="switch-row"><span data-i18n="feed.dupTg">Дублировать в Telegram</span>
<input type="checkbox" name="to_telegram" class="switch"></label> <input type="checkbox" name="to_telegram" class="switch"></label>
<label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span> <label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span>
@@ -234,6 +322,27 @@
</form> </form>
</div> </div>
<!-- ===================== CATEGORY MODAL ===================== -->
<div id="cat-modal" class="modal-backdrop hidden">
<form class="modal" id="cat-form">
<div class="modal-head">
<h3 id="cat-modal-title" data-i18n="cat.addTitle">Добавить категорию</h3>
<button type="button" class="icon-btn" id="cat-modal-close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id">
<label><span data-i18n="cat.name">Название *</span>
<input type="text" name="name" required></label>
<label><span data-i18n="cat.sortOrder">Порядок сортировки</span>
<input type="number" name="sort_order" min="0" value="0"></label>
</div>
<div class="modal-foot">
<button type="button" class="btn ghost" id="cat-modal-cancel" data-i18n="modal.cancel">Отмена</button>
<button type="submit" class="btn primary" data-i18n="modal.save">Сохранить</button>
</div>
</form>
</div>
<!-- ===================== USER MODAL ===================== --> <!-- ===================== USER MODAL ===================== -->
<div id="user-modal" class="modal-backdrop hidden"> <div id="user-modal" class="modal-backdrop hidden">
<form class="modal" id="user-form"> <form class="modal" id="user-form">
+1
View File
@@ -7,3 +7,4 @@ APScheduler==3.11.0
Jinja2==3.1.5 Jinja2==3.1.5
python-multipart==0.0.20 python-multipart==0.0.20
itsdangerous==2.2.0 itsdangerous==2.2.0
trafilatura==2.0.0