neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Add OPML export
c08c3a4 · gmorell · 2026-06-23 20:48
Message
{commit_body(@commit)}
Files changed
modified
lib/feed_pug/groups.ex
+39
−0
@@ -213,6 +213,45 @@ defmodule FeedPug.Groups do
| 213 | 213 | |> tap_ok(fn _ -> notify_followers_of_group_change(group) end) |
| 214 | 214 | end |
| 215 | 215 | |
| 216 | + ## OPML export --------------------------------------------------------------- | |
| 217 | + | |
| 218 | + @doc """ | |
| 219 | + Builds a nested OPML node tree (folders + feeds) of the scoped user's own | |
| 220 | + groups, suitable for `FeedPug.Opml.export/2`. Mirrors the group hierarchy: | |
| 221 | + subgroups become nested folder outlines, feeds become rss outlines. | |
| 222 | + """ | |
| 223 | + def export_tree(%Scope{} = scope) do | |
| 224 | + groups = list_groups(scope) | |
| 225 | + feeds_by_group = Map.new(groups, fn g -> {g.id, list_group_feeds(g)} end) | |
| 226 | + children_by_parent = Enum.group_by(groups, & &1.parent_id) | |
| 227 | + build_export_nodes(Map.get(children_by_parent, nil, []), children_by_parent, feeds_by_group) | |
| 228 | + end | |
| 229 | + | |
| 230 | + defp build_export_nodes(groups, children_by_parent, feeds_by_group) do | |
| 231 | + Enum.map(groups, fn group -> | |
| 232 | + subgroups = | |
| 233 | + build_export_nodes( | |
| 234 | + Map.get(children_by_parent, group.id, []), | |
| 235 | + children_by_parent, | |
| 236 | + feeds_by_group | |
| 237 | + ) | |
| 238 | + | |
| 239 | + feeds = | |
| 240 | + feeds_by_group | |
| 241 | + |> Map.get(group.id, []) | |
| 242 | + |> Enum.map(fn gf -> | |
| 243 | + %{ | |
| 244 | + type: :feed, | |
| 245 | + title: gf.custom_title || gf.feed.title || gf.feed.url, | |
| 246 | + xml_url: gf.feed.url, | |
| 247 | + html_url: gf.feed.site_url | |
| 248 | + } | |
| 249 | + end) | |
| 250 | + | |
| 251 | + %{type: :group, name: group.name, children: subgroups ++ feeds} | |
| 252 | + end) | |
| 253 | + end | |
| 254 | + | |
| 216 | 255 | ## OPML import --------------------------------------------------------------- |
| 217 | 256 | |
| 218 | 257 | @doc """ |
modified
lib/feed_pug/opml.ex
+62
−4
@@ -1,10 +1,11 @@
| 1 | 1 | defmodule FeedPug.Opml do |
| 2 | 2 | @moduledoc """ |
| 3 | − Parses OPML subscription lists into a nested node tree. | |
| 3 | + Parses and renders OPML subscription lists as a nested node tree. | |
| 4 | 4 | |
| 5 | − Each node is either a feed (`%{type: :feed, title, xml_url}`) or a folder | |
| 6 | − (`%{type: :group, name, children}`). Folders nest arbitrarily, mapping | |
| 7 | − directly onto FeedPug's subgroup hierarchy. | |
| 5 | + Each node is either a feed (`%{type: :feed, title, xml_url, html_url}`) or a | |
| 6 | + folder (`%{type: :group, name, children}`). Folders nest arbitrarily, mapping | |
| 7 | + directly onto FeedPug's subgroup hierarchy. `parse/1` reads OPML into nodes; | |
| 8 | + `export/2` renders nodes back to an OPML document (see `FeedPug.Groups`). | |
| 8 | 9 | """ |
| 9 | 10 | require Record |
| 10 | 11 |
@@ -17,6 +18,63 @@ defmodule FeedPug.Opml do
| 17 | 18 | |
| 18 | 19 | @type node_t :: %{required(:type) => :feed | :group, optional(atom()) => any()} |
| 19 | 20 | |
| 21 | + ## Export ------------------------------------------------------------------- | |
| 22 | + | |
| 23 | + @doc """ | |
| 24 | + Renders a nested node tree (folders + feeds) into an OPML 2.0 document. | |
| 25 | + Inverse of `parse/1`. Opts: `:title` (defaults to "FeedPug subscriptions"). | |
| 26 | + """ | |
| 27 | + @spec export([node_t()], keyword()) :: String.t() | |
| 28 | + def export(nodes, opts \\ []) when is_list(nodes) do | |
| 29 | + title = Keyword.get(opts, :title, "FeedPug subscriptions") | |
| 30 | + | |
| 31 | + """ | |
| 32 | + <?xml version="1.0" encoding="UTF-8"?> | |
| 33 | + <opml version="2.0"> | |
| 34 | + <head> | |
| 35 | + <title>#{escape(title)}</title> | |
| 36 | + </head> | |
| 37 | + <body> | |
| 38 | + #{render_nodes(nodes, 4)} </body> | |
| 39 | + </opml> | |
| 40 | + """ | |
| 41 | + end | |
| 42 | + | |
| 43 | + defp render_nodes(nodes, indent), do: Enum.map_join(nodes, "", &render_node(&1, indent)) | |
| 44 | + | |
| 45 | + defp render_node(%{type: :feed} = feed, indent) do | |
| 46 | + pad = String.duplicate(" ", indent) | |
| 47 | + title = feed[:title] || feed[:xml_url] | |
| 48 | + | |
| 49 | + attrs = | |
| 50 | + ~s(text="#{escape(title)}" title="#{escape(title)}" type="rss" xmlUrl="#{escape(feed[:xml_url])}") | |
| 51 | + | |
| 52 | + attrs = | |
| 53 | + if feed[:html_url], do: attrs <> ~s( htmlUrl="#{escape(feed[:html_url])}"), else: attrs | |
| 54 | + | |
| 55 | + "#{pad}<outline #{attrs}/>\n" | |
| 56 | + end | |
| 57 | + | |
| 58 | + defp render_node(%{type: :group, name: name, children: children}, indent) do | |
| 59 | + pad = String.duplicate(" ", indent) | |
| 60 | + open = ~s(<outline text="#{escape(name)}" title="#{escape(name)}">) | |
| 61 | + "#{pad}#{open}\n#{render_nodes(children, indent + 2)}#{pad}</outline>\n" | |
| 62 | + end | |
| 63 | + | |
| 64 | + defp escape(nil), do: "" | |
| 65 | + | |
| 66 | + defp escape(value) do | |
| 67 | + value | |
| 68 | + |> to_string() | |
| 69 | + |> String.replace("&", "&") | |
| 70 | + |> String.replace("<", "<") | |
| 71 | + |> String.replace(">", ">") | |
| 72 | + |> String.replace("\"", """) | |
| 73 | + |> String.replace("'", "'") | |
| 74 | + end | |
| 75 | + | |
| 76 | + ## Parse -------------------------------------------------------------------- | |
| 77 | + | |
| 20 | 78 | @doc "Parses OPML text into a list of top-level nodes." |
| 21 | 79 | @spec parse(binary()) :: {:ok, [node_t()]} | {:error, term()} |
| 22 | 80 | def parse(xml) when is_binary(xml) do |
added
lib/feed_pug_web/controllers/opml_controller.ex
+19
−0
@@ -0,0 +1,19 @@
| 1 | +defmodule FeedPugWeb.OpmlController do | |
| 2 | + use FeedPugWeb, :controller | |
| 3 | + | |
| 4 | + alias FeedPug.{Groups, Opml} | |
| 5 | + | |
| 6 | + @doc "GET /opml/export — download the user's subscriptions as an OPML file." | |
| 7 | + def export(conn, _params) do | |
| 8 | + scope = conn.assigns.current_scope | |
| 9 | + xml = scope |> Groups.export_tree() |> Opml.export(title: "FeedPug subscriptions") | |
| 10 | + | |
| 11 | + conn | |
| 12 | + |> put_resp_content_type("text/x-opml") | |
| 13 | + |> put_resp_header( | |
| 14 | + "content-disposition", | |
| 15 | + ~s(attachment; filename="feedpug-subscriptions.opml") | |
| 16 | + ) | |
| 17 | + |> send_resp(200, xml) | |
| 18 | + end | |
| 19 | +end |
modified
lib/feed_pug_web/live/groups_live.ex
+6
−1
@@ -24,7 +24,12 @@ defmodule FeedPugWeb.GroupsLive do
| 24 | 24 | def render(assigns) do |
| 25 | 25 | ~H""" |
| 26 | 26 | <Layouts.app flash={@flash} current_scope={@current_scope}> |
| 27 | − <h1 class="text-2xl font-bold">Your Groups</h1> | |
| 27 | + <div class="flex items-center justify-between gap-2"> | |
| 28 | + <h1 class="text-2xl font-bold">Your Groups</h1> | |
| 29 | + <a href={~p"/opml/export"} download class="btn btn-ghost btn-sm gap-1"> | |
| 30 | + <.icon name="hero-arrow-down-tray-micro" class="size-4" /> Export OPML | |
| 31 | + </a> | |
| 32 | + </div> | |
| 28 | 33 | |
| 29 | 34 | <div class="grid gap-4 sm:grid-cols-2"> |
| 30 | 35 | <.form |
modified
lib/feed_pug_web/router.ex
+1
−0
@@ -90,6 +90,7 @@ defmodule FeedPugWeb.Router do
| 90 | 90 | end |
| 91 | 91 | |
| 92 | 92 | post "/users/update-password", UserSessionController, :update_password |
| 93 | + get "/opml/export", OpmlController, :export | |
| 93 | 94 | end |
| 94 | 95 | |
| 95 | 96 | scope "/", FeedPugWeb do |
modified
test/feed_pug/groups_test.exs
+24
−0
@@ -145,6 +145,30 @@ defmodule FeedPug.GroupsTest do
| 145 | 145 | end |
| 146 | 146 | end |
| 147 | 147 | |
| 148 | + describe "export_tree/1" do | |
| 149 | + test "mirrors the group hierarchy with nested subgroups and feeds" do | |
| 150 | + s = scope() | |
| 151 | + comics = group_by_path(s, "comics") | |
| 152 | + {:ok, sad} = Groups.create_group(s, %{"name" => "Sad"}, comics) | |
| 153 | + {:ok, _} = Groups.add_feed_to_group(s, comics, "https://ex.com/a.xml") | |
| 154 | + {:ok, _} = Groups.add_feed_to_group(s, sad, "https://ex.com/b.xml") | |
| 155 | + | |
| 156 | + tree = Groups.export_tree(s) | |
| 157 | + comics_node = Enum.find(tree, &(&1.name == "comics")) | |
| 158 | + assert comics_node.type == :group | |
| 159 | + | |
| 160 | + assert Enum.any?(comics_node.children, &(&1[:type] == :group and &1.name == "Sad")) | |
| 161 | + | |
| 162 | + assert Enum.any?( | |
| 163 | + comics_node.children, | |
| 164 | + &(&1[:type] == :feed and &1.xml_url == "https://ex.com/a.xml") | |
| 165 | + ) | |
| 166 | + | |
| 167 | + sad_node = Enum.find(comics_node.children, &(&1[:name] == "Sad")) | |
| 168 | + assert [%{type: :feed, xml_url: "https://ex.com/b.xml"}] = sad_node.children | |
| 169 | + end | |
| 170 | + end | |
| 171 | + | |
| 148 | 172 | describe "import_opml/3" do |
| 149 | 173 | test "creates nested subgroups and feeds under a target group" do |
| 150 | 174 | s = scope() |
modified
test/feed_pug/opml_test.exs
+37
−0
@@ -37,6 +37,43 @@ defmodule FeedPug.OpmlTest do
| 37 | 37 | assert {:error, _} = Opml.parse("this is not xml at all <<<") |
| 38 | 38 | end |
| 39 | 39 | |
| 40 | + test "export renders nodes to OPML that parse/1 round-trips" do | |
| 41 | + nodes = [ | |
| 42 | + %{ | |
| 43 | + type: :group, | |
| 44 | + name: "Tech", | |
| 45 | + children: [ | |
| 46 | + %{ | |
| 47 | + type: :feed, | |
| 48 | + title: "Elixir", | |
| 49 | + xml_url: "https://ex.com/e.xml", | |
| 50 | + html_url: "https://ex.com" | |
| 51 | + }, | |
| 52 | + %{ | |
| 53 | + type: :group, | |
| 54 | + name: "Sub", | |
| 55 | + children: [%{type: :feed, title: "B", xml_url: "https://ex.com/b.xml"}] | |
| 56 | + } | |
| 57 | + ] | |
| 58 | + }, | |
| 59 | + %{type: :feed, title: "Top", xml_url: "https://ex.com/t.xml"} | |
| 60 | + ] | |
| 61 | + | |
| 62 | + assert {:ok, [tech, top]} = nodes |> Opml.export() |> Opml.parse() | |
| 63 | + assert tech.name == "Tech" | |
| 64 | + assert top == %{type: :feed, title: "Top", xml_url: "https://ex.com/t.xml"} | |
| 65 | + assert [%{type: :feed, xml_url: "https://ex.com/e.xml"}, sub] = tech.children | |
| 66 | + assert sub.name == "Sub" | |
| 67 | + assert [%{type: :feed, xml_url: "https://ex.com/b.xml"}] = sub.children | |
| 68 | + end | |
| 69 | + | |
| 70 | + test "export escapes XML-special characters in names" do | |
| 71 | + xml = Opml.export([%{type: :group, name: ~s(Tom & "J" <x>), children: []}]) | |
| 72 | + assert xml =~ "Tom & "J" <x>" | |
| 73 | + assert {:ok, [g]} = Opml.parse(xml) | |
| 74 | + assert g.name == ~s(Tom & "J" <x>) | |
| 75 | + end | |
| 76 | + | |
| 40 | 77 | test "handles UTF-8 (non-ASCII) titles without double-decoding" do |
| 41 | 78 | opml = """ |
| 42 | 79 | <?xml version="1.0" encoding="UTF-8"?> |
added
test/feed_pug_web/controllers/opml_controller_test.exs
+29
−0
@@ -0,0 +1,29 @@
| 1 | +defmodule FeedPugWeb.OpmlControllerTest do | |
| 2 | + use FeedPugWeb.ConnCase, async: true | |
| 3 | + | |
| 4 | + alias FeedPug.Groups | |
| 5 | + | |
| 6 | + setup :register_and_log_in_user | |
| 7 | + | |
| 8 | + test "GET /opml/export downloads the user's subscriptions", %{conn: conn, scope: scope} do | |
| 9 | + comics = Enum.find(Groups.list_groups(scope), &(&1.path == "comics")) | |
| 10 | + {:ok, sub} = Groups.create_group(scope, %{"name" => "Sad"}, comics) | |
| 11 | + {:ok, _} = Groups.add_feed_to_group(scope, comics, "https://ex.com/a.xml") | |
| 12 | + {:ok, _} = Groups.add_feed_to_group(scope, sub, "https://ex.com/b.xml") | |
| 13 | + | |
| 14 | + conn = get(conn, ~p"/opml/export") | |
| 15 | + body = response(conn, 200) | |
| 16 | + | |
| 17 | + assert get_resp_header(conn, "content-type") |> hd() =~ "x-opml" | |
| 18 | + assert get_resp_header(conn, "content-disposition") |> hd() =~ "feedpug-subscriptions.opml" | |
| 19 | + assert body =~ "<opml" | |
| 20 | + assert body =~ ~s(text="comics") | |
| 21 | + assert body =~ ~s(xmlUrl="https://ex.com/a.xml") | |
| 22 | + assert body =~ ~s(xmlUrl="https://ex.com/b.xml") | |
| 23 | + end | |
| 24 | + | |
| 25 | + test "requires authentication", %{} do | |
| 26 | + conn = build_conn() |> get(~p"/opml/export") | |
| 27 | + assert redirected_to(conn) =~ "/users/log-in" | |
| 28 | + end | |
| 29 | +end |
Parents: df5dc2a