Merge branch 'develop' into tests/mastodon_api_controller.ex
[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 # Clear out old emojis
126 :ets.delete_all_objects(@ets)
127
128 true = :ets.insert(@ets, emojis)
129 end
130
131 # Compat thing for old custom emoji handling & default emoji,
132 # it should run even if there are no emoji packs
133 shortcode_globs = Pleroma.Config.get([:emoji, :shortcode_globs], [])
134
135 emojis =
136 (load_from_file("config/emoji.txt", emoji_groups) ++
137 load_from_file("config/custom_emoji.txt", emoji_groups) ++
138 load_from_globs(shortcode_globs, emoji_groups))
139 |> Enum.reject(fn value -> value == nil end)
140
141 true = :ets.insert(@ets, emojis)
142
143 :ok
144 end
145
146 defp load_pack(pack_dir, emoji_groups) do
147 pack_name = Path.basename(pack_dir)
148
149 pack_file = Path.join(pack_dir, "pack.json")
150
151 if File.exists?(pack_file) do
152 contents = Jason.decode!(File.read!(pack_file))
153
154 contents["files"]
155 |> Enum.map(fn {name, rel_file} ->
156 filename = Path.join("/emoji/#{pack_name}", rel_file)
157 {name, filename, pack_name}
158 end)
159 else
160 # Load from emoji.txt / all files
161 emoji_txt = Path.join(pack_dir, "emoji.txt")
162
163 if File.exists?(emoji_txt) do
164 load_from_file(emoji_txt, emoji_groups)
165 else
166 extensions = Pleroma.Config.get([:emoji, :pack_extensions])
167
168 Logger.info(
169 "No emoji.txt found for pack \"#{pack_name}\", assuming all #{
170 Enum.join(extensions, ", ")
171 } files are emoji"
172 )
173
174 make_shortcode_to_file_map(pack_dir, extensions)
175 |> Enum.map(fn {shortcode, rel_file} ->
176 filename = Path.join("/emoji/#{pack_name}", rel_file)
177
178 {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]}
179 end)
180 end
181 end
182 end
183
184 def make_shortcode_to_file_map(pack_dir, exts) do
185 find_all_emoji(pack_dir, exts)
186 |> Enum.map(&Path.relative_to(&1, pack_dir))
187 |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end)
188 |> Enum.into(%{})
189 end
190
191 def find_all_emoji(dir, exts) do
192 Enum.reduce(
193 File.ls!(dir),
194 [],
195 fn f, acc ->
196 filepath = Path.join(dir, f)
197
198 if File.dir?(filepath) do
199 acc ++ find_all_emoji(filepath, exts)
200 else
201 acc ++ [filepath]
202 end
203 end
204 )
205 |> Enum.filter(fn f -> Path.extname(f) in exts end)
206 end
207
208 defp load_from_file(file, emoji_groups) do
209 if File.exists?(file) do
210 load_from_file_stream(File.stream!(file), emoji_groups)
211 else
212 []
213 end
214 end
215
216 defp load_from_file_stream(stream, emoji_groups) do
217 stream
218 |> Stream.map(&String.trim/1)
219 |> Stream.map(fn line ->
220 case String.split(line, ~r/,\s*/) do
221 [name, file] ->
222 {name, file, [to_string(match_extra(emoji_groups, file))]}
223
224 [name, file | tags] ->
225 {name, file, tags}
226
227 _ ->
228 nil
229 end
230 end)
231 |> Enum.to_list()
232 end
233
234 defp load_from_globs(globs, emoji_groups) do
235 static_path = Path.join(:code.priv_dir(:pleroma), "static")
236
237 paths =
238 Enum.map(globs, fn glob ->
239 Path.join(static_path, glob)
240 |> Path.wildcard()
241 end)
242 |> Enum.concat()
243
244 Enum.map(paths, fn path ->
245 tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path)))
246 shortcode = Path.basename(path, Path.extname(path))
247 external_path = Path.join("/", Path.relative_to(path, static_path))
248 {shortcode, external_path, [to_string(tag)]}
249 end)
250 end
251
252 @doc """
253 Finds a matching group for the given emoji filename
254 """
255 @spec match_extra(group_patterns(), String.t()) :: atom() | nil
256 def match_extra(group_patterns, filename) do
257 match_group_patterns(group_patterns, fn pattern ->
258 case pattern do
259 %Regex{} = regex -> Regex.match?(regex, filename)
260 string when is_binary(string) -> filename == string
261 end
262 end)
263 end
264
265 defp match_group_patterns(group_patterns, matcher) do
266 Enum.find_value(group_patterns, fn {group, patterns} ->
267 patterns =
268 patterns
269 |> List.wrap()
270 |> Enum.map(fn pattern ->
271 if String.contains?(pattern, "*") do
272 ~r(#{String.replace(pattern, "*", ".*")})
273 else
274 pattern
275 end
276 end)
277
278 Enum.any?(patterns, matcher) && group
279 end)
280 end
281 end