From 54bba9df8a2617ae1406f440223b6bb89efa6de0 Mon Sep 17 00:00:00 2001 From: Aliaksei Yaletski Date: Mon, 16 Dec 2024 16:16:32 +0100 Subject: [PATCH] ff-175 Export feeds to OPML file (#305) --- changes/unreleased.md | 1 + ffun/ffun/api/http_handlers.py | 17 ++++++++- ffun/ffun/core/tests/helpers.py | 5 +++ ffun/ffun/core/tests/test_middlewares.py | 1 + ffun/ffun/parsers/domain.py | 8 ++-- ffun/ffun/parsers/{feedly.py => opml.py} | 22 +++++++++++ ffun/ffun/parsers/tests/test_opml.py | 48 ++++++++++++++++++++++++ site/src/views/FeedsView.vue | 9 +++++ 8 files changed, 107 insertions(+), 4 deletions(-) rename ffun/ffun/parsers/{feedly.py => opml.py} (67%) create mode 100644 ffun/ffun/parsers/tests/test_opml.py diff --git a/changes/unreleased.md b/changes/unreleased.md index 812391ea..1dd88b1d 100644 --- a/changes/unreleased.md +++ b/changes/unreleased.md @@ -1,2 +1,3 @@ - ff-174 — Custom API entry points for OpenAI and Gemini clients. +- ff-175 — Export feeds to OPML file. diff --git a/ffun/ffun/api/http_handlers.py b/ffun/ffun/api/http_handlers.py index dc6aac63..9466ed87 100644 --- a/ffun/ffun/api/http_handlers.py +++ b/ffun/ffun/api/http_handlers.py @@ -4,7 +4,7 @@ import fastapi from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from ffun.api import entities from ffun.api.settings import settings @@ -300,6 +300,21 @@ async def api_add_opml(request: entities.AddOpmlRequest, user: User) -> entities return entities.AddOpmlResponse() +@router.get("/api/get-opml") +async def api_get_opml(user: User) -> PlainTextResponse: + linked_feeds = await fl_domain.get_linked_feeds(user.id) + + linked_feeds_ids = [link.feed_id for link in linked_feeds] + + feeds = await f_domain.get_feeds(ids=linked_feeds_ids) + + content = p_domain.create_opml(feeds=feeds) + + headers = {"Content-Disposition": "attachment; filename=feeds-fun.opml"} + + return PlainTextResponse(content=content, media_type="application/xml", headers=headers) + + @router.post("/api/unsubscribe") async def api_unsubscribe(request: entities.UnsubscribeRequest, user: User) -> entities.UnsubscribeResponse: await fl_domain.remove_link(user_id=user.id, feed_id=request.feedId) diff --git a/ffun/ffun/core/tests/helpers.py b/ffun/ffun/core/tests/helpers.py index 84ac3e69..c19d7227 100644 --- a/ffun/ffun/core/tests/helpers.py +++ b/ffun/ffun/core/tests/helpers.py @@ -4,6 +4,7 @@ from collections import Counter from types import TracebackType from typing import Any, Callable, Generator, MutableMapping, Optional, Type +from xml.dom import minidom # noqa: S408 import pytest from structlog import _config as structlog_config @@ -257,3 +258,7 @@ def assert_logs_has_no_business_event(logs: list[MutableMapping[str, Any]], name for record in logs: if record.get("b_kind") == "event" and record["event"] == name: pytest.fail(f"Event {name} found in logs") + + +def assert_compare_xml(a: str, b: str) -> None: + assert minidom.parseString(a).toprettyxml() == minidom.parseString(b).toprettyxml() # noqa: S318 diff --git a/ffun/ffun/core/tests/test_middlewares.py b/ffun/ffun/core/tests/test_middlewares.py index 196dabf7..74957f72 100644 --- a/ffun/ffun/core/tests/test_middlewares.py +++ b/ffun/ffun/core/tests/test_middlewares.py @@ -155,6 +155,7 @@ async def test(self, app: fastapi.FastAPI) -> None: "/api/get-rules", "/api/get-collections", "/api/add-opml", + "/api/get-opml", "/api/delete-rule", "/api/add-feed", "/api/unsubscribe", diff --git a/ffun/ffun/parsers/domain.py b/ffun/ffun/parsers/domain.py index 49e7284d..ab79e2ab 100644 --- a/ffun/ffun/parsers/domain.py +++ b/ffun/ffun/parsers/domain.py @@ -1,10 +1,12 @@ -from ffun.parsers import feed +from ffun.parsers import feed, opml from ffun.parsers.entities import FeedInfo -from ffun.parsers.feedly import extract_feeds def parse_opml(data: str) -> list[FeedInfo]: - return extract_feeds(data) + return opml.extract_feeds(data) + + +create_opml = opml.create_opml parse_feed = feed.parse_feed diff --git a/ffun/ffun/parsers/feedly.py b/ffun/ffun/parsers/opml.py similarity index 67% rename from ffun/ffun/parsers/feedly.py rename to ffun/ffun/parsers/opml.py index 8fc01af7..b861b6ca 100644 --- a/ffun/ffun/parsers/feedly.py +++ b/ffun/ffun/parsers/opml.py @@ -3,6 +3,7 @@ from ffun.domain.entities import UnknownUrl from ffun.domain.urls import normalize_classic_unknown_url, to_feed_url, url_to_uid +from ffun.feeds.entities import Feed from ffun.parsers.entities import FeedInfo @@ -60,3 +61,24 @@ def extract_feeds_records(body: ET.Element) -> Generator[FeedInfo, None, None]: continue yield from extract_feeds_records(outline) + + +def create_opml(feeds: list[Feed]) -> str: + + feeds.sort(key=lambda feed: feed.title if feed.title is not None else "") + + opml = ET.Element("opml", version="2.0") + + head = ET.SubElement(opml, "head") + title = ET.SubElement(head, "title") + title.text = "Your subscriptions in feeds.fun" + + body = ET.SubElement(opml, "body") + + outline = ET.SubElement(body, "outline", {"title": "uncategorized", "text": "uncategorized"}) + + for feed in feeds: + feed_title = feed.title if feed.title is not None else "unknown" + ET.SubElement(outline, "outline", {"title": feed_title, "text": feed_title, "type": "rss", "xmlUrl": feed.url}) + + return ET.tostring(opml, encoding="utf-8", method="xml").decode("utf-8") # type: ignore diff --git a/ffun/ffun/parsers/tests/test_opml.py b/ffun/ffun/parsers/tests/test_opml.py new file mode 100644 index 00000000..6db6138b --- /dev/null +++ b/ffun/ffun/parsers/tests/test_opml.py @@ -0,0 +1,48 @@ +from ffun.core.tests.helpers import assert_compare_xml +from ffun.domain.urls import url_to_uid +from ffun.feeds.entities import Feed +from ffun.parsers.entities import FeedInfo +from ffun.parsers.opml import create_opml, extract_feeds + + +class TestCreateOpml: + + def test(self, saved_feed: Feed, another_saved_feed: Feed) -> None: + + feeds = [saved_feed, another_saved_feed] + feeds.sort(key=lambda feed: feed.title if feed.title is not None else "") + + content = create_opml(feeds) + + expected_content = f'Your subscriptions in feeds.fun' # noqa: E501 + + assert_compare_xml(content, expected_content.strip()) + + +class TestExtractFeeds: + + def test(self, saved_feed: Feed, another_saved_feed: Feed) -> None: + feeds = [saved_feed, another_saved_feed] + feeds.sort(key=lambda feed: feed.title if feed.title is not None else "") + + content = create_opml(feeds) + + infos = extract_feeds(content) + + infos.sort(key=lambda info: info.title) + + assert infos[0] == FeedInfo( + url=feeds[0].url, + title=feeds[0].title if feeds[0].title is not None else "", + description="", + entries=[], + uid=url_to_uid(feeds[0].url), + ) + + assert infos[1] == FeedInfo( + url=feeds[1].url, + title=feeds[1].title if feeds[1].title is not None else "", + description="", + entries=[], + uid=url_to_uid(feeds[1].url), + ) diff --git a/site/src/views/FeedsView.vue b/site/src/views/FeedsView.vue index 33702977..264b5561 100644 --- a/site/src/views/FeedsView.vue +++ b/site/src/views/FeedsView.vue @@ -25,6 +25,15 @@ off-text="last" /> + +