PluginProbe
Polylang / 3.7.6
Polylang v3.7.6
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 / include / Options / Options.php

Options.php in Polylang 3.7.6, at include/Options/Options.php

570 lines 13.6 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
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Class that manages Polylang's options:
18 * - Automatically stores the options into the database on `shutdown` if they have been modified.
19 * - Behaves almost like an array, meaning only values can be get/set (implements `ArrayAccess`).
20 * - Handles `switch_to_blog()`.
21 * - Options are always defined: it is not possible to unset them from the list, they are set to their default value instead.
22 * - If an option is not registered but exists in database, its raw value will be kept and remain untouched.
23 *
24 * @since 3.7
25 *
26 * @implements ArrayAccess<non-falsy-string, mixed>
27 * @implements IteratorAggregate<non-empty-string, mixed>
28 */
29 class Options implements ArrayAccess, IteratorAggregate {
30 public const OPTION_NAME = 'polylang';
31
32 /**
33 * Polylang's options, by blog ID.
34 * Raw value if option is not registered yet, `Abstract_Option` instance otherwise.
35 *
36 * @var Abstract_Option[][]|mixed[][]
37 * @phpstan-var array<int, array<non-falsy-string, mixed>>
38 */
39 private $options = array();
40
41 /**
42 * Tells if the options have been modified, by blog ID.
43 *
44 * @var bool[]
45 * @phpstan-var array<int, true>
46 */
47 private $modified = array();
48
49 /**
50 * The original blog ID.
51 *
52 * @var int
53 */
54 private $blog_id;
55
56 /**
57 * The current blog ID.
58 *
59 * @var int
60 */
61 private $current_blog_id;
62
63 /**
64 * Cached options JSON schema by blog ID.
65 *
66 * @var array[]|null
67 */
68 private $schema;
69
70 /**
71 * Constructor.
72 *
73 * @since 3.7
74 */
75 public function __construct() {
76 // Keep track of the blog ID.
77 $this->blog_id = (int) get_current_blog_id();
78 $this->current_blog_id = $this->blog_id;
79
80 // Handle options.
81 $this->init_options_for_current_blog();
82
83 add_filter( 'pre_update_option_polylang', array( $this, 'protect_wp_option_storage' ), 1 );
84 add_action( 'switch_blog', array( $this, 'on_blog_switch' ), -1000 ); // Options must be ready early.
85 add_action( 'shutdown', array( $this, 'save_all' ), 1000 ); // Make sure to save options after everything.
86 }
87
88 /**
89 * Registers an option.
90 * Options must be registered in the right order: some options depend on other options' value.
91 *
92 * @since 3.7
93 *
94 * @param string $class_name Option class to register.
95 * @return self
96 *
97 * @phpstan-param class-string<Abstract_Option> $class_name
98 */
99 public function register( string $class_name ): self {
100 foreach ( $this->options as &$options ) {
101 $key = $class_name::key();
102
103 if ( ! array_key_exists( $key, $options ) ) {
104 // Option raw value doesn't exist in database, use default instead.
105 $options[ $key ] = new $class_name();
106 continue;
107 }
108
109 // If option exists in database, use this value.
110 if ( $options[ $key ] instanceof Abstract_Option ) {
111 // Already registered, do nothing.
112 continue;
113 }
114
115 // Option raw value exists in database, use it.
116 $options[ $key ] = new $class_name( $options[ $key ] );
117 }
118
119 return $this;
120 }
121
122 /**
123 * Prevents storing an instance of `Options` into the database.
124 *
125 * @since 3.7
126 *
127 * @param array|Options $value The options to store.
128 * @return array
129 */
130 public function protect_wp_option_storage( $value ) {
131 if ( $value instanceof self ) {
132 return $value->get_all();
133 }
134 return $value;
135 }
136
137 /**
138 * Initializes options for the newly switched blog if applicable.
139 *
140 * @since 3.7
141 *
142 * @param int $blog_id The blog ID.
143 * @return void
144 */
145 public function on_blog_switch( $blog_id ): void {
146 $this->current_blog_id = (int) $blog_id;
147
148 if ( isset( $this->options[ $blog_id ] ) ) {
149 return;
150 }
151
152 if ( ! pll_is_plugin_active( POLYLANG_BASENAME ) && ! doing_action( 'activate_' . POLYLANG_BASENAME ) ) {
153 return;
154 }
155
156 $this->init_options_for_current_blog();
157 }
158
159 /**
160 * Stores the options into the database for all blogs.
161 * Hooked to `shutdown`.
162 *
163 * @since 3.7
164 *
165 * @return void
166 */
167 public function save_all(): void {
168 // Find blog with modified options.
169 $modified = $this->get_modified();
170
171 if ( empty( $modified ) ) {
172 // Not modified.
173 return;
174 }
175
176 remove_action( 'switch_blog', array( $this, 'on_blog_switch' ), -1000 );
177
178 // Handle the original blog first, maybe this will prevent the use of `switch_to_blog()`.
179 if ( isset( $modified[ $this->blog_id ] ) && $this->current_blog_id === $this->blog_id ) {
180 $this->save();
181 unset( $modified[ $this->blog_id ] );
182
183 if ( empty( $modified ) ) {
184 // All done, no need of `switch_to_blog()`.
185 return;
186 }
187 }
188
189 foreach ( $modified as $blog_id => $_yup ) {
190 switch_to_blog( $blog_id );
191 $this->save();
192 restore_current_blog();
193 }
194 }
195
196 /**
197 * Stores the options into the database.
198 *
199 * @since 3.7
200 *
201 * @return bool True if the options were updated, false otherwise.
202 */
203 public function save(): bool {
204 if ( empty( $this->modified[ $this->current_blog_id ] ) ) {
205 return false;
206 }
207
208 unset( $this->modified[ $this->current_blog_id ] );
209
210 if ( is_multisite() && ! get_site( $this->current_blog_id ) ) { // Cached by `$this->get_modified()` if called from `$this->save_all()`.
211 // Deleted. Should not happen if called from `$this->save_all()`.
212 return false;
213 }
214
215 $options = get_option( self::OPTION_NAME, array() );
216
217 if ( is_array( $options ) ) {
218 // Preserve options that are not from Polylang.
219 $options = array_merge( $options, $this->get_all() );
220 } else {
221 $options = $this->get_all();
222 }
223
224 return update_option( self::OPTION_NAME, $options );
225 }
226
227 /**
228 * Returns all options.
229 *
230 * @since 3.7
231 *
232 * @return mixed[] All options values.
233 */
234 public function get_all(): array {
235 if ( empty( $this->options[ $this->current_blog_id ] ) ) {
236 // No options.
237 return array();
238 }
239
240 return array_map(
241 function ( $value ) {
242 return $value->get();
243 },
244 array_filter(
245 $this->options[ $this->current_blog_id ],
246 function ( $value ) {
247 return $value instanceof Abstract_Option;
248 }
249 )
250 );
251 }
252
253 /**
254 * Merges a subset of options into the current blog ones.
255 *
256 * @since 3.7
257 *
258 * @param array $values Array of raw options.
259 * @return WP_Error
260 */
261 public function merge( array $values ): WP_Error {
262 $errors = new WP_Error();
263
264 foreach ( $this->options[ $this->current_blog_id ] as $key => $option ) {
265 if ( ! isset( $values[ $key ] ) || ! $this->has( $key ) ) {
266 continue;
267 }
268
269 $option_errors = $this->set( $key, $values[ $key ] );
270
271 if ( $option_errors->has_errors() ) {
272 // Blocking and non-blocking errors.
273 $errors->merge_from( $option_errors );
274 }
275
276 unset( $values[ $key ] );
277 }
278
279 if ( empty( $values ) ) {
280 return $errors;
281 }
282
283 // Merge all "unknown option" errors into a single error message.
284 if ( 1 === count( $values ) ) {
285 /* translators: %s is an option name. */
286 $message = __( 'Unknown option key %s.', 'polylang' );
287 } else {
288 /* translators: %s is a list of option names. */
289 $message = __( 'Unknown option keys %s.', 'polylang' );
290 }
291
292 $errors->add(
293 'pll_unknown_option_keys',
294 sprintf(
295 $message,
296 wp_sprintf_l(
297 '%l',
298 array_map(
299 function ( $value ) {
300 return "'$value'";
301 },
302 array_keys( $values )
303 )
304 )
305 )
306 );
307
308 return $errors;
309 }
310
311 /**
312 * Returns JSON schema for all options of the current blog.
313 *
314 * @since 3.7
315 *
316 * @return array The schema.
317 */
318 public function get_schema(): array {
319 if ( isset( $this->schema[ $this->current_blog_id ] ) ) {
320 return $this->schema[ $this->current_blog_id ];
321 }
322
323 $properties = array();
324
325 if ( ! empty( $this->options[ $this->current_blog_id ] ) ) {
326 foreach ( $this->options[ $this->current_blog_id ] as $option ) {
327 if ( ! $option instanceof Abstract_Option ) {
328 continue;
329 }
330
331 $properties[ $option->key() ] = $option->get_schema();
332 }
333 }
334
335 $this->schema[ $this->current_blog_id ] = array(
336 '$schema' => 'http://json-schema.org/draft-04/schema#',
337 'title' => static::OPTION_NAME,
338 'description' => __( 'Polylang options', 'polylang' ),
339 'type' => 'object',
340 'properties' => $properties,
341 'additionalProperties' => false,
342 );
343
344 return $this->schema[ $this->current_blog_id ];
345 }
346
347 /**
348 * Tells if an option exists.
349 *
350 * @since 3.7
351 *
352 * @param string $key The name of the option to check for.
353 * @return bool
354 */
355 public function has( string $key ): bool {
356 return isset( $this->options[ $this->current_blog_id ][ $key ] ) && $this->options[ $this->current_blog_id ][ $key ] instanceof Abstract_Option;
357 }
358
359 /**
360 * Returns the value of the specified option.
361 *
362 * @since 3.7
363 *
364 * @param string $key The name of the option to retrieve.
365 * @return mixed
366 */
367 public function get( string $key ) {
368 if ( ! $this->has( $key ) ) {
369 $v = null;
370 return $v;
371 }
372
373 /** @var Abstract_Option */
374 $option = $this->options[ $this->current_blog_id ][ $key ];
375 return $option->get();
376 }
377
378 /**
379 * Assigns a value to the specified option.
380 *
381 * This doesn't allow to set an unknown option.
382 * When doing multiple `set()`, options must be set in the right order: some options depend on other options' value.
383 *
384 * @since 3.7
385 *
386 * @param string $key The name of the option to assign the value to.
387 * @param mixed $value The value to set.
388 * @return WP_Error
389 */
390 public function set( string $key, $value ): WP_Error {
391 if ( ! $this->has( $key ) ) {
392 /* translators: %s is the name of an option. */
393 return new WP_Error( 'pll_unknown_option_key', sprintf( __( 'Unknown option key %s.', 'polylang' ), "'$key'" ) );
394 }
395
396 /** @var Abstract_Option */
397 $option = $this->options[ $this->current_blog_id ][ $key ];
398 $old_value = $option->get();
399
400 if ( $option->set( $value, $this ) && $option->get() !== $old_value ) {
401 // No blocking errors: the value can be stored.
402 $this->modified[ $this->current_blog_id ] = true;
403 }
404
405 // Return errors.
406 return $option->get_errors();
407 }
408
409 /**
410 * Resets an option to its default value.
411 *
412 * @since 3.7
413 *
414 * @param string $key The name of the option to reset.
415 * @return mixed The new value.
416 */
417 public function reset( string $key ) {
418 if ( ! $this->has( $key ) ) {
419 return null;
420 }
421
422 /** @var Abstract_Option */
423 $option = $this->options[ $this->current_blog_id ][ $key ];
424
425 if ( $option->get() !== $option->reset() ) {
426 $this->modified[ $this->current_blog_id ] = true;
427 }
428
429 return $option->get();
430 }
431
432 /**
433 * Tells if an option exists.
434 * Required by interface `ArrayAccess`.
435 *
436 * @since 3.7
437 *
438 * @param string $offset The name of the option to check for.
439 * @return bool
440 */
441 public function offsetExists( $offset ): bool {
442 return $this->has( (string) $offset );
443 }
444
445 /**
446 * Returns the value of the specified option.
447 * Required by interface `ArrayAccess`.
448 *
449 * @since 3.7
450 *
451 * @param string $offset The name of the option to retrieve.
452 * @return mixed
453 */
454 #[\ReturnTypeWillChange]
455 public function offsetGet( $offset ) {
456 return $this->get( (string) $offset );
457 }
458
459 /**
460 * Assigns a value to the specified option.
461 * This doesn't allow to set an unknown option.
462 * Required by interface `ArrayAccess`.
463 *
464 * @since 3.7
465 *
466 * @param string $offset The name of the option to assign the value to.
467 * @param mixed $value The value to set.
468 * @return void
469 */
470 public function offsetSet( $offset, $value ): void {
471 $this->set( (string) $offset, $value );
472 }
473
474 /**
475 * Resets an option.
476 * This doesn't allow to unset an option, this resets it to its default value instead.
477 * Required by interface `ArrayAccess`.
478 *
479 * @since 3.7
480 *
481 * @param string $offset The name of the option to unset.
482 * @return void
483 */
484 public function offsetUnset( $offset ): void {
485 $this->reset( (string) $offset );
486 }
487
488 /**
489 * Returns all current site's option values.
490 * Required by interface `IteratorAggregate`.
491 *
492 * @since 3.7
493 *
494 * @return ArrayIterator
495 *
496 * @phpstan-return ArrayIterator<non-empty-string, mixed>
497 */
498 public function getIterator(): ArrayIterator {
499 return new ArrayIterator( $this->get_all() );
500 }
501
502 /**
503 * Returns the list of modified sites.
504 * On multisite, sites are cached.
505 * /!\ At this point, some sites may have been deleted. They are removed from `$this->modified` here.
506 *
507 * @since 3.7
508 *
509 * @return bool[]
510 * @phpstan-return array<int, true>
511 */
512 private function get_modified(): array {
513 if ( empty( $this->modified ) ) {
514 // Not modified.
515 return $this->modified;
516 }
517
518 // Cleanup deleted sites and cache existing ones.
519 if ( ! is_multisite() ) {
520 // Not multisite: no need to cache or verify existence.
521 return $this->modified;
522 }
523
524 // Fetch all the data instead of only the IDs, so it is cached.
525 $sites = get_sites(
526 array(
527 'site__in' => array_keys( $this->modified ),
528 'number' => count( $this->modified ),
529 )
530 );
531
532 // Keep only existing blogs.
533 $this->modified = array();
534 foreach ( $sites as $site ) {
535 $this->modified[ $site->id ] = true;
536 }
537
538 return $this->modified;
539 }
540
541 /**
542 * Initializes options for the current blog.
543 *
544 * @since 3.7
545 *
546 * @return void
547 */
548 private function init_options_for_current_blog(): void {
549 $options = get_option( self::OPTION_NAME );
550
551 if ( empty( $options ) || ! is_array( $options ) ) {
552 $this->options[ $this->current_blog_id ] = array();
553 $this->modified[ $this->current_blog_id ] = true;
554 } else {
555 $this->options[ $this->current_blog_id ] = $options;
556 }
557
558 /**
559 * Fires after the options have been init for the current blog.
560 * This is the best place to register options.
561 *
562 * @since 3.7
563 *
564 * @param Options $options Instance of the options.
565 * @param int $current_blog_id Current blog ID.
566 */
567 do_action( 'pll_init_options_for_blog', $this, $this->current_blog_id );
568 }
569 }
570