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