Merge branch 'develop' into 'remove-avatar-header'
[akkoma] / lib / pleroma / emoji.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Emoji do
6 @moduledoc """
7 The emojis are loaded from:
8
9 * emoji packs in INSTANCE-DIR/emoji
10 * the files: `config/emoji.txt` and `config/custom_emoji.txt`
11 * glob paths, nested folder is used as tag name for grouping e.g. priv/static/emoji/custom/nested_folder
12
13 This GenServer stores in an ETS table the list of the loaded emojis, and also allows to reload the list at runtime.
14 """
15 use GenServer
16
17 require Logger
18
19 @type pattern :: Regex.t() | module() | String.t()
20 @type patterns :: pattern() | [pattern()]
21 @type group_patterns :: keyword(patterns())
22
23 @ets __MODULE__.Ets
24 @ets_options [:ordered_set, :protected, :named_table, {:read_concurrency, true}]
25
26 @doc false
27 def start_link do
28 GenServer.start_link(__MODULE__, [], name: __MODULE__)
29 end
30
31 @doc "Reloads the emojis from disk."
32 @spec reload() :: :ok
33 def reload do
34 GenServer.call(__MODULE__, :reload)
35 end
36
37 @doc "Returns the path of the emoji `name`."
38 @spec get(String.t()) :: String.t() | nil
39 def get(name) do
40 case :ets.lookup(@ets, name) do
41 [{_, path}] -> path
42 _ -> nil
43 end
44 end
45
46 @doc "Returns all the emojos!!"
47 @spec get_all() :: [{String.t(), String.t()}, ...]
48 def get_all do
49 :ets.tab2list(@ets)
50 end
51
52 @doc false
53 def init(_) do
54 @ets = :ets.new(@ets, @ets_options)
55 GenServer.cast(self(), :reload)
56 {:ok, nil}
57 end
58
59 @doc false
60 def handle_cast(:reload, state) do
61 load()
62 {:noreply, state}
63 end
64
65 @doc false
66 def handle_call(:reload, _from, state) do
67 load()
68 {:reply, :ok, state}
69 end
70
71 @doc false
72 def terminate(_, _) do
73 :ok
74 end
75
76 @doc false
77 def code_change(_old_vsn, state, _extra) do
78 load()
79 {:ok, state}
80 end
81
82 defp load do
83 emoji_dir_path =
84 Path.join(
85 Pleroma.Config.get!([:instance, :static_dir]),
86 "emoji"
87 )
88
89 emoji_groups = Pleroma.Config.get([:emoji, :groups])
90
91 case File.ls(emoji_dir_path) do
92 {:error, :enoent} ->
93 # The custom emoji directory doesn't exist,
94 # don't do anything
95 nil
96
97 {:error, e} ->
98 # There was some other error
99 Logger.error("Could not access the custom emoji directory #{emoji_dir_path}: #{e}")
100
101 {:ok, results} ->
102 grouped =
103 Enum.group_by(results, fn file -> File.dir?(Path.join(emoji_dir_path, file)) end)
104
105 packs = grouped[true] || []
106 files = grouped[false] || []
107
108 # Print the packs we've found
109 Logger.info("Found emoji packs: #{Enum.join(packs, ", ")}")
110
111 if not Enum.empty?(files) do
112 Logger.warn(
113 "Found files in the emoji folder. These will be ignored, please move them to a subdirectory\nFound files: #{
114 Enum.join(files, ", ")
115 }"
116 )
117 end
118
119 emojis =
120 Enum.flat_map(
121 packs,
122 fn pack -> load_pack(Path.join(emoji_dir_path, pack), emoji_groups) end
123 )
124
125 true = :ets.insert(@ets, emojis)
126 end
127
128 # Compat thing for old custom emoji handling & default emoji,
129 # it should run even if there are no emoji packs
130 shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], [])
131
132 emojis =
133 (load_from_file("config/emoji.txt", emoji_groups) ++
134 load_from_file("config/custom_emoji.txt", emoji_groups) ++
135 load_from_globs(shortcode_globs, emoji_groups))
136 |> Enum.reject(fn value -> value == nil end)
137
138 true = :ets.insert(@ets, emojis)
139
140 :ok
141 end
142
143 defp load_pack(pack_dir, emoji_groups) do
144 pack_name = Path.basename(pack_dir)
145
146 emoji_txt = Path.join(pack_dir, "emoji.txt")
147
148 if File.exists?(emoji_txt) do
149 load_from_file(emoji_txt, emoji_groups)
150 else
151 Logger.info(
152 "No emoji.txt found for pack \"#{pack_name}\", assuming all .png files are emoji"
153 )
154
155 make_shortcode_to_file_map(pack_dir, [".png"])
156 |> Enum.map(fn {shortcode, rel_file} ->
157 filename = Path.join("/emoji/#{pack_name}", rel_file)
158
159 {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]}
160 end)
161 end
162 end
163
164 def make_shortcode_to_file_map(pack_dir, exts) do
165 find_all_emoji(pack_dir, exts)
166 |> Enum.map(&Path.relative_to(&1, pack_dir))
167 |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end)
168 |> Enum.into(%{})
169 end
170
171 def find_all_emoji(dir, exts) do
172 Enum.reduce(
173 File.ls!(dir),
174 [],
175 fn f, acc ->
176 filepath = Path.join(dir, f)
177
178 if File.dir?(filepath) do
179 acc ++ find_all_emoji(filepath, exts)
180 else
181 acc ++ [filepath]
182 end
183 end
184 )
185 |> Enum.filter(fn f -> Path.extname(f) in exts end)
186 end
187
188 defp load_from_file(file, emoji_groups) do
189 if File.exists?(file) do
190 load_from_file_stream(File.stream!(file), emoji_groups)
191 else
192 []
193 end
194 end
195
196 defp load_from_file_stream(stream, emoji_groups) do
197 stream
198 |> Stream.map(&String.trim/1)
199 |> Stream.map(fn line ->
200 case String.split(line, ~r/,\s*/) do
201 [name, file] ->
202 {name, file, [to_string(match_extra(emoji_groups, file))]}
203
204 [name, file | tags] ->
205 {name, file, tags}
206
207 _ ->
208 nil
209 end
210 end)
211 |> Enum.to_list()
212 end
213
214 defp load_from_globs(globs, emoji_groups) do
215 static_path = Path.join(:code.priv_dir(:pleroma), "static")
216
217 paths =
218 Enum.map(globs, fn glob ->
219 Path.join(static_path, glob)
220 |> Path.wildcard()
221 end)
222 |> Enum.concat()
223
224 Enum.map(paths, fn path ->
225 tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path)))
226 shortcode = Path.basename(path, Path.extname(path))
227 external_path = Path.join("/", Path.relative_to(path, static_path))
228 {shortcode, external_path, [to_string(tag)]}
229 end)
230 end
231
232 @doc """
233 Finds a matching group for the given emoji filename
234 """
235 @spec match_extra(group_patterns(), String.t()) :: atom() | nil
236 def match_extra(group_patterns, filename) do
237 match_group_patterns(group_patterns, fn pattern ->
238 case pattern do
239 %Regex{} = regex -> Regex.match?(regex, filename)
240 string when is_binary(string) -> filename == string
241 end
242 end)
243 end
244
245 defp match_group_patterns(group_patterns, matcher) do
246 Enum.find_value(group_patterns, fn {group, patterns} ->
247 patterns =
248 patterns
249 |> List.wrap()
250 |> Enum.map(fn pattern ->
251 if String.contains?(pattern, "*") do
252 ~r(#{String.replace(pattern, "*", ".*")})
253 else
254 pattern
255 end
256 end)
257
258 Enum.any?(patterns, matcher) && group
259 end)
260 end
261 end