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