Skip to content

Commit

Permalink
Merge pull request #6 from nicklayb/2.0.0
Browse files Browse the repository at this point in the history
2.0.0
  • Loading branch information
nicklayb committed Jan 13, 2022
2 parents 7c2e06e + 42af832 commit 6673f57
Show file tree
Hide file tree
Showing 18 changed files with 290 additions and 176 deletions.
2 changes: 1 addition & 1 deletion assets/css/modules/_modules.scss
Expand Up @@ -4,6 +4,6 @@

@import "clock";
@import "weather";
@import "rss";
@import "news";
@import "calendar";
@import "suntime";
12 changes: 6 additions & 6 deletions assets/css/modules/_rss.scss → 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;
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion dev.exs
Expand Up @@ -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"}
Expand Down
12 changes: 6 additions & 6 deletions guides/modules/rss.md → 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.*

Expand All @@ -19,20 +19,20 @@ 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`
- `short_link`: (`boolean`, optional) Flags to toggle the shortening of the urls; fallbacks to `true`

## 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
```
12 changes: 6 additions & 6 deletions lib/modules/rss.ex → lib/modules/news.ex
@@ -1,4 +1,4 @@
defmodule Snowhite.Modules.Rss do
defmodule Snowhite.Modules.News do
use Snowhite.Builder.Module

alias __MODULE__
Expand Down Expand Up @@ -40,8 +40,8 @@ defmodule Snowhite.Modules.Rss do
<li>
<%= if qr_code?, do: Phoenix.HTML.raw(render_qr_code(item)) %>
<div>
<a href="<%= item.id %>"><%= item.title %></a>
<small><%= format_date(locale, item.updated) %></small>
<a href="<%= item.original_url %>"><%= item.title %></a>
<small><%= format_date(locale, item.date) %></small>
</div>
</li>
<% end %>
Expand Down Expand Up @@ -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
Expand All @@ -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
10 changes: 10 additions & 0 deletions 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
34 changes: 34 additions & 0 deletions 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
42 changes: 42 additions & 0 deletions 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
35 changes: 35 additions & 0 deletions 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
16 changes: 16 additions & 0 deletions 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
108 changes: 108 additions & 0 deletions 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

0 comments on commit 6673f57

Please sign in to comment.