"""Flask web application: serves the UI and the REST API used to manage the bridge (feeds, settings, engine control) entirely from the browser.""" import logging from datetime import datetime, timezone import feedparser from flask import Flask, Response, jsonify, render_template, request import opml import store as store_mod from engine import Engine logger = logging.getLogger("bridge") def create_app(store: store_mod.Store, engine: Engine) -> Flask: app = Flask(__name__, template_folder="templates", static_folder="static") # ---- UI ---------------------------------------------------------------- @app.get("/") def index(): return render_template("index.html") @app.get("/api/health") def health(): return jsonify({"ok": True}) # ---- status / control -------------------------------------------------- @app.get("/api/status") def status(): settings = store.get_settings() st = dict(engine.status) st["enabled"] = settings["enabled"] st["sync_interval"] = settings["sync_interval"] st["history_count"] = engine.history_count() feeds = store.get_feeds() st["feed_total"] = len(feeds) st["feed_active"] = len([f for f in feeds if f["enabled"]]) st["topics"] = sorted({f["topic"] for f in feeds if f["topic"]}) st["server_time"] = datetime.now(timezone.utc).isoformat() return jsonify(st) @app.post("/api/sync") def sync_now(): engine.trigger_sync() return jsonify({"ok": True, "message": "Синхронизация запущена"}) @app.post("/api/engine") def engine_control(): data = request.get_json(force=True, silent=True) or {} action = data.get("action") if action == "pause": store.update_settings({"enabled": False}) elif action == "resume": store.update_settings({"enabled": True}) engine.trigger_sync() else: return jsonify({"ok": False, "error": "unknown action"}), 400 return jsonify({"ok": True}) @app.get("/api/logs") def logs(): return jsonify({"logs": list(reversed(engine.ring.records()))}) @app.post("/api/history/clear") def clear_history(): engine.clear_history() return jsonify({"ok": True}) # ---- settings ---------------------------------------------------------- @app.get("/api/settings") def get_settings(): return jsonify(store.get_settings()) @app.put("/api/settings") def put_settings(): data = request.get_json(force=True, silent=True) or {} updated = store.update_settings(data) engine.trigger_sync() return jsonify(updated) # ---- feeds ------------------------------------------------------------- @app.get("/api/feeds") def get_feeds(): return jsonify({"feeds": store.get_feeds()}) @app.post("/api/feeds") def create_feed(): data = request.get_json(force=True, silent=True) or {} if not data.get("url") or not data.get("topic"): return jsonify({"ok": False, "error": "url и topic обязательны"}), 400 feed = store.add_feed(data) return jsonify(feed), 201 @app.put("/api/feeds/") def edit_feed(feed_id): data = request.get_json(force=True, silent=True) or {} feed = store.update_feed(feed_id, data) if feed is None: return jsonify({"ok": False, "error": "not found"}), 404 return jsonify(feed) @app.delete("/api/feeds/") def remove_feed(feed_id): ok = store.delete_feed(feed_id) return (jsonify({"ok": True}) if ok else (jsonify({"ok": False, "error": "not found"}), 404)) @app.post("/api/feeds/preview") def preview_feed(): """Fetch a feed URL and return a few recent entries (for the UI).""" data = request.get_json(force=True, silent=True) or {} url = data.get("url", "").strip() if not url: return jsonify({"ok": False, "error": "url обязателен"}), 400 settings = store.get_settings() parsed = feedparser.parse(url, agent=settings.get("user_agent")) entries = [ {"title": e.get("title", "(no title)"), "link": e.get("link", "")} for e in parsed.entries[:5] ] return jsonify({ "ok": True, "title": parsed.feed.get("title", ""), "count": len(parsed.entries), "entries": entries, "error": str(parsed.get("bozo_exception")) if parsed.bozo else None, }) # ---- OPML import / export ---------------------------------------------- @app.get("/api/export/opml") def export_opml(): xml = opml.feeds_to_opml(store.get_feeds()) return Response( xml, mimetype="text/x-opml", headers={"Content-Disposition": "attachment; filename=feeds.opml"}, ) @app.post("/api/import/opml") def import_opml(): if "file" in request.files: content = request.files["file"].read().decode("utf-8", "replace") else: content = request.get_data(as_text=True) if not content.strip(): return jsonify({"ok": False, "error": "empty file"}), 400 try: parsed = opml.opml_to_feeds(content) except Exception as exc: # noqa: BLE001 return jsonify({"ok": False, "error": f"OPML parse error: {exc}"}), 400 existing = {(f["url"], f["topic"]) for f in store.get_feeds()} added = 0 for feed in parsed: if not feed.get("url"): continue key = (feed["url"], feed["topic"]) if key in existing: continue store.add_feed(feed) existing.add(key) added += 1 if added: engine.trigger_sync() logger.info("OPML import: %d new feed(s) of %d", added, len(parsed)) return jsonify({"ok": True, "added": added, "total": len(parsed)}) # ---- test notification ------------------------------------------------- @app.post("/api/test-notify") def test_notify(): data = request.get_json(force=True, silent=True) or {} topic = (data.get("topic") or "").strip() if not topic: return jsonify({"ok": False, "error": "topic обязателен"}), 400 try: engine.send_test( topic, title=data.get("title") or "rss-bridge-ntfy", message=data.get("message") or "Тестовое уведомление ✅", ) return jsonify({"ok": True}) except Exception as exc: # noqa: BLE001 return jsonify({"ok": False, "error": str(exc)}), 502 return app