blob: 48441e7c29431c5cf268ad525f2d59c6acb693ff [file] [log] [blame]
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +01001<?php
2
3/**
4 * This file is part of the Carbon package.
5 *
6 * (c) Brian Nesbitt <brian@nesbot.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Carbon;
13
14use Closure;
15use ReflectionException;
16use ReflectionFunction;
17use Symfony\Component\Translation;
18use Symfony\Component\Translation\Formatter\MessageFormatterInterface;
19use Symfony\Component\Translation\Loader\ArrayLoader;
20
21abstract class AbstractTranslator extends Translation\Translator
22{
23 /**
24 * Translator singletons for each language.
25 *
26 * @var array
27 */
28 protected static $singletons = [];
29
30 /**
31 * List of custom localized messages.
32 *
33 * @var array
34 */
35 protected $messages = [];
36
37 /**
38 * List of custom directories that contain translation files.
39 *
40 * @var string[]
41 */
42 protected $directories = [];
43
44 /**
45 * Set to true while constructing.
46 *
47 * @var bool
48 */
49 protected $initializing = false;
50
51 /**
52 * List of locales aliases.
53 *
54 * @var string[]
55 */
56 protected $aliases = [
57 'me' => 'sr_Latn_ME',
58 'scr' => 'sh',
59 ];
60
61 /**
62 * Return a singleton instance of Translator.
63 *
64 * @param string|null $locale optional initial locale ("en" - english by default)
65 *
66 * @return static
67 */
68 public static function get($locale = null)
69 {
70 $locale = $locale ?: 'en';
71 $key = static::class === Translator::class ? $locale : static::class.'|'.$locale;
72
73 if (!isset(static::$singletons[$key])) {
74 static::$singletons[$key] = new static($locale);
75 }
76
77 return static::$singletons[$key];
78 }
79
80 public function __construct($locale, MessageFormatterInterface $formatter = null, $cacheDir = null, $debug = false)
81 {
82 parent::setLocale($locale);
83 $this->initializing = true;
84 $this->directories = [__DIR__.'/Lang'];
85 $this->addLoader('array', new ArrayLoader());
86 parent::__construct($locale, $formatter, $cacheDir, $debug);
87 $this->initializing = false;
88 }
89
90 /**
91 * Returns the list of directories translation files are searched in.
92 *
93 * @return array
94 */
95 public function getDirectories(): array
96 {
97 return $this->directories;
98 }
99
100 /**
101 * Set list of directories translation files are searched in.
102 *
103 * @param array $directories new directories list
104 *
105 * @return $this
106 */
107 public function setDirectories(array $directories)
108 {
109 $this->directories = $directories;
110
111 return $this;
112 }
113
114 /**
115 * Add a directory to the list translation files are searched in.
116 *
117 * @param string $directory new directory
118 *
119 * @return $this
120 */
121 public function addDirectory(string $directory)
122 {
123 $this->directories[] = $directory;
124
125 return $this;
126 }
127
128 /**
129 * Remove a directory from the list translation files are searched in.
130 *
131 * @param string $directory directory path
132 *
133 * @return $this
134 */
135 public function removeDirectory(string $directory)
136 {
137 $search = rtrim(strtr($directory, '\\', '/'), '/');
138
139 return $this->setDirectories(array_filter($this->getDirectories(), function ($item) use ($search) {
140 return rtrim(strtr($item, '\\', '/'), '/') !== $search;
141 }));
142 }
143
144 /**
145 * Reset messages of a locale (all locale if no locale passed).
146 * Remove custom messages and reload initial messages from matching
147 * file in Lang directory.
148 *
149 * @param string|null $locale
150 *
151 * @return bool
152 */
153 public function resetMessages($locale = null)
154 {
155 if ($locale === null) {
156 $this->messages = [];
157
158 return true;
159 }
160
161 foreach ($this->getDirectories() as $directory) {
162 $data = @include sprintf('%s/%s.php', rtrim($directory, '\\/'), $locale);
163
164 if ($data !== false) {
165 $this->messages[$locale] = $data;
166 $this->addResource('array', $this->messages[$locale], $locale);
167
168 return true;
169 }
170 }
171
172 return false;
173 }
174
175 /**
176 * Returns the list of files matching a given locale prefix (or all if empty).
177 *
178 * @param string $prefix prefix required to filter result
179 *
180 * @return array
181 */
182 public function getLocalesFiles($prefix = '')
183 {
184 $files = [];
185
186 foreach ($this->getDirectories() as $directory) {
187 $directory = rtrim($directory, '\\/');
188
189 foreach (glob("$directory/$prefix*.php") as $file) {
190 $files[] = $file;
191 }
192 }
193
194 return array_unique($files);
195 }
196
197 /**
198 * Returns the list of internally available locales and already loaded custom locales.
199 * (It will ignore custom translator dynamic loading.)
200 *
201 * @param string $prefix prefix required to filter result
202 *
203 * @return array
204 */
205 public function getAvailableLocales($prefix = '')
206 {
207 $locales = [];
208 foreach ($this->getLocalesFiles($prefix) as $file) {
209 $locales[] = substr($file, strrpos($file, '/') + 1, -4);
210 }
211
212 return array_unique(array_merge($locales, array_keys($this->messages)));
213 }
214
215 protected function translate(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
216 {
217 if ($domain === null) {
218 $domain = 'messages';
219 }
220
221 $catalogue = $this->getCatalogue($locale);
222 $format = $this instanceof TranslatorStrongTypeInterface
223 ? $this->getFromCatalogue($catalogue, (string) $id, $domain) // @codeCoverageIgnore
224 : $this->getCatalogue($locale)->get((string) $id, $domain);
225
226 if ($format instanceof Closure) {
227 // @codeCoverageIgnoreStart
228 try {
229 $count = (new ReflectionFunction($format))->getNumberOfRequiredParameters();
230 } catch (ReflectionException $exception) {
231 $count = 0;
232 }
233 // @codeCoverageIgnoreEnd
234
235 return $format(
236 ...array_values($parameters),
237 ...array_fill(0, max(0, $count - \count($parameters)), null)
238 );
239 }
240
241 return parent::trans($id, $parameters, $domain, $locale);
242 }
243
244 /**
245 * Init messages language from matching file in Lang directory.
246 *
247 * @param string $locale
248 *
249 * @return bool
250 */
251 protected function loadMessagesFromFile($locale)
252 {
253 if (isset($this->messages[$locale])) {
254 return true;
255 }
256
257 return $this->resetMessages($locale);
258 }
259
260 /**
261 * Set messages of a locale and take file first if present.
262 *
263 * @param string $locale
264 * @param array $messages
265 *
266 * @return $this
267 */
268 public function setMessages($locale, $messages)
269 {
270 $this->loadMessagesFromFile($locale);
271 $this->addResource('array', $messages, $locale);
272 $this->messages[$locale] = array_merge(
273 $this->messages[$locale] ?? [],
274 $messages
275 );
276
277 return $this;
278 }
279
280 /**
281 * Set messages of the current locale and take file first if present.
282 *
283 * @param array $messages
284 *
285 * @return $this
286 */
287 public function setTranslations($messages)
288 {
289 return $this->setMessages($this->getLocale(), $messages);
290 }
291
292 /**
293 * Get messages of a locale, if none given, return all the
294 * languages.
295 *
296 * @param string|null $locale
297 *
298 * @return array
299 */
300 public function getMessages($locale = null)
301 {
302 return $locale === null ? $this->messages : $this->messages[$locale];
303 }
304
305 /**
306 * Set the current translator locale and indicate if the source locale file exists
307 *
308 * @param string $locale locale ex. en
309 *
310 * @return bool
311 */
312 public function setLocale($locale)
313 {
314 $locale = preg_replace_callback('/[-_]([a-z]{2,}|[0-9]{2,})/', function ($matches) {
315 // _2-letters or YUE is a region, _3+-letters is a variant
316 $upper = strtoupper($matches[1]);
317
318 if ($upper === 'YUE' || $upper === 'ISO' || \strlen($upper) < 3) {
319 return "_$upper";
320 }
321
322 return '_'.ucfirst($matches[1]);
323 }, strtolower($locale));
324
325 $previousLocale = $this->getLocale();
326
327 if ($previousLocale === $locale && isset($this->messages[$locale])) {
328 return true;
329 }
330
331 unset(static::$singletons[$previousLocale]);
332
333 if ($locale === 'auto') {
334 $completeLocale = setlocale(LC_TIME, '0');
335 $locale = preg_replace('/^([^_.-]+).*$/', '$1', $completeLocale);
336 $locales = $this->getAvailableLocales($locale);
337
338 $completeLocaleChunks = preg_split('/[_.-]+/', $completeLocale);
339
340 $getScore = function ($language) use ($completeLocaleChunks) {
341 return self::compareChunkLists($completeLocaleChunks, preg_split('/[_.-]+/', $language));
342 };
343
344 usort($locales, function ($first, $second) use ($getScore) {
345 return $getScore($second) <=> $getScore($first);
346 });
347
348 $locale = $locales[0];
349 }
350
351 if (isset($this->aliases[$locale])) {
352 $locale = $this->aliases[$locale];
353 }
354
355 // If subtag (ex: en_CA) first load the macro (ex: en) to have a fallback
356 if (str_contains($locale, '_') &&
357 $this->loadMessagesFromFile($macroLocale = preg_replace('/^([^_]+).*$/', '$1', $locale))
358 ) {
359 parent::setLocale($macroLocale);
360 }
361
362 if ($this->loadMessagesFromFile($locale) || $this->initializing) {
363 parent::setLocale($locale);
364
365 return true;
366 }
367
368 return false;
369 }
370
371 /**
372 * Show locale on var_dump().
373 *
374 * @return array
375 */
376 public function __debugInfo()
377 {
378 return [
379 'locale' => $this->getLocale(),
380 ];
381 }
382
383 private static function compareChunkLists($referenceChunks, $chunks)
384 {
385 $score = 0;
386
387 foreach ($referenceChunks as $index => $chunk) {
388 if (!isset($chunks[$index])) {
389 $score++;
390
391 continue;
392 }
393
394 if (strtolower($chunks[$index]) === strtolower($chunk)) {
395 $score += 10;
396 }
397 }
398
399 return $score;
400 }
401}