PluginProbe
Polylang / 3.8.7
Polylang v3.8.7
3.8.9 3.8.8 3.8.7 3.8.6 3.8.5 3.8.4 3.8.3 2.7 2.7.0.1 2.7.1 2.7.2 2.7.3 2.7.4 2.8 2.8.1 2.8.2 2.8.3 2.8.4 2.9 2.9.1 2.9.2 3.0 3.0.1 3.0.2 3.0.3 All 233 releases
polylang / src / Options / Options.php

Options.php in Polylang 3.8.7, at src/Options/Options.php

741 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package Polylang
4 */
5
6 namespace WP_Syntex\Polylang\Options;
7
8 use WP_Error;
9 use ArrayAccess;
10 use ArrayIterator;
11 use IteratorAggregate;
12 use WP_Syntex\Polylang\Options\Abstract_Option;
13 use WP_Syntex\Polylang\Options\Primitive\Abstract_Map;
14 use WP_Syntex\Polylang\Options\Primitive\Abstract_List;
15
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * Class that manages Polylang's options:
20 * - Automatically stores the options into the database on `shutdown` if they have been modified.
21 * - Behaves almost like an array, meaning only values can be get/set (implements `ArrayAccess`).
22 * - Handles `switch_to_blog()`.
23 * - Options are always defined: it is not possible to unset them from the list, they are set to their default value instead.
24 * - If an option is not registered but exists in database, its raw value will be kept and remain untouched.
25 *
26 * @since 3.7
27 *
28 * @implements ArrayAccess<non-falsy-string, mixed>
29 * @implements IteratorAggregate<non-empty-string, mixed>
30 *
31 * @phpstan-import-type Schema from Abstract_Option as OptionSchema
32 * @phpstan-type Schema array{
33 * '$schema': non-falsy-string,
34 * title: non-falsy-string,
35 * description: string,
36 * type: 'object',
37 * properties: array<non-falsy-string, OptionSchema>,
38 * additionalProperties: false
39 * }
40 */
41 class Options implements ArrayAccess, IteratorAggregate {
42 public const OPTION_NAME = 'polylang';
43
44 /**
45 * Polylang's options, by blog ID.
46 * Raw value if option is not registered yet, `Abstract_Option` instance otherwise.
47 *
48 * @var Abstract_Option[][]|mixed[][]
49 * @phpstan-var array<int, array<non-falsy-string, mixed>>
50 */
51 private $options = array();
52
53 /**
54 * Tells if the options have been modified, by blog ID.
55 *
56 * @var bool[]
57 * @phpstan-var array<int, true>
58 */
59 private $modified = array();
60
61 /**
62 * The original blog ID.
63 *
64 * @var int
65 */
66 private $blog_id;
67
68 /**
69 * The current blog ID.
70 *
71 * @var int
72 */
73 private $current_blog_id;
74
75 /**
76 * Map of memoized values of blog IDs to tell if Polylang is active.
77 *
78 * @var bool[]
79 * @phpstan-var array<int, bool>
80 */
81 private $is_plugin_active = array();
82
83 /**
84 * Cached options JSON schema by blog ID.
85 *
86 * @var array[]|null
87 * @phpstan-var array<int, Schema>|null
88 */
89 private $schema;
90
91 /**
92 * Constructor.
93 *
94 * @since 3.7
95 */
96 public function __construct() {
97 // Keep track of the blog ID.
98 $this->blog_id = (int) get_current_blog_id();
99 $this->current_blog_id = $this->blog_id;
100 $this->is_plugin_active = array( $this->blog_id => true );
101
102 // Handle options.
103 $this->init_options_for_current_blog();
104
105 add_filter( 'pre_update_option_polylang', array( $this, 'protect_wp_option_storage' ), 1 );
106 add_action( 'switch_blog', array( $this, 'on_blog_switch' ), -1000 ); // Options must be ready early.
107 add_action( 'shutdown', array( $this, 'save_all' ), 1000 ); // Make sure to save options after everything.
108 }
109
110 /**
111 * Registers an option.
112 * Options must be registered in the right order: some options depend on other options' value.
113 *
114 * @since 3.7
115 *
116 * @param string $class_name Option class to register.
117 * @return self
118 *
119 * @phpstan-param class-string<Abstract_Option> $class_name
120 */
121 public function register( string $class_name ): self {
122 $key = $class_name::key();
123
124 if ( ! array_key_exists( $key, $this->options[ $this->current_blog_id ] ) ) {
125 // Option raw value doesn't exist in database, use default instead.
126 $this->options[ $this->current_blog_id ][ $key ] = $this->maybe_make_option_inactive(
127 new $class_name()
128 );
129 return $this;
130 }
131
132 // If option exists in database, use this value.
133 if ( $this->options[ $this->current_blog_id ][ $key ] instanceof Abstract_Option ) {
134 // Already registered, do nothing.
135 $this->options[ $this->current_blog_id ][ $key ] = $this->maybe_make_option_inactive(
136 $this->options[ $this->current_blog_id ][ $key ]
137 );
138 return $this;
139 }
140
141 // Option raw value exists in database, use it.
142 $this->options[ $this->current_blog_id ][ $key ] = $this->maybe_make_option_inactive(
143 new $class_name( $this->options[ $this->current_blog_id ][ $key ] )
144 );
145
146 return $this;
147 }
148
149 /**
150 * Prevents storing an instance of `Options` into the database.
151 *
152 * @since 3.7
153 *
154 * @param array|Options $value The options to store.
155 * @return array
156 */
157 public function protect_wp_option_storage( $value ) {
158 if ( $value instanceof self ) {
159 return $value->get_all();
160 }
161 return $value;
162 }
163
164 /**
165 * Initializes options for the newly switched blog if applicable.
166 *
167 * @since 3.7
168 *
169 * @param int $blog_id The blog ID.
170 * @return void
171 */
172 public function on_blog_switch( $blog_id ): void {
173 $this->current_blog_id = (int) $blog_id;
174
175 if ( isset( $this->options[ $blog_id ] ) ) {
176 return;
177 }
178
179 $this->init_options_for_current_blog();
180 }
181
182 /**
183 * Stores the options into the database for all blogs.
184 * Hooked to `shutdown`.
185 *
186 * @since 3.7
187 *
188 * @return void
189 */
190 public function save_all(): void {
191 // Find blog with modified options.
192 $modified = $this->get_modified();
193
194 if ( empty( $modified ) ) {
195 // Not modified.
196 return;
197 }
198
199 remove_action( 'switch_blog', array( $this, 'on_blog_switch' ), -1000 );
200
201 // Handle the original blog first, maybe this will prevent the use of `switch_to_blog()`.
202 if ( isset( $modified[ $this->blog_id ] ) && $this->current_blog_id === $this->blog_id ) {
203 $this->save();
204 unset( $modified[ $this->blog_id ] );
205
206 if ( empty( $modified ) ) {
207 // All done, no need of `switch_to_blog()`.
208 return;
209 }
210 }
211
212 foreach ( $modified as $blog_id => $_yup ) {
213 switch_to_blog( $blog_id );
214 $this->save();
215 restore_current_blog();
216 }
217 }
218
219 /**
220 * Stores the options into the database.
221 *
222 * @since 3.7
223 *
224 * @return bool True if the options were updated, false otherwise.
225 */
226 public function save(): bool {
227 if ( empty( $this->modified[ $this->current_blog_id ] ) ) {
228 return false;
229 }
230
231 unset( $this->modified[ $this->current_blog_id ] );
232
233 if ( is_multisite() && ! get_site( $this->current_blog_id ) ) { // Cached by `$this->get_modified()` if called from `$this->save_all()`.
234 // Deleted. Should not happen if called from `$this->save_all()`.
235 return false;
236 }
237
238 $options = get_option( self::OPTION_NAME, array() );
239
240 if ( is_array( $options ) ) {
241 // Preserve options that are not from Polylang.
242 $options = array_merge( $options, $this->get_all() );
243 } else {
244 $options = $this->get_all();
245 }
246
247 return update_option( self::OPTION_NAME, $options );
248 }
249
250 /**
251 * Returns all options.
252 *
253 * @since 3.7
254 *
255 * @return mixed[] All options values.
256 */
257 public function get_all(): array {
258 if ( empty( $this->options[ $this->current_blog_id ] ) ) {
259 // No options.
260 return array();
261 }
262
263 return array_map(
264 function ( $value ) {
265 return $value->get();
266 },
267 array_filter(
268 $this->options[ $this->current_blog_id ],
269 function ( $value ) {
270 return $value instanceof Abstract_Option;
271 }
272 )
273 );
274 }
275
276 /**
277 * Merges a subset of options into the current blog ones.
278 *
279 * @since 3.7
280 *
281 * @param array $values Array of raw options.
282 * @return WP_Error
283 */
284 public function merge( array $values ): WP_Error {
285 $errors = new WP_Error();
286
287 foreach ( $this->options[ $this->current_blog_id ] as $key => $option ) {
288 if ( ! isset( $values[ $key ] ) || ! $this->has( $key ) ) {
289 continue;
290 }
291
292 $option_errors = $this->set( $key, $values[ $key ] );
293
294 if ( $option_errors->has_errors() ) {
295 // Blocking and non-blocking errors.
296 $errors->merge_from( $option_errors );
297 }
298
299 unset( $values[ $key ] );
300 }
301
302 if ( empty( $values ) ) {
303 return $errors;
304 }
305
306 // Merge all "unknown option" errors into a single error message.
307 if ( 1 === count( $values ) ) {
308 /* translators: %s is the name of an option. */
309 $message = __( 'Unknown option key %s.', 'polylang' );
310 } else {
311 /* translators: %s is a list of option names. */
312 $message = __( 'Unknown option keys %s.', 'polylang' );
313 }
314
315 $errors->add(
316 'pll_unknown_option_keys',
317 sprintf(
318 $message,
319 wp_sprintf_l(
320 '%l',
321 array_map(
322 function ( $value ) {
323 return "'$value'";
324 },
325 array_keys( $values )
326 )
327 )
328 )
329 );
330
331 return $errors;
332 }
333
334 /**
335 * Returns JSON schema for all options of the current blog.
336 *
337 * @since 3.7
338 *
339 * @return array The schema.
340 *
341 * @phpstan-return Schema
342 */
343 public function get_schema(): array {
344 if ( isset( $this->schema[ $this->current_blog_id ] ) ) {
345 return $this->schema[ $this->current_blog_id ];
346 }
347
348 $properties = array();
349
350 if ( $this->is_plugin_active() ) {
351 foreach ( $this->options[ $this->current_blog_id ] as $option ) {
352 if ( ! $option instanceof Abstract_Option || empty( $option->get_schema() ) ) {
353 continue;
354 }
355
356 $properties[ $option->key() ] = $option->get_schema();
357 }
358 }
359
360 $this->schema[ $this->current_blog_id ] = array(
361 '$schema' => 'http://json-schema.org/draft-04/schema#',
362 'title' => static::OPTION_NAME,
363 'description' => __( 'Polylang options', 'polylang' ),
364 'type' => 'object',
365 'properties' => $properties,
366 'additionalProperties' => false,
367 );
368
369 return $this->schema[ $this->current_blog_id ];
370 }
371
372 /**
373 * Tells if an option exists.
374 *
375 * @since 3.7
376 *
377 * @param string $key The name of the option to check for.
378 * @return bool
379 */
380 public function has( string $key ): bool {
381 return isset( $this->options[ $this->current_blog_id ][ $key ] ) && $this->options[ $this->current_blog_id ][ $key ] instanceof Abstract_Option;
382 }
383
384 /**
385 * Returns the value of the specified option.
386 *
387 * @since 3.7
388 *
389 * @param string $key The name of the option to retrieve.
390 * @return mixed
391 */
392 public function get( string $key ) {
393 if ( ! $this->has( $key ) ) {
394 $v = null;
395 return $v;
396 }
397
398 /** @var Abstract_Option */
399 $option = $this->options[ $this->current_blog_id ][ $key ];
400 return $option->get();
401 }
402
403 /**
404 * Assigns a value to the specified option.
405 *
406 * This doesn't allow to set an unknown option.
407 * When doing multiple `set()`, options must be set in the right order: some options depend on other options' value.
408 *
409 * @since 3.7
410 *
411 * @param string $key The name of the option to assign the value to.
412 * @param mixed $value The value to set.
413 * @return WP_Error
414 */
415 public function set( string $key, $value ): WP_Error {
416 if ( ! $this->has( $key ) ) {
417 /* translators: %s is the name of an option. */
418 return new WP_Error( 'pll_unknown_option_key', sprintf( __( 'Unknown option key %s.', 'polylang' ), "'$key'" ) );
419 }
420
421 /** @var Abstract_Option */
422 $option = $this->options[ $this->current_blog_id ][ $key ];
423 $old_value = $option->get();
424
425 if ( $option->set( $value, $this ) && $option->get() !== $old_value ) {
426 // No blocking errors: the value can be stored.
427 $this->modified[ $this->current_blog_id ] = true;
428 }
429
430 // Return errors.
431 return $option->get_errors();
432 }
433
434 /**
435 * Resets an option to its default value.
436 *
437 * @since 3.7
438 *
439 * @param string $key The name of the option to reset.
440 * @return mixed The new value.
441 */
442 public function reset( string $key ) {
443 if ( ! $this->has( $key ) ) {
444 return null;
445 }
446
447 /** @var Abstract_Option */
448 $option = $this->options[ $this->current_blog_id ][ $key ];
449
450 if ( $option->get() !== $option->reset() ) {
451 $this->modified[ $this->current_blog_id ] = true;
452 }
453
454 return $option->get();
455 }
456
457 /**
458 * Removes an option sub value from its array.
459 *
460 * @since 3.8
461 *
462 * @param string $key The name of the option to splice.
463 * @param mixed $value The value to remove.
464 * @return WP_Error An error object, empty if the value was removed successfully.
465 */
466 public function remove( string $key, $value ): WP_Error {
467 if ( ! $this->has( $key ) ) {
468 return new WP_Error(
469 'pll_unknown_option_key',
470 /* translators: %s is the name of an option. */
471 sprintf( __( 'Unknown option key %s.', 'polylang' ), "'$key'" )
472 );
473 }
474
475 $option = $this->options[ $this->current_blog_id ][ $key ];
476
477 if ( ! $option instanceof Abstract_List && ! $option instanceof Abstract_Map ) {
478 return new WP_Error(
479 'pll_invalid_option_type',
480 /* translators: %s is the name of an option. */
481 sprintf( __( 'Option %s is not a list or map.', 'polylang' ), "'$key'" )
482 );
483 }
484
485 if ( $option->remove( $value ) ) {
486 $this->modified[ $this->current_blog_id ] = true;
487 return new WP_Error();
488 }
489
490 return new WP_Error(
491 'pll_remove_failed',
492 /* translators: %1$s is the value to remove. %2$s is the name of an option. */
493 sprintf( __( 'Failed to remove %1$s from %2$s.', 'polylang' ), print_r( $value, true ), "'$key'" ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
494 );
495 }
496
497
498 /**
499 * Adds a value to an option.
500 *
501 * @since 3.8
502 *
503 * @param string $key The name of the option to add the value to.
504 * @param mixed $value The value to add.
505 * @return WP_Error An error object, empty if the value was added successfully.
506 */
507 public function add( string $key, $value ): WP_Error {
508 if ( ! $this->has( $key ) ) {
509 return new WP_Error(
510 'pll_unknown_option_key',
511 /* translators: %s is the name of an option. */
512 sprintf( __( 'Unknown option key %s.', 'polylang' ), "'$key'" )
513 );
514 }
515
516 $option = $this->options[ $this->current_blog_id ][ $key ];
517
518 if ( ! $option instanceof Abstract_List && ! $option instanceof Abstract_Map ) {
519 return new WP_Error(
520 'pll_invalid_option_type',
521 /* translators: %s is the name of an option. */
522 sprintf( __( 'Option %s is not a list or map.', 'polylang' ), "'$key'" )
523 );
524 }
525
526 if ( $option->add( $value, $this ) ) {
527 $this->modified[ $this->current_blog_id ] = true;
528 return new WP_Error();
529 }
530
531 return new WP_Error(
532 'pll_add_failed',
533 /* translators: %1$s is the value to add. %2$s is the name of an option. */
534 sprintf( __( 'Failed to add %1$s to %2$s.', 'polylang' ), print_r( $value, true ), "'$key'" ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
535 );
536 }
537
538 /**
539 * Tells if an option exists.
540 * Required by interface `ArrayAccess`.
541 *
542 * @since 3.7
543 *
544 * @param string $offset The name of the option to check for.
545 * @return bool
546 */
547 public function offsetExists( $offset ): bool {
548 return $this->has( (string) $offset );
549 }
550
551 /**
552 * Returns the value of the specified option.
553 * Required by interface `ArrayAccess`.
554 *
555 * @since 3.7
556 *
557 * @param string $offset The name of the option to retrieve.
558 * @return mixed
559 */
560 #[\ReturnTypeWillChange]
561 public function offsetGet( $offset ) {
562 return $this->get( (string) $offset );
563 }
564
565 /**
566 * Assigns a value to the specified option.
567 * This doesn't allow to set an unknown option.
568 * Required by interface `ArrayAccess`.
569 *
570 * @since 3.7
571 *
572 * @param string $offset The name of the option to assign the value to.
573 * @param mixed $value The value to set.
574 * @return void
575 */
576 public function offsetSet( $offset, $value ): void {
577 $this->set( (string) $offset, $value );
578 }
579
580 /**
581 * Resets an option.
582 * This doesn't allow to unset an option, this resets it to its default value instead.
583 * Required by interface `ArrayAccess`.
584 *
585 * @since 3.7
586 *
587 * @param string $offset The name of the option to unset.
588 * @return void
589 */
590 public function offsetUnset( $offset ): void {
591 $this->reset( (string) $offset );
592 }
593
594 /**
595 * Returns all current site's option values.
596 * Required by interface `IteratorAggregate`.
597 *
598 * @since 3.7
599 *
600 * @return ArrayIterator
601 *
602 * @phpstan-return ArrayIterator<non-empty-string, mixed>
603 */
604 public function getIterator(): ArrayIterator {
605 return new ArrayIterator( $this->get_all() );
606 }
607
608 /**
609 * Retrieves site health information based on the current blog's options.
610 *
611 * @since 3.8
612 *
613 * @return array The site health information array.
614 */
615 public function get_site_health_info(): array {
616 $infos = array();
617 foreach ( $this->options[ $this->current_blog_id ] as $option ) {
618 if ( ! $option instanceof Abstract_Option ) {
619 continue;
620 }
621
622 $info = $option->get_site_health_info( $this );
623
624 if ( ! empty( $info ) ) {
625 $infos[ $option::key() ] = $info;
626 }
627 }
628
629 return $infos;
630 }
631
632 /**
633 * Returns the list of modified sites.
634 * On multisite, sites are cached.
635 * /!\ At this point, some sites may have been deleted. They are removed from `$this->modified` here.
636 *
637 * @since 3.7
638 *
639 * @return bool[]
640 * @phpstan-return array<int, true>
641 */
642 private function get_modified(): array {
643 if ( empty( $this->modified ) ) {
644 // Not modified.
645 return $this->modified;
646 }
647
648 // Cleanup deleted sites and cache existing ones.
649 if ( ! is_multisite() ) {
650 // Not multisite: no need to cache or verify existence.
651 return $this->modified;
652 }
653
654 // Fetch all the data instead of only the IDs, so it is cached.
655 $sites = get_sites(
656 array(
657 'site__in' => array_keys( $this->modified ),
658 'number' => count( $this->modified ),
659 )
660 );
661
662 // Keep only existing blogs.
663 $this->modified = array();
664 foreach ( $sites as $site ) {
665 $this->modified[ $site->id ] = true;
666 }
667
668 return $this->modified;
669 }
670
671 /**
672 * Initializes options for the current blog.
673 *
674 * @since 3.7
675 *
676 * @return void
677 */
678 private function init_options_for_current_blog(): void {
679 if ( ! $this->is_plugin_active() ) {
680 // Don't try to get the options from the DB.
681 $this->options[ $this->current_blog_id ] = array();
682 } else {
683 $options = get_option( self::OPTION_NAME );
684
685 if ( empty( $options ) || ! is_array( $options ) ) {
686 $this->options[ $this->current_blog_id ] = array();
687 $this->modified[ $this->current_blog_id ] = true;
688 } else {
689 $this->options[ $this->current_blog_id ] = $options;
690 }
691 }
692
693 /**
694 * Fires after the options have been init for the current blog.
695 * This is the best place to register options.
696 *
697 * @since 3.7
698 * @since 3.8 New parameter `$is_plugin_active`.
699 *
700 * @param Options $options Instance of the options.
701 * @param int $current_blog_id Current blog ID.
702 * @param bool $is_plugin_active True if Polylang is active on the current site, false otherwise.
703 * This can be false after calling `switch_to_blog()`.
704 */
705 do_action( 'pll_init_options_for_blog', $this, $this->current_blog_id, $this->is_plugin_active() );
706 }
707
708 /**
709 * Tells if Polylang is active on the current blog.
710 *
711 * @since 3.8
712 *
713 * @return bool
714 */
715 private function is_plugin_active(): bool {
716 if ( isset( $this->is_plugin_active[ $this->current_blog_id ] ) ) {
717 return $this->is_plugin_active[ $this->current_blog_id ];
718 }
719
720 $this->is_plugin_active[ $this->current_blog_id ] = pll_is_plugin_active( POLYLANG_BASENAME ) || doing_action( 'activate_' . POLYLANG_BASENAME );
721
722 return $this->is_plugin_active[ $this->current_blog_id ];
723 }
724
725 /**
726 * Decorates options if we are on a site where Polylang is not active.
727 *
728 * @since 3.8
729 *
730 * @param Abstract_Option $option The option to decorate.
731 * @return Abstract_Option
732 */
733 private function maybe_make_option_inactive( Abstract_Option $option ): Abstract_Option {
734 if ( $this->is_plugin_active() || $option instanceof Inactive_Option ) {
735 return $option;
736 }
737
738 return new Inactive_Option( $option );
739 }
740 }
741