Add basic metadata to opengraph preview

Also refactor datetime & address utils

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-06-22 13:15:21 +02:00
parent eee2d63309
commit 8caf1e302b
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
9 changed files with 376 additions and 53 deletions

View file

@ -5,6 +5,7 @@ defmodule Mobilizon.Cldr do
use Cldr,
locales: Application.get_env(:mobilizon, :cldr)[:locales],
add_fallback_locales: true,
gettext:
if(Application.fetch_env!(:mobilizon, :env) == :prod,
do: Mobilizon.Web.Gettext,

View file

@ -0,0 +1,95 @@
defmodule Mobilizon.Service.Address do
@moduledoc """
Module to render an `Mobilizon.Addresses.Address` struct to a string
"""
alias Mobilizon.Addresses.Address, as: AddressModel
@type address :: %{name: String.t(), alternative_name: String.t()}
def render_address(%AddressModel{} = address) do
%{name: name, alternative_name: alternative_name} = render_names(address)
cond do
defined?(alternative_name) && defined?(name) ->
"#{name}, #{alternative_name}"
defined?(name) ->
name
defined?(alternative_name) ->
alternative_name
true ->
raise ArgumentError, message: "Invalid address"
end
end
@spec render_names(AddressModel.t()) :: address()
def render_names(%AddressModel{type: nil} = address) do
render_names(%AddressModel{address | type: "house"})
end
def render_names(%AddressModel{
type: type,
description: description,
postal_code: postal_code,
locality: locality,
country: country
})
when type in ["house", "street", "secondary"] do
%{
name: description,
alternative_name: [postal_code, locality, country] |> Enum.filter(& &1) |> Enum.join(", ")
}
end
def render_names(%AddressModel{
type: type,
description: description,
postal_code: postal_code,
locality: locality,
country: country
})
when type in ["zone", "city", "administrative"] do
%{
name: if(defined?(postal_code), do: "#{description} (#{postal_code})", else: description),
alternative_name:
[locality, country]
|> Enum.filter(& &1)
|> Enum.filter(&(&1 != description))
|> Enum.join(", ")
}
end
def render_names(%AddressModel{
description: description,
street: street,
region: region,
locality: locality,
country: country
}) do
alternative_name =
cond do
defined?(street) ->
if defined?(locality), do: "#{street} (#{locality})", else: street
defined?(locality) ->
"#{locality}, #{region}, #{country}"
defined?(region) ->
"#{region}, #{country}"
defined?(country) ->
country
true ->
nil
end
%{name: description, alternative_name: alternative_name}
end
defp defined?(string) when is_binary(string), do: String.trim(string) != ""
defp defined?(_), do: false
end

View file

@ -0,0 +1,41 @@
defmodule Mobilizon.Service.DateTime do
@moduledoc """
Module to represent a datetime in a given locale
"""
alias Cldr.DateTime.Relative
def datetime_to_string(%DateTime{} = datetime, locale \\ "en", format \\ :medium) do
Mobilizon.Cldr.DateTime.to_string!(datetime, format: format, locale: locale_or_default(locale))
end
def datetime_to_time_string(%DateTime{} = datetime, locale \\ "en", format \\ :short) do
Mobilizon.Cldr.Time.to_string!(datetime, format: format, locale: locale_or_default(locale))
end
@spec datetime_tz_convert(DateTime.t(), String.t()) :: DateTime.t()
def datetime_tz_convert(%DateTime{} = datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do
{:ok, datetime_with_user_tz} ->
datetime_with_user_tz
_ ->
datetime
end
end
@spec datetime_relative(DateTime.t(), String.t()) :: String.t()
def datetime_relative(%DateTime{} = datetime, locale \\ "en") do
Relative.to_string!(datetime, Mobilizon.Cldr,
relative_to: DateTime.utc_now(),
locale: locale_or_default(locale)
)
end
defp locale_or_default(locale) do
if Mobilizon.Cldr.known_locale_name(locale) do
locale
else
"en"
end
end
end

View file

@ -1,19 +1,22 @@
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
alias Phoenix.HTML
alias Phoenix.HTML.Tag
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event
alias Mobilizon.Web.JsonLD.ObjectView
import Mobilizon.Service.Metadata.Utils, only: [process_description: 2, strip_tags: 1]
import Mobilizon.Service.Metadata.Utils,
only: [process_description: 2, strip_tags: 1, datetime_to_string: 2, render_address: 1]
def build_tags(%Event{} = event, locale \\ "en") do
event = Map.put(event, :description, process_description(event.description, locale))
formatted_description = description(event, locale)
tags = [
Tag.content_tag(:title, event.title <> " - Mobilizon"),
Tag.tag(:meta, name: "description", content: event.description),
Tag.tag(:meta, name: "description", content: process_description(event.description, locale)),
Tag.tag(:meta, property: "og:title", content: event.title),
Tag.tag(:meta, property: "og:url", content: event.url),
Tag.tag(:meta, property: "og:description", content: event.description),
Tag.tag(:meta, property: "og:description", content: formatted_description),
Tag.tag(:meta, property: "og:type", content: "website"),
# Tell Search Engines what's the origin
Tag.tag(:link, rel: "canonical", href: event.url)
@ -45,4 +48,25 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
|> ObjectView.render(%{event: %{event | title: strip_tags(title)}})
|> Jason.encode!()
end
defp description(
%Event{
description: description,
begins_on: begins_on,
physical_address: %Address{} = address
},
locale
) do
"#{datetime_to_string(begins_on, locale)} - #{render_address(address)} - #{process_description(description, locale)}"
end
defp description(
%Event{
description: description,
begins_on: begins_on
},
locale
) do
"#{datetime_to_string(begins_on, locale)} - #{process_description(description, locale)}"
end
end

View file

@ -3,6 +3,7 @@ defmodule Mobilizon.Service.Metadata.Utils do
Tools to convert tags to string.
"""
alias Mobilizon.Service.{Address, DateTime}
alias Mobilizon.Service.Formatter.HTML, as: HTMLFormatter
alias Phoenix.HTML
import Mobilizon.Web.Gettext
@ -52,6 +53,9 @@ defmodule Mobilizon.Service.Metadata.Utils do
gettext("The event organizer didn't add any description.")
end
defdelegate datetime_to_string(datetime, locale \\ "en", format \\ :medium), to: DateTime
defdelegate render_address(address), to: Address
defp maybe_slice(description, limit) do
if String.length(description) > limit do
description

View file

@ -1,39 +1,15 @@
defmodule Mobilizon.Web.EmailView do
use Mobilizon.Web, :view
alias Cldr.DateTime.Relative
alias Mobilizon.Service.DateTime, as: DateTimeRenderer
import Mobilizon.Web.Gettext
def datetime_to_string(%DateTime{} = datetime, locale \\ "en", format \\ :medium) do
with {:ok, string} <-
Mobilizon.Cldr.DateTime.to_string(datetime, format: format, locale: locale) do
string
end
end
defdelegate datetime_to_string(datetime, locale \\ "en", format \\ :medium),
to: DateTimeRenderer
def datetime_to_time_string(%DateTime{} = datetime, locale \\ "en", format \\ :hm) do
with {:ok, string} <-
Mobilizon.Cldr.DateTime.to_string(datetime, format: format, locale: locale) do
string
end
end
defdelegate datetime_to_time_string(datetime, locale \\ "en", format \\ :short),
to: DateTimeRenderer
@spec datetime_tz_convert(DateTime.t(), String.t()) :: DateTime.t()
def datetime_tz_convert(%DateTime{} = datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do
{:ok, datetime_with_user_tz} ->
datetime_with_user_tz
_ ->
datetime
end
end
@spec datetime_relative(DateTime.t(), String.t()) :: String.t()
def datetime_relative(%DateTime{} = datetime, locale \\ "en") do
Relative.to_string!(datetime, Mobilizon.Cldr,
relative_to: DateTime.utc_now(),
locale: locale
)
end
defdelegate datetime_tz_convert(datetime, timezone), to: DateTimeRenderer
defdelegate datetime_relative(datetime, locale \\ "en"), to: DateTimeRenderer
end

View file

@ -0,0 +1,58 @@
defmodule Mobilizon.Service.AddressTest do
@moduledoc """
Test representing addresses
"""
use Mobilizon.DataCase
alias Mobilizon.Addresses.Address
alias Mobilizon.Service.Address, as: AddressRenderer
import Mobilizon.Factory
describe "render an address" do
test "basic" do
%Address{} = address = insert(:address)
assert AddressRenderer.render_address(address) ==
"#{address.description}, #{address.postal_code}, #{address.locality}, #{address.country}"
end
test "a house" do
assert AddressRenderer.render_address(%Address{
description: "somewhere",
type: "house",
postal_code: "35000",
locality: "Rennes"
}) ==
"somewhere, 35000, Rennes"
end
test "a city" do
assert AddressRenderer.render_address(%Address{
description: "Rennes",
type: "city",
postal_code: "35000",
locality: "Rennes"
}) ==
"Rennes (35000)"
end
test "a region" do
assert AddressRenderer.render_address(%Address{
description: "Ille et Vilaine",
type: "administrative",
postal_code: "",
locality: ""
}) ==
"Ille et Vilaine"
end
test "only with description" do
assert AddressRenderer.render_address(%Address{description: "somewhere"}) == "somewhere"
end
test "with no data" do
assert_raise ArgumentError, "Invalid address", fn ->
AddressRenderer.render_address(%Address{})
end
end
end
end

View file

@ -0,0 +1,71 @@
defmodule Mobilizon.Service.DateTimeTest do
@moduledoc """
Test representing datetimes in defined locale
"""
use Mobilizon.DataCase
alias Mobilizon.Service.DateTime, as: DateTimeRenderer
@datetime "2021-06-22T15:25:29.531539Z"
describe "render a datetime to string" do
test "standard datetime" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
assert DateTimeRenderer.datetime_to_string(datetime) == "Jun 22, 2021, 3:25:29 PM"
assert DateTimeRenderer.datetime_to_string(datetime, "fr") == "22 juin 2021, 15:25:29"
assert DateTimeRenderer.datetime_to_string(datetime, "fr", :long) ==
"22 juin 2021 à 15:25:29 UTC"
end
test "non existing or loaded locale fallbacks to english" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
assert DateTimeRenderer.datetime_to_string(datetime, "es") == "Jun 22, 2021, 3:25:29 PM"
end
end
describe "render a time to string" do
test "standard time" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
assert DateTimeRenderer.datetime_to_time_string(datetime) == "3:25 PM"
assert DateTimeRenderer.datetime_to_time_string(datetime, "fr") == "15:25"
end
test "non existing or loaded locale fallbacks to english" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
assert DateTimeRenderer.datetime_to_time_string(datetime, "pl") == "3:25 PM"
end
end
describe "convert a datetime with a timezone" do
test "with an existing tz" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
converted_datetime = DateTimeRenderer.datetime_tz_convert(datetime, "Europe/Paris")
assert %DateTime{time_zone: "Europe/Paris", utc_offset: 3600} = converted_datetime
assert converted_datetime |> DateTime.to_unix() == datetime |> DateTime.to_unix()
end
test "with an non existing tz" do
{:ok, datetime, _} = DateTime.from_iso8601(@datetime)
converted_datetime = DateTimeRenderer.datetime_tz_convert(datetime, "Planet/Mars")
assert converted_datetime == datetime
end
end
describe "gets relative time to a datetime" do
test "standard time" do
then = DateTime.add(DateTime.utc_now(), 3600 * -5)
assert DateTimeRenderer.datetime_relative(then) == "5 hours ago"
assert DateTimeRenderer.datetime_relative(then, "fr") == "il y a 5 heures"
end
test "non existing or loaded locale fallbacks to english" do
then = DateTime.add(DateTime.utc_now(), 3600 * -4)
assert DateTimeRenderer.datetime_relative(then, "pl") == "4 hours ago"
end
end
end

View file

@ -6,6 +6,7 @@ defmodule Mobilizon.Service.MetadataTest do
alias Mobilizon.Service.Metadata
alias Mobilizon.Tombstone
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.JsonLD.ObjectView
alias Mobilizon.Web.Router.Helpers, as: Routes
use Mobilizon.DataCase
import Mobilizon.Factory
@ -37,29 +38,81 @@ defmodule Mobilizon.Service.MetadataTest do
end
describe "build_tags/2 for an event" do
@long_description """
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer malesuada commodo nunc, dictum dignissim erat aliquet quis. Morbi iaculis scelerisque magna eu dapibus. Morbi ultricies mollis arcu, vel auctor enim dapibus ut. Cras tempus sapien eu lacus blandit suscipit. Fusce tincidunt fringilla velit non elementum. Etiam pretium venenatis placerat. Suspendisse interdum, justo efficitur faucibus commodo, dolor elit vehicula lacus, eu molestie nulla mi vel dolor. Nullam fringilla at lorem a gravida. Praesent viverra, ante eu porttitor rutrum, ex leo condimentum felis, vitae vestibulum neque turpis in nunc. Nullam aliquam rhoncus ornare. Suspendisse finibus finibus est sed eleifend. Nam a massa vestibulum, mollis lorem vel, placerat purus. Nam ex nunc, hendrerit ut lacinia ac, pellentesque eu est.</p>
<p>Fusce nec odio tellus. Aliquam at fermentum turpis, ut dictum tellus. Fusce ac nibh vehicula, imperdiet ipsum sit amet, pellentesque dui. Vivamus venenatis efficitur elementum. Quisque mattis dui ac faucibus mollis. Nullam ac malesuada nisi, vitae scelerisque nisi. Nulla placerat nunc non convallis sollicitudin. Donec sed pulvinar leo, quis tristique eros. Nulla pretium elit ante, consectetur aliquam sapien varius nec. Donec cursus, orci quis suscipit placerat, mi lectus convallis sem, et scelerisque urna libero nec sapien. Nam quis justo ante. Nulla placerat est nec suscipit euismod.</p>
"""
@truncated_description "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer malesuada commodo nunc, dictum dignissim erat aliquet quis. Morbi iaculis scelerisque magna eu dapibus. Morbi ultricies mollis arcu, vel…"
test "gives tags" do
alias Mobilizon.Web.Endpoint
%Event{} = event = insert(:event, description: @long_description)
%Event{} = event = insert(:event)
tags_output = event |> Metadata.build_tags() |> Metadata.Utils.stringify_tags()
{:ok, document} = Floki.parse_fragment(tags_output)
assert "#{event.title} - Mobilizon" == document |> Floki.find("title") |> Floki.text()
# Because the description in Schema.org data is double-escaped
a = "\n"
b = "\\n"
assert @truncated_description ==
document
|> Floki.find("meta[name=\"description\"]")
|> Floki.attribute("content")
|> hd
assert event
|> Metadata.build_tags()
|> Metadata.Utils.stringify_tags() ==
String.trim("""
<title>#{event.title} - Mobilizon</title><meta content="#{event.description}" name="description"><meta content="#{event.title}" property="og:title"><meta content="#{event.url}" property="og:url"><meta content="#{event.description}" property="og:description"><meta content="website" property="og:type"><link href="#{event.url}" rel="canonical"><meta content="#{event.picture.file.url}" property="og:image"><meta content="summary_large_image" property="twitter:card"><script type="application/ld+json">{"@context":"https://schema.org","@type":"Event","description":"#{String.replace(event.description, a, b)}","endDate":"#{DateTime.to_iso8601(event.ends_on)}","eventStatus":"https://schema.org/EventScheduled","image":["#{event.picture.file.url}"],"location":{"@type":"Place","address":{"@type":"PostalAddress","addressCountry":"My Country","addressLocality":"My Locality","addressRegion":"My Region","postalCode":"My Postal Code","streetAddress":"My Street Address"},"name":"#{event.physical_address.description}"},"name":"#{event.title}","organizer":{"@type":"Person","name":"#{event.organizer_actor.preferred_username}"},"performer":{"@type":"Person","name":"#{event.organizer_actor.preferred_username}"},"startDate":"#{DateTime.to_iso8601(event.begins_on)}"}</script>
""")
assert event.title ==
document
|> Floki.find("meta[property=\"og:title\"]")
|> Floki.attribute("content")
|> hd
assert event
assert event.url ==
document
|> Floki.find("meta[property=\"og:url\"]")
|> Floki.attribute("content")
|> hd
assert document
|> Floki.find("meta[property=\"og:description\"]")
|> Floki.attribute("content")
|> hd =~ @truncated_description
assert "website" ==
document
|> Floki.find("meta[property=\"og:type\"]")
|> Floki.attribute("content")
|> hd
assert event.url ==
document
|> Floki.find("link[rel=\"canonical\"]")
|> Floki.attribute("href")
|> hd
assert event.picture.file.url ==
document
|> Floki.find("meta[property=\"og:image\"]")
|> Floki.attribute("content")
|> hd
assert "summary_large_image" ==
document
|> Floki.find("meta[property=\"twitter:card\"]")
|> Floki.attribute("content")
|> hd
assert "event.json" |> ObjectView.render(%{event: event}) |> Jason.encode!() ==
document
|> Floki.find("script[type=\"application/ld+json\"]")
|> Floki.text(js: true)
tags_output =
event
|> Map.put(:picture, nil)
|> Metadata.build_tags()
|> Metadata.Utils.stringify_tags() ==
String.trim("""
<title>#{event.title} - Mobilizon</title><meta content="#{event.description}" name="description"><meta content="#{event.title}" property="og:title"><meta content="#{event.url}" property="og:url"><meta content="#{event.description}" property="og:description"><meta content="website" property="og:type"><link href="#{event.url}" rel="canonical"><meta content="summary_large_image" property="twitter:card"><script type="application/ld+json">{"@context":"https://schema.org","@type":"Event","description":"#{String.replace(event.description, a, b)}","endDate":"#{DateTime.to_iso8601(event.ends_on)}","eventStatus":"https://schema.org/EventScheduled","image":["#{"#{Endpoint.url()}/img/mobilizon_default_card.png"}"],"location":{"@type":"Place","address":{"@type":"PostalAddress","addressCountry":"My Country","addressLocality":"My Locality","addressRegion":"My Region","postalCode":"My Postal Code","streetAddress":"My Street Address"},"name":"#{event.physical_address.description}"},"name":"#{event.title}","organizer":{"@type":"Person","name":"#{event.organizer_actor.preferred_username}"},"performer":{"@type":"Person","name":"#{event.organizer_actor.preferred_username}"},"startDate":"#{DateTime.to_iso8601(event.begins_on)}"}</script>
""")
|> Metadata.Utils.stringify_tags()
{:ok, document} = Floki.parse_fragment(tags_output)
assert [] == Floki.find(document, "meta[property=\"og:image\"]")
end
end