PluginProbe
Polylang / trunk
Polylang vtrunk
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 / crud-posts.php

crud-posts.php in Polylang trunk, at src/crud-posts.php

574 lines 17.2 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\REST\Request;
7 use WP_Syntex\Polylang\Capabilities\Capabilities;
8 use WP_Syntex\Polylang\Capabilities\Create\Post as Create_Post;
9
10 /**
11 * Adds actions and filters related to languages when creating, updating or deleting posts.
12 * Actions and filters triggered when reading posts are handled separately.
13 *
14 * @since 2.4
15 */
16 class PLL_CRUD_Posts {
17 /**
18 * @var PLL_Model
19 */
20 protected $model;
21
22 /**
23 * Preferred language to assign to a new post.
24 *
25 * @var PLL_Language|null
26 */
27 protected $pref_lang;
28
29 /**
30 * Current language.
31 *
32 * @var PLL_Language|null
33 */
34 protected $curlang;
35
36 /**
37 * Reference to the Polylang options array.
38 *
39 * @var \WP_Syntex\Polylang\Options\Options
40 */
41 protected $options;
42
43 /**
44 * Reference to the Polylang Request object.
45 *
46 * @var Request
47 */
48 private $request;
49
50 /**
51 * Constructor
52 *
53 * @since 2.4
54 *
55 * @param PLL_Base $polylang The Polylang object.
56 */
57 public function __construct( PLL_Base &$polylang ) {
58 $this->options = $polylang->options;
59 $this->model = &$polylang->model;
60 $this->pref_lang = &$polylang->pref_lang;
61 $this->curlang = &$polylang->curlang;
62 $this->request = &$polylang->request;
63
64 add_action( 'save_post', array( $this, 'save_post' ), 10, 2 );
65 add_action( 'set_object_terms', array( $this, 'set_object_terms' ), 10, 4 );
66 add_filter( 'wp_insert_post_parent', array( $this, 'wp_insert_post_parent' ), 10, 2 );
67 add_action( 'before_delete_post', array( $this, 'delete_post' ) );
68 add_action( 'post_updated', array( $this, 'force_tags_translation' ), 10, 3 );
69
70 // Specific for media
71 if ( $polylang->options['media_support'] ) {
72 add_action( 'add_attachment', array( $this, 'set_default_language' ) );
73 add_action( 'delete_attachment', array( $this, 'delete_post' ) );
74 add_filter( 'wp_delete_file', array( $this, 'wp_delete_file' ) );
75 }
76
77 // Filter query.
78 add_filter( 'query_vars', array( $this, 'add_query_vars' ) );
79 add_action( 'parse_query', array( $this, 'parse_query' ) );
80 add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 );
81 }
82
83 /**
84 * Allows to set a language by default for posts if it has no language yet.
85 *
86 * @since 1.5
87 *
88 * @param int $post_id Post ID.
89 * @return void
90 */
91 public function set_default_language( $post_id ) {
92 if ( is_multisite() && ms_is_switched() && ! $this->model->has_languages() ) {
93 return;
94 }
95
96 if ( $this->model->post->get_language( $post_id ) ) {
97 return;
98 }
99
100 $post_language = new Create_Post(
101 $this->model,
102 $this->request,
103 $this->pref_lang instanceof PLL_Language ? $this->pref_lang : null, // Can be `false` as well...
104 $this->curlang instanceof PLL_Language ? $this->curlang : null // Can be `false` as well...
105 );
106
107 $this->model->post->set_language(
108 $post_id,
109 $post_language->get_language( (int) $post_id )
110 );
111 }
112
113 /**
114 * Called when a post ( or page ) is saved, published or updated.
115 *
116 * @since 0.1
117 * @since 2.3 Does not save the language and translations anymore, unless the post has no language yet.
118 *
119 * @param int $post_id Post id of the post being saved.
120 * @param WP_Post $post The post being saved.
121 * @return void
122 */
123 public function save_post( $post_id, $post ) {
124 if ( is_multisite() && ms_is_switched() && ! $this->model->has_languages() ) {
125 return;
126 }
127
128 if ( ! $this->model->is_translated_post_type( $post->post_type ) ) {
129 return;
130 }
131
132 if ( $id = wp_is_post_revision( $post_id ) ) {
133 $post_id = $id;
134 }
135
136 $lang = $this->model->post->get_language( $post_id );
137
138 if ( empty( $lang ) ) {
139 $this->set_default_language( $post_id );
140 }
141
142 /**
143 * Fires after the post language and translations are saved.
144 *
145 * @since 1.2
146 *
147 * @param int $post_id Post id.
148 * @param WP_Post $post Post object.
149 * @param int[] $translations The list of translations post ids.
150 */
151 do_action( 'pll_save_post', $post_id, $post, $this->model->post->get_translations( $post_id ) );
152 }
153
154 /**
155 * Makes sure that saved terms are in the right language.
156 *
157 * @since 2.3
158 *
159 * @param int $object_id Object ID.
160 * @param int[]|string[] $terms An array of object term IDs or slugs.
161 * @param int[] $tt_ids An array of term taxonomy IDs.
162 * @param string $taxonomy Taxonomy slug.
163 * @return void
164 */
165 public function set_object_terms( $object_id, $terms, $tt_ids, $taxonomy ) {
166 static $avoid_recursion;
167
168 if ( $avoid_recursion || empty( $terms ) || ! is_array( $terms ) || empty( $tt_ids )
169 || ! $this->model->is_translated_taxonomy( $taxonomy ) ) {
170 return;
171 }
172
173 $lang = $this->model->post->get_language( $object_id );
174
175 if ( empty( $lang ) ) {
176 return;
177 }
178
179 // Use the term_taxonomy_ids to get all the requested terms in 1 query.
180 $new_terms = get_terms(
181 array(
182 'taxonomy' => $taxonomy,
183 'term_taxonomy_id' => array_map( 'intval', $tt_ids ),
184 'lang' => '',
185 )
186 );
187
188 if ( empty( $new_terms ) || ! is_array( $new_terms ) ) {
189 // Terms not found.
190 return;
191 }
192
193 $new_term_ids_translated = $this->translate_terms( $new_terms, $taxonomy, $lang );
194
195 // Query the object's term.
196 $orig_terms = get_terms(
197 array(
198 'taxonomy' => $taxonomy,
199 'object_ids' => $object_id,
200 'lang' => '',
201 )
202 );
203
204 if ( is_array( $orig_terms ) ) {
205 $orig_term_ids = wp_list_pluck( $orig_terms, 'term_id' );
206 $orig_term_ids_translated = $this->translate_terms( $orig_terms, $taxonomy, $lang );
207
208 // Terms that are not in the translated list.
209 $remove_term_ids = array_diff( $orig_term_ids, $orig_term_ids_translated );
210
211 if ( ! empty( $remove_term_ids ) ) {
212 wp_remove_object_terms( $object_id, $remove_term_ids, $taxonomy );
213 }
214 } else {
215 $orig_term_ids = array();
216 $orig_term_ids_translated = array();
217 }
218
219 // Terms to add.
220 $add_term_ids = array_unique( array_merge( $orig_term_ids_translated, $new_term_ids_translated ) );
221 $add_term_ids = array_diff( $add_term_ids, $orig_term_ids );
222
223 if ( ! empty( $add_term_ids ) ) {
224 $avoid_recursion = true;
225 wp_set_object_terms( $object_id, $add_term_ids, $taxonomy, true ); // Append.
226 $avoid_recursion = false;
227 }
228 }
229
230 /**
231 * Make sure that the post parent is in the correct language.
232 *
233 * @since 1.8
234 *
235 * @param int $post_parent Post parent ID.
236 * @param int $post_id Post ID.
237 * @return int
238 */
239 public function wp_insert_post_parent( $post_parent, $post_id ) {
240 $lang = $this->model->post->get_language( $post_id );
241 $parent_post_type = $post_parent > 0 ? get_post_type( $post_parent ) : null;
242 // Dont break the hierarchy in case the post has no language
243 if ( ! empty( $lang ) && ! empty( $parent_post_type ) && $this->model->is_translated_post_type( $parent_post_type ) ) {
244 $post_parent = $this->model->post->get_translation( $post_parent, $lang );
245 }
246
247 return $post_parent;
248 }
249
250 /**
251 * Called when a post, page or media is deleted
252 * Don't delete translations if this is a post revision thanks to AndyDeGroo who caught this bug
253 * http://wordpress.org/support/topic/plugin-polylang-quick-edit-still-breaks-translation-linking-of-pages-in-072
254 *
255 * @since 0.1
256 *
257 * @param int $post_id Post ID.
258 * @return void
259 */
260 public function delete_post( $post_id ) {
261 if ( ! wp_is_post_revision( $post_id ) ) {
262 $this->model->post->delete_translation( $post_id );
263 }
264 }
265
266 /**
267 * Prevents WP deleting files when there are still media using them.
268 *
269 * @since 0.9
270 *
271 * @param string $file Path to the file to delete.
272 * @return string Empty or unmodified path.
273 */
274 public function wp_delete_file( $file ) {
275 global $wpdb;
276
277 $basefile = basename( $file );
278 $upload = wp_upload_dir();
279 $attached_file = trim( str_replace( $upload['basedir'], '', $file ), '/' );
280 $subdir = trim( str_replace( $basefile, '', $attached_file ), '/' );
281 $filetype = wp_check_filetype( $file );
282
283 if ( empty( $filetype['type'] ) || ! str_starts_with( $filetype['type'], 'image/' ) ) {
284 /*
285 * For non-image files, we just have to check the '_wp_attached_file meta'.
286 */
287 $count = $wpdb->get_var(
288 $wpdb->prepare(
289 "SELECT COUNT(*) FROM $wpdb->postmeta
290 WHERE meta_key = '_wp_attached_file' AND meta_value = %s",
291 $attached_file
292 )
293 );
294 } elseif ( ! empty( $subdir ) ) {
295 /*
296 * For images, we must take care to intermediate sizes and backup.
297 * - '_wp_attachment_metadata' stores the base filename without subdir
298 * for intermediate sizes but includes the subdir for the main file.
299 * - '_wp_attachment_backup_sizes' stores the base filename, without the subdir.
300 * - '_wp_attached_file' stores the filename with the subdir.
301 *
302 * For filenames stored without subdir, we get it from '_wp_attached_file' to
303 * avoid a conflict if a file has the same filename in a different subdir.
304 */
305 $count = $wpdb->get_var(
306 $wpdb->prepare(
307 "SELECT COUNT(*) FROM $wpdb->postmeta AS pm1
308 JOIN $wpdb->postmeta AS pm2 ON pm1.post_id = pm2.post_id
309 WHERE pm1.meta_key = '_wp_attached_file' AND pm1.meta_value LIKE %s
310 AND pm2.meta_key IN ( '_wp_attachment_metadata', '_wp_attachment_backup_sizes' )
311 AND ( pm2.meta_value LIKE %s OR pm2.meta_value LIKE %s )",
312 $wpdb->esc_like( $subdir ) . '/%', // The subdir is always present in '_wp_attached_file'.
313 '%"' . $wpdb->esc_like( $basefile ) . '"%', // Intermediate sizes in '_wp_attachment_metadata' + '_wp_attachment_backup_sizes'.
314 '%"' . $wpdb->esc_like( $attached_file ) . '"%' // Main file in '_wp_attachment_metadata'.
315 )
316 );
317 } else {
318 /*
319 * The query above doesn't work if there's no subdir for uploads, so we must handle it
320 * as a specific case.
321 */
322 $count = $wpdb->get_var(
323 $wpdb->prepare(
324 "SELECT COUNT(*) FROM $wpdb->postmeta
325 WHERE meta_key IN ( '_wp_attachment_metadata', '_wp_attachment_backup_sizes' )
326 AND meta_value LIKE %s",
327 '%"' . $wpdb->esc_like( $basefile ) . '"%'
328 )
329 );
330 }
331
332 if ( $count > 0 ) {
333 return ''; // Prevent deleting the file.
334 }
335
336 return $file;
337 }
338
339 /**
340 * Creates a media translation
341 *
342 * @since 1.8
343 * @since 3.7 Deprecated in favor of PLL_Translated_Post::create_media_translation().
344 *
345 * @param int $post_id Original attachment id.
346 * @param string|PLL_Language $lang New translation language.
347 * @return int Attachment id of the translated media.
348 */
349 public function create_media_translation( $post_id, $lang ) {
350 _deprecated_function( __METHOD__, '3.7', 'PLL_Translated_Post::create_media_translation()' );
351 return $this->model->post->create_media_translation( $post_id, $lang );
352 }
353
354 /**
355 * Ensure that tags are in the correct language when a post is updated, due to `tags_input` parameter being removed in `wp_update_post()`.
356 *
357 * @since 3.4.5
358 *
359 * @param int $post_id Post ID, unused.
360 * @param WP_Post $post_after Post object following the update.
361 * @param WP_Post $post_before Post object before the update.
362 * @return void
363 */
364 public function force_tags_translation( $post_id, $post_after, $post_before ) {
365 if ( ! is_object_in_taxonomy( $post_before->post_type, 'post_tag' ) ) {
366 return;
367 }
368
369 $terms = get_the_terms( $post_before, 'post_tag' );
370
371 if ( empty( $terms ) || ! is_array( $terms ) ) {
372 return;
373 }
374
375 $term_ids = wp_list_pluck( $terms, 'term_id' );
376
377 // Let's ensure that `PLL_CRUD_Posts::set_object_terms()` will do its job.
378 wp_set_post_terms( $post_id, $term_ids, 'post_tag' );
379 }
380
381 /**
382 * Makes sure that all terms in the given list are in the given language.
383 * If not the case, the terms are translated or created (for a hierarchical taxonomy, terms are created recursively).
384 *
385 * @since 3.5
386 *
387 * @param WP_Term[] $terms List of terms to translate.
388 * @param string $taxonomy The terms' taxonomy.
389 * @param PLL_Language $language The language to translate the terms into.
390 * @return int[] List of `term_id`s.
391 *
392 * @phpstan-return array<positive-int>
393 */
394 private function translate_terms( array $terms, string $taxonomy, PLL_Language $language ): array {
395 $term_ids_translated = array();
396
397 foreach ( $terms as $term ) {
398 $term_ids_translated[] = $this->translate_term( $term, $taxonomy, $language );
399 }
400
401 return array_filter( $term_ids_translated );
402 }
403
404 /**
405 * Translates the given term into the given language.
406 * If the translation doesn't exist, it is created (for a hierarchical taxonomy, terms are created recursively).
407 *
408 * @since 3.5
409 *
410 * @param WP_Term $term The term to translate.
411 * @param string $taxonomy The term's taxonomy.
412 * @param PLL_Language $language The language to translate the term into.
413 * @return int A `term_id` on success, `0` on failure.
414 *
415 * @phpstan-return int<0, max>
416 */
417 private function translate_term( WP_Term $term, string $taxonomy, PLL_Language $language ): int {
418 // Check if the term is in the correct language or if a translation exists.
419 $tr_term_id = $this->model->term->get( $term->term_id, $language );
420
421 if ( ! empty( $tr_term_id ) ) {
422 // Already in the correct language.
423 return $tr_term_id;
424 }
425
426 // Or choose the correct language for tags (initially defined by name).
427 $tr_term_id = $this->model->term_exists( $term->name, $taxonomy, $term->parent, $language );
428
429 if ( ! empty( $tr_term_id ) ) {
430 return $tr_term_id;
431 }
432
433 // Or create the term in the correct language.
434 $tr_parent_term_id = 0;
435
436 if ( $term->parent > 0 && is_taxonomy_hierarchical( $taxonomy ) ) {
437 $parent = get_term( $term->parent, $taxonomy );
438
439 if ( $parent instanceof WP_Term ) {
440 // Translate the parent recursively.
441 $tr_parent_term_id = $this->translate_term( $parent, $taxonomy, $language );
442 }
443 }
444
445 $lang_callback = function ( $lang, $tax, $slug ) use ( $language, $term, $taxonomy ) {
446 if ( ! $lang instanceof PLL_Language && $tax === $taxonomy && $slug === $term->slug ) {
447 return $language;
448 }
449 return $lang;
450 };
451 $parent_callback = function ( $parent_id, $tax, $slug ) use ( $tr_parent_term_id, $term, $taxonomy ) {
452 if ( empty( $parent_id ) && $tax === $taxonomy && $slug === $term->slug ) {
453 return $tr_parent_term_id;
454 }
455 return $parent_id;
456 };
457 add_filter( 'pll_inserted_term_language', $lang_callback, 10, 3 );
458 add_filter( 'pll_inserted_term_parent', $parent_callback, 10, 3 );
459 $new_term_info = wp_insert_term(
460 $term->name,
461 $taxonomy,
462 array(
463 'parent' => $tr_parent_term_id,
464 'slug' => $term->slug, // Useless but prevents the use of `sanitize_title()` and for consistency with `$lang_callback`.
465 )
466 );
467 remove_filter( 'pll_inserted_term_language', $lang_callback );
468 remove_filter( 'pll_inserted_term_parent', $parent_callback );
469
470 if ( is_wp_error( $new_term_info ) ) {
471 // Term creation failed.
472 return 0;
473 }
474
475 $tr_term_id = max( 0, (int) $new_term_info['term_id'] );
476
477 if ( empty( $tr_term_id ) ) {
478 return 0;
479 }
480
481 $this->model->term->set_language( $tr_term_id, $language );
482
483 $trs = $this->model->term->get_translations( $term->term_id );
484
485 $trs[ $language->slug ] = $tr_term_id;
486
487 $this->model->term->save_translations( $term->term_id, $trs );
488
489 return $tr_term_id;
490 }
491
492 /**
493 * Add public query vars. Currently only 'untranslated in'.
494 *
495 * @since 3.9
496 *
497 * @param string[] $qvars The array of allowed query variable names.
498 * @return string[]
499 */
500 public function add_query_vars( $qvars ) {
501 $qvars[] = 'untranslated_in';
502 return $qvars;
503 }
504
505 /**
506 * Ensures filters are not suppressed when querying untranslated posts.
507 *
508 * @since 3.9
509 *
510 * @param WP_Query $query The WP_Query instance (passed by reference).
511 * @return void
512 */
513 public function parse_query( $query ) {
514 if ( ! empty( $query->query['untranslated_in'] ) ) {
515 unset( $query->query['suppress_filters'] );
516 unset( $query->query_vars['suppress_filters'] );
517 }
518 }
519
520 /**
521 * Filters the WP_Query SQL clauses to remove posts:
522 * - translated in the language queried by `unstranslated_in`.
523 * - in the language queried by `unstranslated_in`.
524 *
525 * This second condition is necessary as a post not translated in any language
526 * has no translation group.
527 *
528 * @since 3.9
529 *
530 * @param string[] $clauses The clauses for the query.
531 * @param WP_Query $query The WP_Query instance (passed by reference).
532 * @return string[]
533 */
534 public function posts_clauses( $clauses, $query ) {
535 global $wpdb;
536
537 if ( empty( $query->query['untranslated_in'] ) ) {
538 return $clauses;
539 }
540
541 $untranslated_in = PLL()->model->languages->get( $query->query['untranslated_in'] );
542
543 if ( empty( $untranslated_in ) ) {
544 return $clauses;
545 }
546
547 $tt_id = $untranslated_in->get_tax_prop( 'language', 'term_taxonomy_id' );
548
549 $clauses['where'] .= $wpdb->prepare(
550 " AND {$wpdb->posts}.ID NOT IN (
551 SELECT pllutr.object_id
552 FROM {$wpdb->term_relationships} AS pllutr
553 WHERE pllutr.term_taxonomy_id = %d
554 )",
555 $tt_id
556 );
557
558 $clauses['where'] .= $wpdb->prepare(
559 " AND {$wpdb->posts}.ID NOT IN (
560 SELECT pllutr1.object_id
561 FROM {$wpdb->term_relationships} AS pllutr1
562 JOIN {$wpdb->term_taxonomy} AS pllutt ON pllutt.term_taxonomy_id = pllutr1.term_taxonomy_id
563 JOIN {$wpdb->term_relationships} AS pllutr2 ON pllutr2.term_taxonomy_id = pllutt.term_taxonomy_id
564 JOIN {$wpdb->term_relationships} AS pllutr3 ON pllutr3.object_id = pllutr2.object_id
565 WHERE pllutt.taxonomy = 'post_translations'
566 AND pllutr3.term_taxonomy_id = %d
567 )",
568 $tt_id
569 );
570
571 return $clauses;
572 }
573 }
574