|
| 1 | +defmodule Beacon.Client.Filters do |
| 2 | + @moduledoc """ |
| 3 | + Built-in filter implementations for Beacon template rendering. |
| 4 | +
|
| 5 | + Filters are the template's only data transformation mechanism. |
| 6 | + Every client SDK must implement the same set. |
| 7 | +
|
| 8 | + ## Available Filters |
| 9 | +
|
| 10 | + **Date:** `format_date`, `time_ago` |
| 11 | + **Text:** `truncate`, `upcase`, `downcase`, `strip_html`, `pluralize` |
| 12 | + **Numbers:** `format_number` |
| 13 | + **Collections:** `size`, `join`, `first`, `last` |
| 14 | + **Utility:** `default`, `json` |
| 15 | + """ |
| 16 | + |
| 17 | + @doc """ |
| 18 | + Apply a named filter to a value with the given arguments. |
| 19 | + """ |
| 20 | + @spec apply(binary(), term(), [term()]) :: term() |
| 21 | + def apply(name, value, args \\ []) |
| 22 | + |
| 23 | + # -- Date -- |
| 24 | + |
| 25 | + def apply("format_date", value, [format]) when is_binary(value) do |
| 26 | + case DateTime.from_iso8601(value) do |
| 27 | + {:ok, dt, _} -> Calendar.strftime(dt, format) |
| 28 | + _ -> |
| 29 | + case NaiveDateTime.from_iso8601(value) do |
| 30 | + {:ok, ndt} -> Calendar.strftime(ndt, format) |
| 31 | + _ -> value |
| 32 | + end |
| 33 | + end |
| 34 | + end |
| 35 | + |
| 36 | + def apply("format_date", %DateTime{} = dt, [format]), do: Calendar.strftime(dt, format) |
| 37 | + def apply("format_date", %NaiveDateTime{} = ndt, [format]), do: Calendar.strftime(ndt, format) |
| 38 | + def apply("format_date", value, _), do: to_string(value) |
| 39 | + |
| 40 | + def apply("time_ago", value, _args) when is_binary(value) do |
| 41 | + case DateTime.from_iso8601(value) do |
| 42 | + {:ok, dt, _} -> time_ago_in_words(dt) |
| 43 | + _ -> value |
| 44 | + end |
| 45 | + end |
| 46 | + |
| 47 | + def apply("time_ago", %DateTime{} = dt, _), do: time_ago_in_words(dt) |
| 48 | + def apply("time_ago", value, _), do: to_string(value) |
| 49 | + |
| 50 | + # -- Text -- |
| 51 | + |
| 52 | + def apply("truncate", value, [length]) when is_binary(value) and is_integer(length) do |
| 53 | + if String.length(value) > length do |
| 54 | + String.slice(value, 0, length) <> "..." |
| 55 | + else |
| 56 | + value |
| 57 | + end |
| 58 | + end |
| 59 | + |
| 60 | + def apply("truncate", value, _), do: to_string(value) |
| 61 | + |
| 62 | + def apply("upcase", value, _) when is_binary(value), do: String.upcase(value) |
| 63 | + def apply("upcase", value, _), do: to_string(value) |
| 64 | + |
| 65 | + def apply("downcase", value, _) when is_binary(value), do: String.downcase(value) |
| 66 | + def apply("downcase", value, _), do: to_string(value) |
| 67 | + |
| 68 | + def apply("strip_html", value, _) when is_binary(value) do |
| 69 | + Regex.replace(~r/<[^>]*>/, value, "") |
| 70 | + end |
| 71 | + |
| 72 | + def apply("strip_html", value, _), do: to_string(value) |
| 73 | + |
| 74 | + def apply("pluralize", count, [singular, plural]) when is_integer(count) do |
| 75 | + if count == 1, do: "#{count} #{singular}", else: "#{count} #{plural}" |
| 76 | + end |
| 77 | + |
| 78 | + def apply("pluralize", value, _), do: to_string(value) |
| 79 | + |
| 80 | + # -- Numbers -- |
| 81 | + |
| 82 | + def apply("format_number", value, []) when is_integer(value) do |
| 83 | + value |
| 84 | + |> Integer.to_string() |
| 85 | + |> add_thousands_separator() |
| 86 | + end |
| 87 | + |
| 88 | + def apply("format_number", value, [precision]) when is_float(value) and is_integer(precision) do |
| 89 | + :erlang.float_to_binary(value, decimals: precision) |
| 90 | + end |
| 91 | + |
| 92 | + def apply("format_number", value, _), do: to_string(value) |
| 93 | + |
| 94 | + # -- Collections -- |
| 95 | + |
| 96 | + def apply("size", value, _) when is_list(value), do: length(value) |
| 97 | + def apply("size", value, _) when is_binary(value), do: String.length(value) |
| 98 | + def apply("size", value, _) when is_map(value), do: map_size(value) |
| 99 | + def apply("size", _, _), do: 0 |
| 100 | + |
| 101 | + def apply("join", value, [separator]) when is_list(value) do |
| 102 | + Enum.join(value, separator) |
| 103 | + end |
| 104 | + |
| 105 | + def apply("join", value, []) when is_list(value), do: Enum.join(value, ", ") |
| 106 | + def apply("join", value, _), do: to_string(value) |
| 107 | + |
| 108 | + def apply("first", [head | _], _), do: head |
| 109 | + def apply("first", _, _), do: nil |
| 110 | + |
| 111 | + def apply("last", list, _) when is_list(list) and length(list) > 0, do: List.last(list) |
| 112 | + def apply("last", _, _), do: nil |
| 113 | + |
| 114 | + # -- Utility -- |
| 115 | + |
| 116 | + def apply("default", nil, [fallback]), do: fallback |
| 117 | + def apply("default", "", [fallback]), do: fallback |
| 118 | + def apply("default", false, [fallback]), do: fallback |
| 119 | + def apply("default", value, _), do: value |
| 120 | + |
| 121 | + def apply("json", value, _), do: Jason.encode!(value) |
| 122 | + |
| 123 | + # -- Unknown filter -- |
| 124 | + |
| 125 | + def apply(_name, value, _args), do: value |
| 126 | + |
| 127 | + # -- Private helpers -- |
| 128 | + |
| 129 | + defp time_ago_in_words(dt) do |
| 130 | + diff = DateTime.diff(DateTime.utc_now(), dt, :second) |
| 131 | + |
| 132 | + cond do |
| 133 | + diff < 60 -> "just now" |
| 134 | + diff < 3600 -> "#{div(diff, 60)} minutes ago" |
| 135 | + diff < 86400 -> "#{div(diff, 3600)} hours ago" |
| 136 | + diff < 2_592_000 -> "#{div(diff, 86400)} days ago" |
| 137 | + diff < 31_536_000 -> "#{div(diff, 2_592_000)} months ago" |
| 138 | + true -> "#{div(diff, 31_536_000)} years ago" |
| 139 | + end |
| 140 | + end |
| 141 | + |
| 142 | + defp add_thousands_separator(str) do |
| 143 | + str |
| 144 | + |> String.reverse() |
| 145 | + |> String.graphemes() |
| 146 | + |> Enum.chunk_every(3) |
| 147 | + |> Enum.join(",") |
| 148 | + |> String.reverse() |
| 149 | + end |
| 150 | +end |
0 commit comments