✨ 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
|
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
@@ -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)
|
||||||
|
|||||||
+75
-15
@@ -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,19 +138,45 @@ 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:
|
||||||
await ntfy.publish(
|
if msg.full_content:
|
||||||
server=server,
|
# Build markdown body with all images inlined + template applied.
|
||||||
topic=feed.ntfy_topic,
|
md_body = formatted_body
|
||||||
title=full_title,
|
if msg.images:
|
||||||
message=msg.body or "(нет описания)",
|
for img_url in msg.images:
|
||||||
click=msg.link,
|
md_body += f"\n\n"
|
||||||
tags=feed.tags,
|
if msg.videos:
|
||||||
priority=feed.priority,
|
for vid_url in msg.videos:
|
||||||
attach=msg.image if feed.attach_image else "",
|
md_body += f"\n\n📹 {vid_url}"
|
||||||
token=token,
|
await ntfy.publish(
|
||||||
username=username,
|
server=server,
|
||||||
password=password,
|
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")
|
result.channels.append("ntfy")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
result.errors.append(f"ntfy: {exc}")
|
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 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,10 +369,23 @@ 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()
|
||||||
query = query.where(
|
# Try FTS5 full-text search first
|
||||||
Notification.title.ilike(like) | Notification.feed_title.ilike(like)
|
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:
|
if only_errors:
|
||||||
query = query.where(Notification.ok == False) # noqa: E712
|
query = query.where(Notification.ok == False) # noqa: E712
|
||||||
notes = session.exec(
|
notes = session.exec(
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
+112
-3
@@ -5,7 +5,9 @@
|
|||||||
<div class="brand"><span class="logo">📡</span> RSS → ntfy</div>
|
<div class="brand"><span class="logo">📡</span> RSS → 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,7 +56,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="tab-history" class="tab-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2 data-i18n="history.heading">История уведомлений</h2>
|
<h2 data-i18n="history.heading">История уведомлений</h2>
|
||||||
@@ -72,7 +100,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section id="tab-users" class="tab-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2 data-i18n="users.heading">Пользователи</h2>
|
<h2 data-i18n="users.heading">Пользователи</h2>
|
||||||
@@ -108,13 +149,40 @@
|
|||||||
<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.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>
|
<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>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user