PluginProbe
Polylang / 3.8.10
Polylang v3.8.10
3.8.10 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 All 234 releases
polylang / src / translatable-object.php

translatable-object.php in Polylang 3.8.10, at src/translatable-object.php

760 lines 19.8 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 use WP_Syntex\Polylang\Model\Languages;
7
8 defined( 'ABSPATH' ) || exit;
9
10 /**
11 * Abstract class to use for object types that support at least one language.
12 *
13 * @since 3.4
14 *
15 * @phpstan-type DBInfo array{
16 * table: non-empty-string,
17 * id_column: non-empty-string,
18 * default_alias: non-empty-string
19 * }
20 */
21 abstract class PLL_Translatable_Object {
22 /**
23 * Model for the languages.
24 *
25 * @var Languages
26 */
27 protected $languages;
28
29 /**
30 * Polylang's options.
31 *
32 * @var \WP_Syntex\Polylang\Options\Options
33 */
34 protected $options;
35
36 /**
37 * Internal non persistent cache object.
38 *
39 * @var PLL_Cache<mixed>
40 */
41 protected $cache;
42
43 /**
44 * List of taxonomies to cache.
45 *
46 * @var string[]
47 * @see PLL_Translatable_Object::get_object_term()
48 *
49 * @phpstan-var list<non-empty-string>
50 */
51 protected $tax_to_cache = array();
52
53 /**
54 * Taxonomy name for the languages.
55 *
56 * @var string
57 *
58 * @phpstan-var non-empty-string
59 */
60 protected $tax_language;
61
62 /**
63 * Identifier that must be unique for each type of content.
64 * Also used when checking capabilities.
65 *
66 * @var string
67 *
68 * @phpstan-var non-empty-string
69 */
70 protected $type;
71
72 /**
73 * Identifier for each type of content to used for cache type.
74 *
75 * @var string
76 *
77 * @phpstan-var non-empty-string
78 */
79 protected $cache_type;
80
81 /**
82 * Object type to use when registering the taxonomy.
83 * Left empty for posts.
84 *
85 * @var string|null
86 *
87 * @phpstan-var non-empty-string|null
88 */
89 protected $object_type = null;
90
91 /**
92 * Constructor.
93 *
94 * @since 3.4
95 *
96 * @param PLL_Model $model Instance of `PLL_Model`.
97 */
98 public function __construct( PLL_Model $model ) {
99 $this->languages = $model->languages;
100 $this->options = $model->options;
101 $this->cache = $model->cache;
102 $this->tax_to_cache[] = $this->tax_language;
103
104 /*
105 * Register our taxonomy as soon as possible.
106 */
107 $this->register_language_taxonomy();
108 }
109
110 /**
111 * Registers the language taxonomy.
112 *
113 * @since 3.7
114 *
115 * @return void
116 */
117 protected function register_language_taxonomy(): void {
118 register_taxonomy(
119 $this->tax_language,
120 (array) $this->object_type,
121 array(
122 'label' => false,
123 'public' => false,
124 'query_var' => false,
125 'rewrite' => false,
126 '_pll' => true,
127 )
128 );
129
130 $this->add_sanitization_hooks( $this->tax_language );
131 }
132
133 /**
134 * Hooks sanitization for a Polylang taxonomy that stores serialized data in term descriptions.
135 *
136 * @since 3.8.10
137 *
138 * @param string $taxonomy Taxonomy name.
139 * @return void
140 *
141 * @phpstan-param non-empty-string $taxonomy
142 */
143 protected function add_sanitization_hooks( string $taxonomy ): void {
144 add_filter( "pre_{$taxonomy}_description", array( $this, 'sanitize_description' ), 0 );
145 add_filter( "get_{$taxonomy}", array( $this, 'sanitize_term' ), 0 );
146 }
147
148 /**
149 * Empties the description of a term hydrated from the database when it holds a disallowed serialized type.
150 *
151 * `get_{$taxonomy}` is fired by `get_term()`, whatever the sanitization context, and thus covers the
152 * terms hydrated by `get_terms()` and `wp_get_object_terms()` too. Only the returned object is modified,
153 * the stored value is left untouched.
154 *
155 * @since 3.8.10
156 *
157 * @param mixed $term Term object, may be anything another callback returned.
158 * @return mixed The term, with a sanitized description.
159 */
160 public function sanitize_term( $term ) {
161 if ( $term instanceof WP_Term && $this->has_disallowed_type( $term->description ) ) {
162 $term->description = '';
163 }
164
165 return $term;
166 }
167
168 /**
169 * Drops serialized values that contain a disallowed PHP type.
170 *
171 * @since 3.8.10
172 *
173 * @param mixed $description Term description.
174 * @return string Empty string for a non-string value or when a disallowed type is found, unchanged otherwise.
175 */
176 public function sanitize_description( $description ) {
177 if ( ! is_string( $description ) || '' === $description ) {
178 return '';
179 }
180
181 return $this->has_disallowed_type( $description ) ? '' : $description;
182 }
183
184 /**
185 * Tells if a serialized value contains a disallowed PHP type.
186 *
187 * The regex does not parse string payloads: a string value containing `{O:` or `";O:`
188 * is treated as a disallowed type.
189 *
190 * Allowed serialized types: array, string, int, and bool.
191 *
192 * @since 3.8.10
193 *
194 * @param string $description Term description.
195 * @return bool
196 */
197 private function has_disallowed_type( string $description ): bool {
198 return 0 !== preg_match( '#(?:^|[;{])(?:[OCEdrR]:|N;)#', $description );
199 }
200
201 /**
202 * Returns the language taxonomy name.
203 *
204 * @since 3.4
205 *
206 * @return string
207 *
208 * @phpstan-return non-empty-string
209 */
210 public function get_tax_language() {
211 return $this->tax_language;
212 }
213
214 /**
215 * Returns the type of object.
216 *
217 * @since 3.4
218 *
219 * @return string
220 *
221 * @phpstan-return non-empty-string
222 */
223 public function get_type() {
224 return $this->type;
225 }
226
227 /**
228 * Adds hooks.
229 *
230 * @since 3.4
231 *
232 * @return static
233 */
234 public function init() {
235 return $this;
236 }
237
238 /**
239 * Stores the object's language into the database.
240 *
241 * @since 3.4
242 *
243 * @param int $id Object ID.
244 * @param PLL_Language|string|int $lang Language (object, slug, or term ID).
245 * @return bool True when successfully assigned. False otherwise (or if the given language is already assigned to
246 * the object).
247 */
248 public function set_language( $id, $lang ) {
249 $id = $this->sanitize_int_id( $id );
250
251 if ( empty( $id ) ) {
252 return false;
253 }
254
255 $old_lang = $this->get_language( $id );
256 $old_lang = $old_lang ? $old_lang->get_tax_prop( $this->tax_language, 'term_id' ) : 0;
257
258 $lang = $this->languages->get( $lang );
259 $lang = $lang ? $lang->get_tax_prop( $this->tax_language, 'term_id' ) : 0;
260
261 if ( $old_lang === $lang ) {
262 return false;
263 }
264
265 $term_taxonomy_ids = wp_set_object_terms( $id, $lang, $this->tax_language );
266
267 wp_cache_set_last_changed( $this->cache_type );
268
269 return is_array( $term_taxonomy_ids );
270 }
271
272 /**
273 * Returns the language of an object.
274 *
275 * @since 0.1
276 * @since 3.4 Renamed the parameter $post_id into $id.
277 *
278 * @param int $id Object ID.
279 * @return PLL_Language|false A `PLL_Language` object. `false` if no language is associated to that object or if the
280 * ID is invalid.
281 */
282 public function get_language( $id ) {
283 $id = $this->sanitize_int_id( $id );
284
285 if ( empty( $id ) ) {
286 return false;
287 }
288
289 // Get the language and make sure it is a PLL_Language object.
290 $lang = $this->get_object_term( $id, $this->tax_language );
291
292 if ( empty( $lang ) ) {
293 return false;
294 }
295
296 return $this->languages->get( $lang->term_id );
297 }
298
299 /**
300 * Removes the term language from the database.
301 *
302 * @since 3.4
303 *
304 * @param int $id Term ID.
305 * @return void
306 */
307 public function delete_language( $id ) {
308 $id = $this->sanitize_int_id( $id );
309
310 if ( empty( $id ) ) {
311 return;
312 }
313
314 wp_delete_object_term_relationships( $id, $this->tax_language );
315 }
316
317 /**
318 * Wraps `wp_get_object_terms()` to cache it for multiple objects.
319 *
320 * @since 3.8
321 *
322 * @param int[] $object_ids Array of object IDs.
323 * @param string $taxonomy Taxonomy name.
324 * @return array<int,WP_Term> Array of terms with object ID as key.
325 */
326 protected function get_object_terms( array $object_ids, string $taxonomy ) {
327 $object_ids = $this->sanitize_int_ids_list( $object_ids );
328 if ( empty( $object_ids ) ) {
329 return array();
330 }
331
332 $cached_values = $this->get_from_object_term_cache( $object_ids, $taxonomy );
333
334 $all_term_ids = array_values( $cached_values );
335 _prime_term_caches( $all_term_ids, false );
336
337 $terms = array();
338 foreach ( $cached_values as $object_id => $term_id ) {
339 /** @var WP_Term $term */
340 $term = get_term( $term_id );
341 $terms[ $object_id ] = $term;
342 }
343
344 return $terms;
345 }
346
347 /**
348 * Caches all object-relationship terms.
349 *
350 * @since 3.8.1
351 *
352 * @param int[] $object_ids Array of object IDs.
353 *
354 * @return int[][][]
355 */
356 protected function update_object_term_cache( array $object_ids ) {
357 $non_cached_ids = array();
358 foreach ( $this->tax_to_cache as $tax ) {
359 $non_cached_ids = array_merge( $non_cached_ids, _get_non_cached_ids( $object_ids, "{$tax}_relationships" ) );
360 }
361
362 if ( empty( $non_cached_ids ) ) {
363 return array();
364 }
365
366 $terms = wp_get_object_terms(
367 array_unique( $non_cached_ids ),
368 $this->tax_to_cache,
369 array(
370 'fields' => 'all_with_object_id',
371 'update_term_meta_cache' => false,
372 )
373 );
374
375 if ( ! is_array( $terms ) ) {
376 return array();
377 }
378
379 $object_terms = array();
380 foreach ( $terms as $term ) {
381 $object_terms[ $term->taxonomy ][ $term->object_id ][] = $term->term_id;
382 }
383
384 foreach ( $non_cached_ids as $id ) {
385 foreach ( $this->tax_to_cache as $tax ) {
386 if ( ! isset( $object_terms[ $tax ][ $id ] ) ) {
387 $object_terms[ $tax ][ $id ] = array();
388 }
389 }
390 }
391
392 foreach ( $object_terms as $tax => $data ) {
393 wp_cache_add_multiple( $data, "{$tax}_relationships" );
394 }
395
396 return $object_terms;
397 }
398
399 /**
400 * Caches all object-relationship terms and returns them for the specified taxonomy.
401 *
402 * @since 3.8
403 *
404 * @param int[] $object_ids Array of object IDs to retrieve terms for.
405 * @param string $taxonomy Taxonomy name.
406 *
407 * @return int[] Array of term IDs with object ID as key.
408 */
409 protected function get_from_object_term_cache( array $object_ids, string $taxonomy ): array {
410 $values = wp_cache_get_multiple( $object_ids, "{$taxonomy}_relationships" );
411
412 // If values are missing, then update the cache and replace missed values by freshly cached ones.
413 $object_terms = $this->update_object_term_cache( $object_ids );
414 if ( isset( $object_terms[ $taxonomy ] ) ) {
415 $values = array_replace( $values, $object_terms[ $taxonomy ] );
416 }
417
418 $sanitized_values = array();
419 foreach ( $values as $object_id => $term_ids ) {
420 if ( ! is_array( $term_ids ) ) {
421 continue;
422 }
423
424 $id = reset( $term_ids );
425 if ( ! is_numeric( $id ) || empty( $id ) ) {
426 continue;
427 }
428
429 $sanitized_values[ $object_id ] = (int) $id;
430 }
431
432 return $sanitized_values;
433 }
434
435 /**
436 * Returns terms associated to the given object in the given taxonomy.
437 *
438 * @since 1.2
439 * @since 3.8 Returns null if the associated term doesn't exist.
440 *
441 * @param int $object_id Object ID.
442 * @param string $taxonomy Polylang taxonomy depending if we are looking for a post (or term, or else) language.
443 * @return WP_Term|null The term associated to the object in the requested taxonomy if it exists, `null` otherwise.
444 */
445 public function get_object_term( $object_id, $taxonomy ) {
446 $terms = $this->get_object_terms( array( $object_id ), $taxonomy );
447 return $terms[ $object_id ] ?? null;
448 }
449
450 /**
451 * A JOIN clause to add to sql queries when filtering by language is needed directly in query.
452 *
453 * @since 3.4
454 *
455 * @param string $alias Optional alias for object table.
456 * @return string The JOIN clause.
457 *
458 * @phpstan-return non-empty-string
459 */
460 public function join_clause( $alias = '' ) {
461 global $wpdb;
462
463 $db = $this->get_db_infos();
464
465 if ( empty( $alias ) ) {
466 $alias = $db['default_alias'];
467 }
468
469 return " INNER JOIN {$wpdb->term_relationships} AS pll_tr ON pll_tr.object_id = {$alias}.{$db['id_column']}";
470 }
471
472 /**
473 * A WHERE clause to add to sql queries when filtering by language is needed directly in query.
474 *
475 * @since 1.2
476 *
477 * @param PLL_Language|PLL_Language[]|string|string[] $lang A `PLL_Language` object, or a comma separated list of language slugs, or an array of language slugs or objects.
478 * @return string The WHERE clause.
479 */
480 public function where_clause( $lang ) {
481 /*
482 * $lang is an object.
483 * This is generally the case if the query is coming from Polylang.
484 */
485 if ( $lang instanceof PLL_Language ) {
486 return ' AND pll_tr.term_taxonomy_id = ' . absint( $lang->get_tax_prop( $this->tax_language, 'term_taxonomy_id' ) );
487 }
488
489 /*
490 * $lang is an array of objects, an array of slugs, or a comma separated list of slugs.
491 * The comma separated list of slugs can happen if the query is coming from outside with a 'lang' parameter.
492 */
493 $languages = is_array( $lang ) ? $lang : explode( ',', $lang );
494 $languages_tt_ids = array();
495
496 foreach ( $languages as $language ) {
497 $language = $this->languages->get( $language );
498
499 if ( ! empty( $language ) ) {
500 $languages_tt_ids[] = absint( $language->get_tax_prop( $this->tax_language, 'term_taxonomy_id' ) );
501 }
502 }
503
504 if ( empty( $languages_tt_ids ) ) {
505 return '';
506 }
507
508 return ' AND pll_tr.term_taxonomy_id IN ( ' . implode( ',', $languages_tt_ids ) . ' )';
509 }
510
511 /**
512 * Returns the IDs of the objects without language.
513 *
514 * @since 3.4
515 *
516 * @param int $limit Max number of objects to return. `-1` to return all of them.
517 * @param array $args The object args.
518 * @return int[] Array of object IDs.
519 *
520 * @phpstan-param -1|positive-int $limit
521 * @phpstan-return list<positive-int>
522 */
523 public function get_objects_with_no_lang( $limit, array $args = array() ) {
524 $language_ids = array();
525
526 foreach ( $this->languages->get_list() as $language ) {
527 $language_ids[] = $language->get_tax_prop( $this->get_tax_language(), 'term_taxonomy_id' );
528 }
529
530 $language_ids = array_filter( $language_ids );
531
532 if ( empty( $language_ids ) ) {
533 return array();
534 }
535
536 $object_ids = $this->query_objects_with_no_lang( $language_ids, $limit, $args );
537
538 return array_values( $this->sanitize_int_ids_list( $object_ids ) );
539 }
540
541 /**
542 * Returns object IDs without language.
543 * Can be overridden by child classes in case queried object doesn't use
544 * `wp_cache_set_last_changed()` or another cache system.
545 *
546 * @since 3.4
547 * @since 3.7 Changed all parameters.
548 *
549 * @param int[] $language_ids List of language `term_taxonomy_id`.
550 * @param int $limit Max number of objects to return. `-1` to return all of them.
551 * @param array $args The object args.
552 * @return string[] An array of numeric object IDs.
553 *
554 * @phpstan-param array<positive-int> $language_ids
555 * @phpstan-param -1|positive-int $limit
556 * @phpstan-param array<empty> $args
557 */
558 protected function query_objects_with_no_lang( array $language_ids, $limit, array $args = array() ) {
559 $key = "{$this->cache_type}_no_lang:" . md5( maybe_serialize( $language_ids ) . maybe_serialize( $args ) . $limit );
560 $object_ids = $this->get_from_cache( $key );
561
562 if ( is_array( $object_ids ) ) {
563 return $object_ids;
564 }
565
566 $object_ids = $this->get_raw_objects_with_no_lang( $language_ids, $limit, $args );
567 $this->set_to_cache( $key, $object_ids );
568
569 return $object_ids;
570 }
571
572 /**
573 * Sanitizes an ID as positive integer.
574 * Kind of similar to `absint()`, but rejects negative integers instead of making them positive.
575 *
576 * @since 3.2
577 *
578 * @param mixed $id A supposedly numeric ID.
579 * @return int A positive integer. `0` for non numeric values and negative integers.
580 *
581 * @phpstan-return int<0,max>
582 */
583 public function sanitize_int_id( $id ) {
584 return is_numeric( $id ) && $id >= 1 ? abs( (int) $id ) : 0;
585 }
586
587 /**
588 * Sanitizes an array of IDs as positive integers.
589 * `0` values are removed.
590 *
591 * @since 3.2
592 *
593 * @param mixed $ids An array of numeric IDs.
594 * @return int[]
595 *
596 * @phpstan-return array<positive-int>
597 */
598 public function sanitize_int_ids_list( $ids ) {
599 if ( empty( $ids ) || ! is_array( $ids ) ) {
600 return array();
601 }
602
603 $ids = array_map( array( $this, 'sanitize_int_id' ), $ids );
604
605 return array_filter( $ids );
606 }
607
608 /**
609 * Fetches the IDs of the objects without language.
610 *
611 * @since 3.7
612 *
613 * @param int[] $language_ids List of language `term_taxonomy_id`.
614 * @param int $limit Max number of objects to return. `-1` to return all of them.
615 * @param array $args The object args.
616 * @return string[]
617 *
618 * @phpstan-param array<positive-int> $language_ids
619 * @phpstan-param -1|positive-int $limit
620 * @phpstan-param array<empty> $args
621 */
622 protected function get_raw_objects_with_no_lang( array $language_ids, $limit, array $args = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
623 global $wpdb;
624
625 $db = $this->get_db_infos();
626
627 return $wpdb->get_col(
628 $wpdb->prepare(
629 sprintf(
630 "SELECT %%i FROM %%i
631 WHERE %%i NOT IN (
632 SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN (%s)
633 )
634 LIMIT %%d",
635 implode( ',', array_fill( 0, count( $language_ids ), '%d' ) )
636 ),
637 array_merge(
638 array( $db['id_column'], $db['table'], $db['id_column'] ),
639 $language_ids,
640 array( $limit >= 1 ? $limit : 4294967295 )
641 )
642 )
643 );
644 }
645
646 /**
647 * Assigns a language to object in mass.
648 *
649 * @since 1.2
650 * @since 3.4 Moved from PLL_Admin_Model class.
651 *
652 * @param int[] $ids Array of post ids or term ids.
653 * @param PLL_Language $lang Language to assign to the posts or terms.
654 * @return void
655 */
656 public function set_language_in_mass( $ids, $lang ) {
657 global $wpdb;
658
659 $tt_id = $lang->get_tax_prop( $this->tax_language, 'term_taxonomy_id' );
660
661 if ( empty( $tt_id ) ) {
662 return;
663 }
664 $ids = array_map( 'intval', $ids );
665 $ids = array_filter( $ids );
666
667 if ( empty( $ids ) ) {
668 return;
669 }
670
671 $values = array();
672
673 foreach ( $ids as $id ) {
674 $values[] = $wpdb->prepare( '( %d, %d )', $id, $tt_id );
675 }
676
677 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
678 $wpdb->query( "INSERT INTO {$wpdb->term_relationships} ( object_id, term_taxonomy_id ) VALUES " . implode( ',', array_unique( $values ) ) );
679
680 // Updating term count is mandatory (thanks to AndyDeGroo).
681 $lang->update_count();
682 clean_term_cache( $ids, $this->tax_language );
683
684 // Invalidate our cache.
685 wp_cache_set_last_changed( $this->cache_type );
686 }
687
688 /**
689 * Returns the description to use for the "language properties" in the REST API.
690 *
691 * @since 3.7
692 * @see WP_Syntex\Polylang\REST\V2\Languages::get_item_schema()
693 *
694 * @return string
695 */
696 public function get_rest_description(): string {
697 /* translators: %s is the name of a database table. */
698 return sprintf( __( 'Language taxonomy properties for table %s.', 'polylang' ), $this->get_db_infos()['table'] );
699 }
700
701 /**
702 * Fetches the value from the cache. Handles backward compatibility with WordPress < 6.9.
703 *
704 * @since 3.8
705 *
706 * @param string $key The cache key.
707 * @return mixed|false The cached value, false if not found.
708 */
709 private function get_from_cache( string $key ) {
710 $last_changed = wp_cache_get_last_changed( $this->cache_type );
711
712 if ( ! function_exists( 'wp_cache_get_salted' ) ) {
713 // Backward compatibility with WordPress < 6.9.
714 $cache_key = "{$key}:{$last_changed}";
715 return wp_cache_get( $cache_key, $this->cache_type );
716 }
717
718 return wp_cache_get_salted( $key, $this->cache_type, $last_changed );
719 }
720
721 /**
722 * Stores the value in the cache. Handles backward compatibility with WordPress < 6.9.
723 *
724 * @since 3.8
725 *
726 * @param string $key The cache key.
727 * @param mixed $value The value to store in the cache.
728 * @return bool True if the value has been stored, false otherwise.
729 */
730 private function set_to_cache( string $key, $value ): bool {
731 $last_changed = wp_cache_get_last_changed( $this->cache_type );
732
733 if ( ! function_exists( 'wp_cache_set_salted' ) ) {
734 // Backward compatibility with WordPress < 6.9.
735 $cache_key = "{$key}:{$last_changed}";
736 return wp_cache_set( $cache_key, $value, $this->cache_type );
737 }
738
739 return wp_cache_set_salted( $key, $value, $this->cache_type, $last_changed );
740 }
741
742 /**
743 * Returns database-related information that can be used in some of this class methods.
744 * These are specific to the table containing the objects.
745 *
746 * @see PLL_Translatable_Object::join_clause()
747 * @see PLL_Translatable_Object::get_raw_objects_with_no_lang()
748 *
749 * @since 3.4.3
750 *
751 * @return string[] {
752 * @type string $table Name of the table.
753 * @type string $id_column Name of the column containing the object's ID.
754 * @type string $default_alias Default alias corresponding to the object's table.
755 * }
756 * @phpstan-return DBInfo
757 */
758 abstract protected function get_db_infos();
759 }
760