diff --git a/lib/annot_at/feeds.ex b/lib/annot_at/feeds.ex index b8a759a..dc9537e 100644 --- a/lib/annot_at/feeds.ex +++ b/lib/annot_at/feeds.ex @@ -4,6 +4,7 @@ defmodule AnnotAt.Feeds do detects format and parses. """ + alias AnnotAt.Feeds.Atom alias AnnotAt.Feeds.Feed alias AnnotAt.Feeds.RSS alias AnnotAt.Feeds.Source @@ -27,7 +28,7 @@ defmodule AnnotAt.Feeds do def parse(body, content_type \\ nil) when is_binary(body) do case detect(body, content_type) do :rss -> RSS.parse(body) - :atom -> {:error, :unsupported_feed} + :atom -> Atom.parse(body) :json -> {:error, :unsupported_feed} :unknown -> {:error, :unrecognized_feed} end diff --git a/lib/annot_at/feeds/atom.ex b/lib/annot_at/feeds/atom.ex new file mode 100644 index 0000000..1c86c90 --- /dev/null +++ b/lib/annot_at/feeds/atom.ex @@ -0,0 +1,166 @@ +defmodule AnnotAt.Feeds.Atom do + @moduledoc """ + Saxy parser for Atom 1.0 feeds. + """ + + @behaviour Saxy.Handler + + alias AnnotAt.Feeds.Entry + alias AnnotAt.Feeds.Feed + + require Logger + + @doc """ + Returns `AnnotAt.Feeds.Feed` with entries. The list of entries can be empty. + + If the feed is invalid or not usable, it returns `{:error, :invalid_feed}`. + """ + @spec parse(binary()) :: {:ok, Feed.t()} | {:error, :invalid_feed} + def parse(body) when is_binary(body) do + case Saxy.parse_string(body, __MODULE__, initial_state()) do + {:ok, state} -> + feed = %{state.feed | entries: Enum.reverse(state.entries)} + + if is_binary(feed.title) do + {:ok, feed} + else + {:error, :invalid_feed} + end + + {:error, %Saxy.ParseError{} = saxy_error} -> + Logger.warning("Feeds.Atom saxy error", error: inspect(saxy_error)) + {:error, :invalid_feed} + end + end + + defp initial_state do + %{ + feed: %Feed{}, + entries: [], + stack: [], + current_text: [] + } + end + + def handle_event(:start_document, _data, state), do: {:ok, state} + + def handle_event(:start_element, {"entry", _attrs}, state) do + {:ok, + %{ + state + | entries: [%Entry{} | state.entries], + stack: ["entry" | state.stack], + current_text: [] + }} + end + + def handle_event(:start_element, {"link", attrs}, state) do + state = apply_link(state, List.first(state.stack), attrs) + {:ok, %{state | stack: ["link" | state.stack], current_text: []}} + end + + def handle_event(:start_element, {name, _attrs}, state) do + {:ok, %{state | stack: [name | state.stack], current_text: []}} + end + + def handle_event(:characters, chars, state) do + {:ok, %{state | current_text: [chars | state.current_text]}} + end + + def handle_event(:end_element, "entry", state) do + ["entry" | rest_stack] = state.stack + [entry | rest] = state.entries + entry = finalize_entry(entry) + {:ok, %{state | entries: [entry | rest], stack: rest_stack, current_text: []}} + end + + def handle_event(:end_element, name, state) do + text = text(state.current_text) + [^name | parent_stack] = state.stack + parent = List.first(parent_stack) + + state = %{state | stack: parent_stack, current_text: []} + + state = + cond do + parent == "entry" -> + [current | entries] = state.entries + %{state | entries: [apply_entry_field(current, name, text) | entries]} + + parent == "feed" -> + %{state | feed: apply_feed_field(state.feed, name, text)} + + true -> + state + end + + {:ok, state} + end + + def handle_event(:end_document, _data, state), do: {:ok, state} + + defp apply_link(state, parent, attrs) do + attrs = Map.new(attrs) + rel = Map.get(attrs, "rel", "alternate") + + case {parent, rel, attrs} do + {"entry", "alternate", %{"href" => href}} -> + [current | entries] = state.entries + %{state | entries: [%{current | url: href} | entries]} + + {"feed", "alternate", %{"href" => href}} -> + %{state | feed: %{state.feed | url: href}} + + _ -> + state + end + end + + defp apply_entry_field(entry, "title", text), do: %{entry | title: text} + defp apply_entry_field(entry, "id", text), do: %{entry | id: text} + defp apply_entry_field(entry, "summary", text), do: %{entry | summary: text} + defp apply_entry_field(entry, "content", text), do: %{entry | content: text} + defp apply_entry_field(entry, "published", text), do: %{entry | published_at: parse_date(text)} + + defp apply_entry_field(entry, "updated", text) do + %{entry | published_at: entry.published_at || parse_date(text)} + end + + defp apply_entry_field(entry, _name, _text), do: entry + + defp apply_feed_field(feed, "title", text), do: %{feed | title: text} + defp apply_feed_field(feed, "subtitle", text), do: %{feed | description: text} + defp apply_feed_field(feed, _name, _text), do: feed + + defp finalize_entry(%Entry{id: nil, url: url} = entry) when is_binary(url) do + %{entry | id: url} + end + + defp finalize_entry(entry), do: entry + + defp text(parts) do + result = + parts + |> Enum.reverse() + |> IO.iodata_to_binary() + |> String.trim() + + case result do + "" -> nil + trimmed -> trimmed + end + end + + defp parse_date(nil), do: nil + + defp parse_date(text) do + case DateTimeParser.parse_datetime(text) do + {:ok, datetime} -> + datetime + + {:error, reason} -> + Logger.debug("Feeds.Atom: unparseable date - #{text}", reason: inspect(reason)) + nil + end + end +end diff --git a/lib/annot_at/feeds/rss.ex b/lib/annot_at/feeds/rss.ex index e79080f..6eefaeb 100644 --- a/lib/annot_at/feeds/rss.ex +++ b/lib/annot_at/feeds/rss.ex @@ -139,7 +139,7 @@ defmodule AnnotAt.Feeds.RSS do datetime {:error, reason} -> - Logger.debug("Feeds.RSS unparseable pubDate #{inspect(text)}: #{inspect(reason)}") + Logger.debug("Feeds.RSS: unparseable pubDate - #{text}", reason: inspect(reason)) nil end end diff --git a/test/annot_at/feeds/atom_test.exs b/test/annot_at/feeds/atom_test.exs new file mode 100644 index 0000000..8a45fe2 --- /dev/null +++ b/test/annot_at/feeds/atom_test.exs @@ -0,0 +1,84 @@ +defmodule AnnotAt.Feeds.AtomTest do + use ExUnit.Case, async: true + + alias AnnotAt.Feeds.Atom + alias AnnotAt.Feeds.Entry + alias AnnotAt.Feeds.Feed + + @fixture "../../support/fixtures/feeds/atom_sample.xml" + |> Path.expand(__DIR__) + |> File.read!() + + test "parses feed-level metadata" do + assert {:ok, %Feed{} = feed} = Atom.parse(@fixture) + assert "Sample Blog" == feed.title + assert "https://example.com" == feed.url + assert "Thoughts about things." == feed.description + end + + test "parses entries with all fields" do + assert {:ok, %{entries: [first, _second]}} = Atom.parse(@fixture) + + assert %Entry{} = first + assert "First Post" == first.title + assert "https://example.com/posts/first" == first.url + assert "abc" == first.id + assert "A short summary of the first post." == first.summary + assert "
The full content\n of the first post.
" == first.content + assert %DateTime{} = first.published_at + assert ~U[2024-10-02 13:00:00Z] == first.published_at + end + + test "falls back to URL as ID when id is missing" do + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) + assert second.id == second.url + end + + test "prefers published over updated for published_at" do + assert {:ok, %{entries: [first, _second]}} = Atom.parse(@fixture) + assert ~U[2024-10-02 13:00:00Z] == first.published_at + end + + test "leaves published_at nil when published and updated are missing" do + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) + refute second.published_at + end + + test "leaves content nil when missing" do + assert {:ok, %{entries: [_first, second]}} = Atom.parse(@fixture) + refute second.content + end + + test "uses the alternate link, not self" do + assert {:ok, %Feed{url: "https://example.com"}} = Atom.parse(@fixture) + end + + test "returns an error on malformed" do + assert {:error, :invalid_feed} = Atom.parse("