PluginProbe
Polylang / 3.8.11
Polylang v3.8.11
3.8.11 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 All 235 releases
polylang / src / translatable-object.php

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

768 lines 20.1 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 * Leading and trailing whitespace is trimmed first, matching `is_serialized()` and `maybe_unserialize()`.
188 *
189 * The regex scans the whole blob for disallowed PHP serialization type tokens (`O:`, `C:`, `E:`, `r:`, `R:`),
190 * including inside serialized string payloads. It does not parse `s:"…"` boundaries.
191 *
192 * Allowed serialized types: array, string, int, float, null and bool.
193 *
194 * @since 3.8.10
195 *
196 * @param string $description Term description.
197 * @return bool
198 */
199 private function has_disallowed_type( string $description ): bool {
200 $description = trim( $description );
201
202 if ( '' === $description ) {
203 return false;
204 }
205
206 return 0 !== preg_match( '#[OCErR]:\d+[:;]#', $description );
207 }
208
209 /**
210 * Returns the language taxonomy name.
211 *
212 * @since 3.4
213 *
214 * @return string
215 *
216 * @phpstan-return non-empty-string
217 */
218 public function get_tax_language() {
219 return $this->tax_language;
220 }
221
222 /**
223 * Returns the type of object.
224 *
225 * @since 3.4
226 *
227 * @return string
228 *
229 * @phpstan-return non-empty-string
230 */
231 public function get_type() {
232 return $this->type;
233 }
234
235 /**
236 * Adds hooks.
237 *
238 * @since 3.4
239 *
240 * @return static
241 */
242 public function init() {
243 return $this;
244 }
245
246 /**
247 * Stores the object's language into the database.
248 *
249 * @since 3.4
250 *
251 * @param int $id Object ID.
252 * @param PLL_Language|string|int $lang Language (object, slug, or term ID).
253 * @return bool True when successfully assigned. False otherwise (or if the given language is already assigned to
254 * the object).
255 */
256 public function set_language( $id, $lang ) {
257 $id = $this->sanitize_int_id( $id );
258
259 if ( empty( $id ) ) {
260 return false;
261 }
262
263 $old_lang = $this->get_language( $id );
264 $old_lang = $old_lang ? $old_lang->get_tax_prop( $this->tax_language, 'term_id' ) : 0;
265
266 $lang = $this->languages->get( $lang );
267 $lang = $lang ? $lang->get_tax_prop( $this->tax_language, 'term_id' ) : 0;
268
269 if ( $old_lang === $lang ) {
270 return false;
271 }
272
273 $term_taxonomy_ids = wp_set_object_terms( $id, $lang, $this->tax_language );
274
275 wp_cache_set_last_changed( $this->cache_type );
276
277 return is_array( $term_taxonomy_ids );
278 }
279
280 /**
281 * Returns the language of an object.
282 *
283 * @since 0.1
284 * @since 3.4 Renamed the parameter $post_id into $id.
285 *
286 * @param int $id Object ID.
287 * @return PLL_Language|false A `PLL_Language` object. `false` if no language is associated to that object or if the
288 * ID is invalid.
289 */
290 public function get_language( $id ) {
291 $id = $this->sanitize_int_id( $id );
292
293 if ( empty( $id ) ) {
294 return false;
295 }
296
297 // Get the language and make sure it is a PLL_Language object.
298 $lang = $this->get_object_term( $id, $this->tax_language );
299
300 if ( empty( $lang ) ) {
301 return false;
302 }
303
304 return $this->languages->get( $lang->term_id );
305 }
306
307 /**
308 * Removes the term language from the database.
309 *
310 * @since 3.4
311 *
312 * @param int $id Term ID.
313 * @return void
314 */
315 public function delete_language( $id ) {
316 $id = $this->sanitize_int_id( $id );
317
318 if ( empty( $id ) ) {
319 return;
320 }
321
322 wp_delete_object_term_relationships( $id, $this->tax_language );
323 }
324
325 /**
326 * Wraps `wp_get_object_terms()` to cache it for multiple objects.
327 *
328 * @since 3.8
329 *
330 * @param int[] $object_ids Array of object IDs.
331 * @param string $taxonomy Taxonomy name.
332 * @return array<int,WP_Term> Array of terms with object ID as key.
333 */
334 protected function get_object_terms( array $object_ids, string $taxonomy ) {
335 $object_ids = $this->sanitize_int_ids_list( $object_ids );
336 if ( empty( $object_ids ) ) {
337 return array();
338 }
339
340 $cached_values = $this->get_from_object_term_cache( $object_ids, $taxonomy );
341
342 $all_term_ids = array_values( $cached_values );
343 _prime_term_caches( $all_term_ids, false );
344
345 $terms = array();
346 foreach ( $cached_values as $object_id => $term_id ) {
347 /** @var WP_Term $term */
348 $term = get_term( $term_id );
349 $terms[ $object_id ] = $term;
350 }
351
352 return $terms;
353 }
354
355 /**
356 * Caches all object-relationship terms.
357 *
358 * @since 3.8.1
359 *
360 * @param int[] $object_ids Array of object IDs.
361 *
362 * @return int[][][]
363 */
364 protected function update_object_term_cache( array $object_ids ) {
365 $non_cached_ids = array();
366 foreach ( $this->tax_to_cache as $tax ) {
367 $non_cached_ids = array_merge( $non_cached_ids, _get_non_cached_ids( $object_ids, "{$tax}_relationships" ) );
368 }
369
370 if ( empty( $non_cached_ids ) ) {
371 return array();
372 }
373
374 $terms = wp_get_object_terms(
375 array_unique( $non_cached_ids ),
376 $this->tax_to_cache,
377 array(
378 'fields' => 'all_with_object_id',
379 'update_term_meta_cache' => false,
380 )
381 );
382
383 if ( ! is_array( $terms ) ) {
384 return array();
385 }
386
387 $object_terms = array();
388 foreach ( $terms as $term ) {
389 $object_terms[ $term->taxonomy ][ $term->object_id ][] = $term->term_id;
390 }
391
392 foreach ( $non_cached_ids as $id ) {
393 foreach ( $this->tax_to_cache as $tax ) {
394 if ( ! isset( $object_terms[ $tax ][ $id ] ) ) {
395 $object_terms[ $tax ][ $id ] = array();
396 }
397 }
398 }
399
400 foreach ( $object_terms as $tax => $data ) {
401 wp_cache_add_multiple( $data, "{$tax}_relationships" );
402 }
403
404 return $object_terms;
405 }
406
407 /**
408 * Caches all object-relationship terms and returns them for the specified taxonomy.
409 *
410 * @since 3.8
411 *
412 * @param int[] $object_ids Array of object IDs to retrieve terms for.
413 * @param string $taxonomy Taxonomy name.
414 *
415 * @return int[] Array of term IDs with object ID as key.
416 */
417 protected function get_from_object_term_cache( array $object_ids, string $taxonomy ): array {
418 $values = wp_cache_get_multiple( $object_ids, "{$taxonomy}_relationships" );
419
420 // If values are missing, then update the cache and replace missed values by freshly cached ones.
421 $object_terms = $this->update_object_term_cache( $object_ids );
422 if ( isset( $object_terms[ $taxonomy ] ) ) {
423 $values = array_replace( $values, $object_terms[ $taxonomy ] );
424 }
425
426 $sanitized_values = array();
427 foreach ( $values as $object_id => $term_ids ) {
428 if ( ! is_array( $term_ids ) ) {
429 continue;
430 }
431
432 $id = reset( $term_ids );
433 if ( ! is_numeric( $id ) || empty( $id ) ) {
434 continue;
435 }
436
437 $sanitized_values[ $object_id ] = (int) $id;
438 }
439
440 return $sanitized_values;
441 }
442
443 /**
444 * Returns terms associated to the given object in the given taxonomy.
445 *
446 * @since 1.2
447 * @since 3.8 Returns null if the associated term doesn't exist.
448 *
449 * @param int $object_id Object ID.
450 * @param string $taxonomy Polylang taxonomy depending if we are looking for a post (or term, or else) language.
451 * @return WP_Term|null The term associated to the object in the requested taxonomy if it exists, `null` otherwise.
452 */
453 public function get_object_term( $object_id, $taxonomy ) {
454 $terms = $this->get_object_terms( array( $object_id ), $taxonomy );
455 return $terms[ $object_id ] ?? null;
456 }
457
458 /**
459 * A JOIN clause to add to sql queries when filtering by language is needed directly in query.
460 *
461 * @since 3.4
462 *
463 * @param string $alias Optional alias for object table.
464 * @return string The JOIN clause.
465 *
466 * @phpstan-return non-empty-string
467 */
468 public function join_clause( $alias = '' ) {
469 global $wpdb;
470
471 $db = $this->get_db_infos();
472
473 if ( empty( $alias ) ) {
474 $alias = $db['default_alias'];
475 }
476
477 return " INNER JOIN {$wpdb->term_relationships} AS pll_tr ON pll_tr.object_id = {$alias}.{$db['id_column']}";
478 }
479
480 /**
481 * A WHERE clause to add to sql queries when filtering by language is needed directly in query.
482 *
483 * @since 1.2
484 *
485 * @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.
486 * @return string The WHERE clause.
487 */
488 public function where_clause( $lang ) {
489 /*
490 * $lang is an object.
491 * This is generally the case if the query is coming from Polylang.
492 */
493 if ( $lang instanceof PLL_Language ) {
494 return ' AND pll_tr.term_taxonomy_id = ' . absint( $lang->get_tax_prop( $this->tax_language, 'term_taxonomy_id' ) );
495 }
496
497 /*
498 * $lang is an array of objects, an array of slugs, or a comma separated list of slugs.
499 * The comma separated list of slugs can happen if the query is coming from outside with a 'lang' parameter.
500 */
501 $languages = is_array( $lang ) ? $lang : explode( ',', $lang );
502 $languages_tt_ids = array();
503
504 foreach ( $languages as $language ) {
505 $language = $this->languages->get( $language );
506
507 if ( ! empty( $language ) ) {
508 $languages_tt_ids[] = absint( $language->get_tax_prop( $this->tax_language, 'term_taxonomy_id' ) );
509 }
510 }
511
512 if ( empty( $languages_tt_ids ) ) {
513 return '';
514 }
515
516 return ' AND pll_tr.term_taxonomy_id IN ( ' . implode( ',', $languages_tt_ids ) . ' )';
517 }
518
519 /**
520 * Returns the IDs of the objects without language.
521 *
522 * @since 3.4
523 *
524 * @param int $limit Max number of objects to return. `-1` to return all of them.
525 * @param array $args The object args.
526 * @return int[] Array of object IDs.
527 *
528 * @phpstan-param -1|positive-int $limit
529 * @phpstan-return list<positive-int>
530 */
531 public function get_objects_with_no_lang( $limit, array $args = array() ) {
532 $language_ids = array();
533
534 foreach ( $this->languages->get_list() as $language ) {
535 $language_ids[] = $language->get_tax_prop( $this->get_tax_language(), 'term_taxonomy_id' );
536 }
537
538 $language_ids = array_filter( $language_ids );
539
540 if ( empty( $language_ids ) ) {
541 return array();
542 }
543
544 $object_ids = $this->query_objects_with_no_lang( $language_ids, $limit, $args );
545
546 return array_values( $this->sanitize_int_ids_list( $object_ids ) );
547 }
548
549 /**
550 * Returns object IDs without language.
551 * Can be overridden by child classes in case queried object doesn't use
552 * `wp_cache_set_last_changed()` or another cache system.
553 *
554 * @since 3.4
555 * @since 3.7 Changed all parameters.
556 *
557 * @param int[] $language_ids List of language `term_taxonomy_id`.
558 * @param int $limit Max number of objects to return. `-1` to return all of them.
559 * @param array $args The object args.
560 * @return string[] An array of numeric object IDs.
561 *
562 * @phpstan-param array<positive-int> $language_ids
563 * @phpstan-param -1|positive-int $limit
564 * @phpstan-param array<empty> $args
565 */
566 protected function query_objects_with_no_lang( array $language_ids, $limit, array $args = array() ) {
567 $key = "{$this->cache_type}_no_lang:" . md5( maybe_serialize( $language_ids ) . maybe_serialize( $args ) . $limit );
568 $object_ids = $this->get_from_cache( $key );
569
570 if ( is_array( $object_ids ) ) {
571 return $object_ids;
572 }
573
574 $object_ids = $this->get_raw_objects_with_no_lang( $language_ids, $limit, $args );
575 $this->set_to_cache( $key, $object_ids );
576
577 return $object_ids;
578 }
579
580 /**
581 * Sanitizes an ID as positive integer.
582 * Kind of similar to `absint()`, but rejects negative integers instead of making them positive.
583 *
584 * @since 3.2
585 *
586 * @param mixed $id A supposedly numeric ID.
587 * @return int A positive integer. `0` for non numeric values and negative integers.
588 *
589 * @phpstan-return int<0,max>
590 */
591 public function sanitize_int_id( $id ) {
592 return is_numeric( $id ) && $id >= 1 ? abs( (int) $id ) : 0;
593 }
594
595 /**
596 * Sanitizes an array of IDs as positive integers.
597 * `0` values are removed.
598 *
599 * @since 3.2
600 *
601 * @param mixed $ids An array of numeric IDs.
602 * @return int[]
603 *
604 * @phpstan-return array<positive-int>
605 */
606 public function sanitize_int_ids_list( $ids ) {
607 if ( empty( $ids ) || ! is_array( $ids ) ) {
608 return array();
609 }
610
611 $ids = array_map( array( $this, 'sanitize_int_id' ), $ids );
612
613 return array_filter( $ids );
614 }
615
616 /**
617 * Fetches the IDs of the objects without language.
618 *
619 * @since 3.7
620 *
621 * @param int[] $language_ids List of language `term_taxonomy_id`.
622 * @param int $limit Max number of objects to return. `-1` to return all of them.
623 * @param array $args The object args.
624 * @return string[]
625 *
626 * @phpstan-param array<positive-int> $language_ids
627 * @phpstan-param -1|positive-int $limit
628 * @phpstan-param array<empty> $args
629 */
630 protected function get_raw_objects_with_no_lang( array $language_ids, $limit, array $args = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
631 global $wpdb;
632
633 $db = $this->get_db_infos();
634
635 return $wpdb->get_col(
636 $wpdb->prepare(
637 sprintf(
638 "SELECT %%i FROM %%i
639 WHERE %%i NOT IN (
640 SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN (%s)
641 )
642 LIMIT %%d",
643 implode( ',', array_fill( 0, count( $language_ids ), '%d' ) )
644 ),
645 array_merge(
646 array( $db['id_column'], $db['table'], $db['id_column'] ),
647 $language_ids,
648 array( $limit >= 1 ? $limit : 4294967295 )
649 )
650 )
651 );
652 }
653
654 /**
655 * Assigns a language to object in mass.
656 *
657 * @since 1.2
658 * @since 3.4 Moved from PLL_Admin_Model class.
659 *
660 * @param int[] $ids Array of post ids or term ids.
661 * @param PLL_Language $lang Language to assign to the posts or terms.
662 * @return void
663 */
664 public function set_language_in_mass( $ids, $lang ) {
665 global $wpdb;
666
667 $tt_id = $lang->get_tax_prop( $this->tax_language, 'term_taxonomy_id' );
668
669 if ( empty( $tt_id ) ) {
670 return;
671 }
672 $ids = array_map( 'intval', $ids );
673 $ids = array_filter( $ids );
674
675 if ( empty( $ids ) ) {
676 return;
677 }
678
679 $values = array();
680
681 foreach ( $ids as $id ) {
682 $values[] = $wpdb->prepare( '( %d, %d )', $id, $tt_id );
683 }
684
685 // PHPCS:ignore WordPress.DB.PreparedSQL.NotPrepared
686 $wpdb->query( "INSERT INTO {$wpdb->term_relationships} ( object_id, term_taxonomy_id ) VALUES " . implode( ',', array_unique( $values ) ) );
687
688 // Updating term count is mandatory (thanks to AndyDeGroo).
689 $lang->update_count();
690 clean_term_cache( $ids, $this->tax_language );
691
692 // Invalidate our cache.
693 wp_cache_set_last_changed( $this->cache_type );
694 }
695
696 /**
697 * Returns the description to use for the "language properties" in the REST API.
698 *
699 * @since 3.7
700 * @see WP_Syntex\Polylang\REST\V2\Languages::get_item_schema()
701 *
702 * @return string
703 */
704 public function get_rest_description(): string {
705 /* translators: %s is the name of a database table. */
706 return sprintf( __( 'Language taxonomy properties for table %s.', 'polylang' ), $this->get_db_infos()['table'] );
707 }
708
709 /**
710 * Fetches the value from the cache. Handles backward compatibility with WordPress < 6.9.
711 *
712 * @since 3.8
713 *
714 * @param string $key The cache key.
715 * @return mixed|false The cached value, false if not found.
716 */
717 private function get_from_cache( string $key ) {
718 $last_changed = wp_cache_get_last_changed( $this->cache_type );
719
720 if ( ! function_exists( 'wp_cache_get_salted' ) ) {
721 // Backward compatibility with WordPress < 6.9.
722 $cache_key = "{$key}:{$last_changed}";
723 return wp_cache_get( $cache_key, $this->cache_type );
724 }
725
726 return wp_cache_get_salted( $key, $this->cache_type, $last_changed );
727 }
728
729 /**
730 * Stores the value in the cache. Handles backward compatibility with WordPress < 6.9.
731 *
732 * @since 3.8
733 *
734 * @param string $key The cache key.
735 * @param mixed $value The value to store in the cache.
736 * @return bool True if the value has been stored, false otherwise.
737 */
738 private function set_to_cache( string $key, $value ): bool {
739 $last_changed = wp_cache_get_last_changed( $this->cache_type );
740
741 if ( ! function_exists( 'wp_cache_set_salted' ) ) {
742 // Backward compatibility with WordPress < 6.9.
743 $cache_key = "{$key}:{$last_changed}";
744 return wp_cache_set( $cache_key, $value, $this->cache_type );
745 }
746
747 return wp_cache_set_salted( $key, $value, $this->cache_type, $last_changed );
748 }
749
750 /**
751 * Returns database-related information that can be used in some of this class methods.
752 * These are specific to the table containing the objects.
753 *
754 * @see PLL_Translatable_Object::join_clause()
755 * @see PLL_Translatable_Object::get_raw_objects_with_no_lang()
756 *
757 * @since 3.4.3
758 *
759 * @return string[] {
760 * @type string $table Name of the table.
761 * @type string $id_column Name of the column containing the object's ID.
762 * @type string $default_alias Default alias corresponding to the object's table.
763 * }
764 * @phpstan-return DBInfo
765 */
766 abstract protected function get_db_infos();
767 }
768