PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / modules / related-posts / jetpack-related-posts.php

jetpack-related-posts.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at modules/related-posts/jetpack-related-posts.php

2,196 lines 70.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * The Jetpack_RelatedPosts class.
4 *
5 * @package automattic/jetpack
6 */
7
8 use Automattic\Jetpack\Assets;
9 use Automattic\Jetpack\Blocks;
10 use Automattic\Jetpack\Post_Media\Images;
11 use Automattic\Jetpack\SEO\Content_Gate;
12 use Automattic\Jetpack\Status\Request;
13 use Automattic\Jetpack\Sync\Settings;
14
15 /**
16 * The Jetpack_RelatedPosts class.
17 */
18 class Jetpack_RelatedPosts {
19 const VERSION = '20240116';
20 const SHORTCODE = 'jetpack-related-posts';
21
22 /**
23 * Instance of the class.
24 *
25 * @var Jetpack_RelatedPosts
26 */
27 private static $instance = null;
28
29 /**
30 * Instance of the raw class (?).
31 *
32 * @var Jetpack_RelatedPosts
33 */
34 private static $instance_raw = null;
35
36 /**
37 * Creates and returns a static instance of Jetpack_RelatedPosts.
38 *
39 * @return Jetpack_RelatedPosts
40 */
41 public static function init() {
42 if ( ! self::$instance ) {
43 if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init' ) ) {
44 self::$instance = WPCOM_RelatedPosts::init();
45 } else {
46 self::$instance = new Jetpack_RelatedPosts();
47 }
48 }
49
50 return self::$instance;
51 }
52
53 /**
54 * Creates and returns a static instance of Jetpack_RelatedPosts_Raw.
55 *
56 * @return Jetpack_RelatedPosts
57 */
58 public static function init_raw() {
59 if ( ! self::$instance_raw ) {
60 if ( class_exists( 'WPCOM_RelatedPosts' ) && method_exists( 'WPCOM_RelatedPosts', 'init_raw' ) ) {
61 self::$instance_raw = WPCOM_RelatedPosts::init_raw();
62 } else {
63 self::$instance_raw = new Jetpack_RelatedPosts_Raw();
64 }
65 }
66
67 return self::$instance_raw;
68 }
69
70 /**
71 * Options.
72 *
73 * @var array $options
74 */
75 protected $options;
76
77 /**
78 * Allow feature toggle variable.
79 *
80 * @var bool
81 */
82 protected $allow_feature_toggle;
83
84 /**
85 * Blog character set.
86 *
87 * @var mixed
88 */
89 protected $blog_charset;
90
91 /**
92 * Convert character set.
93 *
94 * @var bool
95 */
96 protected $convert_charset;
97
98 /**
99 * Previous Post ID
100 *
101 * @var int
102 */
103 protected $previous_post_id;
104
105 /**
106 * Shortcode usage.
107 *
108 * @var bool
109 */
110 protected $found_shortcode = false;
111
112 /**
113 * Constructor for Jetpack_RelatedPosts.
114 *
115 * @uses get_option, add_action, apply_filters
116 */
117 public function __construct() {
118 $this->blog_charset = get_option( 'blog_charset' );
119 $this->convert_charset = ( function_exists( 'iconv' ) && ! preg_match( '/^utf\-?8$/i', $this->blog_charset ) );
120 add_action( 'admin_init', array( $this, 'action_admin_init' ) );
121 add_action( 'wp', array( $this, 'action_frontend_init' ) );
122
123 if ( ! class_exists( 'Jetpack_Media_Summary' ) ) {
124 require_once JETPACK__PLUGIN_DIR . '_inc/lib/class.media-summary.php';
125 }
126
127 // Add Related Posts to the REST API Post response.
128 add_action( 'rest_api_init', array( $this, 'rest_register_related_posts' ) );
129 }
130
131 /**
132 * Get the blog ID.
133 *
134 * @return mixed current blog id.
135 */
136 protected function get_blog_id() {
137 return Jetpack_Options::get_option( 'id' );
138 }
139
140 /**
141 * =================
142 * ACTIONS & FILTERS
143 * =================
144 */
145
146 /**
147 * Add a checkbox field to Settings > Reading for enabling related posts.
148 *
149 * @action admin_init
150 * @uses add_settings_field, __, register_setting, add_action
151 */
152 public function action_admin_init() {
153
154 // Add the setting field [jetpack_relatedposts] and place it in Settings > Reading.
155 add_settings_field( 'jetpack_relatedposts', '<span id="jetpack_relatedposts">' . __( 'Related posts', 'jetpack' ) . '</span>', array( $this, 'print_setting_html' ), 'reading' );
156 register_setting( 'reading', 'jetpack_relatedposts', array( $this, 'parse_options' ) );
157 add_action( 'admin_head', array( $this, 'print_setting_head' ) );
158
159 if ( 'options-reading.php' === $GLOBALS['pagenow'] ) {
160 // Enqueue style for live preview on the reading settings page.
161 $this->enqueue_assets( false, true );
162 }
163 }
164
165 /**
166 * Load related posts assets if it's an eligible front end page or execute search and return JSON if it's an endpoint request.
167 *
168 * @global $_GET
169 * @action wp
170 * @uses add_shortcode, get_the_ID
171 */
172 public function action_frontend_init() {
173 // Add a shortcode handler that outputs nothing, this gets overridden later if we can display related content.
174 add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html_unsupported' ) );
175
176 if ( ! $this->enabled_for_request() ) {
177 return;
178 }
179
180 if ( isset( $_GET['relatedposts'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading and checking if we need to generate a list of excuded posts, does not update anything on the site.
181 $excludes = $this->parse_numeric_get_arg( 'relatedposts_exclude' );
182 $this->action_frontend_init_ajax( $excludes );
183 } else {
184 if ( isset( $_GET['relatedposts_hit'] ) && isset( $_GET['relatedposts_origin'] ) && isset( $_GET['relatedposts_position'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- checking if fields are set to setup tracking, nothing is changing on the site.
185 $this->previous_post_id = (int) $_GET['relatedposts_origin']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- fetching a previous post ID for tracking, nothing is changing on the site.
186 $this->log_click( $this->previous_post_id, get_the_ID(), sanitize_text_field( wp_unslash( $_GET['relatedposts_position'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- logging the click for tracking, nothing is changing on the site.
187 }
188
189 $this->action_frontend_init_page();
190 }
191 }
192
193 /**
194 * Render insertion point.
195 *
196 * @since 4.2.0
197 *
198 * @return string
199 */
200 public function get_headline() {
201 $options = $this->get_options();
202
203 if ( ! empty( $options['show_headline'] ) ) {
204 $headline = sprintf(
205 /** This filter is already documented in modules/sharedaddy/sharing-service.php */
206 apply_filters( 'jetpack_sharing_headline_html', '<h3 class="jp-relatedposts-headline"><em>%s</em></h3>', esc_html( $options['headline'] ), 'related-posts' ),
207 esc_html( $options['headline'] )
208 );
209 } else {
210 $headline = '';
211 }
212 return $headline;
213 }
214
215 /**
216 * Adds a target to the post content to load related posts into if a shortcode for it did not already exist.
217 * Will skip adding the target if the post content contains a Related Posts block, if the 'get_the_excerpt'
218 * hook is in the current filter list, or if the site is running an FSE/Site Editor theme.
219 *
220 * @filter the_content
221 *
222 * @param string $content Post content.
223 *
224 * @return string
225 */
226 public function filter_add_target_to_dom( $content ) {
227 // Do not output related posts for ActivityPub requests.
228 if (
229 function_exists( '\Activitypub\is_activitypub_request' )
230 && \Activitypub\is_activitypub_request()
231 ) {
232 return $content;
233 }
234
235 if ( has_block( 'jetpack/related-posts' ) || Blocks::is_fse_theme() ) {
236 return $content;
237 }
238
239 if ( ! $this->found_shortcode && ! doing_filter( 'get_the_excerpt' ) ) {
240 if ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) {
241 $content .= "\n" . $this->get_server_rendered_html();
242 } else {
243 $content .= "\n" . $this->get_client_rendered_html();
244 }
245 }
246
247 return $content;
248 }
249
250 /**
251 * Render static markup based on the Gutenberg block code
252 *
253 * @return string Rendered related posts HTML.
254 */
255 public function get_server_rendered_html() {
256 $rp_settings = $this->get_options();
257 $block_rp_settings = array(
258 'displayThumbnails' => $rp_settings['show_thumbnails'],
259 'showHeadline' => $rp_settings['show_headline'],
260 'displayDate' => isset( $rp_settings['show_date'] ) ? (bool) $rp_settings['show_date'] : true,
261 'displayContext' => isset( $rp_settings['show_context'] ) && $rp_settings['show_context'],
262 'postLayout' => $rp_settings['layout'] ?? 'grid',
263 'postsToShow' => $rp_settings['size'] ?? 3,
264 /** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
265 'headline' => apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() ),
266 'isServerRendered' => true,
267 );
268
269 return $this->render_block( $block_rp_settings, '' );
270 }
271
272 /**
273 * Looks for our shortcode on the unfiltered content, this has to execute early.
274 *
275 * @filter the_content
276 * @param string $content - content of the post.
277 * @uses has_shortcode
278 * @return string $content
279 */
280 public function test_for_shortcode( $content ) {
281 $this->found_shortcode = has_shortcode( $content, self::SHORTCODE );
282
283 return $content;
284 }
285
286 /**
287 * Returns the HTML for the related posts section.
288 *
289 * @uses esc_html__, apply_filters
290 * @return string
291 */
292 public function get_client_rendered_html() {
293 if ( Settings::is_syncing() ) {
294 return '';
295 }
296
297 /**
298 * Filter the Related Posts headline.
299 *
300 * @module related-posts
301 *
302 * @since 3.0.0
303 *
304 * @param string $headline Related Posts heading.
305 */
306 $headline = apply_filters( 'jetpack_relatedposts_filter_headline', $this->get_headline() );
307
308 if ( $this->previous_post_id ) {
309 $exclude = "data-exclude='{$this->previous_post_id}'";
310 } else {
311 $exclude = '';
312 }
313
314 return <<<EOT
315 <div id='jp-relatedposts' class='jp-relatedposts' $exclude>
316 $headline
317 </div>
318 EOT;
319 }
320
321 /**
322 * Returns the HTML for the related posts section if it's running in the loop or other instances where we don't support related posts.
323 *
324 * @return string
325 */
326 public function get_client_rendered_html_unsupported() {
327 if ( Settings::is_syncing() ) {
328 return '';
329 }
330 return "\n\n<!-- Jetpack Related Posts is not supported in this context. -->\n\n";
331 }
332
333 /**
334 * ===============
335 * GUTENBERG BLOCK
336 * ===============
337 */
338
339 /**
340 * Echoes out items for the Gutenberg block
341 *
342 * @param array $related_post The post object.
343 * @param array $block_attributes The block attributes.
344 */
345 public function render_block_item( $related_post, $block_attributes ) {
346 $instance_id = 'related-posts-item-' . uniqid();
347 $label_id = $instance_id . '-label';
348 $title = $related_post['title'];
349 $url = $related_post['url'];
350 $rel = $related_post['rel'];
351 $img = '';
352 $list = '';
353
354 $item_markup = sprintf(
355 '<li id="%1$s" class="jp-related-posts-i2__post">',
356 esc_attr( $instance_id )
357 );
358
359 // Thumbnail
360 if ( ! empty( $block_attributes['show_thumbnails'] ) && ! empty( $related_post['img']['src'] ) ) {
361 $img = sprintf(
362 '<img loading="lazy" class="jp-related-posts-i2__post-img" src="%1$s" alt="%2$s" %3$s/>',
363 esc_url( $related_post['img']['src'] ),
364 esc_attr( $related_post['img']['alt_text'] ),
365 ( ! empty( $related_post['img']['srcset'] ) ? 'srcset="' . esc_attr( $related_post['img']['srcset'] ) . '"' : '' )
366 );
367 }
368
369 // Link
370 $item_markup .= sprintf(
371 '<a id="%1$s" href="%2$s" class="jp-related-posts-i2__post-link" %3$s>%4$s%5$s</a>',
372 esc_attr( $label_id ),
373 esc_url( $url ),
374 ( ! empty( $rel ) ? 'rel="' . esc_attr( $rel ) . '"' : '' ),
375 esc_html( $title ),
376 $img
377 );
378
379 // Date
380 if ( $block_attributes['show_date'] ) {
381 $list .= '<dt>' . __( 'Date', 'jetpack' ) . '</dt>';
382 $list .= '<dd class="jp-related-posts-i2__post-date">';
383 $list .= esc_html( $related_post['date'] );
384 $list .= '</dd>';
385 }
386
387 // Author
388 if ( $block_attributes['show_author'] ) {
389 $list .= '<dt>' . __( 'Author', 'jetpack' ) . '</dt>';
390 $list .= '<dd class="jp-related-posts-i2__post-author">';
391 $list .= esc_html( $related_post['author'] );
392 $list .= '</dd>';
393 }
394
395 // Context
396 if ( ( $block_attributes['show_context'] ) && ! empty( $related_post['block_context'] ) ) {
397 // translators: this is followed by the reason why the item is related to the current post
398 $list .= '<dt>' . __( 'In relation to', 'jetpack' ) . '</dt>';
399 $list .= '<dd class="jp-related-posts-i2__post-context">';
400
401 // Note: The original 'context' value is not used when rendering the block.
402 // It is still generated and available for the legacy rendering code path though.
403 // See './related-posts.js' for that usage.
404 $block_context = $related_post['block_context'];
405
406 if ( ! empty( $block_context['link'] ) ) {
407 $list .= sprintf(
408 '<a href="%1$s">%2$s</a>',
409 esc_url( $block_context['link'] ),
410 esc_html( $block_context['text'] )
411 );
412 } else {
413 $list .= esc_html( $block_context['text'] );
414 }
415
416 $list .= '</dd>';
417 }
418
419 // Metadata
420 if ( ! empty( $list ) ) {
421 $item_markup .= '<dl class="jp-related-posts-i2__post-defs">' . $list . '</dl>';
422 }
423
424 $item_markup .= '</li>';
425
426 return $item_markup;
427 }
428
429 /**
430 * Render the list of related posts.
431 *
432 * @param array $posts The posts to render into the list.
433 * @param array $block_attributes Block attributes.
434 * @return string
435 */
436 public function render_post_list( $posts, $block_attributes ) {
437 $markup = '';
438
439 foreach ( $posts as $post ) {
440 $markup .= $this->render_block_item( $post, $block_attributes );
441 }
442
443 return sprintf(
444 // role="list" is required for accessibility as VoiceOver ignores unstyled lists.
445 '<ul class="jp-related-posts-i2__list" role="list" data-post-count="%1$s">%2$s</ul>',
446 count( $posts ),
447 $markup
448 );
449 }
450
451 /**
452 * Render the related posts markup.
453 *
454 * @param array $attributes Block attributes.
455 * @param string $content String containing the related Posts block content.
456 * @param WP_Block $block The block object.
457 * @return string
458 */
459 public function render_block( $attributes, $content, $block = null ) {
460 if ( ! Request::is_frontend() ) {
461 return $content;
462 }
463
464 $wrapper_attributes = array();
465 $post_id = get_the_ID();
466 $block_attributes = array(
467 'headline' => $attributes['headline'] ?? null,
468 'show_thumbnails' => isset( $attributes['displayThumbnails'] ) && $attributes['displayThumbnails'],
469 'show_author' => isset( $attributes['displayAuthor'] ) ? (bool) $attributes['displayAuthor'] : false,
470 'show_headline' => isset( $attributes['displayHeadline'] ) ? (bool) $attributes['displayHeadline'] : false,
471 'show_date' => isset( $attributes['displayDate'] ) ? (bool) $attributes['displayDate'] : true,
472 'show_context' => isset( $attributes['displayContext'] ) && $attributes['displayContext'],
473 'layout' => isset( $attributes['postLayout'] ) && 'list' === $attributes['postLayout'] ? $attributes['postLayout'] : 'grid',
474 'size' => ! empty( $attributes['postsToShow'] ) ? absint( $attributes['postsToShow'] ) : 3,
475 );
476
477 $excludes = $this->parse_numeric_get_arg( 'relatedposts_origin' );
478
479 $related_posts = $this->get_for_post_id(
480 $post_id,
481 array(
482 'size' => $block_attributes['size'],
483 'exclude_post_ids' => $excludes,
484 )
485 );
486
487 if ( empty( $related_posts ) ) {
488 return '';
489 }
490
491 /*
492 * The block renders through its own block callback, independently of the
493 * module's front-end asset gate (enabled_for_request()). That gate only
494 * enqueues our assets on single posts in classic themes, so a block placed
495 * on a page (or any view the gate skips) would render as unstyled HTML.
496 * Enqueue the stylesheet here, whenever the block actually outputs markup,
497 * to keep it styled everywhere it can be used. We intentionally do not widen
498 * enabled_for_request() itself: that governs the automatic the_content
499 * insertion and was deliberately scoped in #39784 to avoid showing related
500 * posts on classic-theme pages.
501 */
502 $this->enqueue_assets( false, true );
503
504 $list_markup = $this->render_post_list( $related_posts, $block_attributes );
505
506 if ( empty( $attributes['isServerRendered'] ) ) {
507 // The get_server_rendered_html() path won't register a block,
508 // so only apply block supports when not server rendered.
509 $wrapper_attributes = \WP_Block_Supports::get_instance()->apply_block_supports();
510 }
511
512 $headline_markup = '';
513
514 if ( isset( $block ) ) {
515 foreach ( $block->inner_blocks as $inner_block ) {
516 if ( 'core/heading' === $inner_block->name && ! empty( wp_strip_all_tags( $inner_block->inner_html ) ) ) {
517 $headline_markup = trim( $inner_block->inner_html );
518 break;
519 }
520 }
521 }
522
523 if ( empty( $headline_markup ) && $block_attributes['show_headline'] ) {
524 $headline = $block_attributes['headline'];
525 if ( strlen( trim( $headline ) ) !== 0 ) {
526 $headline_markup = sprintf(
527 '<h3 class="jp-relatedposts-headline">%1$s</h3>',
528 esc_html( $headline )
529 );
530 }
531 }
532
533 $display_markup = sprintf(
534 '<nav class="jp-relatedposts-i2%1$s"%2$s data-layout="%3$s" aria-label="%6$s">%4$s%5$s</nav>',
535 ! empty( $wrapper_attributes['class'] ) ? ' ' . esc_attr( $wrapper_attributes['class'] ) : '',
536 ! empty( $wrapper_attributes['style'] ) ? ' style="' . esc_attr( $wrapper_attributes['style'] ) . '"' : '',
537 esc_attr( $block_attributes['layout'] ),
538 $headline_markup,
539 $list_markup,
540 empty( $headline_markup ) ? esc_attr__( 'Related Posts', 'jetpack' ) : esc_attr( wp_strip_all_tags( $headline_markup ) )
541 );
542
543 /**
544 * Filter the output HTML of Related Posts.
545 *
546 * @module related-posts
547 *
548 * @since 10.7
549 *
550 * @param string $display_markup HTML output of Related Posts.
551 * @param int|false get_the_ID() Post ID of the post for which we are retrieving Related Posts.
552 * @param array $related_posts Array of related posts.
553 * @param array $block_attributes Array of Block attributes.
554 */
555 return (string) apply_filters( 'jetpack_related_posts_display_markup', $display_markup, $post_id, $related_posts, $block_attributes );
556 }
557
558 /**
559 * ========================
560 * PUBLIC UTILITY FUNCTIONS
561 * ========================
562 */
563
564 /**
565 * Parse a numeric GET variable to an array of values.
566 *
567 * @since 6.9.0
568 *
569 * @uses absint
570 *
571 * @param string $arg Name of the GET variable.
572 * @return array $result Parsed value(s)
573 */
574 public function parse_numeric_get_arg( $arg ) {
575 $result = array();
576
577 if ( isset( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- requests are used to generate a list of related posts we want to exclude.
578 if ( is_string( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
579 $result = explode( ',', sanitize_text_field( wp_unslash( $_GET[ $arg ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
580 } elseif ( is_array( $_GET[ $arg ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
581 $args = array_map( 'sanitize_text_field', wp_unslash( $_GET[ $arg ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
582 $result = array_values( $args );
583 }
584
585 $result = array_unique( array_filter( array_map( 'absint', $result ) ) );
586 }
587
588 return $result;
589 }
590
591 /**
592 * Gets options set for Jetpack_RelatedPosts and merge with defaults.
593 *
594 * @uses Jetpack_Options::get_option, apply_filters
595 * @return array
596 */
597 public function get_options() {
598 if ( null === $this->options ) {
599 $this->options = Jetpack_Options::get_option( 'relatedposts', array() );
600 if ( ! is_array( $this->options ) ) {
601 $this->options = array();
602 }
603 if ( ! isset( $this->options['enabled'] ) ) {
604 $this->options['enabled'] = true;
605 }
606 if ( ! isset( $this->options['show_headline'] ) ) {
607 $this->options['show_headline'] = true;
608 }
609 if ( ! isset( $this->options['show_thumbnails'] ) ) {
610 $this->options['show_thumbnails'] = false;
611 }
612 if ( ! isset( $this->options['show_date'] ) ) {
613 $this->options['show_date'] = true;
614 }
615 if ( ! isset( $this->options['show_context'] ) ) {
616 $this->options['show_context'] = true;
617 }
618 if ( ! isset( $this->options['layout'] ) ) {
619 $this->options['layout'] = 'grid';
620 }
621 if ( ! isset( $this->options['headline'] ) ) {
622 $this->options['headline'] = esc_html__( 'Related', 'jetpack' );
623 }
624 if ( empty( $this->options['size'] ) || (int) $this->options['size'] < 1 ) {
625 $this->options['size'] = 3;
626 }
627
628 /**
629 * Filter Related Posts basic options.
630 *
631 * @module related-posts
632 *
633 * @since 2.8.0
634 *
635 * @param array $this->_options Array of basic Related Posts options.
636 */
637 $this->options = apply_filters( 'jetpack_relatedposts_filter_options', $this->options );
638 }
639
640 return $this->options;
641 }
642
643 /**
644 * Gets options.
645 *
646 * @param string $option_name - option we want to get.
647 */
648 public function get_option( $option_name ) {
649 $options = $this->get_options();
650
651 if ( isset( $options[ $option_name ] ) ) {
652 return $options[ $option_name ];
653 }
654
655 return false;
656 }
657
658 /**
659 * Parses input and returns normalized options array.
660 *
661 * @param array $input - input we're parsing.
662 * @uses self::get_options
663 * @return array
664 */
665 public function parse_options( $input ) {
666 $current = $this->get_options();
667
668 if ( ! is_array( $input ) ) {
669 $input = array();
670 }
671
672 if (
673 ! isset( $input['enabled'] )
674 || isset( $input['show_date'] )
675 || isset( $input['show_context'] )
676 || isset( $input['layout'] )
677 || isset( $input['headline'] )
678 ) {
679 $input['enabled'] = '1';
680 }
681
682 if ( '1' == $input['enabled'] ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- expecting string, but may return bools.
683 $current['enabled'] = true;
684 $current['show_headline'] = ( isset( $input['show_headline'] ) && '1' == $input['show_headline'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
685 $current['show_thumbnails'] = ( isset( $input['show_thumbnails'] ) && '1' == $input['show_thumbnails'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
686 $current['show_date'] = ( isset( $input['show_date'] ) && '1' == $input['show_date'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
687 $current['show_context'] = ( isset( $input['show_context'] ) && '1' == $input['show_context'] ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
688 $current['layout'] = isset( $input['layout'] ) && in_array( $input['layout'], array( 'grid', 'list' ), true ) ? $input['layout'] : 'grid';
689 $current['headline'] = $input['headline'] ?? esc_html__( 'Related', 'jetpack' );
690 } else {
691 $current['enabled'] = false;
692 }
693
694 if ( isset( $input['size'] ) && (int) $input['size'] > 0 ) {
695 $current['size'] = (int) $input['size'];
696 } else {
697 $current['size'] = null;
698 }
699 return $current;
700 }
701
702 /**
703 * HTML for admin settings page.
704 *
705 * @uses self::get_options, checked, esc_html__
706 */
707 public function print_setting_html() {
708 $options = $this->get_options();
709
710 $ui_settings_template = <<<'EOT'
711 <p class="description">%s</p>
712 <ul id="settings-reading-relatedposts-customize">
713 <li>
714 <label><input name="jetpack_relatedposts[show_headline]" type="checkbox" value="1" %s /> %s</label>
715 </li>
716 <li>
717 <label><input name="jetpack_relatedposts[show_thumbnails]" type="checkbox" value="1" %s /> %s</label>
718 </li>
719 <li>
720 <label><input name="jetpack_relatedposts[show_date]" type="checkbox" value="1" %s /> %s</label>
721 </li>
722 <li>
723 <label><input name="jetpack_relatedposts[show_context]" type="checkbox" value="1" %s /> %s</label>
724 </li>
725 </ul>
726 <div id='settings-reading-relatedposts-preview'>
727 %s
728 <div id="jp-relatedposts" class="jp-relatedposts"></div>
729 </div>
730 EOT;
731 $ui_settings = sprintf(
732 $ui_settings_template,
733 esc_html__( 'The following settings will impact all related posts on your site, except for those you created via the block editor:', 'jetpack' ),
734 checked( $options['show_headline'], true, false ),
735 esc_html__( 'Highlight related content with a heading', 'jetpack' ),
736 checked( $options['show_thumbnails'], true, false ),
737 esc_html__( 'Show a thumbnail image where available', 'jetpack' ),
738 checked( $options['show_date'], true, false ),
739 esc_html__( 'Show entry date', 'jetpack' ),
740 checked( $options['show_context'], true, false ),
741 esc_html__( 'Show context (category or tag)', 'jetpack' ),
742 esc_html__( 'Preview:', 'jetpack' )
743 );
744
745 if ( ! $this->allow_feature_toggle() ) {
746 $template = <<<'EOT'
747 <input type="hidden" name="jetpack_relatedposts[enabled]" value="1" />
748 %s
749 EOT;
750 printf(
751 $template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
752 $ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
753 );
754 } else {
755 $template = <<<'EOT'
756 <ul id="settings-reading-relatedposts">
757 <li>
758 <label><input type="radio" name="jetpack_relatedposts[enabled]" value="0" class="tog" %s /> %s</label>
759 </li>
760 <li>
761 <label><input type="radio" name="jetpack_relatedposts[enabled]" value="1" class="tog" %s /> %s</label>
762 %s
763 </li>
764 </ul>
765 EOT;
766 printf(
767 $template, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
768 checked( $options['enabled'], false, false ),
769 esc_html__( 'Hide related content after posts', 'jetpack' ),
770 checked( $options['enabled'], true, false ),
771 esc_html__( 'Show related content after posts', 'jetpack' ),
772 $ui_settings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- data is escaped when variable is set.
773 );
774 }
775 }
776
777 /**
778 * Head JS/CSS for admin settings page.
779 *
780 * @uses esc_html__
781 * @return null
782 */
783 public function print_setting_head() {
784
785 // only dislay the Related Posts JavaScript on the Reading Settings Admin Page.
786 $current_screen = get_current_screen();
787
788 if ( $current_screen === null ) {
789 return;
790 }
791
792 if ( 'options-reading' !== $current_screen->id ) {
793 return;
794 }
795
796 $related_headline = sprintf(
797 '<h3 class="jp-relatedposts-headline"><em>%s</em></h3>',
798 esc_html__( 'Related', 'jetpack' )
799 );
800
801 $href_params = 'class="jp-relatedposts-post-a" href="#jetpack_relatedposts" rel="nofollow" data-origin="0" data-position="0"';
802 $related_with_images = <<<EOT
803 <div class="jp-relatedposts-items jp-relatedposts-items-visual">
804 <div class="jp-relatedposts-post jp-relatedposts-post0 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
805 <a $href_params>
806 <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/cat-blog.png" width="350" alt="Big iPhone/iPad Update Now Available" scale="0">
807 </a>
808 <h4 class="jp-relatedposts-post-title">
809 <a $href_params>Big iPhone/iPad Update Now Available</a>
810 </h4>
811 <p class="jp-relatedposts-post-excerpt">Big iPhone/iPad Update Now Available</p>
812 <p class="jp-relatedposts-post-context">In "Mobile"</p>
813 </div>
814 <div class="jp-relatedposts-post jp-relatedposts-post1 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
815 <a $href_params>
816 <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/devices.jpg" width="350" alt="The WordPress for Android App Gets a Big Facelift" scale="0">
817 </a>
818 <h4 class="jp-relatedposts-post-title">
819 <a $href_params>The WordPress for Android App Gets a Big Facelift</a>
820 </h4>
821 <p class="jp-relatedposts-post-excerpt">The WordPress for Android App Gets a Big Facelift</p>
822 <p class="jp-relatedposts-post-context">In "Mobile"</p>
823 </div>
824 <div class="jp-relatedposts-post jp-relatedposts-post2 jp-relatedposts-post-thumbs" data-post-id="0" data-post-format="image">
825 <a $href_params>
826 <img class="jp-relatedposts-post-img" src="https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg" width="350" alt="Upgrade Focus: VideoPress For Weddings" scale="0">
827 </a>
828 <h4 class="jp-relatedposts-post-title">
829 <a $href_params>Upgrade Focus: VideoPress For Weddings</a>
830 </h4>
831 <p class="jp-relatedposts-post-excerpt">Upgrade Focus: VideoPress For Weddings</p>
832 <p class="jp-relatedposts-post-context">In "Upgrade"</p>
833 </div>
834 </div>
835 EOT;
836 $related_with_images = str_replace( "\n", '', $related_with_images );
837 $related_without_images = <<<EOT
838 <div class="jp-relatedposts-items jp-relatedposts-items-minimal">
839 <p class="jp-relatedposts-post jp-relatedposts-post0" data-post-id="0" data-post-format="image">
840 <span class="jp-relatedposts-post-title"><a $href_params>Big iPhone/iPad Update Now Available</a></span>
841 <span class="jp-relatedposts-post-context">In "Mobile"</span>
842 </p>
843 <p class="jp-relatedposts-post jp-relatedposts-post1" data-post-id="0" data-post-format="image">
844 <span class="jp-relatedposts-post-title"><a $href_params>The WordPress for Android App Gets a Big Facelift</a></span>
845 <span class="jp-relatedposts-post-context">In "Mobile"</span>
846 </p>
847 <p class="jp-relatedposts-post jp-relatedposts-post2" data-post-id="0" data-post-format="image">
848 <span class="jp-relatedposts-post-title"><a $href_params>Upgrade Focus: VideoPress For Weddings</a></span>
849 <span class="jp-relatedposts-post-context">In "Upgrade"</span>
850 </p>
851 </div>
852 EOT;
853 $related_without_images = str_replace( "\n", '', $related_without_images );
854
855 if ( $this->allow_feature_toggle() ) {
856 $extra_css = '#settings-reading-relatedposts-customize { padding-left:2em; margin-top:.5em; }';
857 } else {
858 $extra_css = '';
859 }
860 // phpcs:disable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped -- Escaped above where needed.
861 echo <<<EOT
862 <style type="text/css">
863 #settings-reading-relatedposts .disabled { opacity:.5; filter:Alpha(opacity=50); }
864 #settings-reading-relatedposts-preview .jp-relatedposts { background:#fff; padding:.5em; width:75%; }
865 $extra_css
866 </style>
867 <script type="text/javascript">
868 jQuery( document ).ready( function($) {
869 var update_ui = function() {
870 var is_enabled = true;
871 if ( 'radio' == $( 'input[name="jetpack_relatedposts[enabled]"]' ).attr('type') ) {
872 if ( '0' == $( 'input[name="jetpack_relatedposts[enabled]"]:checked' ).val() ) {
873 is_enabled = false;
874 }
875 }
876 if ( is_enabled ) {
877 $( '#settings-reading-relatedposts-customize' )
878 .removeClass( 'disabled' )
879 .find( 'input' )
880 .attr( 'disabled', false );
881 $( '#settings-reading-relatedposts-preview' )
882 .removeClass( 'disabled' );
883 } else {
884 $( '#settings-reading-relatedposts-customize' )
885 .addClass( 'disabled' )
886 .find( 'input' )
887 .attr( 'disabled', true );
888 $( '#settings-reading-relatedposts-preview' )
889 .addClass( 'disabled' );
890 }
891 };
892
893 var update_preview = function() {
894 var html = '';
895 if ( $( 'input[name="jetpack_relatedposts[show_headline]"]:checked' ).length ) {
896 html += '$related_headline';
897 }
898 if ( $( 'input[name="jetpack_relatedposts[show_thumbnails]"]:checked' ).length ) {
899 html += '$related_with_images';
900 } else {
901 html += '$related_without_images';
902 }
903 $( '#settings-reading-relatedposts-preview .jp-relatedposts' ).html( html );
904 if ( $( 'input[name="jetpack_relatedposts[show_date]"]:checked' ).length ) {
905 $( '.jp-relatedposts-post-title' ).each( function() {
906 $( this ).after( $( '<span>August 8, 2005</span>' ) );
907 } );
908 }
909 if ( $( 'input[name="jetpack_relatedposts[show_context]"]:checked' ).length ) {
910 $( '.jp-relatedposts-post-context' ).show();
911 } else {
912 $( '.jp-relatedposts-post-context' ).hide();
913 }
914 $( '#settings-reading-relatedposts-preview .jp-relatedposts' ).show();
915 };
916
917 // Update on load
918 update_preview();
919 update_ui();
920
921 // Update on change
922 $( '#settings-reading-relatedposts-customize input' )
923 .change( update_preview );
924 $( '#settings-reading-relatedposts' )
925 .find( 'input.tog' )
926 .change( update_ui );
927 });
928 </script>
929 EOT;
930 // phpcs:enable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped
931 }
932
933 /**
934 * Gets an array of related posts that match the given post_id.
935 *
936 * @param int $post_id Post which we want to find related posts for.
937 * @param array $args - params to use when building Elasticsearch filters to narrow down the search domain.
938 * @uses self::get_options, get_post_type, wp_parse_args, apply_filters
939 * @return array
940 */
941 public function get_for_post_id( $post_id, array $args ) {
942 $options = $this->get_options();
943
944 if ( ! empty( $args['size'] ) ) {
945 $options['size'] = $args['size'];
946 }
947
948 if (
949 empty( $options['enabled'] )
950 || 0 === (int) $post_id
951 || empty( $options['size'] )
952 ) {
953 return array();
954 }
955
956 $defaults = array(
957 'size' => (int) $options['size'],
958 'post_type' => get_post_type( $post_id ),
959 'post_formats' => array(),
960 'has_terms' => array(),
961 'date_range' => array(),
962 'exclude_post_ids' => array(),
963 );
964 $args = wp_parse_args( $args, $defaults );
965 /**
966 * Filter the arguments used to retrieve a list of Related Posts.
967 *
968 * @module related-posts
969 *
970 * @since 2.8.0
971 *
972 * @param array $args Array of options to retrieve Related Posts.
973 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
974 */
975 $args = apply_filters( 'jetpack_relatedposts_filter_args', $args, $post_id );
976
977 $filters = $this->get_es_filters_from_args( $post_id, $args );
978 /**
979 * Filter Elasticsearch options used to calculate Related Posts.
980 *
981 * @module related-posts
982 *
983 * @since 2.8.0
984 *
985 * @param array $filters Array of Elasticsearch filters based on the post_id and args.
986 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
987 */
988 $filters = apply_filters( 'jetpack_relatedposts_filter_filters', $filters, $post_id );
989
990 $results = $this->get_related_posts( $post_id, $args['size'], $filters );
991 /**
992 * Filter the array of related posts matched by Elasticsearch.
993 *
994 * @module related-posts
995 *
996 * @since 2.8.0
997 *
998 * @param array $results Array of related posts matched by Elasticsearch.
999 * @param int $post_id Post ID of the post for which we are retrieving Related Posts.
1000 */
1001 return apply_filters( 'jetpack_relatedposts_returned_results', $results, $post_id );
1002 }
1003
1004 /**
1005 * =========================
1006 * PRIVATE UTILITY FUNCTIONS
1007 * =========================
1008 */
1009
1010 /**
1011 * Creates an array of Elasticsearch filters based on the post_id and args.
1012 *
1013 * @param int $post_id - the post ID.
1014 * @param array $args - the arguments.
1015 * @uses apply_filters, get_post_types, get_post_format_strings
1016 * @return array
1017 */
1018 protected function get_es_filters_from_args( $post_id, array $args ) {
1019 $filters = array();
1020
1021 /**
1022 * Filter the terms used to search for Related Posts.
1023 *
1024 * @module related-posts
1025 *
1026 * @since 2.8.0
1027 *
1028 * @param array $args['has_terms'] Array of terms associated to the Related Posts.
1029 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1030 */
1031 $args['has_terms'] = apply_filters( 'jetpack_relatedposts_filter_has_terms', $args['has_terms'], $post_id );
1032 if ( ! empty( $args['has_terms'] ) ) {
1033 foreach ( (array) $args['has_terms'] as $term ) {
1034 if ( mb_strlen( $term->taxonomy ) ) {
1035 switch ( $term->taxonomy ) {
1036 case 'post_tag':
1037 $tax_fld = 'tag.slug';
1038 break;
1039 case 'category':
1040 $tax_fld = 'category.slug';
1041 break;
1042 default:
1043 $tax_fld = 'taxonomy.' . $term->taxonomy . '.slug';
1044 break;
1045 }
1046 $filters[] = array( 'term' => array( $tax_fld => $term->slug ) );
1047 }
1048 }
1049 }
1050
1051 /**
1052 * Filter the Post Types where we search Related Posts.
1053 *
1054 * @module related-posts
1055 *
1056 * @since 2.8.0
1057 *
1058 * @param array $args['post_type'] Array of Post Types.
1059 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1060 */
1061 $args['post_type'] = apply_filters( 'jetpack_relatedposts_filter_post_type', $args['post_type'], $post_id );
1062 $valid_post_types = get_post_types();
1063 if ( is_array( $args['post_type'] ) ) {
1064 $sanitized_post_types = array();
1065 foreach ( $args['post_type'] as $pt ) {
1066 if ( in_array( $pt, $valid_post_types, true ) ) {
1067 $sanitized_post_types[] = $pt;
1068 }
1069 }
1070 if ( ! empty( $sanitized_post_types ) ) {
1071 $filters[] = array( 'terms' => array( 'post_type' => $sanitized_post_types ) );
1072 }
1073 } elseif ( in_array( $args['post_type'], $valid_post_types, true ) && 'all' !== $args['post_type'] ) {
1074 $filters[] = array( 'term' => array( 'post_type' => $args['post_type'] ) );
1075 }
1076
1077 /**
1078 * Filter the Post Formats where we search Related Posts.
1079 *
1080 * @module related-posts
1081 *
1082 * @since 3.3.0
1083 *
1084 * @param array $args['post_formats'] Array of Post Formats.
1085 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1086 */
1087 $args['post_formats'] = apply_filters( 'jetpack_relatedposts_filter_post_formats', $args['post_formats'], $post_id );
1088 $valid_post_formats = get_post_format_strings();
1089 $sanitized_post_formats = array();
1090 foreach ( $args['post_formats'] as $pf ) {
1091 if ( array_key_exists( $pf, $valid_post_formats ) ) {
1092 $sanitized_post_formats[] = $pf;
1093 }
1094 }
1095 if ( ! empty( $sanitized_post_formats ) ) {
1096 $filters[] = array( 'terms' => array( 'post_format' => $sanitized_post_formats ) );
1097 }
1098
1099 /**
1100 * Filter the date range used to search Related Posts.
1101 *
1102 * @module related-posts
1103 *
1104 * @since 2.8.0
1105 *
1106 * @param array $args['date_range'] Array of a month interval where we search Related Posts.
1107 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1108 */
1109 $args['date_range'] = apply_filters( 'jetpack_relatedposts_filter_date_range', $args['date_range'], $post_id );
1110 if ( is_array( $args['date_range'] ) && ! empty( $args['date_range'] ) ) {
1111 $args['date_range'] = array_map( 'intval', $args['date_range'] );
1112 if ( ! empty( $args['date_range']['from'] ) && ! empty( $args['date_range']['to'] ) ) {
1113 $filters[] = array(
1114 'range' => array(
1115 'date_gmt' => $this->get_coalesced_range( $args['date_range'] ),
1116 ),
1117 );
1118 }
1119 }
1120
1121 /**
1122 * Filter the Post IDs excluded from appearing in Related Posts.
1123 *
1124 * @module related-posts
1125 *
1126 * @since 2.9.0
1127 *
1128 * @param array $args['exclude_post_ids'] Array of Post IDs.
1129 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1130 */
1131 $args['exclude_post_ids'] = apply_filters( 'jetpack_relatedposts_filter_exclude_post_ids', $args['exclude_post_ids'], $post_id );
1132 if ( ! empty( $args['exclude_post_ids'] ) && is_array( $args['exclude_post_ids'] ) ) {
1133 $excluded_post_ids = array();
1134 foreach ( $args['exclude_post_ids'] as $exclude_post_id ) {
1135 $exclude_post_id = (int) $exclude_post_id;
1136 if ( $exclude_post_id > 0 ) {
1137 $excluded_post_ids[] = $exclude_post_id;
1138 }
1139 }
1140 $filters[] = array( 'not' => array( 'terms' => array( 'post_id' => $excluded_post_ids ) ) );
1141 }
1142
1143 return $filters;
1144 }
1145
1146 /**
1147 * Takes a range and coalesces it into a month interval bracketed by a time as determined by the blog_id to enhance caching.
1148 *
1149 * @todo Rewrite this function with proper date handling rather than `strtotime()` and `date()`.
1150 *
1151 * @param array $date_range - the date range.
1152 * @return array
1153 */
1154 protected function get_coalesced_range( array $date_range ) {
1155 $now = time();
1156 $coalesce_time = $this->get_blog_id() % 86400;
1157 $current_time = $now - strtotime( 'today', $now );
1158
1159 if ( $current_time < $coalesce_time && '01' === date( 'd', $now ) ) { // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1160 // Move back 1 period.
1161 return array(
1162 'from' => date( 'Y-m-01', strtotime( '-1 month', $date_range['from'] ) ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1163 'to' => date( 'Y-m-01', $date_range['to'] ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1164 );
1165 } else {
1166 // Use current period.
1167 return array(
1168 'from' => date( 'Y-m-01', $date_range['from'] ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1169 'to' => date( 'Y-m-01', strtotime( '+1 month', $date_range['to'] ) ) . ' ' . date( 'H:i:s', $coalesce_time ), //phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1170 );
1171 }
1172 }
1173
1174 /**
1175 * Generate and output ajax response for related posts API call.
1176 * NOTE: Calls exit() to end all further processing after payload has been outputed.
1177 *
1178 * @param array $excludes array of post_ids to exclude.
1179 * @uses send_nosniff_header, self::get_for_post_id, get_the_ID
1180 * @return never
1181 */
1182 protected function action_frontend_init_ajax( array $excludes ) {
1183 define( 'DOING_AJAX', true );
1184
1185 header( 'Content-type: application/json; charset=utf-8' ); // JSON can only be UTF-8.
1186 send_nosniff_header();
1187
1188 $options = $this->get_options();
1189
1190 if ( isset( $_GET['jetpackrpcustomize'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- adds dummy content if we're in the customizer.
1191
1192 // If we're in the customizer, add dummy content.
1193 $date_now = current_time( get_option( 'date_format' ) );
1194 $related_posts = array(
1195 array(
1196 'id' => - 1,
1197 'url' => 'https://jetpackme.files.wordpress.com/2019/03/cat-blog.png',
1198 'url_meta' => array(
1199 'origin' => 0,
1200 'position' => 0,
1201 ),
1202 'title' => esc_html__( 'Big iPhone/iPad Update Now Available', 'jetpack' ),
1203 'date' => $date_now,
1204 'format' => false,
1205 'excerpt' => esc_html__( 'It is that time of the year when devices are shiny again.', 'jetpack' ),
1206 'rel' => 'nofollow',
1207 'context' => esc_html__( 'In "Mobile"', 'jetpack' ),
1208 'img' => array(
1209 'src' => 'https://jetpackme.files.wordpress.com/2019/03/cat-blog.png',
1210 'width' => 350,
1211 'height' => 200,
1212 ),
1213 'classes' => array(),
1214 ),
1215 array(
1216 'id' => - 1,
1217 'url' => 'https://jetpackme.files.wordpress.com/2019/03/devices.jpg',
1218 'url_meta' => array(
1219 'origin' => 0,
1220 'position' => 0,
1221 ),
1222 'title' => esc_html__( 'The WordPress for Android App Gets a Big Facelift', 'jetpack' ),
1223 'date' => $date_now,
1224 'format' => false,
1225 'excerpt' => esc_html__( 'Writing is new again in Android with the new WordPress app.', 'jetpack' ),
1226 'rel' => 'nofollow',
1227 'context' => esc_html__( 'In "Mobile"', 'jetpack' ),
1228 'img' => array(
1229 'src' => 'https://jetpackme.files.wordpress.com/2019/03/devices.jpg',
1230 'width' => 350,
1231 'height' => 200,
1232 ),
1233 'classes' => array(),
1234 ),
1235 array(
1236 'id' => - 1,
1237 'url' => 'https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg',
1238 'url_meta' => array(
1239 'origin' => 0,
1240 'position' => 0,
1241 ),
1242 'title' => esc_html__( 'Upgrade Focus, VideoPress for weddings', 'jetpack' ),
1243 'date' => $date_now,
1244 'format' => false,
1245 'excerpt' => esc_html__( 'Weddings are in the spotlight now with VideoPress for weddings.', 'jetpack' ),
1246 'rel' => 'nofollow',
1247 'context' => esc_html__( 'In "Mobile"', 'jetpack' ),
1248 'img' => array(
1249 'src' => 'https://jetpackme.files.wordpress.com/2019/03/mobile-wedding.jpg',
1250 'width' => 350,
1251 'height' => 200,
1252 ),
1253 'classes' => array(),
1254 ),
1255 );
1256
1257 for ( $total = 0; $total < $options['size'] - 3; $total++ ) {
1258 $related_posts[] = $related_posts[ $total ];
1259 }
1260
1261 $current_post = get_post();
1262
1263 // Exclude current post after filtering to make sure it's excluded and not lost during filtering.
1264 $excluded_posts = array_merge(
1265 /** This filter is already documented in modules/related-posts/jetpack-related-posts.php */
1266 apply_filters( 'jetpack_relatedposts_filter_exclude_post_ids', array() ),
1267 array( $current_post->ID )
1268 );
1269
1270 // Fetch posts with featured image.
1271 $with_post_thumbnails = get_posts(
1272 array(
1273 'posts_per_page' => $options['size'],
1274 'post__not_in' => $excluded_posts,
1275 'post_type' => $current_post->post_type,
1276 'meta_key' => '_thumbnail_id',
1277 'suppress_filters' => false,
1278 )
1279 );
1280
1281 // If we don't have enough, fetch posts without featured image.
1282 $count_post_with_thumbnails = is_countable( $with_post_thumbnails ) ? count( $with_post_thumbnails ) : 0;
1283 $more = $options['size'] - $count_post_with_thumbnails;
1284 if ( 0 < $more ) {
1285 $no_post_thumbnails = get_posts(
1286 array(
1287 'posts_per_page' => $more,
1288 'post__not_in' => $excluded_posts,
1289 'post_type' => $current_post->post_type,
1290 'meta_query' => array(
1291 array(
1292 'key' => '_thumbnail_id',
1293 'compare' => 'NOT EXISTS',
1294 ),
1295 ),
1296 'suppress_filters' => false,
1297 )
1298 );
1299 } else {
1300 $no_post_thumbnails = array();
1301 }
1302
1303 foreach ( array_merge( $with_post_thumbnails, $no_post_thumbnails ) as $index => $real_post ) {
1304 $related_posts[ $index ]['id'] = $real_post->ID;
1305 $related_posts[ $index ]['url'] = esc_url( get_permalink( $real_post ) );
1306 $related_posts[ $index ]['title'] = $this->to_utf8( $this->get_title( $real_post->post_title, $real_post->post_content, $real_post->ID ) );
1307 $related_posts[ $index ]['date'] = get_the_date( '', $real_post );
1308 $related_posts[ $index ]['excerpt'] = html_entity_decode( $this->to_utf8( $this->get_excerpt( $real_post->post_excerpt, $real_post->post_content, $real_post->ID ) ), ENT_QUOTES, 'UTF-8' );
1309 $related_posts[ $index ]['img'] = $this->generate_related_post_image_params( $real_post->ID );
1310 $related_posts[ $index ]['context'] = $this->generate_related_post_context( $real_post->ID );
1311 }
1312 } else {
1313 $related_posts = $this->get_for_post_id(
1314 get_the_ID(),
1315 array(
1316 'exclude_post_ids' => $excludes,
1317 )
1318 );
1319 }
1320
1321 $response = array(
1322 'version' => self::VERSION,
1323 'show_thumbnails' => (bool) ( $options['show_thumbnails'] ?? false ),
1324 'show_date' => (bool) ( $options['show_date'] ?? true ),
1325 'show_context' => (bool) ( $options['show_context'] ?? true ),
1326 'layout' => (string) ( $options['layout'] ?? 'grid' ),
1327 'headline' => (string) ( $options['headline'] ?? '' ),
1328 'items' => array(),
1329 );
1330
1331 if ( ! empty( $options['size'] ) && count( $related_posts ) === $options['size'] ) {
1332 $response['items'] = $related_posts;
1333 }
1334
1335 // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
1336 wp_send_json( $response, null, JSON_UNESCAPED_SLASHES );
1337 }
1338
1339 /**
1340 * Returns a UTF-8 encoded array of post information for the given post_id
1341 *
1342 * @param int $post_id - the post ID.
1343 * @param int $position - position of the post.
1344 * @param int $origin - The post id that this is related to.
1345 * @uses get_post, get_permalink, remove_query_arg, get_post_format, apply_filters
1346 * @return array
1347 */
1348 public function get_related_post_data_for_post( $post_id, $position, $origin ) {
1349 $post = get_post( $post_id );
1350 return array(
1351 'id' => $post->ID,
1352 'url' => get_permalink( $post->ID ),
1353 'url_meta' => array(
1354 'origin' => $origin,
1355 'position' => $position,
1356 ),
1357 'title' => $this->to_utf8( $this->get_title( $post->post_title, $post->post_content, $post->ID ) ),
1358 'author' => $this->generate_related_post_display_author( $post->ID ),
1359 'date' => get_the_date( '', $post->ID ),
1360 'format' => get_post_format( $post->ID ),
1361 'excerpt' => html_entity_decode( $this->to_utf8( $this->get_excerpt( $post->post_excerpt, $post->post_content, $post->ID ) ), ENT_QUOTES, 'UTF-8' ),
1362 /**
1363 * Filters the rel attribute for the Related Posts' links.
1364 *
1365 * @module related-posts
1366 *
1367 * @since 3.7.0
1368 * @since 7.9.0 - Change Default value to empty.
1369 *
1370 * @param string $link_rel Link rel attribute for Related Posts' link. Default is empty.
1371 * @param int $post->ID Post ID.
1372 */
1373 'rel' => apply_filters( 'jetpack_relatedposts_filter_post_link_rel', '', $post->ID ),
1374 /**
1375 * Filter the context displayed below each Related Post.
1376 *
1377 * This context is used when rendering the legacy 'widget' version of Related Posts.
1378 * It is not used when rendering the block-based version. See 'block_context' below for that.
1379 *
1380 * @module related-posts
1381 *
1382 * @since 3.0.0
1383 *
1384 * @param string $this->to_utf8( $this->generate_related_post_context( $post->ID ) ) Context displayed below each related post.
1385 * @param int $post_id Post ID of the post for which we are retrieving Related Posts.
1386 */
1387 'context' => apply_filters(
1388 'jetpack_relatedposts_filter_post_context',
1389 $this->to_utf8( $this->generate_related_post_context( $post->ID ) ),
1390 $post->ID
1391 ),
1392 // The context used when rendering as a Block. No filtering applied.
1393 'block_context' => $this->generate_related_post_context_block( $post->ID ),
1394 'img' => $this->generate_related_post_image_params( $post->ID ),
1395 /**
1396 * Filter the post css classes added on HTML markup.
1397 *
1398 * @module related-posts
1399 *
1400 * @since 3.8.0
1401 *
1402 * @param array array() CSS classes added on post HTML markup.
1403 * @param string $post_id Post ID.
1404 */
1405 'classes' => apply_filters(
1406 'jetpack_relatedposts_filter_post_css_classes',
1407 array(),
1408 $post->ID
1409 ),
1410 );
1411 }
1412
1413 /**
1414 * Returns either the title or a small excerpt to use as title for post.
1415 *
1416 * @uses strip_shortcodes, wp_trim_words, __, apply_filters
1417 *
1418 * @param string $post_title Post title.
1419 * @param string $post_content Post content.
1420 * @param int $post_id Post ID.
1421 *
1422 * @return string
1423 */
1424 protected function get_title( $post_title, $post_content, $post_id ) {
1425 if ( ! empty( $post_title ) ) {
1426 return wp_strip_all_tags(
1427 /** This filter is documented in core/src/wp-includes/post-template.php */
1428 apply_filters( 'the_title', $post_title, $post_id )
1429 );
1430 }
1431
1432 // Same gate as get_excerpt(): this fallback is five words of the raw body.
1433 if ( ! Content_Gate::is_gated( $post_id ) ) {
1434 $post_title = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post_content ) ), 5, '' );
1435 if ( ! empty( $post_title ) ) {
1436 return $post_title;
1437 }
1438 }
1439
1440 return __( 'Untitled Post', 'jetpack' );
1441 }
1442
1443 /**
1444 * Returns a plain text post excerpt for title attribute of links.
1445 *
1446 * @param string $post_excerpt - the post excerpt.
1447 * @param string $post_content - the post content.
1448 * @param int $post_id - the post ID.
1449 * @uses strip_shortcodes, wp_strip_all_tags, wp_trim_words
1450 * @return string
1451 */
1452 protected function get_excerpt( $post_excerpt, $post_content, $post_id ) {
1453 if ( ! empty( $post_excerpt ) ) {
1454 $excerpt = $post_excerpt;
1455 } elseif ( Content_Gate::is_gated( $post_id ) ) {
1456 // The body fall-through never passes through the `the_content` paywall, so ask the gate directly.
1457 return '';
1458 } else {
1459 $excerpt = $post_content;
1460 }
1461
1462 return wp_trim_words( wp_strip_all_tags( strip_shortcodes( $excerpt ) ), 50, '' );
1463 }
1464
1465 /**
1466 * Generates the thumbnail image to be used for the post. Uses the
1467 * image as returned by Images::get_image()
1468 *
1469 * @param int $post_id - the post ID.
1470 * @uses self::get_options, apply_filters, Images::get_image, Images::fit_image_url
1471 * @return string
1472 */
1473 protected function generate_related_post_image_params( $post_id ) {
1474 $image_params = array(
1475 'alt_text' => '',
1476 'src' => '',
1477 'width' => 0,
1478 'height' => 0,
1479 );
1480
1481 /**
1482 * Filter the size of the Related Posts images.
1483 *
1484 * @module related-posts
1485 *
1486 * @since 2.8.0
1487 *
1488 * @param array array( 'width' => 350, 'height' => 200 ) Size of the images displayed below each Related Post.
1489 */
1490 $thumbnail_size = apply_filters(
1491 'jetpack_relatedposts_filter_thumbnail_size',
1492 array(
1493 'width' => 350,
1494 'height' => 200,
1495 )
1496 );
1497 if ( ! is_array( $thumbnail_size ) ) {
1498 $thumbnail_size = array(
1499 'width' => (int) $thumbnail_size,
1500 'height' => (int) $thumbnail_size,
1501 );
1502 }
1503
1504 // Try to get post image.
1505 $img_url = '';
1506 $is_gated = Content_Gate::is_gated( $post_id );
1507 $image_args = $thumbnail_size;
1508 if ( $is_gated ) {
1509 // Restrict to the featured image; every other source parses the body.
1510 $image_args = array_merge(
1511 $image_args,
1512 array(
1513 'from_slideshow' => false,
1514 'from_gallery' => false,
1515 'from_attachment' => false,
1516 'from_blocks' => false,
1517 'from_html' => false,
1518 )
1519 );
1520 }
1521 $post_image = Images::get_image( $post_id, $image_args );
1522
1523 if ( is_array( $post_image ) ) {
1524 $img_url = $post_image['src'];
1525 } elseif ( ! $is_gated && class_exists( 'Jetpack_Media_Summary' ) ) {
1526 $media = Jetpack_Media_Summary::get( $post_id );
1527
1528 if ( is_array( $media ) && ! empty( $media['image'] ) ) {
1529 $img_url = $media['image'];
1530 }
1531 }
1532
1533 if ( ! empty( $img_url ) ) {
1534 if ( ! empty( $post_image['alt_text'] ) ) {
1535 $image_params['alt_text'] = $post_image['alt_text'];
1536 } else {
1537 $image_params['alt_text'] = '';
1538 }
1539
1540 $thumbnail_width = 0;
1541 $thumbnail_height = 0;
1542
1543 if ( ! empty( $thumbnail_size['width'] ) ) {
1544 $thumbnail_width = $thumbnail_size['width'];
1545 $image_params['width'] = $thumbnail_width;
1546 }
1547
1548 if ( ! empty( $thumbnail_size['height'] ) ) {
1549 $thumbnail_height = $thumbnail_size['height'];
1550 $image_params['height'] = $thumbnail_height;
1551 }
1552
1553 $image_params['src'] = Images::fit_image_url(
1554 $img_url,
1555 $thumbnail_width,
1556 $thumbnail_height
1557 );
1558
1559 // Add a srcset to handle zoomed views and high-density screens.
1560 $srcset = Images::generate_cropped_srcset(
1561 $post_image,
1562 $thumbnail_width,
1563 $thumbnail_height
1564 );
1565 if ( ! empty( $srcset ) ) {
1566 $image_params['srcset'] = $srcset;
1567 }
1568 }
1569
1570 return $image_params;
1571 }
1572
1573 /**
1574 * Returns the string UTF-8 encoded
1575 *
1576 * @param string $text - the text we want to convert.
1577 * @return string
1578 */
1579 protected function to_utf8( $text ) {
1580 if ( $this->convert_charset ) {
1581 return iconv( $this->blog_charset, 'UTF-8', $text );
1582 } else {
1583 return $text;
1584 }
1585 }
1586
1587 /**
1588 * =============================================
1589 * PROTECTED UTILITY FUNCTIONS EXTENDED BY WPCOM
1590 * =============================================
1591 */
1592
1593 /**
1594 * Workhorse method to return array of related posts matched by Elasticsearch.
1595 *
1596 * @param int $post_id - the ID of the post.
1597 * @param int $size - the size of the post.
1598 * @param array $filters - filters.
1599 * @uses wp_remote_post, is_wp_error, get_option, wp_remote_retrieve_body, get_post, add_query_arg, remove_query_arg, get_permalink, get_post_format, apply_filters
1600 * @return array
1601 */
1602 protected function get_related_posts( $post_id, $size, array $filters ) {
1603 $hits = $this->filter_non_public_posts(
1604 $this->get_related_post_ids(
1605 $post_id,
1606 $size,
1607 $filters
1608 )
1609 );
1610
1611 /**
1612 * Filter the Related Posts matched by Elasticsearch.
1613 *
1614 * @module related-posts
1615 *
1616 * @since 2.9.0
1617 *
1618 * @param array $hits Array of Post IDs matched by Elasticsearch.
1619 * @param string $post_id Post ID of the post for which we are retrieving Related Posts.
1620 */
1621 $hits = apply_filters( 'jetpack_relatedposts_filter_hits', $hits, $post_id );
1622
1623 $related_posts = array();
1624 foreach ( $hits as $i => $hit ) {
1625 $related_posts[] = $this->get_related_post_data_for_post( $hit['id'], $i, $post_id );
1626 }
1627 return $related_posts;
1628 }
1629
1630 /**
1631 * Get array of related posts matched by Elasticsearch.
1632 *
1633 * @param int $post_id - the post ID.
1634 * @param int $size - the size.
1635 * @param array $filters - some filters.
1636 * @uses wp_remote_post, is_wp_error, wp_remote_retrieve_body, get_post_meta, update_post_meta
1637 * @return array
1638 */
1639 protected function get_related_post_ids( $post_id, $size, array $filters ) {
1640 $transient_name = null;
1641 $now_ts = time();
1642 $cache_meta_key = '_jetpack_related_posts_cache';
1643
1644 $body = array(
1645 'size' => (int) $size,
1646 );
1647
1648 if ( ! empty( $filters ) ) {
1649 $body['filter'] = array( 'and' => $filters );
1650 }
1651
1652 // Build cache key.
1653 $cache_key = md5( serialize( $body ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- this is used for caching.
1654
1655 // Load all cached values.
1656 if ( wp_using_ext_object_cache() ) {
1657 $transient_name = "{$cache_meta_key}_{$cache_key}_{$post_id}";
1658 $cache = get_transient( $transient_name );
1659 if ( false !== $cache ) {
1660 return $cache;
1661 }
1662 } else {
1663 $cache = get_post_meta( $post_id, $cache_meta_key, true );
1664
1665 if ( empty( $cache ) ) {
1666 $cache = array();
1667 }
1668
1669 // Cache is valid! Return cached value.
1670 if ( isset( $cache[ $cache_key ] ) && is_array( $cache[ $cache_key ] ) && $cache[ $cache_key ]['expires'] > $now_ts ) {
1671 return $cache[ $cache_key ]['payload'];
1672 }
1673 }
1674
1675 $user_agent = '';
1676 if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
1677 $user_agent = strtolower( filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) );
1678 }
1679
1680 $response = wp_remote_post(
1681 "https://public-api.wordpress.com/rest/v1/sites/{$this->get_blog_id()}/posts/$post_id/related/",
1682 array(
1683 'timeout' => 10,
1684 'user-agent' => "jetpack_related_posts, $user_agent",
1685 'sslverify' => true,
1686 'body' => $body,
1687 )
1688 );
1689
1690 // Oh no... return nothing don't cache errors. Also, don't cache HTTP 409 conflict responses.
1691 if ( is_wp_error( $response ) || WP_Http::CONFLICT === wp_remote_retrieve_response_code( $response ) ) {
1692 if ( isset( $cache[ $cache_key ] ) && is_array( $cache[ $cache_key ] ) ) {
1693 return $cache[ $cache_key ]['payload']; // return stale.
1694 } else {
1695 return array();
1696 }
1697 }
1698
1699 $results = json_decode( wp_remote_retrieve_body( $response ), true );
1700 $related_posts = array();
1701 if ( is_array( $results ) && ! empty( $results['hits'] ) ) {
1702 foreach ( $results['hits'] as $hit ) {
1703 $related_posts[] = array(
1704 'id' => $hit['fields']['post_id'],
1705 );
1706 }
1707 }
1708
1709 // An empty array might indicate no related posts or that posts
1710 // are not yet synced to WordPress.com, so we cache for only 1
1711 // minute in this case.
1712 if ( empty( $related_posts ) ) {
1713 $cache_ttl = 60;
1714 } else {
1715 $cache_ttl = 12 * HOUR_IN_SECONDS;
1716 }
1717
1718 // Update cache.
1719 if ( wp_using_ext_object_cache() ) {
1720 set_transient( $transient_name, $related_posts, $cache_ttl );
1721 } else {
1722 // Copy all valid cache values.
1723 $new_cache = array();
1724 foreach ( $cache as $k => $v ) {
1725 if ( is_array( $v ) && $v['expires'] > $now_ts ) {
1726 $new_cache[ $k ] = $v;
1727 }
1728 }
1729
1730 // Set new cache value.
1731 $cache_expires = $cache_ttl + $now_ts;
1732 $new_cache[ $cache_key ] = array(
1733 'expires' => $cache_expires,
1734 'payload' => $related_posts,
1735 );
1736 update_post_meta( $post_id, $cache_meta_key, $new_cache );
1737 }
1738
1739 return $related_posts;
1740 }
1741
1742 /**
1743 * Filter out any hits that are not public anymore.
1744 *
1745 * @param array $related_posts - the related posts.
1746 * @uses get_post_stati, get_post_status
1747 * @return array
1748 */
1749 protected function filter_non_public_posts( array $related_posts ) {
1750 $public_stati = get_post_stati( array( 'public' => true ) );
1751
1752 $filtered = array();
1753 foreach ( $related_posts as $hit ) {
1754 if ( in_array( get_post_status( $hit['id'] ), $public_stati, true ) ) {
1755 $filtered[] = $hit;
1756 }
1757 }
1758 return $filtered;
1759 }
1760
1761 /**
1762 * Generates the author byline for the related post.
1763 *
1764 * @param int $post_id - the post ID.
1765 * @uses get_post_field, get_the_author_meta
1766 * @return string
1767 */
1768 protected function generate_related_post_display_author( $post_id ) {
1769 $post_author = get_post_field( 'post_author', $post_id );
1770 $author_display_name = get_the_author_meta( 'display_name', $post_author );
1771 if ( ! empty( $author_display_name ) ) {
1772 return $author_display_name;
1773 }
1774 return '';
1775 }
1776
1777 /**
1778 * Generates a context for the related content (second line in related post output).
1779 * Order of importance:
1780 * - First category (Not 'Uncategorized')
1781 * - First post tag
1782 * - Number of comments
1783 *
1784 * @param int $post_id - the post ID.
1785 * @uses get_the_category, get_the_terms, get_comments_number, number_format_i18n, __, _n
1786 * @return string
1787 */
1788 protected function generate_related_post_context_block( $post_id ) {
1789 $categories = get_the_category( $post_id );
1790 if ( is_array( $categories ) ) {
1791 foreach ( $categories as $category ) {
1792 if ( $category instanceof WP_Term && 'uncategorized' !== $category->slug && '' !== trim( $category->name ) ) {
1793 $cat_link = get_category_link( $category );
1794 return array(
1795 'text' => trim( $category->name ),
1796 'link' => $cat_link,
1797 );
1798 }
1799 }
1800 }
1801 $tags = get_the_terms( $post_id, 'post_tag' );
1802 if ( is_array( $tags ) ) {
1803 foreach ( $tags as $tag ) {
1804 if ( $tag instanceof WP_Term && '' !== trim( $tag->name ) ) {
1805 $tag_link = get_tag_link( $tag );
1806 return array(
1807 'text' => trim( $tag->name ),
1808 'link' => $tag_link,
1809 );
1810 }
1811 }
1812 }
1813 $comment_count = get_comments_number( $post_id );
1814 if ( $comment_count > 0 ) {
1815 $comments_string = sprintf(
1816 // Translators: amount of comments.
1817 _n( 'With %s comment', 'With %s comments', $comment_count, 'jetpack' ),
1818 number_format_i18n( $comment_count )
1819 );
1820 $comments_link = get_comments_link( $post_id );
1821 return array(
1822 'text' => $comments_string,
1823 'link' => $comments_link,
1824 );
1825 }
1826 $fallback_string = __( 'Similar post', 'jetpack' );
1827 return array(
1828 'text' => $fallback_string,
1829 'link' => '',
1830 );
1831 }
1832
1833 /**
1834 * Generates a context for the related content (second line in related post output).
1835 * Order of importance:
1836 * - First category (Not 'Uncategorized')
1837 * - First post tag
1838 * - Number of comments
1839 *
1840 * @param int $post_id - the post ID.
1841 * @uses get_the_category, get_the_terms, get_comments_number, number_format_i18n, __, _n
1842 * @return string
1843 */
1844 protected function generate_related_post_context( $post_id ) {
1845 $categories = get_the_category( $post_id );
1846 if ( is_array( $categories ) ) {
1847 foreach ( $categories as $category ) {
1848 if ( $category instanceof WP_Term && 'uncategorized' !== $category->slug && '' !== trim( $category->name ) ) {
1849 $post_cat_context = sprintf(
1850 // Translators: The category or tag name.
1851 esc_html_x( 'In "%s"', 'in {category/tag name}', 'jetpack' ),
1852 $category->name
1853 );
1854 /**
1855 * Filter the "In Category" line displayed in the post context below each Related Post.
1856 *
1857 * @module related-posts
1858 *
1859 * @since 3.2.0
1860 *
1861 * @param string $post_cat_context "In Category" line displayed in the post context below each Related Post.
1862 * @param array $category Array containing information about the category.
1863 */
1864 return apply_filters( 'jetpack_relatedposts_post_category_context', $post_cat_context, $category );
1865 }
1866 }
1867 }
1868
1869 $tags = get_the_terms( $post_id, 'post_tag' );
1870 if ( is_array( $tags ) ) {
1871 foreach ( $tags as $tag ) {
1872 if ( $tag instanceof WP_Term && '' !== trim( $tag->name ) ) {
1873 $post_tag_context = sprintf(
1874 // Translators: the category or tag name.
1875 _x( 'In "%s"', 'in {category/tag name}', 'jetpack' ),
1876 $tag->name
1877 );
1878 /**
1879 * Filter the "In Tag" line displayed in the post context below each Related Post.
1880 *
1881 * @module related-posts
1882 *
1883 * @since 3.2.0
1884 *
1885 * @param string $post_tag_context "In Tag" line displayed in the post context below each Related Post.
1886 * @param array $tag Array containing information about the tag.
1887 */
1888 return apply_filters( 'jetpack_relatedposts_post_tag_context', $post_tag_context, $tag );
1889 }
1890 }
1891 }
1892
1893 $comment_count = get_comments_number( $post_id );
1894 if ( $comment_count > 0 ) {
1895 return sprintf(
1896 // Translators: amount of comments.
1897 _n( 'With %s comment', 'With %s comments', $comment_count, 'jetpack' ),
1898 number_format_i18n( $comment_count )
1899 );
1900 }
1901
1902 return __( 'Similar post', 'jetpack' );
1903 }
1904
1905 /**
1906 * Logs clicks for clickthrough analysis and related result tuning.
1907 *
1908 * @param int $post_id - the post ID.
1909 * @param int $to_post_id - the to post ID.
1910 * @param int $link_position - the link position.
1911 */
1912 protected function log_click( $post_id, $to_post_id, $link_position ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1913 }
1914
1915 /**
1916 * Determines if the current post is able to use related posts.
1917 *
1918 * @since 14.0 Checks for singular instead of single to allow usage on non-posts CPTs in block themes.
1919 * @uses self::get_options, is_admin, is_singular, apply_filters
1920 * @return bool
1921 */
1922 protected function enabled_for_request() {
1923 /*
1924 * On block themes, allow usage on any singular view (post, page, CPT).
1925 * On classic themes, only allow usage on single posts by default.
1926 */
1927 $enabled_on_singular_views = wp_is_block_theme()
1928 ? is_singular()
1929 : is_single();
1930
1931 $enabled = $enabled_on_singular_views
1932 && ! is_attachment()
1933 && ! is_admin()
1934 && ! is_embed()
1935 && ( ! $this->allow_feature_toggle() || $this->get_option( 'enabled' ) );
1936
1937 /**
1938 * Filter the Enabled value to allow related posts to be selectively enabled/disabled.
1939 *
1940 * @module related-posts
1941 *
1942 * @since 3.3.0
1943 *
1944 * @param bool $enabled Should Related Posts be enabled on the current page.
1945 */
1946 return apply_filters( 'jetpack_relatedposts_filter_enabled_for_request', $enabled );
1947 }
1948
1949 /**
1950 * Adds filters.
1951 *
1952 * @uses self::enqueue_assets, self::setup_shortcode, add_filter
1953 */
1954 protected function action_frontend_init_page() {
1955 $this->enqueue_assets( true, true );
1956 $this->setup_shortcode();
1957
1958 add_filter( 'the_content', array( $this, 'filter_add_target_to_dom' ), 40 );
1959 }
1960
1961 /**
1962 * Determines if the scripts need be enqueued.
1963 *
1964 * @return bool
1965 */
1966 protected function requires_scripts() {
1967 return (
1968 ! ( class_exists( 'Jetpack_AMP_Support' ) && Jetpack_AMP_Support::is_amp_request() ) &&
1969 ! has_block( 'jetpack/related-posts' ) &&
1970 ! Blocks::is_fse_theme()
1971 );
1972 }
1973
1974 /**
1975 * Enqueues assets needed to do async loading of related posts.
1976 *
1977 * @param string $script - the script we're enqueing.
1978 * @param string $style - the style we're enqueing.
1979 *
1980 * @uses wp_enqueue_script, wp_enqueue_style, plugins_url
1981 */
1982 protected function enqueue_assets( $script, $style ) {
1983 $dependencies = is_customize_preview() ? array( 'customize-base' ) : array();
1984 // Do not enqueue scripts unless they are required.
1985 if ( $script && $this->requires_scripts() ) {
1986 wp_enqueue_script(
1987 'jetpack_related-posts',
1988 Assets::get_file_url_for_environment(
1989 '_inc/build/related-posts/related-posts.min.js',
1990 'modules/related-posts/related-posts.js'
1991 ),
1992 $dependencies,
1993 self::VERSION,
1994 false
1995 );
1996 $related_posts_js_options = array(
1997 /**
1998 * Filter each Related Post Heading structure.
1999 *
2000 * @since 4.0.0
2001 *
2002 * @param string $str Related Post Heading structure. Default to h4.
2003 */
2004 'post_heading' => apply_filters( 'jetpack_relatedposts_filter_post_heading', esc_attr( 'h4' ) ),
2005 );
2006 wp_localize_script( 'jetpack_related-posts', 'related_posts_js_options', $related_posts_js_options );
2007 }
2008 if ( $style ) {
2009 wp_enqueue_style( 'jetpack_related-posts', plugins_url( 'related-posts.css', __FILE__ ), array(), self::VERSION );
2010 wp_style_add_data( 'jetpack_related-posts', 'rtl', 'replace' );
2011 add_action( 'amp_post_template_css', array( $this, 'render_amp_reader_mode_css' ) );
2012 }
2013 }
2014
2015 /**
2016 * Render AMP's reader mode CSS.
2017 */
2018 public function render_amp_reader_mode_css() {
2019 echo file_get_contents( __DIR__ . '/related-posts.css' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- this is loading a CSS file.
2020 }
2021
2022 /**
2023 * Sets up the shortcode processing.
2024 *
2025 * @uses add_filter, add_shortcode
2026 */
2027 protected function setup_shortcode() {
2028 add_filter( 'the_content', array( $this, 'test_for_shortcode' ), 0 );
2029
2030 add_shortcode( self::SHORTCODE, array( $this, 'get_client_rendered_html' ) );
2031 }
2032
2033 /**
2034 * Return status of related posts toggle.
2035 */
2036 protected function allow_feature_toggle() {
2037 if ( null === $this->allow_feature_toggle ) {
2038 /**
2039 * Filter the display of the Related Posts toggle in Settings > Reading.
2040 *
2041 * @module related-posts
2042 *
2043 * @since 2.8.0
2044 *
2045 * @param bool $allow_feature_toggle Display a feature toggle. Default to false.
2046 */
2047 $this->allow_feature_toggle = apply_filters( 'jetpack_relatedposts_filter_allow_feature_toggle', false );
2048 }
2049 return $this->allow_feature_toggle;
2050 }
2051
2052 /**
2053 * ===================================================
2054 * FUNCTIONS EXPOSING RELATED POSTS IN THE WP REST API
2055 * ===================================================
2056 */
2057
2058 /**
2059 * Add Related Posts to the REST API Post response.
2060 *
2061 * @since 4.4.0
2062 *
2063 * @action rest_api_init
2064 * @uses register_rest_field, self::rest_get_related_posts
2065 */
2066 public function rest_register_related_posts() {
2067 /** This filter is already documented in class.json-api-endpoints.php */
2068 $post_types = apply_filters( 'rest_api_allowed_post_types', array( 'post', 'page', 'revision' ) );
2069
2070 /**
2071 * Filter the post types that are allowed to have related posts.
2072 *
2073 * @since 15.3
2074 *
2075 * @param array $post_types The post types that are allowed to have related posts.
2076 */
2077 $post_types = apply_filters( 'jetpack_related_posts_rest_api_allowed_post_types', $post_types );
2078
2079 foreach ( $post_types as $post_type ) {
2080 register_rest_field(
2081 $post_type,
2082 'jetpack-related-posts',
2083 array(
2084 'get_callback' => array( $this, 'rest_get_related_posts' ),
2085 'update_callback' => null,
2086 'schema' => null,
2087 )
2088 );
2089 }
2090 }
2091
2092 /**
2093 * Build an array of Related Posts.
2094 * By default returns cached results that are stored for up to 12 hours.
2095 *
2096 * @since 4.4.0
2097 *
2098 * @param array $object Details of current post.
2099 *
2100 * @uses self::get_for_post_id
2101 *
2102 * @return array
2103 */
2104 public function rest_get_related_posts( $object ) {
2105 if ( ! isset( $object['id'] ) ) {
2106 return array();
2107 }
2108
2109 // If the Related Posts option is turned off, don't get the related posts.
2110 $options = \Jetpack_Options::get_option( 'relatedposts', array() );
2111 if ( empty( $options['enabled'] ) || ! $options['enabled'] ) {
2112 return array();
2113 }
2114
2115 // If the current post doesn't contain a Related Posts block, and we're also on an admin page, don't get the related posts.
2116 // This will ensure that if the feature is enabled, we can still retrieve Related Posts via the REST API.
2117 if ( ! has_block( 'jetpack/related-posts' ) && is_admin() ) {
2118 return array();
2119 }
2120
2121 return $this->get_for_post_id( $object['id'], array( 'size' => 6 ) );
2122 }
2123 }
2124
2125 /**
2126 * The raw related posts class can be used by other plugins or themes
2127 * to get related content. This class wraps the existing RelatedPosts
2128 * logic thus we never want to add anything to the DOM or do anything
2129 * for event hooks. We will also not present any settings for this
2130 * class and keep it enabled as calls to this class are done
2131 * programmatically.
2132 */
2133 class Jetpack_RelatedPosts_Raw extends Jetpack_RelatedPosts { //phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace, Generic.Files.OneObjectStructurePerFile.MultipleFound
2134
2135 /**
2136 * The query name we want to look up.
2137 *
2138 * @var string
2139 */
2140 protected $query_name;
2141
2142 /**
2143 * Allows callers of this class to tag each query with a unique name for tracking purposes.
2144 *
2145 * @param string $name - the name of the query.
2146 * @return Jetpack_RelatedPosts_Raw
2147 */
2148 public function set_query_name( $name ) {
2149 $this->query_name = (string) $name;
2150 return $this;
2151 }
2152
2153 /**
2154 * Initialize admin.
2155 */
2156 public function action_admin_init() {}
2157
2158 /**
2159 * Initialize front end.
2160 */
2161 public function action_frontend_init() {}
2162
2163 /**
2164 * Get options.
2165 */
2166 public function get_options() {
2167 return array(
2168 'enabled' => true,
2169 );
2170 }
2171
2172 /**
2173 * Workhorse method to return array of related posts ids matched by Elasticsearch.
2174 *
2175 * @param int $post_id - the post ID.
2176 * @param int $size - size of the post.
2177 * @param array $filters - filters we're using.
2178 * @uses wp_remote_post, is_wp_error, wp_remote_retrieve_body
2179 * @return array
2180 */
2181 protected function get_related_posts( $post_id, $size, array $filters ) {
2182 $hits = $this->filter_non_public_posts(
2183 $this->get_related_post_ids(
2184 $post_id,
2185 $size,
2186 $filters
2187 )
2188 );
2189
2190 /** This filter is already documented in modules/related-posts/related-posts.php */
2191 $hits = apply_filters( 'jetpack_relatedposts_filter_hits', $hits, $post_id );
2192
2193 return $hits;
2194 }
2195 }
2196