Skip to content

Commit

Permalink
ff-175 Export feeds to OPML file (#305)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tiendil authored Dec 16, 2024
1 parent 6e92026 commit 54bba9d
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 4 deletions.
1 change: 1 addition & 0 deletions changes/unreleased.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@

- ff-174 — Custom API entry points for OpenAI and Gemini clients.
- ff-175 — Export feeds to OPML file.
17 changes: 16 additions & 1 deletion ffun/ffun/api/http_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions ffun/ffun/core/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions ffun/ffun/core/tests/test_middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions ffun/ffun/parsers/domain.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions ffun/ffun/parsers/feedly.py → ffun/ffun/parsers/opml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
48 changes: 48 additions & 0 deletions ffun/ffun/parsers/tests/test_opml.py
Original file line number Diff line number Diff line change
@@ -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'<opml version="2.0"><head><title>Your subscriptions in feeds.fun</title></head><body><outline title="uncategorized" text="uncategorized"><outline title="{feeds[0].title}" text="{feeds[0].title}" type="rss" xmlUrl="{feeds[0].url}" /><outline title="{feeds[1].title}" text="{feeds[1].title}" type="rss" xmlUrl="{feeds[1].url}" /></outline></body></opml>' # 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),
)
9 changes: 9 additions & 0 deletions site/src/views/FeedsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@
off-text="last" />
</template>

<template #side-menu-item-4>
<a
class="ffun-form-button p-1 my-1 block w-full text-center"
href="/api/get-opml"
target="_blank"
>Download OPML</a
>
</template>

<template #main-header>
Feeds
<span v-if="sortedFeeds"> [{{ sortedFeeds.length }}] </span>
Expand Down

0 comments on commit 54bba9d

Please sign in to comment.