PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.16
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 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 All 163 releases
woocommerce-pos / includes / i18n.php

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

477 lines 15.0 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 $this->languages_path = $languages_path ?? $this->resolve_languages_path();
87
88 $this->load_translations();
89 }
90
91 /**
92 * Load translations directly from plugin's languages folder.
93 * Downloads from jsDelivr if not cached or version changed.
94 */
95 protected function load_translations(): void {
96 $requested_locale = determine_locale();
97
98 // Skip English.
99 if ( 'en_US' === $requested_locale || empty( $requested_locale ) ) {
100 return;
101 }
102
103 $locale_candidates = $this->get_locale_candidates( $requested_locale );
104 $stale_file = null;
105 $stale_locale = null;
106
107 // Prefer an up-to-date local file, including base-language fallback.
108 foreach ( $locale_candidates as $candidate_locale ) {
109 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
110 $cached_version = get_transient( $this->transient_key . '_' . $candidate_locale );
111
112 if ( file_exists( $file ) && $this->version === $cached_version ) {
113 delete_transient( $this->get_missing_locale_transient_key( $requested_locale ) );
114 $this->load_translation_file( $candidate_locale, $file );
115
116 return;
117 }
118
119 if ( file_exists( $file ) && null === $stale_file ) {
120 $stale_file = $file;
121 $stale_locale = $candidate_locale;
122 }
123 }
124
125 // Avoid repeated fetch attempts when we already know this locale is missing for this version.
126 if ( get_transient( $this->get_missing_locale_transient_key( $requested_locale ) ) === $this->version ) {
127 if ( $stale_file && $stale_locale ) {
128 $this->load_translation_file( $stale_locale, $stale_file );
129 }
130
131 return;
132 }
133
134 // Avoid repeated download attempts when filesystem is not writable.
135 if ( get_transient( $this->get_write_failed_transient_key() ) === $this->version ) {
136 if ( $stale_file && $stale_locale ) {
137 $this->load_translation_file( $stale_locale, $stale_file );
138 }
139
140 return;
141 }
142
143 // Prevent thundering herd: if another request is already downloading, skip.
144 $download_lock_key = $this->get_download_lock_transient_key( $requested_locale );
145 if ( get_transient( $download_lock_key ) ) {
146 if ( $stale_file && $stale_locale ) {
147 $this->load_translation_file( $stale_locale, $stale_file );
148 }
149
150 return;
151 }
152
153 // Acquire download lock before attempting HTTP requests.
154 set_transient( $download_lock_key, true, self::DOWNLOAD_LOCK_TTL );
155
156 try {
157 $last_candidate_index = count( $locale_candidates ) - 1;
158 $all_candidates_404 = true;
159 foreach ( $locale_candidates as $index => $candidate_locale ) {
160 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
161 $downloaded = $this->download_translation( $candidate_locale, $file, $index < $last_candidate_index );
162
163 if ( $downloaded ) {
164 // Recompute file path — download_translation() may have switched to fallback path.
165 $file = $this->languages_path . $this->text_domain . '-' . $candidate_locale . '.l10n.php';
166 set_transient( $this->transient_key . '_' . $candidate_locale, $this->version, WEEK_IN_SECONDS );
167 delete_transient( $this->get_missing_locale_transient_key( $requested_locale ) );
168 delete_transient( $this->get_write_failed_transient_key() );
169 $this->load_translation_file( $candidate_locale, $file );
170
171 return;
172 }
173
174 if ( 404 !== $this->last_download_status_code ) {
175 $all_candidates_404 = false;
176 }
177 }
178
179 if ( $all_candidates_404 ) {
180 set_transient( $this->get_missing_locale_transient_key( $requested_locale ), $this->version, self::MISSING_LOCALE_CACHE_TTL );
181 } elseif ( $this->last_write_failed ) {
182 set_transient( $this->get_write_failed_transient_key(), $this->version, self::WRITE_FAILED_CACHE_TTL );
183 }
184
185 if ( $stale_file && $stale_locale ) {
186 $this->load_translation_file( $stale_locale, $stale_file );
187
188 return;
189 }
190
191 Logger::log( sprintf( 'i18n: No translation file available for %s (%s)', $this->text_domain, $requested_locale ) );
192 } finally {
193 // Release download lock — runs even if an exception is thrown.
194 delete_transient( $download_lock_key );
195 }
196 }
197
198 /**
199 * Get locale candidates in order of preference.
200 *
201 * For regional locales (e.g., da_DK), return both the full locale and the
202 * base language fallback (da).
203 *
204 * @param string $locale Requested locale.
205 *
206 * @return string[]
207 */
208 protected function get_locale_candidates( string $locale ): array {
209 $candidates = array( $locale );
210
211 if ( false !== strpos( $locale, '_' ) ) {
212 $base_locale = explode( '_', $locale )[0];
213 if ( ! empty( $base_locale ) ) {
214 $candidates[] = $base_locale;
215 }
216 }
217
218 return array_values( array_unique( $candidates ) );
219 }
220
221 /**
222 * Load an existing translation file.
223 *
224 * @param string $locale Locale code for the file.
225 * @param string $file Path to the l10n PHP file.
226 */
227 protected function load_translation_file( string $locale, string $file ): void {
228 try {
229 $this->maybe_convert_file_format( $file );
230 } catch ( \ParseError $e ) {
231 // File is corrupt — delete it and clear the version transient so it re-downloads.
232 Logger::log( sprintf( 'i18n: Corrupt translation file deleted (%s): %s', $file, $e->getMessage() ) );
233 wp_delete_file( $file );
234 delete_transient( $this->transient_key . '_' . $locale );
235
236 return;
237 }
238
239 // Pass the .mo path — WordPress internally looks for .l10n.php first.
240 $mofile = $this->languages_path . $this->text_domain . '-' . $locale . '.mo';
241 load_textdomain( $this->text_domain, $mofile );
242 }
243
244 /**
245 * Build the transient key used for missing-locale caching.
246 *
247 * @param string $locale Requested locale.
248 *
249 * @return string
250 */
251 protected function get_missing_locale_transient_key( string $locale ): string {
252 return $this->transient_key . '_missing_' . $locale;
253 }
254
255 /**
256 * Get the fallback languages path using the uploads directory.
257 *
258 * Used when the primary languages path (WP_LANG_DIR/plugins/) is not writable.
259 * The uploads directory is writable on any functioning WordPress install.
260 *
261 * @return string
262 */
263 protected function get_fallback_languages_path(): string {
264 $upload_dir = wp_upload_dir();
265
266 return trailingslashit( $upload_dir['basedir'] ) . 'wcpos-languages/';
267 }
268
269 /**
270 * Build the transient key used for write-failure caching.
271 *
272 * @return string
273 */
274 protected function get_write_failed_transient_key(): string {
275 return $this->transient_key . '_write_failed';
276 }
277
278 /**
279 * Build the transient key used for download-in-progress locking.
280 *
281 * @param string $locale Requested locale.
282 *
283 * @return string
284 */
285 protected function get_download_lock_transient_key( string $locale ): string {
286 return $this->transient_key . '_downloading_' . $locale;
287 }
288
289 /**
290 * Determine the languages path to use.
291 *
292 * Checks if a previous session fell back to the uploads directory and
293 * returns that path if so. Otherwise returns the standard WordPress
294 * languages/plugins/ directory.
295 *
296 * @return string
297 */
298 protected function resolve_languages_path(): string {
299 $active = get_transient( $this->transient_key . '_active_path' );
300 if ( 'uploads' === $active ) {
301 return $this->get_fallback_languages_path();
302 }
303
304 return WP_LANG_DIR . '/plugins/';
305 }
306
307 /**
308 * Ensure .l10n.php file uses WordPress 6.5+ format with 'messages' key.
309 *
310 * CDN files use a flat array format, but WordPress expects:
311 * array( 'messages' => array( 'key' => 'translation', ... ) )
312 *
313 * @param string $file The .l10n.php file path.
314 */
315 protected function maybe_convert_file_format( string $file ): void {
316 $data = include $file;
317
318 if ( ! is_array( $data ) || isset( $data['messages'] ) ) {
319 return;
320 }
321
322 // Wrap flat translations array in WordPress expected format.
323 $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.
324
325 global $wp_filesystem;
326 if ( empty( $wp_filesystem ) ) {
327 require_once ABSPATH . '/wp-admin/includes/file.php';
328 WP_Filesystem();
329 }
330
331 if ( $wp_filesystem && is_object( $wp_filesystem ) ) {
332 $wp_filesystem->put_contents( $file, $wrapped, $this->get_fs_chmod_file() );
333 }
334 }
335
336 /**
337 * Resolve the file permission mode for translation writes.
338 *
339 * FS_CHMOD_FILE only exists once WP_Filesystem() has defined it. When the
340 * runtime pre-populates $wp_filesystem without calling WP_Filesystem()
341 * (WP-CLI does this), referencing the bare constant is a fatal error.
342 * Mirror the fallback WordPress core uses when defining the constant.
343 *
344 * @return int
345 */
346 protected function get_fs_chmod_file(): int {
347 if ( \defined( 'FS_CHMOD_FILE' ) ) {
348 return FS_CHMOD_FILE;
349 }
350
351 // Core derives the file mode from a known file (ABSPATH is a directory,
352 // so its mode carries execute bits that must not land on written files).
353 return ( fileperms( ABSPATH . 'index.php' ) & 0777 ) | 0644;
354 }
355
356 /**
357 * Write translation content to a file using WP_Filesystem.
358 *
359 * @param string $file The target file path.
360 * @param string $body The file content to write.
361 *
362 * @return bool Whether the write was successful.
363 */
364 protected function write_translation_file( string $file, string $body ): bool {
365 $dir = dirname( $file );
366 if ( ! is_dir( $dir ) ) {
367 wp_mkdir_p( $dir );
368 }
369
370 global $wp_filesystem;
371 if ( empty( $wp_filesystem ) ) {
372 require_once ABSPATH . '/wp-admin/includes/file.php';
373 WP_Filesystem();
374 }
375
376 if ( ! $wp_filesystem || ! is_object( $wp_filesystem ) ) {
377 return false;
378 }
379
380 if ( ! $wp_filesystem->put_contents( $file, $body, $this->get_fs_chmod_file() ) ) {
381 return false;
382 }
383
384 // Verify the write was complete (catches partial/truncated writes).
385 $written_size = $wp_filesystem->size( $file );
386 if ( false === $written_size || strlen( $body ) !== $written_size ) {
387 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.
388 wp_delete_file( $file );
389
390 return false;
391 }
392
393 return true;
394 }
395
396 /**
397 * Download a translation file from jsDelivr.
398 *
399 * Tries writing to the primary languages path first. If that fails,
400 * falls back to the uploads directory. If both fail, sets
401 * $last_write_failed so the caller can cache the failure.
402 *
403 * @param string $locale The locale code (e.g., de_DE).
404 * @param string $file The target file path.
405 * @param bool $suppress_404_logs Suppress 404 logging for fallback attempts.
406 *
407 * @return bool Whether the download and write was successful.
408 */
409 protected function download_translation( string $locale, string $file, bool $suppress_404_logs = false ): bool {
410 $url = sprintf( self::CDN_BASE_URL, $this->version, $locale, $this->text_domain, $locale );
411 $this->last_download_status_code = null;
412 $this->last_write_failed = false;
413
414 $response = wp_remote_get(
415 $url,
416 array(
417 'timeout' => 10,
418 )
419 );
420
421 if ( is_wp_error( $response ) ) {
422 Logger::log( sprintf( 'i18n: Failed to download %s translation - HTTP error: %s', $locale, $response->get_error_message() ) );
423
424 return false;
425 }
426
427 $status_code = wp_remote_retrieve_response_code( $response );
428 if ( 200 !== $status_code ) {
429 $this->last_download_status_code = $status_code;
430
431 if ( ! ( $suppress_404_logs && 404 === $status_code ) ) {
432 Logger::log( sprintf( 'i18n: Failed to download %s translation - HTTP %d from %s', $locale, $status_code, $url ) );
433 }
434
435 return false;
436 }
437
438 $body = wp_remote_retrieve_body( $response );
439 if ( empty( $body ) ) {
440 Logger::log( sprintf( 'i18n: Failed to download %s translation - empty response body from %s', $locale, $url ) );
441
442 return false;
443 }
444
445 // Validate the response is a PHP translation file (catches truncated downloads).
446 if ( 0 !== strpos( $body, '<?php' ) || false === strpos( $body, 'return' ) ) {
447 Logger::log( sprintf( 'i18n: Downloaded %s translation is not valid PHP — possible truncated download from %s', $locale, $url ) );
448
449 return false;
450 }
451
452 // Try writing to primary path.
453 if ( $this->write_translation_file( $file, $body ) ) {
454 return true;
455 }
456
457 // Primary write failed — try uploads fallback.
458 $fallback_path = $this->get_fallback_languages_path();
459 if ( $fallback_path !== $this->languages_path ) {
460 $fallback_file = $fallback_path . basename( $file );
461 if ( $this->write_translation_file( $fallback_file, $body ) ) {
462 Logger::log( sprintf( 'i18n: Primary path not writable, using fallback for %s translations: %s', $locale, $fallback_path ) );
463 $this->languages_path = $fallback_path;
464 set_transient( $this->transient_key . '_active_path', 'uploads', MONTH_IN_SECONDS );
465
466 return true;
467 }
468 }
469
470 // Both paths failed (or already at fallback and it failed).
471 $this->last_write_failed = true;
472 Logger::log( sprintf( 'i18n: Failed to write %s translation to any writable location', $locale ) );
473
474 return false;
475 }
476 }
477