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