Merge branch '1149-oban-job-queue' into 'develop'
[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 extensions = Pleroma.Config.get([:emoji, :pack_extensions])
152
153 Logger.info(
154 "No emoji.txt found for pack \"#{pack_name}\", assuming all #{Enum.join(extensions, ", ")} files are emoji"
155 )
156
157 make_shortcode_to_file_map(pack_dir, extensions)
158 |> Enum.map(fn {shortcode, rel_file} ->
159 filename = Path.join("/emoji/#{pack_name}", rel_file)
160
161 {shortcode, filename, [to_string(match_extra(emoji_groups, filename))]}
162 end)
163 end
164 end
165
166 def make_shortcode_to_file_map(pack_dir, exts) do
167 find_all_emoji(pack_dir, exts)
168 |> Enum.map(&Path.relative_to(&1, pack_dir))
169 |> Enum.map(fn f -> {f |> Path.basename() |> Path.rootname(), f} end)
170 |> Enum.into(%{})
171 end
172
173 def find_all_emoji(dir, exts) do
174 Enum.reduce(
175 File.ls!(dir),
176 [],
177 fn f, acc ->
178 filepath = Path.join(dir, f)
179
180 if File.dir?(filepath) do
181 acc ++ find_all_emoji(filepath, exts)
182 else
183 acc ++ [filepath]
184 end
185 end
186 )
187 |> Enum.filter(fn f -> Path.extname(f) in exts end)
188 end
189
190 defp load_from_file(file, emoji_groups) do
191 if File.exists?(file) do
192 load_from_file_stream(File.stream!(file), emoji_groups)
193 else
194 []
195 end
196 end
197
198 defp load_from_file_stream(stream, emoji_groups) do
199 stream
200 |> Stream.map(&String.trim/1)
201 |> Stream.map(fn line ->
202 case String.split(line, ~r/,\s*/) do
203 [name, file] ->
204 {name, file, [to_string(match_extra(emoji_groups, file))]}
205
206 [name, file | tags] ->
207 {name, file, tags}
208
209 _ ->
210 nil
211 end
212 end)
213 |> Enum.to_list()
214 end
215
216 defp load_from_globs(globs, emoji_groups) do
217 static_path = Path.join(:code.priv_dir(:pleroma), "static")
218
219 paths =
220 Enum.map(globs, fn glob ->
221 Path.join(static_path, glob)
222 |> Path.wildcard()
223 end)
224 |> Enum.concat()
225
226 Enum.map(paths, fn path ->
227 tag = match_extra(emoji_groups, Path.join("/", Path.relative_to(path, static_path)))
228 shortcode = Path.basename(path, Path.extname(path))
229 external_path = Path.join("/", Path.relative_to(path, static_path))
230 {shortcode, external_path, [to_string(tag)]}
231 end)
232 end
233
234 @doc """
235 Finds a matching group for the given emoji filename
236 """
237 @spec match_extra(group_patterns(), String.t()) :: atom() | nil
238 def match_extra(group_patterns, filename) do
239 match_group_patterns(group_patterns, fn pattern ->
240 case pattern do
241 %Regex{} = regex -> Regex.match?(regex, filename)
242 string when is_binary(string) -> filename == string
243 end
244 end)
245 end
246
247 defp match_group_patterns(group_patterns, matcher) do
248 Enum.find_value(group_patterns, fn {group, patterns} ->
249 patterns =
250 patterns
251 |> List.wrap()
252 |> Enum.map(fn pattern ->
253 if String.contains?(pattern, "*") do
254 ~r(#{String.replace(pattern, "*", ".*")})
255 else
256 pattern
257 end
258 end)
259
260 Enum.any?(patterns, matcher) && group
261 end)
262 end
263 end