✨ 8 major features: trafilatura, digest, ntfy actions, templates, FTS5 search, backup/restore, proxy, RSS reader
build-and-push / docker (push) Has been cancelled
build-and-push / docker (push) Has been cancelled
- Full article extraction via trafilatura (fetch_full_article)
- Digest mode with configurable period (digest_enabled, digest_period_hours)
- ntfy Actions buttons (Open article, Open feed)
- Notification templates with {title}, {body}, {link}, {source}, {image_url}
- FTS5 full-text search in notification history
- Database backup/restore (download/upload .db)
- HTTP/SOCKS proxy for RSS feed fetching (proxy_url setting)
- Built-in RSS reader tab with categories, unread counts, article detail view
- Auto-category 'Общее' for feeds without a category
- Article storage (Article table) for reader
- DigestEntry model for pending digest entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+200
-9
@@ -2,30 +2,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
|
||||
import httpx
|
||||
from io import StringIO
|
||||
|
||||
import feedparser
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from . import delivery
|
||||
from .database import engine, get_settings
|
||||
from .delivery import Message
|
||||
from .models import Feed, Notification, SeenEntry
|
||||
from .models import Article, DigestEntry, Feed, Notification, SeenEntry
|
||||
|
||||
log = logging.getLogger("checker")
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_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:
|
||||
text = unescape(_TAG_RE.sub(" ", text or ""))
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
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] + " …"
|
||||
return text
|
||||
|
||||
@@ -57,6 +65,60 @@ def _extract_image(entry) -> str:
|
||||
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:
|
||||
"""Keyword include/exclude check (case-insensitive)."""
|
||||
haystack = f"{title}\n{body}".lower()
|
||||
@@ -69,9 +131,43 @@ def _passes_filters(feed: Feed, title: str, body: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _parse(url: str):
|
||||
"""Blocking feedparser call (run in a thread)."""
|
||||
return feedparser.parse(url, agent="rss-ntfy/1.0 (+https://github.com)")
|
||||
def _parse_raw(xml: str):
|
||||
"""Blocking feedparser call on XML string (run in a thread)."""
|
||||
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:
|
||||
@@ -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.
|
||||
"""
|
||||
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:
|
||||
raise ValueError(str(getattr(parsed, "bozo_exception", "parse error")))
|
||||
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:
|
||||
"""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:
|
||||
exc = getattr(parsed, "bozo_exception", "parse error")
|
||||
@@ -145,7 +250,24 @@ async def check_feed(feed: Feed) -> str:
|
||||
continue
|
||||
|
||||
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):
|
||||
skipped += 1
|
||||
@@ -155,9 +277,78 @@ async def check_feed(feed: Feed) -> str:
|
||||
source=db_feed.title or feed_title,
|
||||
title=title,
|
||||
body=body,
|
||||
link=entry.get("link", ""),
|
||||
link=link,
|
||||
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)
|
||||
|
||||
session.add(
|
||||
|
||||
+37
-1
@@ -9,7 +9,7 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from . import config
|
||||
from .auth import hash_password
|
||||
from .models import Settings, User
|
||||
from .models import Category, Settings, User
|
||||
|
||||
engine = create_engine(
|
||||
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:
|
||||
"""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."""
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_migrate()
|
||||
_setup_fts()
|
||||
with Session(engine) as session:
|
||||
if session.get(Settings, 1) is None:
|
||||
session.add(
|
||||
@@ -83,6 +111,14 @@ def init_db() -> None:
|
||||
)
|
||||
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:
|
||||
settings = session.get(Settings, 1)
|
||||
|
||||
+75
-15
@@ -24,7 +24,11 @@ class Message:
|
||||
title: str # entry title
|
||||
body: str # plain-text summary
|
||||
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
|
||||
@@ -51,7 +55,13 @@ async def _send_telegram(settings: Settings, msg: Message) -> None:
|
||||
if msg.source:
|
||||
text = f"📡 <i>{_esc(msg.source)}</i>\n{text}"
|
||||
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:
|
||||
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,
|
||||
"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:
|
||||
resp = await client.post(url, json=payload)
|
||||
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
|
||||
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.
|
||||
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
|
||||
@@ -104,19 +138,45 @@ async def dispatch(feed: Feed, settings: Settings, msg: Message) -> DispatchResu
|
||||
# --- ntfy (default channel; requires a topic) ---
|
||||
if feed.ntfy_topic.strip():
|
||||
try:
|
||||
await ntfy.publish(
|
||||
server=server,
|
||||
topic=feed.ntfy_topic,
|
||||
title=full_title,
|
||||
message=msg.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,
|
||||
)
|
||||
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"
|
||||
if msg.videos:
|
||||
for vid_url in msg.videos:
|
||||
md_body += f"\n\n📹 {vid_url}"
|
||||
await ntfy.publish(
|
||||
server=server,
|
||||
topic=feed.ntfy_topic,
|
||||
title=full_title,
|
||||
message=md_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,
|
||||
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")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.errors.append(f"ntfy: {exc}")
|
||||
|
||||
+386
-8
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
PlainTextResponse,
|
||||
@@ -16,7 +17,7 @@ from fastapi.responses import (
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import Integer, text
|
||||
from sqlmodel import Session, func, select
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
@@ -24,8 +25,8 @@ from . import config, ntfy, opml, scheduler
|
||||
from .auth import hash_password, verify_password
|
||||
from .checker import check_feed, fetch_preview
|
||||
from .database import engine, get_session, get_settings, init_db
|
||||
from .models import Feed, Notification, SeenEntry, User
|
||||
from .schemas import FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
|
||||
from .models import Article, Category, Feed, Notification, SeenEntry, User
|
||||
from .schemas import CategoryIn, FeedIn, PreviewIn, SettingsIn, TestIn, UserIn
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -160,6 +161,11 @@ def _feed_dict(feed: Feed) -> dict:
|
||||
"filter_exclude": feed.filter_exclude,
|
||||
"interval": feed.interval,
|
||||
"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_status": feed.last_status,
|
||||
"error_streak": feed.error_streak,
|
||||
@@ -178,7 +184,14 @@ def create_feed(
|
||||
session: Session = Depends(get_session),
|
||||
_: 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.commit()
|
||||
session.refresh(feed)
|
||||
@@ -269,6 +282,79 @@ async def import_feeds(
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -283,10 +369,23 @@ def history(
|
||||
limit = min(500, max(1, limit))
|
||||
query = select(Notification)
|
||||
if q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
|
||||
)
|
||||
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(
|
||||
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
|
||||
)
|
||||
if only_errors:
|
||||
query = query.where(Notification.ok == False) # noqa: E712
|
||||
notes = session.exec(
|
||||
@@ -376,6 +475,78 @@ async def preview(
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -397,6 +568,12 @@ def read_settings(session: Session = Depends(get_session), _: User = Depends(req
|
||||
"alerts_enabled": s.alerts_enabled,
|
||||
"alert_topic": s.alert_topic,
|
||||
"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.alert_topic = data.alert_topic.strip()
|
||||
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.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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@app.post("/api/test")
|
||||
|
||||
@@ -44,6 +44,14 @@ class Feed(SQLModel, table=True):
|
||||
interval: int = 0
|
||||
|
||||
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 ---
|
||||
last_checked: Optional[datetime] = None
|
||||
@@ -52,6 +60,46 @@ class Feed(SQLModel, table=True):
|
||||
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):
|
||||
"""Tracks which feed entries have already been pushed to avoid duplicates."""
|
||||
|
||||
@@ -113,3 +161,15 @@ class Settings(SQLModel, table=True):
|
||||
alerts_enabled: bool = False
|
||||
alert_topic: str = "" # ntfy topic to notify when a feed keeps failing
|
||||
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
@@ -36,6 +36,8 @@ async def publish(
|
||||
token: str = "",
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
markdown: bool = False,
|
||||
actions: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Send one notification to ntfy. Raises httpx.HTTPStatusError on failure.
|
||||
|
||||
@@ -69,6 +71,17 @@ async def publish(
|
||||
headers["Attach"] = attach
|
||||
except UnicodeEncodeError:
|
||||
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:
|
||||
resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||
|
||||
@@ -7,16 +7,98 @@ import logging
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
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 .database import engine, get_settings
|
||||
from .delivery import Message
|
||||
from .models import DigestEntry, Feed, Notification
|
||||
|
||||
log = logging.getLogger("scheduler")
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
_JOB_ID = "check-feeds"
|
||||
_JOB_DIGEST = "send-digests"
|
||||
# Fixed tick; per-feed/global intervals are honoured inside check_all_feeds.
|
||||
_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:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
@@ -30,6 +112,13 @@ def start(interval_minutes: int) -> None:
|
||||
coalesce=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()
|
||||
log.info("Планировщик запущен (тик 60с), интервал по умолчанию %d мин", interval_minutes)
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ class FeedIn(BaseModel):
|
||||
filter_exclude: str = ""
|
||||
interval: int = 0
|
||||
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")
|
||||
@classmethod
|
||||
@@ -63,6 +68,13 @@ class SettingsIn(BaseModel):
|
||||
alerts_enabled: bool = False
|
||||
alert_topic: str = ""
|
||||
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")
|
||||
@classmethod
|
||||
@@ -74,6 +86,16 @@ class SettingsIn(BaseModel):
|
||||
def _threshold_min(cls, v: int) -> int:
|
||||
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):
|
||||
server: str = ""
|
||||
@@ -98,6 +120,19 @@ class PreviewIn(BaseModel):
|
||||
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):
|
||||
username: str
|
||||
password: str = "" # empty on edit = keep existing
|
||||
|
||||
+247
-8
@@ -4,6 +4,8 @@ const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
let ME = { role: "admin", auth_enabled: false };
|
||||
let SETTINGS = {};
|
||||
let CATEGORIES = [];
|
||||
|
||||
// ---------- API helper ----------
|
||||
async function api(method, url, body) {
|
||||
@@ -105,6 +107,87 @@ async function loadActivity() {
|
||||
`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="chart-svg">${bars}</svg>`;
|
||||
}
|
||||
|
||||
// ---------- Categories ----------
|
||||
async function loadCategories() {
|
||||
try { CATEGORIES = await api("GET", "/api/categories"); } catch (e) { CATEGORIES = []; }
|
||||
var list = document.getElementById("categories-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
var empty = document.getElementById("categories-empty");
|
||||
if (empty) empty.classList.toggle("hidden", CATEGORIES.length > 0);
|
||||
CATEGORIES.forEach(function(c) { list.appendChild(categoryCard(c)); });
|
||||
}
|
||||
|
||||
function categoryCard(c) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "feed-card";
|
||||
el.innerHTML = '<div class="feed-top"><span class="dot on"></span>' +
|
||||
'<div style="flex:1">' +
|
||||
'<div class="feed-title">' + escapeHtml(c.name) + '</div>' +
|
||||
'<div class="feed-meta"><span class="chip">' + t("cat.sortOrder") + ': ' + c.sort_order + '</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="feed-actions">' +
|
||||
'<button class="btn ghost small" data-act="edit">✎</button>' +
|
||||
'<button class="btn danger small" data-act="del">🗑</button>' +
|
||||
'</div></div>';
|
||||
el.querySelector('[data-act="edit"]').onclick = function() { openCatModal(c); };
|
||||
el.querySelector('[data-act="del"]').onclick = function() { deleteCategory(c); };
|
||||
return el;
|
||||
}
|
||||
|
||||
var catModal = document.getElementById("cat-modal");
|
||||
var catForm = document.getElementById("cat-form");
|
||||
|
||||
function openCatModal(cat) {
|
||||
catForm.reset();
|
||||
document.getElementById("cat-modal-title").textContent = cat ? t("cat.editTitle") : t("cat.addTitle");
|
||||
catForm.id.value = cat ? (cat.id || "") : "";
|
||||
catForm.name.value = cat ? (cat.name || "") : "";
|
||||
catForm.sort_order.value = (cat && cat.sort_order != null) ? cat.sort_order : 0;
|
||||
catModal.classList.remove("hidden");
|
||||
}
|
||||
function closeCatModal() { catModal.classList.add("hidden"); }
|
||||
|
||||
document.getElementById("add-category").onclick = function() { openCatModal(null); };
|
||||
document.getElementById("cat-modal-close").onclick = closeCatModal;
|
||||
document.getElementById("cat-modal-cancel").onclick = closeCatModal;
|
||||
catModal.addEventListener("click", function(e) { if (e.target === catModal) closeCatModal(); });
|
||||
|
||||
catForm.addEventListener("submit", async function(e) {
|
||||
e.preventDefault();
|
||||
var payload = {
|
||||
name: catForm.name.value.trim(),
|
||||
sort_order: parseInt(catForm.sort_order.value, 10) || 0,
|
||||
};
|
||||
var id = catForm.id.value;
|
||||
try {
|
||||
if (id) await api("PUT", "/api/categories/" + id, payload);
|
||||
else await api("POST", "/api/categories", payload);
|
||||
toast(id ? t("toast.categoryUpdated") : t("toast.categoryAdded"));
|
||||
closeCatModal();
|
||||
await loadCategories();
|
||||
loadFeeds().catch(function() {});
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
});
|
||||
|
||||
async function deleteCategory(c) {
|
||||
var feedCount = 0;
|
||||
try {
|
||||
var feeds = await api("GET", "/api/feeds");
|
||||
feedCount = feeds.filter(function(f) { return f.category_id === c.id; }).length;
|
||||
} catch (e) {}
|
||||
var msg = feedCount
|
||||
? t("confirm.deleteCategoryFeeds", { name: c.name, n: feedCount })
|
||||
: t("confirm.deleteCategory", { name: c.name });
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
await api("DELETE", "/api/categories/" + c.id);
|
||||
toast(t("toast.categoryDeleted"));
|
||||
await loadCategories();
|
||||
loadFeeds().catch(function() {});
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
}
|
||||
|
||||
// ---------- Feeds ----------
|
||||
function feedCard(f) {
|
||||
const el = document.createElement("div");
|
||||
@@ -119,6 +202,11 @@ function feedCard(f) {
|
||||
if (f.to_webhook) chips.push(`<span class="chip">🔗 hook</span>`);
|
||||
if (f.filter_include || f.filter_exclude) chips.push(`<span class="chip">🧩</span>`);
|
||||
if (f.tags) chips.push(`<span class="chip">🏷️ ${escapeHtml(f.tags)}</span>`);
|
||||
if (f.send_full_content) chips.push('<span class="chip full">📄 full</span>');
|
||||
if (f.fetch_full_article) chips.push('<span class="chip full">📄 trafilatura</span>');
|
||||
if (f.digest_enabled) chips.push('<span class="chip">📬 digest ' + f.digest_period_hours + 'h</span>');
|
||||
var cat = CATEGORIES.find(function(c) { return c.id === f.category_id; });
|
||||
if (cat) chips.push('<span class="chip cat">🏷️ ' + escapeHtml(cat.name) + '</span>');
|
||||
|
||||
const admin = ME.role === "admin";
|
||||
el.innerHTML = `
|
||||
@@ -185,12 +273,28 @@ function openModal(feed) {
|
||||
$("#preview-area").innerHTML = "";
|
||||
$("#modal-title").textContent = feed ? t("modal.editFeed") : t("modal.addFeed");
|
||||
feedForm.id.value = feed?.id || "";
|
||||
const f = feed || { attach_image: true, enabled: true, priority: 3, interval: 0 };
|
||||
const defaults = {
|
||||
attach_image: SETTINGS.default_attach_image ?? true,
|
||||
enabled: true,
|
||||
priority: SETTINGS.default_priority ?? 3,
|
||||
interval: SETTINGS.default_interval ?? 0,
|
||||
tags: SETTINGS.default_tags ?? "",
|
||||
};
|
||||
const f = feed || defaults;
|
||||
for (const el of feedForm.elements) {
|
||||
if (!el.name || el.name === "id") continue;
|
||||
if (el.type === "checkbox") el.checked = !!f[el.name];
|
||||
else if (f[el.name] !== undefined) el.value = f[el.name];
|
||||
}
|
||||
// Populate category dropdown
|
||||
const sel = feedForm.category_id;
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">' + t("feed.categoryNone") + '</option>';
|
||||
CATEGORIES.forEach(function(c) {
|
||||
sel.innerHTML += '<option value="' + c.id + '">' + escapeHtml(c.name) + '</option>';
|
||||
});
|
||||
if (feed && feed.category_id) sel.value = feed.category_id;
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
function closeModal() { modal.classList.add("hidden"); }
|
||||
@@ -240,9 +344,14 @@ feedForm.addEventListener("submit", async e => {
|
||||
filter_include: feedForm.filter_include.value.trim(),
|
||||
filter_exclude: feedForm.filter_exclude.value.trim(),
|
||||
attach_image: feedForm.attach_image.checked,
|
||||
send_full_content: feedForm.send_full_content.checked,
|
||||
fetch_full_article: feedForm.fetch_full_article.checked,
|
||||
digest_enabled: feedForm.digest_enabled.checked,
|
||||
digest_period_hours: parseInt(feedForm.digest_period_hours.value, 10) || 24,
|
||||
to_telegram: feedForm.to_telegram.checked,
|
||||
to_webhook: feedForm.to_webhook.checked,
|
||||
enabled: feedForm.enabled.checked,
|
||||
category_id: feedForm.category_id.value || null,
|
||||
};
|
||||
const id = feedForm.id.value;
|
||||
try {
|
||||
@@ -267,6 +376,25 @@ $("#check-all").onclick = async (e) => {
|
||||
};
|
||||
|
||||
// ---------- OPML ----------
|
||||
// --- Backup / Restore ---
|
||||
$("#backup-btn").onclick = function() { location.href = "/api/backup"; };
|
||||
$("#restore-btn").onclick = function() { document.getElementById("restore-file").click(); };
|
||||
$("#restore-file").onchange = async function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!confirm(t("confirm.restore"))) { e.target.value = ""; return; }
|
||||
var fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
var res = await fetch("/api/backup", { method: "POST", body: fd });
|
||||
var data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || "Error");
|
||||
toast(t("toast.restored"));
|
||||
setTimeout(function() { location.reload(); }, 1500);
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
finally { e.target.value = ""; }
|
||||
};
|
||||
|
||||
$("#export-btn").onclick = () => { location.href = "/api/feeds/export"; };
|
||||
$("#import-btn").onclick = () => $("#opml-file").click();
|
||||
$("#opml-file").onchange = async (e) => {
|
||||
@@ -398,11 +526,11 @@ userForm.addEventListener("submit", async e => {
|
||||
const sForm = $("#settings-form");
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await api("GET", "/api/settings");
|
||||
SETTINGS = await api("GET", "/api/settings");
|
||||
for (const el of sForm.elements) {
|
||||
if (!el.name) continue;
|
||||
if (el.type === "checkbox") el.checked = !!s[el.name];
|
||||
else if (s[el.name] !== undefined) el.value = s[el.name];
|
||||
if (el.type === "checkbox") el.checked = !!SETTINGS[el.name];
|
||||
else if (SETTINGS[el.name] !== undefined) el.value = SETTINGS[el.name];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +551,12 @@ sForm.addEventListener("submit", async e => {
|
||||
alerts_enabled: sForm.alerts_enabled.checked,
|
||||
alert_topic: sForm.alert_topic.value.trim(),
|
||||
alert_threshold: parseInt(sForm.alert_threshold.value, 10) || 3,
|
||||
default_priority: parseInt(sForm.default_priority.value, 10) || 3,
|
||||
default_tags: sForm.default_tags.value.trim(),
|
||||
default_attach_image: sForm.default_attach_image.checked,
|
||||
default_interval: parseInt(sForm.default_interval.value, 10) || 0,
|
||||
notification_template: sForm.notification_template.value,
|
||||
proxy_url: sForm.proxy_url.value.trim(),
|
||||
};
|
||||
try {
|
||||
await api("PUT", "/api/settings", payload);
|
||||
@@ -452,6 +586,8 @@ $$(".tab").forEach(tab => tab.addEventListener("click", () => {
|
||||
tab.classList.add("active");
|
||||
$(`#tab-${tab.dataset.tab}`).classList.add("active");
|
||||
if (tab.dataset.tab === "history") loadHistory().catch(() => {});
|
||||
if (tab.dataset.tab === "reader") loadReader().catch(() => {});
|
||||
if (tab.dataset.tab === "categories") loadCategories().catch(() => {});
|
||||
if (tab.dataset.tab === "users") loadUsers().catch(() => {});
|
||||
}));
|
||||
|
||||
@@ -467,6 +603,8 @@ langSelect.onchange = () => {
|
||||
// Re-render dynamic content in the new language.
|
||||
loadFeeds().catch(() => {});
|
||||
if ($("#tab-history").classList.contains("active")) loadHistory().catch(() => {});
|
||||
if ($("#tab-reader").classList.contains("active")) loadReader().catch(() => {});
|
||||
if ($("#tab-categories").classList.contains("active")) loadCategories().catch(() => {});
|
||||
if ($("#tab-users").classList.contains("active")) loadUsers().catch(() => {});
|
||||
};
|
||||
|
||||
@@ -476,13 +614,114 @@ function renderWhoami() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reader ----------
|
||||
var READER = { cats: [], selectedCat: "all" };
|
||||
|
||||
async function loadReaderStats() {
|
||||
try { READER.cats = await api("GET", "/api/articles/stats"); } catch (e) { READER.cats = []; }
|
||||
var list = document.getElementById("reader-cat-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="reader-cat active" data-cat="all"><span>' + t("reader.all") + '</span></div>';
|
||||
var totalUnread = 0;
|
||||
READER.cats.forEach(function(c) {
|
||||
totalUnread += c.unread;
|
||||
list.innerHTML += '<div class="reader-cat" data-cat="' + c.category_id + '">' +
|
||||
'<span>' + escapeHtml(c.category_name) + '</span>' +
|
||||
(c.unread ? '<span class="badge">' + c.unread + '</span>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
var allEl = list.querySelector('[data-cat="all"]');
|
||||
if (allEl && totalUnread) allEl.innerHTML = '<span>' + t("reader.all") + '</span><span class="badge">' + totalUnread + '</span>';
|
||||
|
||||
var cats = list.querySelectorAll('.reader-cat');
|
||||
cats.forEach(function(el) {
|
||||
el.onclick = function() {
|
||||
cats.forEach(function(x) { x.classList.remove("active"); });
|
||||
el.classList.add("active");
|
||||
READER.selectedCat = el.dataset.cat;
|
||||
loadReaderArticles();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReaderArticles() {
|
||||
var url = "/api/articles?limit=100";
|
||||
if (READER.selectedCat !== "all") url += "&category_id=" + READER.selectedCat;
|
||||
var articles;
|
||||
try { articles = await api("GET", url); } catch (e) { return; }
|
||||
var list = document.getElementById("reader-list");
|
||||
var empty = document.getElementById("reader-empty");
|
||||
list.innerHTML = "";
|
||||
if (empty) empty.classList.toggle("hidden", articles.length > 0);
|
||||
articles.forEach(function(a) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "article-row" + (a.is_read ? "" : " unread");
|
||||
var imgHtml = a.image ? '<img src="' + escapeHtml(a.image) + '" alt="" loading="lazy" style="max-width:80px;max-height:60px;border-radius:6px;flex-shrink:0">' : '';
|
||||
el.innerHTML = '<span class="article-dot">' + (a.is_read ? '○' : '●') + '</span>' +
|
||||
'<div class="article-content">' +
|
||||
'<div class="article-title">' + escapeHtml(a.title) + '</div>' +
|
||||
'<div class="article-meta">' +
|
||||
'<span class="chip">' + escapeHtml(a.feed_title) + '</span>' +
|
||||
'<span class="muted">' + fmtDate(a.created_at) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="article-body-preview">' + escapeHtml(a.body) + '</div>' +
|
||||
'</div>' +
|
||||
imgHtml;
|
||||
el.onclick = async function() {
|
||||
try {
|
||||
var full = await api("GET", "/api/articles/" + a.id);
|
||||
showArticleDetail(full);
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
};
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function showArticleDetail(article) {
|
||||
var detail = document.getElementById("reader-detail");
|
||||
var list = document.getElementById("reader-list");
|
||||
detail.classList.remove("hidden");
|
||||
list.classList.add("hidden");
|
||||
var body = article.full_html || escapeHtml(article.body).replace(/\n/g, '<br>');
|
||||
detail.innerHTML = '<button class="btn ghost" id="reader-back">← ' + t("reader.back") + '</button>' +
|
||||
'<h2>' + escapeHtml(article.title) + '</h2>' +
|
||||
'<div class="article-meta" style="margin:12px 0">' +
|
||||
'<span class="chip">' + escapeHtml(article.feed_title) + '</span>' +
|
||||
'<span class="muted">' + fmtDate(article.created_at) + '</span>' +
|
||||
(article.link ? '<a href="' + escapeHtml(article.link) + '" target="_blank" rel="noopener" class="btn ghost small">' + t("reader.open") + '</a>' : '') +
|
||||
'</div>' +
|
||||
'<div class="article-body">' + body + '</div>';
|
||||
document.getElementById("reader-back").onclick = function() {
|
||||
detail.classList.add("hidden");
|
||||
list.classList.remove("hidden");
|
||||
};
|
||||
}
|
||||
|
||||
async function loadReader() {
|
||||
await loadReaderStats();
|
||||
await loadReaderArticles();
|
||||
}
|
||||
|
||||
document.getElementById("reader-mark-all").onclick = async function() {
|
||||
var url = "/api/articles/read-all";
|
||||
if (READER.selectedCat !== "all") url += "?category_id=" + READER.selectedCat;
|
||||
try {
|
||||
var r = await api("PUT", url);
|
||||
toast(t("toast.articlesMarked"));
|
||||
loadReader();
|
||||
} catch (err) { toast(err.message, "err"); }
|
||||
};
|
||||
|
||||
// ---------- init ----------
|
||||
async function init() {
|
||||
applyI18n();
|
||||
try { ME = await api("GET", "/api/me"); } catch (_) {}
|
||||
if (ME.role !== "admin") $$(".admin-only").forEach(el => el.classList.add("hidden"));
|
||||
if (ME.auth_enabled) { $("#logout-btn").style.display = ""; renderWhoami(); }
|
||||
loadFeeds().catch(e => toast(e.message, "err"));
|
||||
if (ME.role === "admin") loadSettings().catch(e => toast(e.message, "err"));
|
||||
if (ME.role !== "admin") $$(".admin-only").forEach(function(el) { el.classList.add("hidden"); });
|
||||
if (ME.auth_enabled) { document.getElementById("logout-btn").style.display = ""; renderWhoami(); }
|
||||
if (ME.role === "admin") {
|
||||
loadCategories().catch(function() {});
|
||||
loadSettings().catch(function(e) { toast(e.message, "err"); });
|
||||
}
|
||||
loadFeeds().catch(function(e) { toast(e.message, "err"); });
|
||||
}
|
||||
init();
|
||||
|
||||
@@ -142,6 +142,60 @@ const I18N = {
|
||||
"status.sendError": "Ошибка отправки: {msg}",
|
||||
"status.dash": "—",
|
||||
|
||||
|
||||
"nav.categories": "Категории",
|
||||
|
||||
"categories.heading": "Категории",
|
||||
"categories.add": "+ Добавить категорию",
|
||||
"categories.empty": "Категорий пока нет.",
|
||||
|
||||
"cat.addTitle": "Добавить категорию",
|
||||
"cat.editTitle": "Редактировать категорию",
|
||||
"cat.name": "Название *",
|
||||
"cat.sortOrder": "Порядок сортировки",
|
||||
|
||||
"feed.category": "Категория",
|
||||
"feed.categoryNone": "— без категории —",
|
||||
"feed.fullContent": "Отправлять полный контент",
|
||||
"feed.fullContentHint": "Весь текст, все картинки и видео. Для ntfy — Markdown.",
|
||||
|
||||
"settings.feedDefaults": "Значения по умолчанию для новых лент",
|
||||
|
||||
"confirm.deleteCategory": "Удалить категорию «{name}»?",
|
||||
"confirm.deleteCategoryFeeds": "Удалить категорию «{name}»? {n} лент будут откреплены.",
|
||||
|
||||
"toast.categoryAdded": "Категория добавлена",
|
||||
"toast.categoryUpdated": "Категория обновлена",
|
||||
"toast.categoryDeleted": "Категория удалена",
|
||||
|
||||
|
||||
"nav.reader": "Чтение",
|
||||
|
||||
"reader.all": "Все",
|
||||
"reader.markAll": "Отметить все прочитанными",
|
||||
"reader.back": "← Назад",
|
||||
"reader.open": "Открыть оригинал →",
|
||||
"reader.empty": "Статей пока нет. Добавьте ленты, чтобы начать читать.",
|
||||
|
||||
"feed.digest": "Дайджест",
|
||||
"feed.digestEnable": "Накапливать записи (дайджест)",
|
||||
"feed.digestPeriod": "Период дайджеста (часы)",
|
||||
"feed.fetchArticle": "Загружать полную статью (trafilatura)",
|
||||
"feed.fetchArticleHint": "Загружает страницу статьи и извлекает основной текст.",
|
||||
|
||||
"settings.template": "Шаблон уведомлений",
|
||||
"settings.templateHint": "Переменные: {title}, {body}, {link}, {source}, {image_url}",
|
||||
"settings.proxyUrl": "URL прокси",
|
||||
"settings.proxyHint": "Например: http://proxy:8080 или socks5://proxy:1080",
|
||||
|
||||
"feeds.backup": "💾 Бэкап",
|
||||
"feeds.restore": "📥 Восстановить",
|
||||
|
||||
"confirm.restore": "Восстановление заменит всю текущую базу данных. Продолжить?",
|
||||
|
||||
"toast.restored": "База восстановлена. Перезагрузка...",
|
||||
"toast.articlesMarked": "Все статьи отмечены прочитанными",
|
||||
|
||||
"role.admin": "админ",
|
||||
"role.viewer": "наблюдатель",
|
||||
"login.subtitle": "Войдите, чтобы продолжить",
|
||||
@@ -291,6 +345,60 @@ const I18N = {
|
||||
"status.sendError": "Send error: {msg}",
|
||||
"status.dash": "—",
|
||||
|
||||
|
||||
"nav.categories": "Categories",
|
||||
|
||||
"categories.heading": "Categories",
|
||||
"categories.add": "+ Add category",
|
||||
"categories.empty": "No categories yet.",
|
||||
|
||||
"cat.addTitle": "Add category",
|
||||
"cat.editTitle": "Edit category",
|
||||
"cat.name": "Name *",
|
||||
"cat.sortOrder": "Sort order",
|
||||
|
||||
"feed.category": "Category",
|
||||
"feed.categoryNone": "— no category —",
|
||||
"feed.fullContent": "Send full content",
|
||||
"feed.fullContentHint": "Full text, all images and videos. For ntfy — Markdown.",
|
||||
|
||||
"settings.feedDefaults": "Default values for new feeds",
|
||||
|
||||
"confirm.deleteCategory": "Delete category «{name}»?",
|
||||
"confirm.deleteCategoryFeeds": "Delete category «{name}»? {n} feeds will be uncategorized.",
|
||||
|
||||
"toast.categoryAdded": "Category added",
|
||||
"toast.categoryUpdated": "Category updated",
|
||||
"toast.categoryDeleted": "Category deleted",
|
||||
|
||||
|
||||
"nav.reader": "Reader",
|
||||
|
||||
"reader.all": "All",
|
||||
"reader.markAll": "Mark all read",
|
||||
"reader.back": "← Back",
|
||||
"reader.open": "Open original →",
|
||||
"reader.empty": "No articles yet. Add feeds to start reading.",
|
||||
|
||||
"feed.digest": "Digest",
|
||||
"feed.digestEnable": "Accumulate entries (digest)",
|
||||
"feed.digestPeriod": "Digest period (hours)",
|
||||
"feed.fetchArticle": "Fetch full article (trafilatura)",
|
||||
"feed.fetchArticleHint": "Fetches the article page and extracts main text.",
|
||||
|
||||
"settings.template": "Notification template",
|
||||
"settings.templateHint": "Variables: {title}, {body}, {link}, {source}, {image_url}",
|
||||
"settings.proxyUrl": "Proxy URL",
|
||||
"settings.proxyHint": "Example: http://proxy:8080 or socks5://proxy:1080",
|
||||
|
||||
"feeds.backup": "💾 Backup",
|
||||
"feeds.restore": "📥 Restore",
|
||||
|
||||
"confirm.restore": "Restore will replace the entire database. Continue?",
|
||||
|
||||
"toast.restored": "Database restored. Reloading...",
|
||||
"toast.articlesMarked": "All articles marked read",
|
||||
|
||||
"role.admin": "admin",
|
||||
"role.viewer": "viewer",
|
||||
"login.subtitle": "Sign in to continue",
|
||||
|
||||
@@ -133,6 +133,8 @@ h1, h2, h3 { margin: 0; font-weight: 600; }
|
||||
}
|
||||
.chip.topic { background: rgba(79, 124, 255, .16); color: #aebfff; }
|
||||
.chip.tg { background: rgba(34, 158, 217, .18); color: #7fd0f0; }
|
||||
.chip.cat { background: rgba(168, 85, 247, .18); color: #c9a2f5; }
|
||||
.chip.full { background: rgba(245, 158, 11, .16); color: #fbbf24; }
|
||||
.feed-status { font-size: 12.5px; color: var(--muted); }
|
||||
.feed-status .ok { color: var(--success); }
|
||||
.feed-status .err { color: var(--danger); }
|
||||
@@ -316,3 +318,52 @@ details.adv[open] { padding-bottom: 8px; }
|
||||
.np-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; word-break: break-word; }
|
||||
.np-body { font-size: 13.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
||||
.ntfy-preview img { max-width: 100%; border-radius: 8px; margin-top: 10px; display: block; }
|
||||
|
||||
/* ---------- Reader layout ---------- */
|
||||
.reader-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 20px;
|
||||
min-height: 60vh;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.reader-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.reader-sidebar { position: sticky; top: 80px; align-self: start; }
|
||||
.reader-cats { display: flex; flex-direction: column; gap: 4px; }
|
||||
.reader-cat {
|
||||
padding: 8px 14px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 14px; font-weight: 500; transition: .15s;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.reader-cat:hover { background: rgba(255,255,255,.04); }
|
||||
.reader-cat.active { background: rgba(79,124,255,.16); color: var(--primary); }
|
||||
.reader-cat .badge {
|
||||
background: var(--primary); color: #fff; font-size: 11px;
|
||||
padding: 1px 7px; border-radius: 999px; min-width: 20px; text-align: center;
|
||||
}
|
||||
|
||||
/* Article rows */
|
||||
.article-row {
|
||||
display: flex; gap: 12px; padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border); cursor: pointer; transition: .1s;
|
||||
}
|
||||
.article-row:hover { background: rgba(255,255,255,.03); }
|
||||
.article-row.unread { border-left: 3px solid var(--primary); }
|
||||
.article-dot { color: var(--primary); font-size: 12px; flex-shrink: 0; margin-top: 3px; }
|
||||
.article-content { flex: 1; min-width: 0; }
|
||||
.article-title { font-weight: 600; font-size: 15px; margin-bottom: 4px; }
|
||||
.article-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; font-size: 12.5px; }
|
||||
.article-body-preview {
|
||||
font-size: 13px; color: var(--muted); white-space: pre-wrap;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
|
||||
/* Article detail (full view) */
|
||||
.article-body {
|
||||
font-size: 15px; line-height: 1.7; margin-top: 20px;
|
||||
padding: 20px; background: var(--bg-soft); border-radius: 12px;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.article-body img { max-width: 100%; border-radius: 8px; margin: 12px 0; }
|
||||
#reader-back { margin-bottom: 16px; }
|
||||
|
||||
+112
-3
@@ -5,7 +5,9 @@
|
||||
<div class="brand"><span class="logo">📡</span> RSS → ntfy</div>
|
||||
<nav class="tabs">
|
||||
<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 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="settings" data-i18n="nav.settings">Настройки</button>
|
||||
</nav>
|
||||
@@ -39,6 +41,9 @@
|
||||
<div class="panel-head-actions">
|
||||
<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="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 primary admin-only" id="add-feed" data-i18n="feeds.add">+ Добавить ленту</button>
|
||||
<input type="file" id="opml-file" accept=".opml,.xml,text/xml" hidden>
|
||||
@@ -51,7 +56,30 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== HISTORY ===================== -->
|
||||
<!-- ===================== 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 ===================== -->
|
||||
<section id="tab-history" class="tab-panel">
|
||||
<div class="panel-head">
|
||||
<h2 data-i18n="history.heading">История уведомлений</h2>
|
||||
@@ -72,7 +100,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== USERS ===================== -->
|
||||
<!-- ===================== 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 ===================== -->
|
||||
<section id="tab-users" class="tab-panel">
|
||||
<div class="panel-head">
|
||||
<h2 data-i18n="users.heading">Пользователи</h2>
|
||||
@@ -108,13 +149,40 @@
|
||||
<button type="button" class="btn ghost" id="test-btn" data-i18n="settings.testBtn">Отправить тест</button>
|
||||
</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>
|
||||
<label><span data-i18n="settings.interval">Интервал проверки по умолчанию (минуты)</span>
|
||||
<input type="number" name="check_interval" min="1" value="5">
|
||||
<small class="muted" data-i18n="settings.intervalHint"></small>
|
||||
</label>
|
||||
|
||||
<h3 data-i18n="settings.telegram">Telegram</h3>
|
||||
<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>
|
||||
<label class="switch-row"><span data-i18n="settings.tgEnable">Включить доставку в Telegram</span>
|
||||
<input type="checkbox" name="telegram_enabled" class="switch"></label>
|
||||
<div class="grid-2">
|
||||
@@ -169,6 +237,11 @@
|
||||
<label><span data-i18n="feed.title">Название</span> <small class="muted" data-i18n="feed.titleOpt"></small>
|
||||
<input type="text" name="title">
|
||||
</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">
|
||||
<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>
|
||||
@@ -201,6 +274,15 @@
|
||||
<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>
|
||||
</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>
|
||||
<input type="text" name="tags" placeholder="newspaper,fire"></label>
|
||||
|
||||
@@ -214,6 +296,12 @@
|
||||
<div class="switch-grid">
|
||||
<label class="switch-row"><span data-i18n="feed.attach">Прикреплять картинку</span>
|
||||
<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>
|
||||
<input type="checkbox" name="to_telegram" class="switch"></label>
|
||||
<label class="switch-row"><span data-i18n="feed.toWebhook">Отправлять в webhook</span>
|
||||
@@ -234,6 +322,27 @@
|
||||
</form>
|
||||
</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 ===================== -->
|
||||
<div id="user-modal" class="modal-backdrop hidden">
|
||||
<form class="modal" id="user-form">
|
||||
|
||||
@@ -7,3 +7,4 @@ APScheduler==3.11.0
|
||||
Jinja2==3.1.5
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
trafilatura==2.0.0
|
||||
|
||||
Reference in New Issue
Block a user