PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 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 All 508 releases
← All changes | modules/related-posts/jetpack-related-posts.php +2195 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,2195 @@
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 +}