1<?php
2/**
3 * I18N: WP_Translation_Controller class.
4 *
5 * @package WordPress
6 * @subpackage I18N
7 * @since 6.5.0
8 */
9
10/**
11 * Class WP_Translation_Controller.
12 *
13 * @since 6.5.0
14 */
15final class WP_Translation_Controller {
16 /**
17 * Current locale.
18 *
19 * @since 6.5.0
20 * @var string
21 */
22 protected $current_locale = 'en_US';
23
24 /**
25 * Map of loaded translations per locale and text domain.
26 *
27 * [ Locale => [ Textdomain => [ ..., ... ] ] ]
28 *
29 * @since 6.5.0
30 * @var array<string, array<string, WP_Translation_File[]>>
31 */
32 protected $loaded_translations = array();
33
34 /**
35 * List of loaded translation files.
36 *
37 * [ Filename => [ Locale => [ Textdomain => WP_Translation_File ] ] ]
38 *
39 * @since 6.5.0
40 * @var array<string, array<string, array<string, WP_Translation_File|false>>>
41 */
42 protected $loaded_files = array();
43
44 /**
45 * Container for the main instance of the class.
46 *
47 * @since 6.5.0
48 * @var WP_Translation_Controller|null
49 */
50 private static $instance = null;
51
52 /**
53 * Utility method to retrieve the main instance of the class.
54 *
55 * The instance will be created if it does not exist yet.
56 *
57 * @since 6.5.0
58 *
59 * @return WP_Translation_Controller
60 */
61 public static function get_instance(): WP_Translation_Controller {
62 if ( null === self::$instance ) {
63 self::$instance = new self();
64 }
65
66 return self::$instance;
67 }
68
69 /**
70 * Returns the current locale.
71 *
72 * @since 6.5.0
73 *
74 * @return string Locale.
75 */
76 public function get_locale(): string {
77 return $this->current_locale;
78 }
79
80 /**
81 * Sets the current locale.
82 *
83 * @since 6.5.0
84 *
85 * @param string $locale Locale.
86 */
87 public function set_locale( string $locale ) {
88 $this->current_locale = $locale;
89 }
90
91 /**
92 * Loads a translation file for a given text domain.
93 *
94 * @since 6.5.0
95 *
96 * @param string $translation_file Translation file.
97 * @param string $textdomain Optional. Text domain. Default 'default'.
98 * @param string $locale Optional. Locale. Default current locale.
99 * @return bool True on success, false otherwise.
100 */
101 public function load_file( string $translation_file, string $textdomain = 'default', ?string $locale = null ): bool {
102 if ( null === $locale ) {
103 $locale = $this->current_locale;
104 }
105
106 $translation_file = realpath( $translation_file );
107
108 if ( false === $translation_file ) {
109 return false;
110 }
111
112 if (
113 isset( $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] ) &&
114 false !== $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ]
115 ) {
116 return null === $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ]->error();
117 }
118
119 if (
120 isset( $this->loaded_files[ $translation_file ][ $locale ] ) &&
121 array() !== $this->loaded_files[ $translation_file ][ $locale ]
122 ) {
123 $moe = reset( $this->loaded_files[ $translation_file ][ $locale ] );
124 } else {
125 $moe = WP_Translation_File::create( $translation_file );
126 if ( false === $moe || null !== $moe->error() ) {
127 $moe = false;
128 }
129 }
130
131 $this->loaded_files[ $translation_file ][ $locale ][ $textdomain ] = $moe;
132
133 if ( ! $moe instanceof WP_Translation_File ) {
134 return false;
135 }
136
137 if ( ! isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) {
138 $this->loaded_translations[ $locale ][ $textdomain ] = array();
139 }
140
141 $this->loaded_translations[ $locale ][ $textdomain ][] = $moe;
142
143 return true;
144 }
145
146 /**
147 * Unloads a translation file for a given text domain.
148 *
149 * @since 6.5.0
150 *
151 * @param WP_Translation_File|string $file Translation file instance or file name.
152 * @param string $textdomain Optional. Text domain. Default 'default'.
153 * @param string $locale Optional. Locale. Defaults to all locales.
154 * @return bool True on success, false otherwise.
155 */
156 public function unload_file( $file, string $textdomain = 'default', ?string $locale = null ): bool {
157 if ( is_string( $file ) ) {
158 $file = realpath( $file );
159 }
160
161 if ( null !== $locale ) {
162 if ( isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) {
163 foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $i => $moe ) {
164 if ( $file === $moe || $file === $moe->get_file() ) {
165 unset( $this->loaded_translations[ $locale ][ $textdomain ][ $i ] );
166 unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] );
167 return true;
168 }
169 }
170 }
171
172 return true;
173 }
174
175 foreach ( $this->loaded_translations as $l => $domains ) {
176 if ( ! isset( $domains[ $textdomain ] ) ) {
177 continue;
178 }
179
180 foreach ( $domains[ $textdomain ] as $i => $moe ) {
181 if ( $file === $moe || $file === $moe->get_file() ) {
182 unset( $this->loaded_translations[ $l ][ $textdomain ][ $i ] );
183 unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] );
184 return true;
185 }
186 }
187 }
188
189 return false;
190 }
191
192 /**
193 * Unloads all translation files for a given text domain.
194 *
195 * @since 6.5.0
196 *
197 * @param string $textdomain Optional. Text domain. Default 'default'.
198 * @param string $locale Optional. Locale. Defaults to all locales.
199 * @return bool True on success, false otherwise.
200 */
201 public function unload_textdomain( string $textdomain = 'default', ?string $locale = null ): bool {
202 $unloaded = false;
203
204 if ( null !== $locale ) {
205 if ( isset( $this->loaded_translations[ $locale ][ $textdomain ] ) ) {
206 $unloaded = true;
207 foreach ( $this->loaded_translations[ $locale ][ $textdomain ] as $moe ) {
208 unset( $this->loaded_files[ $moe->get_file() ][ $locale ][ $textdomain ] );
209 }
210 }
211
212 unset( $this->loaded_translations[ $locale ][ $textdomain ] );
213
214 return $unloaded;
215 }
216
217 foreach ( $this->loaded_translations as $l => $domains ) {
218 if ( ! isset( $domains[ $textdomain ] ) ) {
219 continue;
220 }
221
222 $unloaded = true;
223
224 foreach ( $domains[ $textdomain ] as $moe ) {
225 unset( $this->loaded_files[ $moe->get_file() ][ $l ][ $textdomain ] );
226 }
227
228 unset( $this->loaded_translations[ $l ][ $textdomain ] );
229 }
230
231 return $unloaded;
232 }
233
234 /**
235 * Determines whether translations are loaded for a given text domain.
236 *
237 * @since 6.5.0
238 *
239 * @param string $textdomain Optional. Text domain. Default 'default'.
240 * @param string $locale Optional. Locale. Default current locale.
241 * @return bool True if there are any loaded translations, false otherwise.
242 */
243 public function is_textdomain_loaded( string $textdomain = 'default', ?string $locale = null ): bool {
244 if ( null === $locale ) {
245 $locale = $this->current_locale;
246 }
247
248 return isset( $this->loaded_translations[ $locale ][ $textdomain ] ) &&
249 array() !== $this->loaded_translations[ $locale ][ $textdomain ];
250 }
251
252 /**
253 * Translates a singular string.
254 *
255 * @since 6.5.0
256 *
257 * @param string $text Text to translate.
258 * @param string $context Optional. Context for the string. Default empty string.
259 * @param string $textdomain Optional. Text domain. Default 'default'.
260 * @param string $locale Optional. Locale. Default current locale.
261 * @return string|false Translation on success, false otherwise.
262 */
263 public function translate( string $text, string $context = '', string $textdomain = 'default', ?string $locale = null ) {
264 if ( '' !== $context ) {
265 $context .= "\4";
266 }
267
268 $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
269
270 if ( false === $translation ) {
271 return false;
272 }
273
274 return $translation['entries'][0];
275 }
276
277 /**
278 * Translates plurals.
279 *
280 * Checks both singular+plural combinations as well as just singulars,
281 * in case the translation file does not store the plural.
282 *
283 * @since 6.5.0
284 *
285 * @param array $plurals {
286 * Pair of singular and plural translations.
287 *
288 * @type string $0 Singular translation.
289 * @type string $1 Plural translation.
290 * }
291 * @param int $number Number of items.
292 * @param string $context Optional. Context for the string. Default empty string.
293 * @param string $textdomain Optional. Text domain. Default 'default'.
294 * @param string|null $locale Optional. Locale. Default current locale.
295 * @return string|false Translation on success, false otherwise.
296 */
297 public function translate_plural( array $plurals, int $number, string $context = '', string $textdomain = 'default', ?string $locale = null ) {
298 if ( '' !== $context ) {
299 $context .= "\4";
300 }
301
302 $text = implode( "\0", $plurals );
303 $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
304
305 if ( false === $translation ) {
306 $text = $plurals[0];
307 $translation = $this->locate_translation( "{$context}{$text}", $textdomain, $locale );
308
309 if ( false === $translation ) {
310 return false;
311 }
312 }
313
314 /** @var WP_Translation_File $source */
315 $source = $translation['source'];
316 $num = $source->get_plural_form( $number );
317
318 // See \Translations::translate_plural().
319 return $translation['entries'][ $num ] ?? $translation['entries'][0];
320 }
321
322 /**
323 * Returns all existing headers for a given text domain.
324 *
325 * @since 6.5.0
326 *
327 * @param string $textdomain Optional. Text domain. Default 'default'.
328 * @return array<string, string> Headers.
329 */
330 public function get_headers( string $textdomain = 'default' ): array {
331 if ( array() === $this->loaded_translations ) {
332 return array();
333 }
334
335 $headers = array();
336
337 foreach ( $this->get_files( $textdomain ) as $moe ) {
338 foreach ( $moe->headers() as $header => $value ) {
339 $headers[ $this->normalize_header( $header ) ] = $value;
340 }
341 }
342
343 return $headers;
344 }
345
346 /**
347 * Normalizes header names to be capitalized.
348 *
349 * @since 6.5.0
350 *
351 * @param string $header Header name.
352 * @return string Normalized header name.
353 */
354 protected function normalize_header( string $header ): string {
355 $parts = explode( '-', $header );
356 $parts = array_map( 'ucfirst', $parts );
357 return implode( '-', $parts );
358 }
359
360 /**
361 * Returns all entries for a given text domain.
362 *
363 * @since 6.5.0
364 *
365 * @param string $textdomain Optional. Text domain. Default 'default'.
366 * @return array<string, string> Entries.
367 */
368 public function get_entries( string $textdomain = 'default' ): array {
369 if ( array() === $this->loaded_translations ) {
370 return array();
371 }
372
373 $entries = array();
374
375 foreach ( $this->get_files( $textdomain ) as $moe ) {
376 $entries = array_merge( $entries, $moe->entries() );
377 }
378
379 return $entries;
380 }
381
382 /**
383 * Locates translation for a given string and text domain.
384 *
385 * @since 6.5.0
386 *
387 * @param string $singular Singular translation.
388 * @param string $textdomain Optional. Text domain. Default 'default'.
389 * @param string $locale Optional. Locale. Default current locale.
390 * @return array{source: WP_Translation_File, entries: string[]}|false {
391 * Translations on success, false otherwise.
392 *
393 * @type WP_Translation_File $source Translation file instance.
394 * @type string[] $entries Array of translation entries.
395 * }
396 */
397 protected function locate_translation( string $singular, string $textdomain = 'default', ?string $locale = null ) {
398 if ( array() === $this->loaded_translations ) {
399 return false;
400 }
401
402 // Find the translation in all loaded files for this text domain.
403 foreach ( $this->get_files( $textdomain, $locale ) as $moe ) {
404 $translation = $moe->translate( $singular );
405 if ( false !== $translation ) {
406 return array(
407 'entries' => explode( "\0", $translation ),
408 'source' => $moe,
409 );
410 }
411 if ( null !== $moe->error() ) {
412 // Unload this file, something is wrong.
413 $this->unload_file( $moe, $textdomain, $locale );
414 }
415 }
416
417 // Nothing could be found.
418 return false;
419 }
420
421 /**
422 * Returns all translation files for a given text domain.
423 *
424 * @since 6.5.0
425 *
426 * @param string $textdomain Optional. Text domain. Default 'default'.
427 * @param string $locale Optional. Locale. Default current locale.
428 * @return WP_Translation_File[] List of translation files.
429 */
430 protected function get_files( string $textdomain = 'default', ?string $locale = null ): array {
431 if ( null === $locale ) {
432 $locale = $this->current_locale;
433 }
434
435 return $this->loaded_translations[ $locale ][ $textdomain ] ?? array();
436 }
437
438 /**
439 * Returns a boolean to indicate whether a translation exists for a given string with optional text domain and locale.
440 *
441 * @since 6.7.0
442 *
443 * @param string $singular Singular translation to check.
444 * @param string $textdomain Optional. Text domain. Default 'default'.
445 * @param ?string $locale Optional. Locale. Default current locale.
446 * @return bool True if the translation exists, false otherwise.
447 */
448 public function has_translation( string $singular, string $textdomain = 'default', ?string $locale = null ): bool {
449 if ( null === $locale ) {
450 $locale = $this->current_locale;
451 }
452
453 return false !== $this->locate_translation( $singular, $textdomain, $locale );
454 }
455}
456