Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.73% covered (warning)
50.73%
139 / 274
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V3_Endpoint_Blogging_Prompts
51.29% covered (warning)
51.29%
139 / 271
13.33% covered (danger)
13.33%
2 / 15
506.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 register_routes
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 get_items
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
8.21
 get_item
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
6.80
 modify_query
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 map_date_query
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 filter_sql
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 prepare_item_for_response
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
240
 is_in_bloganuary
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_bloganuary_id
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 prepare_date_response
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 get_collection_params
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
4
 get_item_schema
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
1
 permissions_check
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 build_answering_users_sample
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * Blogging prompts endpoint for wpcom/v3.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * REST API endpoint wpcom/v3/sites/%s/blogging-prompts.
16 */
17class WPCOM_REST_API_V3_Endpoint_Blogging_Prompts extends WP_REST_Posts_Controller {
18
19    use WPCOM_REST_API_Proxy_Request;
20
21    const TEMPLATE_BLOG_ID = 205876834;
22
23    /**
24     * Whether the endpoint is running on wpcom, or not.
25     *
26     * @var bool
27     */
28    public $is_wpcom;
29
30    /**
31     * Day of the year, from 1 to 366, and 0 representing no query.
32     *
33     * Used with yearless dates like `--12-20`, to get prompts by month and day, regardless of year.
34     *
35     * @var integer
36     */
37    public $day_of_year_query = 0;
38
39    /**
40     * A year used to force one prompt per day for a specific year.
41     *
42     * @var integer
43     */
44    public $force_year = 0;
45
46    /**
47     * Constructor.
48     */
49    public function __construct() {
50        $this->post_type                       = 'post';
51        $this->base_api_path                   = 'wpcom';
52        $this->version                         = 'v3';
53        $this->namespace                       = $this->base_api_path . '/' . $this->version;
54        $this->rest_base                       = 'blogging-prompts';
55        $this->wpcom_is_wpcom_only_endpoint    = true;
56        $this->wpcom_is_site_specific_endpoint = true;
57        $this->is_wpcom                        = defined( 'IS_WPCOM' ) && IS_WPCOM;
58
59        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
60    }
61
62    /**
63     * Registers the routes for blogging prompts.
64     *
65     * @see register_rest_route()
66     */
67    public function register_routes() {
68        register_rest_route(
69            $this->namespace,
70            '/' . $this->rest_base,
71            array(
72                array(
73                    'methods'             => WP_REST_Server::READABLE,
74                    'callback'            => array( $this, 'get_items' ),
75                    'permission_callback' => array( $this, 'permissions_check' ),
76                    'args'                => $this->get_collection_params(),
77                ),
78                'schema' => array( $this, 'get_item_schema' ),
79            )
80        );
81
82        register_rest_route(
83            $this->namespace,
84            '/' . $this->rest_base . '/(?P<id>[\d]+)',
85            array(
86                'args'   => array(
87                    'id' => array(
88                        'description' => __( 'Unique identifier for the prompt.', 'jetpack' ),
89                        'type'        => 'integer',
90                    ),
91                ),
92                array(
93                    'methods'             => WP_REST_Server::READABLE,
94                    'callback'            => array( $this, 'get_item' ),
95                    'permission_callback' => array( $this, 'permissions_check' ),
96                ),
97                'schema' => array( $this, 'get_item_schema' ),
98            )
99        );
100    }
101
102    /**
103     * Retrieves a collection of blogging prompts.
104     *
105     * @param WP_REST_Request $request Full details about the request.
106     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
107     */
108    public function get_items( $request ) {
109        if ( ! $this->is_wpcom ) {
110            return $this->proxy_request_to_wpcom( $request, '', 'user', true );
111        }
112
113        if ( $request->get_param( 'force_year' ) ) {
114            $this->force_year = $request->get_param( 'force_year' );
115        }
116
117        switch_to_blog( self::TEMPLATE_BLOG_ID );
118        add_action( 'pre_get_posts', array( $this, 'modify_query' ) );
119        add_filter( 'posts_clauses', array( $this, 'filter_sql' ) );
120        $items = parent::get_items( $request );
121        remove_filter( 'posts_clauses', array( $this, 'filter_sql' ) );
122        remove_action( 'pre_get_posts', array( $this, 'modify_query' ) );
123        restore_current_blog();
124
125        return $items;
126    }
127
128    /**
129     * Retrieves a single blogging prompt.
130     *
131     * @param WP_REST_Request $request Full details about the request.
132     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
133     */
134    public function get_item( $request ) {
135        if ( ! $this->is_wpcom ) {
136            return $this->proxy_request_to_wpcom( $request, $request->get_param( 'id' ), 'user', true );
137        }
138
139        if ( $request->get_param( 'force_year' ) ) {
140            $this->force_year = $request->get_param( 'force_year' );
141        }
142
143        switch_to_blog( self::TEMPLATE_BLOG_ID );
144        $item = parent::get_item( $request );
145        restore_current_blog();
146
147        return $item;
148    }
149
150    /**
151     * Modify the posts query using the {@see 'pre_get_posts'} hook.
152     *
153     * @param WP_Query $wp_query The WP_Query instance (passed by reference).
154     */
155    public function modify_query( &$wp_query ) {
156        if ( is_array( $wp_query->query_vars['date_query'] ) ) {
157            $wp_query->query_vars['date_query'] = array_map(
158                array( $this, 'map_date_query' ),
159                $wp_query->query_vars['date_query']
160            );
161        }
162    }
163
164    /**
165     * Modify date_query items when querying prompts.
166     *
167     * @link https://developer.wordpress.org/reference/classes/WP_Query/#date-parameters
168     *
169     * @param  array|string|null $date_query Date query.
170     * @return array|string|null             Modified date query.
171     */
172    public function map_date_query( $date_query ) {
173        if ( isset( $date_query['after'] ) ) {
174            // `after` date queries should include posts on the specified date, so force `inclusive` queries.
175            $date_query['inclusive'] = true;
176
177            // If using a "year-less" date, e.g. `--03-16`, override the date_query, and prepare to modify sql manually.
178            // `after` should be a date string when making API requests, rather than an array.
179            if ( is_string( $date_query['after'] ) && str_starts_with( $date_query['after'], '-' ) ) {
180                $date = date_create_from_format( '--m-d', $date_query['after'] );
181
182                if ( false !== $date ) {
183                    // PHP day of the year starts with 0; normalize to match SQL DAYOFTHEYEAR which starts with 1.
184                    $this->day_of_year_query = $date->format( 'z' ) + 1;
185
186                    // Unset the date query, since we'll by modifying the SQL manually.
187                    return null;
188                }
189            }
190        }
191
192        return $date_query;
193    }
194
195    /**
196     * Modify post sql for custom date ordering using the {@see 'posts_clauses'} hook.
197     *
198     * @param array $clauses SQL clauses for the current query.
199     * @return array         Modified SQL clauses.
200     */
201    public function filter_sql( $clauses ) {
202        global $wpdb;
203        if ( $this->day_of_year_query > 0 ) {
204            $day  = $this->day_of_year_query;
205            $year = $this->force_year ? $this->force_year : wp_date( 'Y' );
206
207            // Grab the current sort order, `ASC` or `DESC`, so we can reuse it.
208            $exploded = explode( ' ', $clauses['orderby'] );
209            $order    = end( $exploded );
210
211            // Calculate the day of year for each prompt, from 1 to 366, but use the current year so that prompts published
212            // during leap years have the correct day for non-leap years.
213            $fields = $clauses['fields'] . $wpdb->prepare( ", DAYOFYEAR(CONCAT(%d, DATE_FORMAT({$wpdb->posts}.post_date, '-%%m-%%d'))) AS day_of_year", $year );
214
215            // When it's not a leap year, exclude posts used for Feb 29th. DAYOFYEAR will return null for Feb 29th on non-leap years.
216            $where = $clauses['where'] . $wpdb->prepare( " AND DAYOFYEAR(CONCAT(%d, DATE_FORMAT({$wpdb->posts}.post_date, '-%%m-%%d'))) IS NOT NULL", $year );
217
218            // Order posts regardless of year: get a list of posts for each day,
219            // starting with the query date through the end of the year, then from the start of the year through the day before.
220            $orderby = $wpdb->prepare(
221                'CASE ' .
222                    'WHEN day_of_year < %d ' .
223                    // Push posts from the beginning of the year until the day before to the end.
224                    'THEN day_of_year + 366 ' .
225                    // Otherwise order posts from the query date through the end of the year.
226                    'ELSE day_of_year ' .
227                'END' .
228                // Sort posts for the same day by year, in asc or desc order.
229                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- order string cannot be escaped.
230                ", YEAR({$wpdb->posts}.post_date) " . ( 'DESC' === $order ? 'DESC' : 'ASC' ),
231                $day
232            );
233
234            if ( $this->force_year ) {
235                // If we're forcing the year, group by day of year, so that we only get one prompt per day.
236                $clauses['groupby'] = 'day_of_year';
237
238                // Ensure we get either to newest or oldest prompt for each day of the year, depending on the sort order.
239                // GROUP BY runs and collects the prompts for each day of the year before ORDER BY is run, so we first need to use MAX/MIN on post_date
240                // to find the most recent/oldest prompt for each day and join the results to the main query.
241                $clauses['join'] = $wpdb->prepare(
242                    'INNER JOIN (' .
243                        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL function cannot be escaped.
244                        'SELECT ' . ( 'DESC' === $order ? 'MAX' : 'MIN' ) . "({$wpdb->posts}.post_date) AS post_date, DAYOFYEAR(CONCAT(%d, DATE_FORMAT(post_date, '-%%m-%%d'))) AS day_of_year " .
245                        "FROM {$wpdb->posts} " .
246                        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- reuses unmodified existing clause.
247                        "WHERE 1=1 {$clauses['where']} " .
248                        'GROUP BY day_of_year' .
249                    ") AS newest_prompts ON {$wpdb->posts}.post_date = newest_prompts.post_date",
250                    $year
251                );
252            }
253
254            $clauses['fields']  = $fields;
255            $clauses['where']   = $where;
256            $clauses['orderby'] = $orderby;
257        }
258
259        return $clauses;
260    }
261
262    /**
263     * Prepares a single blogging prompt output for response.
264     *
265     * @param WP_Post         $prompt  Post object.
266     * @param WP_REST_Request $request Request object.
267     * @return WP_REST_Response        Response object.
268     */
269    public function prepare_item_for_response( $prompt, $request ) {
270        require_once WP_CONTENT_DIR . '/lib/blogging-prompts/answers.php';
271        require_once WP_CONTENT_DIR . '/lib/blogging-prompts/utils.php';
272
273        $fields = $this->get_fields_for_response( $request );
274
275        // Base fields for every post.
276        $data = array();
277
278        if ( rest_is_field_included( 'id', $fields ) ) {
279            $data['id'] = $prompt->ID;
280        }
281
282        if ( rest_is_field_included( 'date', $fields ) ) {
283            $data['date'] = $this->prepare_date_response( $prompt->post_date_gmt );
284        }
285
286        if ( rest_is_field_included( 'label', $fields ) ) {
287            if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) ) {
288                $data['label'] = __( 'Bloganuary writing prompt', 'jetpack' );
289            } else {
290                $data['label'] = __( 'Daily writing prompt', 'jetpack' );
291            }
292        }
293
294        if ( rest_is_field_included( 'text', $fields ) ) {
295            $text = \BloggingPrompts\prompt_without_blocks( $prompt->post_content );
296            // Allow translating a variable, since this text is imported from bloggingpromptstemplates.wordpress.com for translation.
297            $translated_text = __( $text, 'jetpack' ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
298            $data['text']    = wp_kses( $translated_text, wp_kses_allowed_html( 'post' ) );
299        }
300
301        if ( rest_is_field_included( 'attribution', $fields ) ) {
302            $data['attribution'] = esc_html( get_post_meta( $prompt->ID, 'blogging_prompts_attribution', true ) );
303        }
304
305        // Will always be false when requesting as blog.
306        if ( rest_is_field_included( 'answered', $fields ) ) {
307            $data['answered'] = (bool) \A8C\BloggingPrompts\Answers::is_answered_by_user( $prompt->ID, get_current_user_id() );
308        }
309
310        if ( rest_is_field_included( 'answered_users_count', $fields ) ) {
311            $data['answered_users_count'] = (int) \A8C\BloggingPrompts\Answers::get_count( $prompt->ID );
312        }
313
314        if ( rest_is_field_included( 'answered_users_sample', $fields ) ) {
315            $data['answered_users_sample'] = $this->build_answering_users_sample( $prompt->ID );
316        }
317
318        if ( rest_is_field_included( 'answered_link', $fields ) ) {
319            if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) ) {
320                $bloganuary_id         = $this->get_bloganuary_id( $prompt->post_date_gmt );
321                $data['answered_link'] = esc_url( "https://wordpress.com/tag/{$bloganuary_id}" );
322            } else {
323                $data['answered_link'] = esc_url( "https://wordpress.com/tag/dailyprompt-{$prompt->ID}" );
324            }
325        }
326
327        if ( rest_is_field_included( 'answered_link_text', $fields ) ) {
328            $data['answered_link_text'] = __( 'View all responses', 'jetpack' );
329        }
330
331        if ( $this->is_in_bloganuary( $prompt->post_date_gmt ) && rest_is_field_included( 'bloganuary_id', $fields ) ) {
332            $data['bloganuary_id'] = $this->get_bloganuary_id( $prompt->post_date_gmt );
333        }
334
335        return $data;
336    }
337
338    /**
339     * Return true if the post is in "Bloganuary"
340     *
341     * @param string $post_date_gmt Unused - Post date in GMT.
342     * @return bool Always returns false as Bloganuary is disabled.
343     */
344    protected function is_in_bloganuary( $post_date_gmt ) { //phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
345
346        /*
347        Disable for January 2025 and beyond (see https://wp.me/p5uIfZ-gxX).
348            Previously, this method would check if the post was published in January:
349            - Extract month from post_date_gmt -- $post_month = gmdate( 'm', strtotime( $post_date_gmt ) );
350            - Return true if month was '01' -- return $post_month === '01';
351        */
352        return false;
353    }
354
355    /**
356     * Return the bloganuary id of the form `bloganuary-yyyy-dd`
357     *
358     * @param string $post_date_gmt Post date in GMT.
359     * @return string Bloganuary id.
360     */
361    protected function get_bloganuary_id( $post_date_gmt ) {
362        $post_year_day = gmdate( 'Y-d', strtotime( $post_date_gmt ) );
363        if ( $this->force_year ) {
364            $post_year_day = $this->force_year . '-' . gmdate( 'd', strtotime( $post_date_gmt ) );
365        }
366        return 'bloganuary-' . $post_year_day;
367    }
368
369    /**
370     * Format a date for a blogging prompt, omiting the time.
371     *
372     * @param string $date_gmt Publish datetime of the prompt in GMT, i.e. 0000-00-00 00:00:00.
373     * @param string $date     Publish datetime of the prompt, i.e. 0000-00-00 00:00:00.
374     * @return string Publish date of the prompt in YYYY-MM-DD format.
375     */
376    public function prepare_date_response( $date_gmt, $date = null ) {
377        $post_date = $date ? $date : $date_gmt;
378        $date_obj  = date_create( $post_date );
379
380        if ( $this->force_year ) {
381            $date_obj->setDate( $this->force_year, (int) $date_obj->format( 'n' ), (int) $date_obj->format( 'j' ) );
382
383            // If ascending by day of year, go to the next year when we pass the last day of the year.
384            if ( $date_obj->format( 'm-d' ) === '12-31' ) {
385                $this->force_year += 1;
386            }
387        }
388
389        return false !== $date_obj ? $date_obj->format( 'Y-m-d' ) : substr( $post_date, 0, 10 );
390    }
391
392    /**
393     * Retrieves the query params for blogging prompt collections.
394     *
395     * @return array Query parameters for the collection.
396     */
397    public function get_collection_params() {
398        $parent_args = parent::get_collection_params();
399
400        $args = array(
401            // Modify date args so that will except a YYYY-MM-DD without a time.
402            'after'      => array(
403                'description'       => __( 'Show prompts following a given date.', 'jetpack' ),
404                'type'              => 'string',
405                'validate_callback' => function ( $param ) {
406                    // Allow month and day without year, e.g. `--02-28`
407                    if ( str_starts_with( $param, '-' ) ) {
408                        return false !== date_create_from_format( '--m-d', $param );
409                    }
410
411                    return false !== date_create( $param );
412                },
413            ),
414            'before'     => array(
415                'description'       => __( 'Show prompts before a given date.', 'jetpack' ),
416                'type'              => 'string',
417                'validate_callback' => function ( $param ) {
418                    return false !== date_create( $param );
419                },
420            ),
421            'force_year' => array(
422                'description'       => __( 'Force the returned prompts to be for a specific year. Returns only one prompt for each day.', 'jetpack' ),
423                'type'              => 'integer',
424                'validate_callback' => function ( $param ) {
425                    return is_numeric( $param ) && intval( $param ) > 0 && intval( $param ) < 9999;
426                },
427            ),
428        );
429
430        $args['exclude']          = $parent_args['exclude'];
431        $args['include']          = $parent_args['include'];
432        $args['page']             = $parent_args['page'];
433        $args['per_page']         = $parent_args['per_page'];
434        $args['order']            = $parent_args['order'];
435        $args['order']['default'] = 'asc';
436        $args['orderby']          = $parent_args['orderby'];
437        $args['search']           = $parent_args['search'];
438
439        return $args;
440    }
441
442    /**
443     * Retrieves the blogging prompt's schema, conforming to JSON Schema.
444     *
445     * @return array Item schema data.
446     */
447    public function get_item_schema() {
448        return array(
449            '$schema'    => 'http://json-schema.org/draft-04/schema#',
450            'title'      => 'blogging-prompt',
451            'type'       => 'object',
452            'properties' => array(
453                'id'                    => array(
454                    'description' => __( 'Unique identifier for the post.', 'jetpack' ),
455                    'type'        => 'integer',
456                ),
457                'date'                  => array(
458                    'description' => __( "The date the post was published, in the site's timezone.", 'jetpack' ),
459                    'type'        => 'string',
460                ),
461                'label'                 => array(
462                    'description' => __( 'Label for the prompt.', 'jetpack' ),
463                    'type'        => 'string',
464                ),
465                'text'                  => array(
466                    'description' => __( 'The text of the prompt. May include html tags like <em>.', 'jetpack' ),
467                    'type'        => 'string',
468                ),
469                'attribution'           => array(
470                    'description' => __( 'Source of the prompt, if known.', 'jetpack' ),
471                    'type'        => 'string',
472                ),
473                'answered'              => array(
474                    'description' => __( 'Whether the user has answered the prompt.', 'jetpack' ),
475                    'type'        => 'boolean',
476                ),
477                'answered_users_count'  => array(
478                    'description' => __( 'Number of users who have answered the prompt.', 'jetpack' ),
479                    'type'        => 'integer',
480                ),
481                'answered_users_sample' => array(
482                    'description' => __( 'Sample of users who have answered the prompt.', 'jetpack' ),
483                    'type'        => 'array',
484                    'items'       => array(
485                        'type'       => 'object',
486                        'properties' => array(
487                            'avatar' => array(
488                                'description' => __( "Gravatar URL for the user's avatar image.", 'jetpack' ),
489                                'type'        => 'string',
490                                'format'      => 'uri',
491                            ),
492                        ),
493                    ),
494                ),
495                'answered_link'         => array(
496                    'description' => __( 'Link to answers for the prompt.', 'jetpack' ),
497                    'type'        => 'string',
498                    'format'      => 'uri',
499                ),
500                'answered_link_text'    => array(
501                    'description' => __( 'Text for the link to answers for the prompt.', 'jetpack' ),
502                    'type'        => 'string',
503                ),
504                'bloganuary_id'         => array(
505                    'description' => __( 'Id used by the bloganuary promotion', 'jetpack' ),
506                    'type'        => 'string',
507                ),
508            ),
509        );
510    }
511
512    /**
513     * Checks if a given request has access to read blogging prompts for a site.
514     *
515     * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
516     */
517    public function permissions_check() {
518        if ( current_user_can( 'edit_posts' ) ) {
519            return true;
520        }
521
522        // Allow "as blog" requests to wpcom so users without accounts can insert the Writing prompt block in the editor.
523        if ( $this->is_wpcom && is_jetpack_site( get_current_blog_id() ) ) {
524            if ( ! class_exists( 'WPCOM_REST_API_V2_Endpoint_Jetpack_Auth' ) ) {
525                require_once dirname( __DIR__ ) . '/rest-api-plugins/endpoints/jetpack-auth.php';
526            }
527
528            $jp_auth_endpoint = new WPCOM_REST_API_V2_Endpoint_Jetpack_Auth();
529            if ( true === $jp_auth_endpoint->is_jetpack_authorized_for_site() ) {
530                return true;
531            }
532        }
533
534        return new WP_Error(
535            'rest_cannot_read_prompts',
536            __( 'Sorry, you are not allowed to access blogging prompts on this site.', 'jetpack' ),
537            array( 'status' => rest_authorization_required_code() )
538        );
539    }
540
541    /**
542     * Creates a sample of users who have answered a blogging prompt.
543     *
544     * @param int $prompt_id Prompt ID.
545     * @return array List of users, including a gravatar url for each user.
546     */
547    protected function build_answering_users_sample( $prompt_id ) {
548        $results = \A8C\BloggingPrompts\Answers::get_sample_users_by( $prompt_id );
549
550        if ( ! $results ) {
551            return array();
552        }
553
554        $users = array();
555
556        foreach ( $results as $user ) {
557            $url = wpcom_get_avatar_url( $user->user_id, 96, 'identicon', false );
558            if ( has_gravatar( $user->user_id ) && ! empty( $url[0] ) && ! is_wp_error( $url[0] ) ) {
559                $users[] = array(
560                    'avatar' => (string) esc_url_raw( htmlspecialchars_decode( $url[0], ENT_COMPAT ) ),
561                );
562            }
563        }
564
565        return array_slice( $users, 0, 3 );
566    }
567}
568
569wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V3_Endpoint_Blogging_Prompts' );