diff --git a/assets/css/modules/_modules.scss b/assets/css/modules/_modules.scss index 6cf0a4e..c05f442 100644 --- a/assets/css/modules/_modules.scss +++ b/assets/css/modules/_modules.scss @@ -4,6 +4,6 @@ @import "clock"; @import "weather"; -@import "rss"; +@import "news"; @import "calendar"; @import "suntime"; diff --git a/assets/css/modules/_rss.scss b/assets/css/modules/_news.scss similarity index 76% rename from assets/css/modules/_rss.scss rename to assets/css/modules/_news.scss index 4b475fe..80f8ba3 100644 --- a/assets/css/modules/_rss.scss +++ b/assets/css/modules/_news.scss @@ -1,5 +1,5 @@ -$rss-svg-size: 60px; -.snowhite-modules-rss { +$news-svg-size: 60px; +.snowhite-modules-news { font-family: $font-family-sans; ul.feeds > li { font-weight: bold; @@ -25,10 +25,10 @@ $rss-svg-size: 60px; } svg { - min-width: $rss-svg-size; - min-height: $rss-svg-size; - max-width: $rss-svg-size; - max-height: $rss-svg-size; + min-width: $news-svg-size; + min-height: $news-svg-size; + max-width: $news-svg-size; + max-height: $news-svg-size; margin-right: 1em; } diff --git a/dev.exs b/dev.exs index 4e1b169..a6e77b1 100644 --- a/dev.exs +++ b/dev.exs @@ -53,7 +53,7 @@ defmodule Snowhite.Profiles.Default do longitude: -79.383186 ) - register_module(:top_left, Snowhite.Modules.Rss, + register_module(:top_left, Snowhite.Modules.News, feeds: [ {"L'Hebdo", "https://www.lhebdojournal.com/feed/rss2/"}, {"RC", "https://ici.radio-canada.ca/rss/4159"} diff --git a/guides/modules/rss.md b/guides/modules/news.md similarity index 69% rename from guides/modules/rss.md rename to guides/modules/news.md index 45bd0c5..2b1b9b0 100644 --- a/guides/modules/rss.md +++ b/guides/modules/news.md @@ -1,6 +1,6 @@ -# RSS +# News -The RSS feed is a handy way to get your local news right on your mirror. One feature that is even more handy within the feeds is the phone accessible QR code that you can show beside your news. +The News feed is a handy way to get your local news right on your mirror. One feature that is even more handy within the feeds is the phone accessible QR code that you can show beside your news. *Note: Reading the QR from your phone might be harder as QRs are quite close. If your having difficulties, check your phone's app store if it has an app that let's you scan multiple QR at once.* @@ -19,7 +19,7 @@ So help me to help you, if you use another server for url shortening that you wo ## Options - `feeds`: (`[feed]`, required) List of RSS feeds to fetch - - `feed`: A tuple `{name, feed_url}`. + - `feed`: A tuple `{name, feed_url}` which defaults to the RSS adapter. To use a other adapters, see the `Snowhite.Modules.News.Adapter` module documentaiton. To override the adapter, it needs to be a map with `:name`, `:url`, `:adapter` and, optionally, `:options` for the adapter. - `persist_app`: (`atom`, required) Your app's atom name. This is required so the dets can be persisted. If your project is called `MyAwesomeProject`, then the atom name should be `my_awesome_project` and the dets will be persisted in the app's privs. - `visible_news`: (`integer`, optional) Sets the number of news to be visible on the page; fallbacks to `3` - `qr_codes`: (`boolean`, optional) Flags to toggle the use of QR codes; fallbacks to `true` @@ -27,12 +27,12 @@ So help me to help you, if you use another server for url shortening that you wo ## Server -As QR generation quite greedy, the server holds instances of `RssItem` of the every rss feed item with QR codes already generated. They are simply displayed as SVG in the page. +As QR generation quite greedy, the server holds instances of `News.Item` of the every news feed item with QR codes already generated. They are simply displayed as SVG in the page. ### Another url shortener -In case you have a different way to shorten your URLs, you can implement the `Snowhite.Modules.Rss.UrlShortener` behaviour and provide your own implementation through the following config +In case you have a different way to shorten your URLs, you can implement the `Snowhite.UrlShortener` behaviour and provide your own implementation through the following config ```elixir -config :snowhite, Snowhite.Modules.Rss.UrlShortener, url_shortener: MyApp.MyShortener +config :snowhite, Snowhite.UrlShortener, url_shortener: MyApp.MyShortener ``` diff --git a/lib/modules/rss.ex b/lib/modules/news.ex similarity index 84% rename from lib/modules/rss.ex rename to lib/modules/news.ex index 6c088e9..565bc9c 100644 --- a/lib/modules/rss.ex +++ b/lib/modules/news.ex @@ -1,4 +1,4 @@ -defmodule Snowhite.Modules.Rss do +defmodule Snowhite.Modules.News do use Snowhite.Builder.Module alias __MODULE__ @@ -40,8 +40,8 @@ defmodule Snowhite.Modules.Rss do
  • <%= if qr_code?, do: Phoenix.HTML.raw(render_qr_code(item)) %>
    - <%= item.title %> - <%= format_date(locale, item.updated) %> + <%= item.title %> + <%= format_date(locale, item.date) %>
  • <% end %> @@ -71,7 +71,7 @@ defmodule Snowhite.Modules.Rss do end defp update(socket) do - news = Rss.Server.news() + news = News.Server.news() assign(socket, :news, news) end @@ -82,8 +82,8 @@ defmodule Snowhite.Modules.Rss do persist_app = Keyword.fetch!(options, :persist_app) [ - {Rss.Server, [feeds: feeds, qr_codes: qr_codes]}, - {Rss.UrlShortener, [persist_app: persist_app]} + {News.Server, [feeds: feeds, qr_codes: qr_codes]}, + {Snowhite.UrlShortener, [persist_app: persist_app]} ] end end diff --git a/lib/modules/news/adapter.ex b/lib/modules/news/adapter.ex new file mode 100644 index 0000000..b5ef4a2 --- /dev/null +++ b/lib/modules/news/adapter.ex @@ -0,0 +1,10 @@ +defmodule Snowhite.Modules.News.Adapter do + @moduledoc """ + Adapter for a given news feed. By default when you use the News module, it uses + the RSS adapter because it assumes that the news feed you provided is a RSS feed. + + To use another adapter, simply implements the following callback. You can also + look at the provided adapters to see one matches your needs. + """ + @callback fetch(url :: String.t(), options :: map()) :: [News.Item.t()] +end diff --git a/lib/modules/news/adapters/json.ex b/lib/modules/news/adapters/json.ex new file mode 100644 index 0000000..ce060ae --- /dev/null +++ b/lib/modules/news/adapters/json.ex @@ -0,0 +1,34 @@ +defmodule Snowhite.Modules.News.Adapters.Json do + @moduledoc """ + The JSON adapter can be used to fetch JSON endpoints. It must have a `item_mapper` + to know how each fields are mapped since every JSON endpoints behaves differently. + It also supports couples of HTTP options to customize the underlying `HTTPoison.request/5` + call. + + ## Options + + - `item_mapper` **required**: A MF (`{module, funciton}`) that received two arguments, + the json payload and the options. This function must return a list of `Item.t()`. + - `body` (default `GET`): The method to use for the HTTP call. + - `headers` (default `[]`): Headers for the HTTP call. + - `body` (default `""`): Body content, in case of a non-GET call. + - `http_poison_options` (default `[]`): Other option for `HTTPoison`. + """ + @behaviour Snowhite.Modules.News.Adapter + + def fetch(url, %{item_mapper: {module, function}} = options) do + method = Map.get(options, :method, "GET") + body = Map.get(options, :body, "") + headers = Map.get(options, :headers, []) + http_poison_options = Map.get(options, :http_poison_options, []) + + with {:ok, %{body: body}} <- + HTTPoison.request(method, url, body, headers, http_poison_options), + {:ok, json} <- Jason.decode(body) do + apply(module, function, [json, options]) + else + _error -> + [] + end + end +end diff --git a/lib/modules/news/adapters/rss.ex b/lib/modules/news/adapters/rss.ex new file mode 100644 index 0000000..c14d586 --- /dev/null +++ b/lib/modules/news/adapters/rss.ex @@ -0,0 +1,42 @@ +defmodule Snowhite.Modules.News.Adapters.Rss do + @moduledoc """ + Implements RSS feed lookup. The Feeds must have the following properties + + - `id` a unique identifier of the feed + - `title` the title, most likely the headline of the article + - `rss2:link` the url to read the news. This will be used to generate the QR code. + - `date` date at which the article has been published. + + The RSS adapter doesn't support any options by now. + """ + @behaviour Snowhite.Modules.News.Adapter + + alias Snowhite.Helpers.List, as: ListHelper + alias Snowhite.Modules.News.Item + + def fetch(url, _) do + with {:ok, %{entries: entries}} <- Rss.Poller.poll(url) do + ListHelper.filter_map(entries, &valid?/1, &to_rss_item/1) + else + _ -> [] + end + end + + defp to_rss_item(%{id: id} = entry) do + %Item{ + id: id, + original_url: Map.get(entry, :"rss2:link"), + date: Map.get(entry, :updated), + title: String.trim(Map.get(entry, :title, "")) + } + end + + @required_fields [:id, :title, :"rss2:link", :updated] + defp valid?(item) do + Enum.all?(@required_fields, fn field -> + not (item + |> Map.get(field) + |> is_nil()) + end) + end +end diff --git a/lib/modules/news/feed.ex b/lib/modules/news/feed.ex new file mode 100644 index 0000000..786b4aa --- /dev/null +++ b/lib/modules/news/feed.ex @@ -0,0 +1,35 @@ +defmodule Snowhite.Modules.News.Feed do + @moduledoc """ + News Feed structure + """ + defstruct [:name, :url, :adapter, :options] + + require Logger + + alias __MODULE__ + + @default_adapter Snowhite.Modules.News.Adapters.Rss + + @type init_arguments :: + {String.t(), String.t()} + | %{name: String.t(), url: String.t(), adapter: module(), options: map()} + + @type t :: %Feed{name: String.t(), url: String.t(), adapter: module(), options: map()} + + @doc "Initialize a feed structure" + @spec new(init_arguments()) :: t() + def new({name, url}) do + new(%{name: name, url: url, adapter: @default_adapter, options: %{}}) + end + + def new(%{name: name, url: url, adapter: adapter, options: options}) do + %Feed{name: name, url: url, adapter: adapter, options: options} + end + + def call_adapter(%Feed{name: name, url: url, adapter: adapter, options: options}) do + Logger.info("[#{inspect(adapter)}] [#{name}] Polling #{url}") + news = apply(adapter, :fetch, [url, options]) + Logger.info("[#{inspect(adapter)}] [#{name}] got #{length(news)} news") + news + end +end diff --git a/lib/modules/news/item.ex b/lib/modules/news/item.ex new file mode 100644 index 0000000..889cb81 --- /dev/null +++ b/lib/modules/news/item.ex @@ -0,0 +1,16 @@ +defmodule Snowhite.Modules.News.Item do + @moduledoc """ + News feed item + """ + defstruct [:id, :title, :original_url, :short_url, :date, :qr_code] + + alias __MODULE__ + + @type t :: %Item{ + title: String.t(), + original_url: String.t(), + short_url: String.t(), + date: String.t(), + qr_code: EQRCode.Matrix.t() + } +end diff --git a/lib/modules/news/server.ex b/lib/modules/news/server.ex new file mode 100644 index 0000000..87fe102 --- /dev/null +++ b/lib/modules/news/server.ex @@ -0,0 +1,108 @@ +defmodule Snowhite.Modules.News.Server do + use GenServer + + alias Snowhite.Modules.News.Item + alias Snowhite.Modules.News.Feed + alias Snowhite.UrlShortener + import Snowhite.Helpers.Timing + require Logger + + @auto_sync_timer ~d(15m) + + @spec start_link(any) :: GenServer.on_start() + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + @doc "Updates the news feed" + @spec update() :: :ok + def update do + GenServer.cast(__MODULE__, :update) + end + + @doc "Gets server's news feeds" + @spec news() :: [{String.t(), [Item.t()]}] + def news do + GenServer.call(__MODULE__, :news) + end + + @impl GenServer + def init(options) do + {feeds, options} = Keyword.pop!(options, :feeds) + Logger.info("[#{__MODULE__}] Started with #{length(feeds)} feeds") + send(self(), :auto_sync) + {:ok, %{options: options, feeds: init_feeds(feeds), news: []}} + end + + @impl GenServer + def handle_cast(:update, state) do + send(self(), :notify) + {:noreply, update(state)} + end + + @impl GenServer + def handle_call(:news, _, %{news: news} = state) do + {:reply, news, state} + end + + @impl GenServer + def handle_info(:auto_sync, %{options: options} = state) do + state = update(state) + update_later(options) + send(self(), :notify) + {:noreply, state} + end + + def handle_info(:notify, state) do + Phoenix.PubSub.broadcast!(Snowhite.PubSub, "snowhite:modules:news", :updated) + {:noreply, state} + end + + defp update(%{feeds: feeds} = state) do + news = Enum.map(feeds, &map_item(&1, state)) + + %{state | news: news} + end + + defp map_item(%Feed{name: name} = feed, state) do + news = + feed + |> Feed.call_adapter() + |> Enum.map(fn item -> + item + |> shorten_url(state) + |> put_qr_code(state) + end) + + {name, news} + end + + defp put_qr_code(%Item{short_url: short_url} = item, %{options: options}) do + if Keyword.get(options, :qr_codes, true) do + %Item{item | qr_code: EQRCode.encode(short_url)} + else + item + end + end + + defp shorten_url(%Item{original_url: url} = item, %{options: options}) do + short = if Keyword.get(options, :short_link, true), do: shorten_url(url), else: url + + %Item{item | short_url: short} + end + + defp shorten_url(link) when is_bitstring(link) do + case UrlShortener.shorten(link) do + nil -> link + short -> short + end + end + + defp update_later(options) do + Process.send_after(self(), :auto_sync, Keyword.get(options, :refresh, @auto_sync_timer)) + end + + defp init_feeds(feeds) do + Enum.map(feeds, &Feed.new/1) + end +end diff --git a/lib/modules/rss/rss_item.ex b/lib/modules/rss/rss_item.ex deleted file mode 100644 index e0112f6..0000000 --- a/lib/modules/rss/rss_item.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Snowhite.Modules.Rss.RssItem do - @moduledoc """ - Represents a RSS item that is displayable in the view - """ - alias __MODULE__ - defstruct id: nil, title: nil, original_link: nil, link: nil, qr_code: nil, updated: nil - - @type t :: %__MODULE__{} - alias Snowhite.Modules.Rss.UrlShortener - - @type raw_entry :: %{ - :id => String.t(), - :title => String.t(), - :"rss2:link" => String.t(), - :updated => Timex.Types.datetime() - } - @type option :: {:qr_codes, boolean()} | {:short_link, boolean()} - @doc """ - Casts a Rss entry to a RssItem and puts short url and qr_code. - """ - @spec new(raw_entry(), [option()]) :: t() - def new(entry, options \\ []) do - link = Map.get(entry, :"rss2:link") - - %RssItem{ - id: entry.id, - title: String.trim(entry.title), - original_link: link, - updated: entry.updated - } - |> shorten_link(options) - |> put_qr_code(options) - end - - defp put_qr_code(%RssItem{link: link} = item, options) do - if Keyword.get(options, :qr_codes, true) do - %RssItem{item | qr_code: EQRCode.encode(link)} - else - item - end - end - - defp shorten_link(%RssItem{original_link: link} = item, options) do - short = if Keyword.get(options, :short_link, true), do: shorten_link(link), else: link - - %RssItem{item | link: short} - end - - defp shorten_link(link) when is_bitstring(link) do - case UrlShortener.shorten(link) do - nil -> link - short -> short - end - end -end diff --git a/lib/modules/rss/server.ex b/lib/modules/rss/server.ex deleted file mode 100644 index 1d46827..0000000 --- a/lib/modules/rss/server.ex +++ /dev/null @@ -1,89 +0,0 @@ -defmodule Snowhite.Modules.Rss.Server do - @moduledoc """ - Server that holds polled feeds and syncs them - """ - use GenServer - import Snowhite.Helpers.Timing - require Logger - alias Snowhite.Modules.Rss.Poller - alias Snowhite.Modules.Rss.RssItem - - @auto_sync_timer ~d(15m) - - @type feed :: {String.t(), String.t()} - - def start_link(args) do - GenServer.start_link(__MODULE__, args, name: __MODULE__) - end - - def update do - GenServer.cast(__MODULE__, :update) - end - - def news do - GenServer.call(__MODULE__, :news) - end - - @spec init(keyword) :: {:ok, %{feeds: any, news: [], options: [{any, any}]}} - def init(options) do - feeds = Keyword.fetch!(options, :feeds) - Logger.info("[#{__MODULE__}] Started with #{length(feeds)} feeds") - update() - update_later(options) - {:ok, %{options: options, feeds: feeds, news: []}} - end - - def handle_cast(:update, state) do - send(self(), :notify) - {:noreply, update(state)} - end - - def handle_call(:news, _, %{news: news} = state) do - {:reply, news, state} - end - - def handle_info(:auto_sync, %{options: options} = state) do - state = update(state) - update_later(options) - send(self(), :notify) - {:noreply, state} - end - - def handle_info(:notify, state) do - Phoenix.PubSub.broadcast!(Snowhite.PubSub, "snowhite:modules:rss", :updated) - {:noreply, state} - end - - defp update_later(options) do - Process.send_after(self(), :auto_sync, Keyword.get(options, :refresh, @auto_sync_timer)) - end - - defp update(%{feeds: feeds} = state) do - news = - feeds - |> Poller.poll() - |> Enum.map(fn - {name, %{entries: entries}} -> - entries = - entries - |> Enum.reject(&invalid?/1) - |> Enum.map(&RssItem.new(&1)) - - {name, entries} - - {name, _} -> - {name, []} - end) - - %{state | news: news} - end - - @required_fields [:id, :title, :"rss2:link", :updated] - defp invalid?(item) do - Enum.any?(@required_fields, fn field -> - item - |> Map.get(field) - |> is_nil() - end) - end -end diff --git a/lib/modules/rss/poller.ex b/lib/rss/poller.ex similarity index 90% rename from lib/modules/rss/poller.ex rename to lib/rss/poller.ex index 020dd84..6a887d8 100644 --- a/lib/modules/rss/poller.ex +++ b/lib/rss/poller.ex @@ -1,16 +1,15 @@ -defmodule Snowhite.Modules.Rss.Poller do +defmodule Rss.Poller do @moduledoc """ Polls a given list of feeds in parrallel. """ require Logger alias Snowhite.Helpers.TaskRunner - alias Snowhte.Modules.Rss @doc """ Polls given feeds in parrallel """ - @type feed :: [Rss.Server.feed()] | String.t() + @type feed :: [{String.t(), String.t()}] | String.t() @type loaded_feed :: {String.t(), [map()]} | {:error, any} | {:ok, any} @spec poll(feed()) :: loaded_feed() def poll(feeds) when is_list(feeds) do diff --git a/lib/snowhite/helpers/list.ex b/lib/snowhite/helpers/list.ex index 5687cef..c2754a2 100644 --- a/lib/snowhite/helpers/list.ex +++ b/lib/snowhite/helpers/list.ex @@ -23,4 +23,18 @@ defmodule Snowhite.Helpers.List do Enum.reduce(range, list, fn _, [head | tail] -> tail ++ [head] end) end + + @doc "Filters and maps a list with two different function" + @spec filter_map(Enum.t(), (any() -> boolean()), (any() -> any())) :: [any()] + def filter_map(items, filter_function, map_function) do + items + |> Enum.reduce([], fn item, acc -> + if filter_function.(item) do + [map_function.(item) | acc] + else + acc + end + end) + |> Enum.reverse() + end end diff --git a/lib/modules/rss/url_shortener.ex b/lib/url_shortener.ex similarity index 98% rename from lib/modules/rss/url_shortener.ex rename to lib/url_shortener.ex index 6257e77..b366696 100644 --- a/lib/modules/rss/url_shortener.ex +++ b/lib/url_shortener.ex @@ -1,4 +1,4 @@ -defmodule Snowhite.Modules.Rss.UrlShortener do +defmodule Snowhite.UrlShortener do @moduledoc """ Shortens url using a given url shortener and keeps the value persisted in a dets table """ diff --git a/lib/modules/rss/url_shortener/bitly.ex b/lib/url_shortener/bitly.ex similarity index 67% rename from lib/modules/rss/url_shortener/bitly.ex rename to lib/url_shortener/bitly.ex index 4983dc1..f387986 100644 --- a/lib/modules/rss/url_shortener/bitly.ex +++ b/lib/url_shortener/bitly.ex @@ -1,5 +1,5 @@ -defmodule Snowhite.Modules.Rss.UrlShortener.Bitly do - @behaviour Snowhite.Modules.Rss.UrlShortener +defmodule Snowhite.UrlShortener.Bitly do + @behaviour Snowhite.UrlShortener def shorten(url) do with %Bitly.Link{status_code: 200, data: %{url: short_url}} <- Bitly.Link.shorten(url) do diff --git a/mix.exs b/mix.exs index 16dc280..96f8acd 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Snowhite.MixProject do @github "https://github.com/nicklayb/snowhite" @description "Smart mirror framework" - @version "1.2.0" + @version "2.0.0" def project do [ app: :snowhite, @@ -98,16 +98,16 @@ defmodule Snowhite.MixProject do Snowhite.Modules.Weather, Snowhite.Modules.Weather.Current, Snowhite.Modules.Weather.Forecast, - Snowhite.Modules.Rss, - Snowhite.Modules.Rss.Poller, - Snowhite.Modules.Rss.RssItem, - Snowhite.Modules.Rss.UrlShortener, + Snowhite.Modules.News, + Snowhite.Modules.News.Poller, + Snowhite.Modules.News.NewsItem, + Snowhite.UrlShortener, Snowhite.Modules.Suntime, Snowhite.Modules.Suntime.Server ], Servers: [ Snowhite.Modules.Clock.Server, - Snowhite.Modules.Rss.Server, + Snowhite.Modules.News.Server, Snowhite.Modules.Weather.Server, Snowhite.Modules.Suntime.Server, Snowhite.Scheduler,