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