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" />
+
+ Download OPML
+
+
Feeds
[{{ sortedFeeds.length }}]