PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / i18n.php

i18n.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/i18n.php

602 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Define the internationalization functionality.
4 *
5 * Loads translations from jsDelivr CDN, downloading on-demand to the plugin's
6 * languages folder. This bypasses WordPress.org translations entirely.
7 *
8 * @author Paul Kilmurray <paul@kilbot.com>
9 *
10 * @see http://wcpos.com
11 * @package WCPOS\WooCommercePOS
12 */
13
14 namespace WCPOS\WooCommercePOS;
15
16 use WCPOS\WooCommercePOS\Logger;
17 use const WCPOS\WooCommercePOS\TRANSLATION_VERSION;
18
19 /**
20 * I18n class.
21 *
22 * Can be extended by pro plugin with different configuration.
23 */
24 class i18n { // phpcs:ignore PEAR.NamingConventions.ValidClassName.StartWithCapital, Generic.Classes.OpeningBraceSameLine.ContentAfterBrace
25
26 private const CDN_BASE_URL = 'https://cdn.jsdelivr.net/gh/wcpos/translations@%s/translations/php/%s/%s-%s.l10n.php';
27 private const MISSING_LOCALE_CACHE_TTL = DAY_IN_SECONDS;
28 private const WRITE_FAILED_CACHE_TTL = HOUR_IN_SECONDS;
29 private const DOWNLOAD_LOCK_TTL = 30;
30
31 /**
32 * Text domain for the plugin.
33 *
34 * @var string
35 */
36 protected string $text_domain = 'woocommerce-pos';
37
38 /**
39 * Plugin version.
40 *
41 * @var string
42 */
43 protected string $version;
44
45 /**
46 * Path to the plugin's languages folder.
47 *
48 * @var string
49 */
50 protected string $languages_path;
51
52 /**
53 * Transient key prefix for caching.
54 *
55 * @var string
56 */
57 protected string $transient_key = 'wcpos_i18n_version';
58
59 /**
60 * Most recent HTTP status code from translation download attempt.
61 *
62 * Null means the failure was not an HTTP status response (network/transport/write error).
63 *
64 * @var int|null
65 */
66 protected ?int $last_download_status_code = null;
67
68 /**
69 * Whether the last download attempt failed due to filesystem write errors.
70 *
71 * @var bool
72 */
73 protected bool $last_write_failed = false;
74
75 /**
76 * Load translations from jsDelivr.
77 *
78 * @param string|null $text_domain Optional text domain override.
79 * @param string|null $version Optional version override.
80 * @param string|null $languages_path Optional languages path override.
81 */
82 public function __construct( ?string $text_domain = null, ?string $version = null, ?string $languages_path = null ) {
83 $this->text_domain = $text_domain ?? 'woocommerce-pos';
84 $this->version = $version ?? TRANSLATION_VERSION;
85 $this->transient_key = 'wcpos_i18n_' . $this->text_domain;
86
87 if ( ! $this->maintain() ) {
88 // A shopper's page only needs the file already on disk. Resolving the
89 // active path, checking versions, downloading and repairing are
90 // maintenance work (see is_maintenance_request()) — and each of them
91 // cost a query on EVERY storefront request without an object cache.
92 $this->languages_path = $languages_path ?? WP_LANG_DIR . '/plugins/';
93 $this->load_existing_translation( null === $languages_path );
94 return;
95 }
96
97 $this->languages_path = $languages_path ?? $this->resolve_languages_path();
98
99 $this->load_translations();
100 }
101
102 /**
103 * Whether this request keeps the translation files fresh, or only reads them.
104 *
105 * Admin, POS, REST (the Store API included), cron and WP-CLI requests
106 * maintain: they check the version markers and download. A plain storefront
107 * request loads whatever file exists and touches nothing else.
108 *
109 * Detection runs on `init`, before `REST_REQUEST` is defined and before the
110 * rewrite rules populate the POS query vars, so REST and the browser-loaded
111 * POS routes (`/<slug>/`, `/wcpos-auth/`, `/wcpos-checkout/…`) are matched
112 * from the request path. `wc-ajax` (add to cart, cart fragments, the classic
113 * checkout) is shopper traffic: WooCommerce marks it DOING_AJAX, which is
114 * why `wp_doing_ajax()` is deliberately not consulted — admin-ajax.php is
115 * already covered by `is_admin()`.
116 *
117 * @return bool
118 */
119 public static function is_maintenance_request(): bool {
120 // The lane classifier owns the detection (it moved there when the same
121 // question started deciding which services a request constructs).
122 return ! Services\Request_Lane::is_storefront();
123 }
124
125 /**
126 * The maintenance decision, filterable so tests and hosts can force either path.
127 *
128 * @return bool
129 */
130 protected function maintain(): bool {
131 /**
132 * Filters whether this request maintains the translation files (version
133 * checks and downloads) or only loads an existing one.
134 *
135 * @param bool $maintain Default from {@see i18n::is_maintenance_request()}.
136 */
137 return (bool) apply_filters( 'woocommerce_pos_i18n_maintain', self::is_maintenance_request() );
138 }
139
140 /**
141 * Load the first existing translation file for the locale candidates, reading nothing else.
142 *
143 * @param bool $also_uploads Whether to also look in the uploads fallback directory
144 * (the default when no explicit path was given).
145 */
146 protected function load_existing_translation( bool $also_uploads ): void {
147 $locale = determine_locale();
148 if ( 'en_US' === $locale || empty( $locale ) ) {
149 return;
150 }
151 $primary = $this->languages_path;
152 $fallback = null; // Resolved lazily: wp_upload_dir() is not free.
153 foreach ( $this->get_locale_candidates( $locale ) as $candidate_locale ) {
154 $name = $this->text_domain . '-' . $candidate_locale . '.l10n.php';
155 $primary_file = $primary . $name;
156 if ( $also_uploads && file_exists( $primary_file ) ) {
157 $fallback = $fallback ?? $this->get_fallback_languages_path();
158 $fallback_file = $fallback . $name;
159 if ( file_exists( $fallback_file ) && 'uploads' === get_transient( $this->transient_key . '_active_path' ) ) {
160 $this->languages_path = $fallback;
161 if ( $this->load_translation_file( $candidate_locale, $fallback_file, false ) ) {
162 return;
163 }
164 }
165 }
166 if ( file_exists( $primary_file ) ) {
167 $this->languages_path = $primary;
168 if ( $this->load_translation_file( $candidate_locale, $primary_file, false ) ) {
169 return;
170 }
171 }
172 if ( ! $also_uploads ) {
173 continue;
174 }
175 if ( null === $fallback ) {
176 $fallback = $this->get_fallback_languages_path();
177 }
178 if ( file_exists( $fallback . $name ) ) {
179 // load_translation_file() derives the .mo path WordPress expects from
180 // languages_path, so it must point at the directory the file is in.
181 $this->languages_path = $fallback;
182 if ( $this->load_translation_file( $candidate_locale, $fallback . $name, false ) ) {
183 return;
184 }
185 }
186 }
187 }
188
189 /**
190 * Load translations directly from plugin's languages folder.
191 * Downloads from jsDelivr if not cached or version changed.
192 */
193 protected function load_translations(): void {
194 $requested_locale = determine_locale();
195
196 // Skip English.
197 if ( 'en_US' === $requested_locale || empty( $requested_locale ) ) {
198 return;
199 }
200
201 $locale_candidates = $this->get_locale_candidates( $requested_locale );
202 $stale_file = null;
203 $stale_locale = null;
204
205 // Prefer an up-to-date local file, including base-language fallback.
206 foreach ( $locale_candidates as $candidate_locale ) {
207 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
208 $cached_version = get_transient( $this->transient_key . '_' . $candidate_locale );
209
210 if ( file_exists( $file ) && $this->version === $cached_version ) {
211 delete_transient( $this->get_missing_locale_transient_key( $requested_locale ) );
212 $this->load_translation_file( $candidate_locale, $file );
213
214 return;
215 }
216
217 if ( file_exists( $file ) && null === $stale_file ) {
218 $stale_file = $file;
219 $stale_locale = $candidate_locale;
220 }
221 }
222
223 // Avoid repeated fetch attempts when we already know this locale is missing for this version.
224 if ( get_transient( $this->get_missing_locale_transient_key( $requested_locale ) ) === $this->version ) {
225 if ( $stale_file && $stale_locale ) {
226 $this->load_translation_file( $stale_locale, $stale_file );
227 }
228
229 return;
230 }
231
232 // Avoid repeated download attempts when filesystem is not writable.
233 if ( get_transient( $this->get_write_failed_transient_key() ) === $this->version ) {
234 if ( $stale_file && $stale_locale ) {
235 $this->load_translation_file( $stale_locale, $stale_file );
236 }
237
238 return;
239 }
240
241 // Prevent thundering herd: if another request is already downloading, skip.
242 $download_lock_key = $this->get_download_lock_transient_key( $requested_locale );
243 if ( get_transient( $download_lock_key ) ) {
244 if ( $stale_file && $stale_locale ) {
245 $this->load_translation_file( $stale_locale, $stale_file );
246 }
247
248 return;
249 }
250
251 // Acquire download lock before attempting HTTP requests.
252 set_transient( $download_lock_key, true, self::DOWNLOAD_LOCK_TTL );
253
254 try {
255 $last_candidate_index = count( $locale_candidates ) - 1;
256 $all_candidates_404 = true;
257 foreach ( $locale_candidates as $index => $candidate_locale ) {
258 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
259 $downloaded = $this->download_translation( $candidate_locale, $file, $index < $last_candidate_index );
260
261 if ( $downloaded ) {
262 // Recompute file path — download_translation() may have switched to fallback path.
263 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
264 set_transient( $this->transient_key . '_' . $candidate_locale, $this->version, WEEK_IN_SECONDS );
265 delete_transient( $this->get_missing_locale_transient_key( $requested_locale ) );
266 delete_transient( $this->get_write_failed_transient_key() );
267 $this->load_translation_file( $candidate_locale, $file );
268
269 return;
270 }
271
272 if ( 404 !== $this->last_download_status_code ) {
273 $all_candidates_404 = false;
274 }
275 }
276
277 if ( $all_candidates_404 ) {
278 set_transient( $this->get_missing_locale_transient_key( $requested_locale ), $this->version, self::MISSING_LOCALE_CACHE_TTL );
279 } elseif ( $this->last_write_failed ) {
280 set_transient( $this->get_write_failed_transient_key(), $this->version, self::WRITE_FAILED_CACHE_TTL );
281 }
282
283 if ( $stale_file && $stale_locale ) {
284 $this->load_translation_file( $stale_locale, $stale_file );
285
286 return;
287 }
288
289 Logger::log( sprintf( 'i18n: No translation file available for %s (%s)', $this->text_domain, $requested_locale ) );
290 } finally {
291 // Release download lock — runs even if an exception is thrown.
292 delete_transient( $download_lock_key );
293 }
294 }
295
296 /**
297 * Get locale candidates in order of preference.
298 *
299 * For regional locales (e.g., da_DK), return both the full locale and the
300 * base language fallback (da).
301 *
302 * @param string $locale Requested locale.
303 *
304 * @return string[]
305 */
306 protected function get_locale_candidates( string $locale ): array {
307 $candidates = array( $locale );
308
309 if ( false !== strpos( $locale, '_' ) ) {
310 $base_locale = explode( '_', $locale )[0];
311 if ( ! empty( $base_locale ) ) {
312 $candidates[] = $base_locale;
313 }
314 }
315
316 return array_values( array_unique( $candidates ) );
317 }
318
319 /**
320 * Load an existing translation file.
321 *
322 * @param string $locale Locale code for the file.
323 * @param string $file Path to the l10n PHP file.
324 * @param bool $repair Whether a flat-format file may be converted and a corrupt one
325 * deleted (maintenance requests). The storefront path passes
326 * false and only loads a file that is already valid.
327 *
328 * @return bool Whether the translation file was loaded.
329 */
330 protected function load_translation_file( string $locale, string $file, bool $repair = true ): bool {
331 if ( ! $repair ) {
332 // Storefront path: read-only. A file in the old flat format or one
333 // that does not parse is simply not loaded; the next maintenance
334 // request converts or replaces it.
335 try {
336 $data = include $file;
337 } catch ( \ParseError $e ) {
338 return false;
339 }
340 if ( ! \is_array( $data ) || ! isset( $data['messages'] ) ) {
341 return false;
342 }
343 return load_textdomain( $this->text_domain, $this->languages_path . $this->text_domain . '-' . $locale . '.mo' );
344 }
345
346 try {
347 $this->maybe_convert_file_format( $file );
348 } catch ( \ParseError $e ) {
349 // File is corrupt — delete it and clear the version transient so it re-downloads.
350 Logger::log( sprintf( 'i18n: Corrupt translation file deleted (%s): %s', $file, $e->getMessage() ) );
351 wp_delete_file( $file );
352 delete_transient( $this->transient_key . '_' . $locale );
353
354 return false;
355 }
356
357 // Pass the .mo path — WordPress internally looks for .l10n.php first.
358 $mofile = $this->languages_path . $this->text_domain . '-' . $locale . '.mo';
359 return load_textdomain( $this->text_domain, $mofile );
360 }
361
362 /**
363 * Build the transient key used for missing-locale caching.
364 *
365 * @param string $locale Requested locale.
366 *
367 * @return string
368 */
369 protected function get_missing_locale_transient_key( string $locale ): string {
370 return $this->transient_key . '_missing_' . $locale;
371 }
372
373 /**
374 * Get the fallback languages path using the uploads directory.
375 *
376 * Used when the primary languages path (WP_LANG_DIR/plugins/) is not writable.
377 * The uploads directory is writable on any functioning WordPress install.
378 *
379 * @return string
380 */
381 protected function get_fallback_languages_path(): string {
382 $upload_dir = wp_upload_dir();
383
384 return trailingslashit( $upload_dir['basedir'] ) . 'wcpos-languages/';
385 }
386
387 /**
388 * Build the transient key used for write-failure caching.
389 *
390 * @return string
391 */
392 protected function get_write_failed_transient_key(): string {
393 return $this->transient_key . '_write_failed';
394 }
395
396 /**
397 * Build the transient key used for download-in-progress locking.
398 *
399 * @param string $locale Requested locale.
400 *
401 * @return string
402 */
403 protected function get_download_lock_transient_key( string $locale ): string {
404 return $this->transient_key . '_downloading_' . $locale;
405 }
406
407 /**
408 * Determine the languages path to use.
409 *
410 * Checks if a previous session fell back to the uploads directory and
411 * returns that path if so. Otherwise returns the standard WordPress
412 * languages/plugins/ directory.
413 *
414 * @return string
415 */
416 protected function resolve_languages_path(): string {
417 $active = get_transient( $this->transient_key . '_active_path' );
418 if ( 'uploads' === $active ) {
419 return $this->get_fallback_languages_path();
420 }
421
422 return WP_LANG_DIR . '/plugins/';
423 }
424
425 /**
426 * Ensure .l10n.php file uses WordPress 6.5+ format with 'messages' key.
427 *
428 * CDN files use a flat array format, but WordPress expects:
429 * array( 'messages' => array( 'key' => 'translation', ... ) )
430 *
431 * @param string $file The .l10n.php file path.
432 */
433 protected function maybe_convert_file_format( string $file ): void {
434 $data = include $file;
435
436 if ( ! is_array( $data ) || isset( $data['messages'] ) ) {
437 return;
438 }
439
440 // Wrap flat translations array in WordPress expected format.
441 $wrapped = "<?php\nreturn array(\n\t'messages' => " . var_export( $data, true ) . ",\n);\n"; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Generating a PHP translation file.
442
443 global $wp_filesystem;
444 if ( empty( $wp_filesystem ) ) {
445 require_once ABSPATH . '/wp-admin/includes/file.php';
446 WP_Filesystem();
447 }
448
449 if ( $wp_filesystem && is_object( $wp_filesystem ) ) {
450 $wp_filesystem->put_contents( $file, $wrapped, $this->get_fs_chmod_file() );
451 }
452 }
453
454 /**
455 * Resolve the file permission mode for translation writes.
456 *
457 * FS_CHMOD_FILE only exists once WP_Filesystem() has defined it. When the
458 * runtime pre-populates $wp_filesystem without calling WP_Filesystem()
459 * (WP-CLI does this), referencing the bare constant is a fatal error.
460 * Mirror the fallback WordPress core uses when defining the constant.
461 *
462 * @param bool|null $constant_defined Override for the defined() check — tests
463 * force the fallback branch, which is
464 * otherwise unreachable once anything in
465 * the process has called WP_Filesystem().
466 *
467 * @return int
468 */
469 protected function get_fs_chmod_file( ?bool $constant_defined = null ): int {
470 $constant_defined = null === $constant_defined ? \defined( 'FS_CHMOD_FILE' ) : $constant_defined;
471 if ( $constant_defined ) {
472 return FS_CHMOD_FILE;
473 }
474
475 // Core derives the file mode from a known file (ABSPATH is a directory,
476 // so its mode carries execute bits that must not land on written files).
477 return ( fileperms( ABSPATH . 'index.php' ) & 0777 ) | 0644;
478 }
479
480 /**
481 * Write translation content to a file using WP_Filesystem.
482 *
483 * @param string $file The target file path.
484 * @param string $body The file content to write.
485 * @param bool|null $constant_defined Override for the FS_CHMOD_FILE check.
486 *
487 * @return bool Whether the write was successful.
488 */
489 protected function write_translation_file( string $file, string $body, ?bool $constant_defined = null ): bool {
490 $dir = dirname( $file );
491 if ( ! is_dir( $dir ) ) {
492 wp_mkdir_p( $dir );
493 }
494
495 global $wp_filesystem;
496 if ( empty( $wp_filesystem ) ) {
497 require_once ABSPATH . '/wp-admin/includes/file.php';
498 WP_Filesystem();
499 }
500
501 if ( ! $wp_filesystem || ! is_object( $wp_filesystem ) ) {
502 return false;
503 }
504
505 if ( ! $wp_filesystem->put_contents( $file, $body, $this->get_fs_chmod_file( $constant_defined ) ) ) {
506 return false;
507 }
508
509 // Verify the write was complete (catches partial/truncated writes).
510 $written_size = $wp_filesystem->size( $file );
511 if ( false === $written_size || strlen( $body ) !== $written_size ) {
512 Logger::log( sprintf( 'i18n: Write verification failed — expected %d bytes, got %s', strlen( $body ), var_export( $written_size, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Logging diagnostic info.
513 wp_delete_file( $file );
514
515 return false;
516 }
517
518 return true;
519 }
520
521 /**
522 * Download a translation file from jsDelivr.
523 *
524 * Tries writing to the primary languages path first. If that fails,
525 * falls back to the uploads directory. If both fail, sets
526 * $last_write_failed so the caller can cache the failure.
527 *
528 * @param string $locale The locale code (e.g., de_DE).
529 * @param string $file The target file path.
530 * @param bool $suppress_404_logs Suppress 404 logging for fallback attempts.
531 *
532 * @return bool Whether the download and write was successful.
533 */
534 protected function download_translation( string $locale, string $file, bool $suppress_404_logs = false ): bool {
535 $url = sprintf( self::CDN_BASE_URL, $this->version, $locale, $this->text_domain, $locale );
536 $this->last_download_status_code = null;
537 $this->last_write_failed = false;
538
539 $response = wp_remote_get(
540 $url,
541 array(
542 'timeout' => 10,
543 )
544 );
545
546 if ( is_wp_error( $response ) ) {
547 Logger::log( sprintf( 'i18n: Failed to download %s translation - HTTP error: %s', $locale, $response->get_error_message() ) );
548
549 return false;
550 }
551
552 $status_code = wp_remote_retrieve_response_code( $response );
553 if ( 200 !== $status_code ) {
554 $this->last_download_status_code = $status_code;
555
556 if ( ! ( $suppress_404_logs && 404 === $status_code ) ) {
557 Logger::log( sprintf( 'i18n: Failed to download %s translation - HTTP %d from %s', $locale, $status_code, $url ) );
558 }
559
560 return false;
561 }
562
563 $body = wp_remote_retrieve_body( $response );
564 if ( empty( $body ) ) {
565 Logger::log( sprintf( 'i18n: Failed to download %s translation - empty response body from %s', $locale, $url ) );
566
567 return false;
568 }
569
570 // Validate the response is a PHP translation file (catches truncated downloads).
571 if ( 0 !== strpos( $body, '<?php' ) || false === strpos( $body, 'return' ) ) {
572 Logger::log( sprintf( 'i18n: Downloaded %s translation is not valid PHP — possible truncated download from %s', $locale, $url ) );
573
574 return false;
575 }
576
577 // Try writing to primary path.
578 if ( $this->write_translation_file( $file, $body ) ) {
579 return true;
580 }
581
582 // Primary write failed — try uploads fallback.
583 $fallback_path = $this->get_fallback_languages_path();
584 if ( $fallback_path !== $this->languages_path ) {
585 $fallback_file = $fallback_path . basename( $file );
586 if ( $this->write_translation_file( $fallback_file, $body ) ) {
587 Logger::log( sprintf( 'i18n: Primary path not writable, using fallback for %s translations: %s', $locale, $fallback_path ) );
588 $this->languages_path = $fallback_path;
589 set_transient( $this->transient_key . '_active_path', 'uploads', MONTH_IN_SECONDS );
590
591 return true;
592 }
593 }
594
595 // Both paths failed (or already at fallback and it failed).
596 $this->last_write_failed = true;
597 Logger::log( sprintf( 'i18n: Failed to write %s translation to any writable location', $locale ) );
598
599 return false;
600 }
601 }
602