PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Illuminate / Multilingual.php

Multilingual.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.4.0, at includes/Illuminate/Multilingual.php

496 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WPML / Polylang compatibility.
4 *
5 * @package SpringDevs\Subscription\Illuminate
6 */
7
8 namespace SpringDevs\Subscription\Illuminate;
9
10 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
11 use WC_Email;
12
13 /**
14 * Class Multilingual
15 *
16 * Everything here goes through WPML's hook API — `wpml_object_id`,
17 * `wpml_switch_language`, `wpml_translate_single_string` and friends — never
18 * `icl_*()` or `pll_*()` functions. Polylang implements the same hooks, so one
19 * code path serves both, and with neither installed every filter returns the
20 * default it was given: nothing to guard, nothing to fatal.
21 *
22 * Three things need it:
23 *
24 * - Plans are attached to a product id, and a translation is another post, so
25 * plan lookups read across the product's whole translation group.
26 * - Plan and plan-group titles live in the plan tables, not in posts, so they are
27 * registered as translatable strings and translated on the way out.
28 * - Customer emails are mostly sent by cron, with no visitor to take a language
29 * from, so the shopper's language is recorded on the subscription at checkout
30 * and each customer email switches to it while it renders.
31 *
32 * @package SpringDevs\Subscription\Illuminate
33 */
34 class Multilingual {
35
36 /**
37 * String-translation context (WPML) / group (Polylang) for plan titles.
38 */
39 const STRING_CONTEXT = 'WPSubscription';
40
41 /**
42 * Subscription meta: the locale the shopper checked out in.
43 */
44 const LOCALE_META = '_subscrpt_locale';
45
46 /**
47 * Subscription meta: the WPML / Polylang language code the shopper checked out in.
48 */
49 const LANGUAGE_META = '_subscrpt_language';
50
51 /**
52 * Option: hash of the plan titles last registered, so they are registered once per change.
53 */
54 const STRINGS_HASH_OPTION = 'subscrpt_plan_strings_hash';
55
56 /**
57 * WooCommerce session key: the locale and language of the storefront page the shopper last saw.
58 */
59 const SESSION_KEY = 'subscrpt_shopper_language';
60
61 /**
62 * Translation groups already looked up this request, by post id.
63 *
64 * @var array<int,int[]>
65 */
66 private static $groups = array();
67
68 /**
69 * What each open switch changed, innermost last: [ locale switched, language switched ].
70 *
71 * @var array<int,bool[]>
72 */
73 private static $switches = array();
74
75 /**
76 * Initialize the class.
77 */
78 public function __construct() {
79 add_action( 'template_redirect', array( $this, 'remember_shopper_language' ) );
80 add_action( 'save_post_subscrpt_order', array( $this, 'remember_language' ), 10, 3 );
81 add_filter( 'subscrpt_before_saving_renewal_order', array( $this, 'set_renewal_order_language' ), 10, 3 );
82 add_action( 'admin_init', array( $this, 'register_plan_strings' ) );
83 }
84
85 /**
86 * Whether WPML or Polylang is running with at least one language set up.
87 *
88 * @return bool
89 */
90 public static function is_active(): bool {
91 return ! empty( apply_filters( 'wpml_default_language', null ) );
92 }
93
94 /**
95 * Every post in a post's translation group, the post itself first.
96 *
97 * A single-item list when no multilingual plugin is active or the post has no
98 * translations.
99 *
100 * @param int $post_id Post id.
101 *
102 * @return int[]
103 */
104 public static function translation_ids( $post_id ): array {
105 $post_id = absint( $post_id );
106
107 if ( ! $post_id || ! self::is_active() ) {
108 return array( $post_id );
109 }
110
111 if ( isset( self::$groups[ $post_id ] ) ) {
112 return self::$groups[ $post_id ];
113 }
114
115 $ids = array( $post_id );
116 $post_type = get_post_type( $post_id );
117
118 // One lookup per language rather than `wpml_get_element_translations`:
119 // Polylang's version of that queries without a post type, so it only
120 // ever finds posts, never products.
121 if ( $post_type ) {
122 $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) );
123
124 foreach ( array_keys( (array) $languages ) as $language ) {
125 $ids[] = (int) apply_filters( 'wpml_object_id', $post_id, $post_type, false, $language );
126 }
127 }
128
129 self::$groups[ $post_id ] = array_values( array_unique( array_filter( $ids ) ) );
130
131 return self::$groups[ $post_id ];
132 }
133
134 /**
135 * One id that stands for a post in every language — the lowest in its group.
136 *
137 * Used to key caches, so every translation of a product shares one entry and
138 * one flush clears it for all of them.
139 *
140 * @param int $post_id Post id.
141 *
142 * @return int
143 */
144 public static function canonical_id( $post_id ): int {
145 return (int) min( self::translation_ids( $post_id ) );
146 }
147
148 /**
149 * Translate a string registered with {@see register_plan_strings()}.
150 *
151 * @param string $value Original value.
152 * @param string $name String name, e.g. `plan_12_title`.
153 *
154 * @return string
155 */
156 public static function translate_string( $value, $name ): string {
157 $value = (string) $value;
158
159 if ( '' === $value || ! self::is_active() ) {
160 return $value;
161 }
162
163 return (string) apply_filters( 'wpml_translate_single_string', $value, self::STRING_CONTEXT, $name );
164 }
165
166 /**
167 * Translate the plan and group titles of resolved plan rows.
168 *
169 * @param array $rows Rows from {@see PlanRepository::resolve_for_product()}.
170 *
171 * @return array
172 */
173 public static function translate_plan_rows( array $rows ): array {
174 if ( ! self::is_active() ) {
175 return $rows;
176 }
177
178 foreach ( $rows as &$row ) {
179 if ( isset( $row['group_title'], $row['plan_group_id'] ) ) {
180 $row['group_title'] = self::translate_string( $row['group_title'], self::group_string_name( $row['plan_group_id'] ) );
181 }
182 if ( isset( $row['plan_title'], $row['plan_id'] ) ) {
183 $row['plan_title'] = self::translate_string( $row['plan_title'], self::plan_string_name( $row['plan_id'] ) );
184 }
185 }
186 unset( $row );
187
188 return $rows;
189 }
190
191 /**
192 * String name for a plan group's title.
193 *
194 * @param int $group_id Plan group id.
195 *
196 * @return string
197 */
198 public static function group_string_name( $group_id ): string {
199 return 'plan_group_' . absint( $group_id ) . '_title';
200 }
201
202 /**
203 * String name for a plan's title.
204 *
205 * @param int $plan_id Plan id.
206 *
207 * @return string
208 */
209 public static function plan_string_name( $plan_id ): string {
210 return 'plan_' . absint( $plan_id ) . '_title';
211 }
212
213 /**
214 * Register every plan and plan-group title for translation.
215 *
216 * Runs on admin_init but only does work when a title has been added or changed
217 * since the last run, so the strings appear in WPML String Translation /
218 * Polylang's Translations screen without hooking every write path.
219 *
220 * @return void
221 */
222 public function register_plan_strings() {
223 if ( ! self::is_active() ) {
224 return;
225 }
226
227 $titles = PlanRepository::get_titles();
228 $hash = md5( (string) wp_json_encode( $titles ) );
229
230 if ( get_option( self::STRINGS_HASH_OPTION ) === $hash ) {
231 return;
232 }
233
234 foreach ( $titles['groups'] as $group_id => $title ) {
235 do_action( 'wpml_register_single_string', self::STRING_CONTEXT, self::group_string_name( $group_id ), $title );
236 }
237 foreach ( $titles['plans'] as $plan_id => $title ) {
238 do_action( 'wpml_register_single_string', self::STRING_CONTEXT, self::plan_string_name( $plan_id ), $title );
239 }
240
241 update_option( self::STRINGS_HASH_OPTION, $hash, false );
242 }
243
244 /**
245 * Keep the language of the shop pages the shopper browses in their session.
246 *
247 * The block checkout places its order through the Store API, a REST request
248 * with no language in its URL, which Polylang and WPML answer in the default
249 * language. The page the shopper was looking at knew their language, so it is
250 * kept here for {@see remember_language()}. Only written to a session that
251 * already exists, so a visitor with no cart costs nothing.
252 *
253 * Shop, product, cart and checkout pages only: any other front-end request —
254 * the browser's own `/favicon.ico`, a 404, a feed — has no language prefix
255 * either, and would overwrite it with the default.
256 *
257 * @return void
258 */
259 public function remember_shopper_language() {
260 if ( ! function_exists( 'WC' ) || ! WC()->session || ! WC()->session->has_session() ) {
261 return;
262 }
263
264 if ( ! is_woocommerce() && ! is_cart() && ! ( is_checkout() && ! is_wc_endpoint_url() ) ) {
265 return;
266 }
267
268 WC()->session->set(
269 self::SESSION_KEY,
270 array(
271 'locale' => determine_locale(),
272 'language' => (string) apply_filters( 'wpml_current_language', null ),
273 )
274 );
275 }
276
277 /**
278 * Record the shopper's language on a subscription when checkout creates it.
279 *
280 * The language of the last storefront page they saw, else this request's.
281 * Only when it differs from the site's — a subscription with nothing recorded
282 * gets emails in the site language, and keeps following it if that changes.
283 * Admin and cron requests are skipped: their language is not the customer's.
284 *
285 * @param int $post_id Subscription id.
286 * @param \WP_Post $post Subscription post.
287 * @param bool $update Whether this is an existing post being updated.
288 *
289 * @return void
290 */
291 public function remember_language( $post_id, $post, $update ) {
292 if ( $update || is_admin() || wp_doing_cron() || metadata_exists( 'post', $post_id, self::LOCALE_META ) ) {
293 return;
294 }
295
296 $seen = function_exists( 'WC' ) && WC()->session ? WC()->session->get( self::SESSION_KEY ) : null;
297
298 $locale = ! empty( $seen['locale'] ) ? $seen['locale'] : determine_locale();
299 $site_locale = get_option( 'WPLANG' ) ? get_option( 'WPLANG' ) : 'en_US';
300 if ( $locale && $locale !== $site_locale ) {
301 update_post_meta( $post_id, self::LOCALE_META, $locale );
302 }
303
304 $language = ! empty( $seen['language'] ) ? $seen['language'] : apply_filters( 'wpml_current_language', null );
305 if ( $language && 'all' !== $language && apply_filters( 'wpml_default_language', null ) !== $language ) {
306 update_post_meta( $post_id, self::LANGUAGE_META, $language );
307 }
308 }
309
310 /**
311 * Give a renewal order its subscription's language.
312 *
313 * Renewal orders are built in cron, so nothing sets a language on them, and
314 * WooCommerce Multilingual sends an order's own emails — "order received",
315 * "completed" — in the language stored in its `wpml_language` meta.
316 * Polylang for WooCommerce keeps order languages its own way, which the WPML
317 * hook API cannot set.
318 *
319 * @param \WC_Order $new_order Renewal order, not yet saved.
320 * @param \WC_Order $old_order Order it renews.
321 * @param int $subscription_id Subscription id.
322 *
323 * @return \WC_Order
324 */
325 public function set_renewal_order_language( $new_order, $old_order, $subscription_id ) {
326 if ( ! $new_order instanceof \WC_Order || ! self::is_active() || $new_order->get_meta( 'wpml_language' ) ) {
327 return $new_order;
328 }
329
330 $language = self::subscription_language( $subscription_id )['language'];
331 if ( '' === $language && $old_order instanceof \WC_Order ) {
332 $language = (string) $old_order->get_meta( 'wpml_language' );
333 }
334
335 if ( '' !== $language ) {
336 $new_order->update_meta_data( 'wpml_language', $language );
337 }
338
339 return $new_order;
340 }
341
342 /**
343 * The locale and language a subscription's customer emails should be written in.
344 *
345 * What checkout recorded; for subscriptions from before that, the language
346 * WPML stored on the parent order, then the customer's own profile language.
347 * Empty values mean the site language.
348 *
349 * @param int $subscription_id Subscription id.
350 *
351 * @return array{locale:string,language:string}
352 */
353 public static function subscription_language( $subscription_id ): array {
354 $locale = (string) get_post_meta( $subscription_id, self::LOCALE_META, true );
355 $language = (string) get_post_meta( $subscription_id, self::LANGUAGE_META, true );
356
357 if ( '' === $locale && '' === $language ) {
358 $order_item_id = (int) get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
359 $order_id = $order_item_id ? wc_get_order_id_by_order_item_id( $order_item_id ) : 0;
360 $order = $order_id ? wc_get_order( $order_id ) : false;
361
362 if ( $order && self::is_active() ) {
363 $language = (string) $order->get_meta( 'wpml_language' );
364 $locale = self::locale_for_language( $language );
365 }
366
367 $user_id = (int) get_post_field( 'post_author', $subscription_id );
368 if ( '' === $locale && $user_id && get_user_meta( $user_id, 'locale', true ) ) {
369 $locale = get_user_locale( $user_id );
370 }
371 }
372
373 /**
374 * Filter the language a subscription's customer emails are written in.
375 *
376 * @param array $language {
377 * @type string $locale WordPress locale, e.g. `de_DE`. Empty for the site locale.
378 * @type string $language WPML / Polylang language code, e.g. `de`. Empty for the current language.
379 * }
380 * @param int $subscription_id Subscription id.
381 */
382 return apply_filters(
383 'subscrpt_subscription_language',
384 array(
385 'locale' => $locale,
386 'language' => $language,
387 ),
388 $subscription_id
389 );
390 }
391
392 /**
393 * The locale WPML has for a language code.
394 *
395 * @param string $language Language code.
396 *
397 * @return string Empty when unknown.
398 */
399 private static function locale_for_language( $language ): string {
400 if ( '' === (string) $language ) {
401 return '';
402 }
403
404 $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) );
405
406 return isset( $languages[ $language ]['default_locale'] ) ? (string) $languages[ $language ]['default_locale'] : '';
407 }
408
409 /**
410 * Switch translations to a subscription's language.
411 *
412 * Switches the locale for gettext, and the WPML / Polylang language for their
413 * translated options and strings. Pair every `true` with
414 * {@see restore_language()}.
415 *
416 * @param int $subscription_id Subscription id.
417 *
418 * @return bool Whether anything was switched.
419 */
420 public static function switch_to_subscription_language( $subscription_id ): bool {
421 $target = self::subscription_language( $subscription_id );
422
423 $locale_switched = ! empty( $target['locale'] ) && switch_to_locale( $target['locale'] );
424
425 $language_switched = false;
426 if ( ! empty( $target['language'] ) && self::is_active() && apply_filters( 'wpml_current_language', null ) !== $target['language'] ) {
427 do_action( 'wpml_switch_language', $target['language'] );
428 $language_switched = true;
429 }
430
431 if ( ! $locale_switched && ! $language_switched ) {
432 return false;
433 }
434
435 self::$switches[] = array( $locale_switched, $language_switched );
436
437 return true;
438 }
439
440 /**
441 * Undo the last {@see switch_to_subscription_language()}.
442 *
443 * @return void
444 */
445 public static function restore_language() {
446 $switch = array_pop( self::$switches );
447
448 if ( ! $switch ) {
449 return;
450 }
451
452 list( $locale_switched, $language_switched ) = $switch;
453
454 if ( $language_switched ) {
455 do_action( 'wpml_switch_language', null );
456 }
457 if ( $locale_switched ) {
458 restore_previous_locale();
459 }
460 }
461
462 /**
463 * Switch an email to its subscription's language before it builds anything.
464 *
465 * WooCommerce reads an email's saved settings when the email object is built,
466 * long before a trigger runs, so they are re-read after the switch to pick up a
467 * translated subject and heading.
468 *
469 * @param WC_Email $email Email about to be sent.
470 * @param int $subscription_id Subscription id.
471 *
472 * @return bool Whether it switched; pass `true` on to {@see restore_email_language()}.
473 */
474 public static function switch_email_language( WC_Email $email, $subscription_id ): bool {
475 if ( ! self::switch_to_subscription_language( $subscription_id ) ) {
476 return false;
477 }
478
479 $email->init_settings();
480
481 return true;
482 }
483
484 /**
485 * Put an email back in the site language after sending.
486 *
487 * @param WC_Email $email Email that was sent.
488 *
489 * @return void
490 */
491 public static function restore_email_language( WC_Email $email ) {
492 self::restore_language();
493 $email->init_settings();
494 }
495 }
496