Add OPML export

c08c3a4 · gmorell · 2026-06-23 20:48

17 files +217 -5
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("&", "&amp;")
70 + |> String.replace("<", "&lt;")
71 + |> String.replace(">", "&gt;")
72 + |> String.replace("\"", "&quot;")
73 + |> String.replace("'", "&#39;")
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 &amp; &quot;J&quot; &lt;x&gt;"
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