Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.50% covered (warning)
57.50%
69 / 120
30.77% covered (danger)
30.77%
4 / 13
CRAP
n/a
0 / 0
jetpack_blogging_prompts_add_meta_data
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
jetpack_setup_blogging_prompt_response
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
jetpack_setup_blogging_prompt_response_rest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
jetpack_apply_blogging_prompt_response
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
jetpack_mark_if_post_answers_blogging_prompt
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
22
jetpack_get_blogging_prompt_route
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
jetpack_get_blogging_prompt_by_id
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
jetpack_get_daily_blogging_prompts
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
jetpack_has_or_will_publish_posts
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
jetpack_has_posts_page
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
jetpack_has_write_intent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
jetpack_is_new_post_screen
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
jetpack_is_potential_blogging_site
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Used by the blogging prompt feature.
4 *
5 * @package automattic/jetpack
6 */
7
8if ( ! defined( 'ABSPATH' ) ) {
9    exit( 0 );
10}
11
12/**
13 * Hooked functions.
14 */
15
16/**
17 * Adds the blogging prompt key post meta to the list of allowed post meta to be updated by rest api.
18 *
19 * @param array $keys Array of post meta keys that are allowed public metadata.
20 *
21 * @return array
22 */
23function jetpack_blogging_prompts_add_meta_data( $keys ) {
24    $keys[] = '_jetpack_blogging_prompt_key';
25    return $keys;
26}
27
28add_filter( 'rest_api_allowed_public_metadata', 'jetpack_blogging_prompts_add_meta_data' );
29
30/**
31 * Sets up a new post as an answer to a blogging prompt (classic new-post screen).
32 *
33 * When we know a user is explicitly answering a prompt, pre-populate the post meta to mark the post as a prompt response,
34 * in case they decide to remove the block from the post content, preventing they meta from being added later.
35 *
36 * REST creations (e.g. the Write editor's POST /wp/v2/posts?answer_prompt=…) are
37 * handled by jetpack_setup_blogging_prompt_response_rest() on rest_after_insert_post
38 * instead â€” that hook runs after the REST controller sets the request's tags, so the
39 * prompt tags we add aren't overwritten.
40 *
41 * Called on `wp_insert_post` hook.
42 *
43 * @param int $post_id ID of post being inserted.
44 * @return void
45 */
46function jetpack_setup_blogging_prompt_response( $post_id ) {
47    if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
48        return;
49    }
50
51    if ( ! jetpack_is_new_post_screen() ) {
52        return;
53    }
54
55    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Clicking a prompt response link can happen from notifications, Calypso, wp-admin, email, etc and only sets up a response post (tag, meta, prompt text); the user must take action to actually publish the post.
56    $prompt_id = isset( $_GET['answer_prompt'] ) ? absint( $_GET['answer_prompt'] ) : 0;
57    if ( $prompt_id ) {
58        jetpack_apply_blogging_prompt_response( $post_id, $prompt_id );
59    }
60}
61
62add_action( 'wp_insert_post', 'jetpack_setup_blogging_prompt_response' );
63
64/**
65 * Sets up a REST-created post (e.g. the Write editor) as an answer to a prompt.
66 *
67 * Runs on `rest_after_insert_post`, which fires after the REST controller has set
68 * the request's tags â€” so the prompt tags added here survive.
69 *
70 * @param WP_Post         $post     Inserted post object.
71 * @param WP_REST_Request $request  Request object.
72 * @param bool            $creating True when creating, false when updating.
73 * @return void
74 */
75function jetpack_setup_blogging_prompt_response_rest( $post, $request, $creating ) {
76    if ( ! $creating || ! $post instanceof WP_Post || 'post' !== $post->post_type ) {
77        return;
78    }
79
80    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only GET param forwarded by the Write editor on the create request; only sets up a response post (tag, meta).
81    $prompt_id = isset( $_GET['answer_prompt'] ) ? absint( $_GET['answer_prompt'] ) : 0;
82    if ( $prompt_id ) {
83        jetpack_apply_blogging_prompt_response( $post->ID, $prompt_id );
84    }
85}
86
87add_action( 'rest_after_insert_post', 'jetpack_setup_blogging_prompt_response_rest', 10, 3 );
88
89/**
90 * Stamp a post as a blogging-prompt answer: prompt-key meta + prompt tags.
91 *
92 * Shared by the classic (wp_insert_post) and REST (rest_after_insert_post) entry
93 * points so both mark the answer identically.
94 *
95 * @param int $post_id   Post ID.
96 * @param int $prompt_id Prompt ID.
97 * @return void
98 */
99function jetpack_apply_blogging_prompt_response( $post_id, $prompt_id ) {
100    // Make sure the prompt exists.
101    $prompt = jetpack_get_blogging_prompt_by_id( $prompt_id );
102
103    if ( ! $prompt ) {
104        return;
105    }
106
107    update_post_meta( $post_id, '_jetpack_blogging_prompt_key', $prompt_id );
108    wp_add_post_tags( $post_id, array( 'dailyprompt', "dailyprompt-$prompt_id" ) );
109    if ( is_array( $prompt ) && array_key_exists( 'bloganuary_id', $prompt ) ) {
110        wp_add_post_tags( $post_id, array( 'bloganuary', $prompt['bloganuary_id'] ) );
111    }
112}
113
114/**
115 * When a published posts answers a blogging prompt, store the prompt id in the post meta.
116 *
117 * @param int          $post_id     Post ID.
118 * @param WP_Post      $post        Post object.
119 * @param bool         $update      Whether this is an existing post being updated.
120 * @param null|WP_Post $post_before Null for new posts, the WP_Post object prior
121 *                                  to the update for updated posts.
122 */
123function jetpack_mark_if_post_answers_blogging_prompt( $post_id, $post, $update, $post_before ) {
124    if ( ! $post instanceof WP_Post ) {
125        return;
126    }
127
128    $post_type    = $post->post_type ?? null;
129    $post_content = $post->post_content ?? null;
130
131    if ( 'post' !== $post_type || ! $post_content ) {
132        return;
133    }
134
135    $new_status = $post->post_status ?? null;
136    $old_status = $post_before && isset( $post_before->post_status ) ? $post_before->post_status : null;
137
138    // Make sure we are publishing a post, and it's not already published.
139    if ( 'publish' !== $new_status || 'publish' === $old_status ) {
140        return;
141    }
142
143    $scanner = \Automattic\Block_Scanner::create( $post->post_content );
144    if ( ! $scanner ) {
145        return;
146    }
147
148    $prompt_id          = null;
149    $total_blocks       = 0;
150    $found_prompt_block = false;
151
152    while ( $scanner->next_delimiter() ) {
153        if ( $scanner->opens_block() ) {
154            ++$total_blocks;
155
156            if ( ! $found_prompt_block && $scanner->is_block_type( 'jetpack/blogging-prompt' ) ) {
157                $attributes = $scanner->allocate_and_return_parsed_attributes();
158                if ( $attributes && isset( $attributes['promptId'] ) ) {
159                    $prompt_id = absint( $attributes['promptId'] );
160                }
161                $found_prompt_block = true;
162            }
163
164            // Early exit: if we found the prompt and have >1 blocks, we have all info needed
165            if ( $found_prompt_block && $total_blocks > 1 ) {
166                break;
167            }
168        }
169    }
170
171    if ( ! $found_prompt_block || ! $prompt_id || $total_blocks <= 1 ) {
172        return;
173    }
174
175    $has_prompt_tag = has_tag( 'dailyprompt', $post ) || has_tag( "dailyprompt-{$prompt_id}", $post );
176
177    if ( ! $has_prompt_tag ) {
178        return;
179    }
180
181    update_post_meta( $post->ID, '_jetpack_blogging_prompt_key', $prompt_id );
182}
183
184add_action( 'wp_after_insert_post', 'jetpack_mark_if_post_answers_blogging_prompt', 10, 4 );
185
186/**
187 * Utility functions.
188 */
189
190/**
191 * Build the blogging-prompts route for the REST context we're running in.
192 *
193 * The endpoint sets `wpcom_is_site_specific_endpoint`, so WordPress.com's
194 * centralized REST API registers it site-scoped, as
195 * `/wpcom/v3/sites/<blog_id>/blogging-prompts/<id>`. Everywhere else â€” Atomic,
196 * self-hosted, and ordinary wp-admin requests on Simple â€” it registers at
197 * `/wpcom/v3/blogging-prompts/<id>`. Which shape is live depends on the request
198 * we happen to be running inside, and asking for the wrong one just 404s and
199 * silently loses the prompt, so resolve it against the route table each time.
200 *
201 * Call this only after the endpoint file is required, so its routes are in
202 * place by the time `rest_get_server()` fires `rest_api_init`.
203 *
204 * @since 16.2
205 *
206 * @param int $prompt_id ID of the prompt to fetch.
207 * @return string REST route for that prompt.
208 */
209function jetpack_get_blogging_prompt_route( $prompt_id ) {
210    foreach ( array_keys( rest_get_server()->get_routes() ) as $route ) {
211        if ( str_starts_with( $route, '/wpcom/v3/blogging-prompts/' ) ) {
212            return sprintf( '/wpcom/v3/blogging-prompts/%d', $prompt_id );
213        }
214    }
215
216    return sprintf( '/wpcom/v3/sites/%d/blogging-prompts/%d', get_current_blog_id(), $prompt_id );
217}
218
219/**
220 * Retrieve a blogging prompt by prompt ID.
221 *
222 * @param int $prompt_id ID of the prompt fetch.
223 * @return array|null Prompt object or null.
224 */
225function jetpack_get_blogging_prompt_by_id( $prompt_id ) {
226    // Ensure the REST API endpoint we need is loaded.
227    require_once __DIR__ . '/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v3-endpoint-blogging-prompts.php';
228
229    $locale = get_locale();
230    $route  = jetpack_get_blogging_prompt_route( $prompt_id );
231
232    $request = new WP_REST_Request( 'GET', $route );
233    $request->set_param( '_locale', $locale );
234    $request->set_param( 'force_year', gmdate( 'Y' ) );
235
236    $response = rest_do_request( $request );
237
238    if ( $response->is_error() || WP_Http::OK !== $response->get_status() ) {
239        return null;
240    }
241
242    $prompt = $response->get_data();
243
244    return $prompt;
245}
246
247/**
248 * Retrieve daily blogging prompts from the wpcom API and cache them.
249 *
250 * @param int $time Unix timestamp representing the day for which to get blogging prompts.
251 * @return stdClass[]|null Array of blogging prompt objects or null.
252 */
253function jetpack_get_daily_blogging_prompts( $time = 0 ) {
254    $timestamp = $time ? $time : time();
255
256    // Include prompts from the previous day, just in case someone has an outdated prompt id.
257    $day_before    = wp_date( 'Y-m-d', $timestamp - DAY_IN_SECONDS );
258    $locale        = get_locale();
259    $transient_key = 'jetpack_blogging_prompt_' . $day_before . '_' . $locale;
260    $daily_prompts = get_transient( $transient_key );
261
262    // Return the cached prompt, if we have it. Otherwise fetch it from the API.
263    if ( false !== $daily_prompts ) {
264        return $daily_prompts;
265    }
266
267    $blog_id = \Jetpack_Options::get_option( 'id' );
268    $path    = '/sites/' . rawurldecode( $blog_id ) . '/blogging-prompts?from=' . rawurldecode( $day_before ) . '&number=10&_locale=' . rawurldecode( $locale );
269
270    $args = array(
271        'headers' => array(
272            'Content-Type'    => 'application/json',
273            'X-Forwarded-For' => ( new \Automattic\Jetpack\Status\Visitor() )->get_ip( true ),
274        ),
275        // `method` and `url` are needed for using `WPCOM_API_Direct::do_request`
276        // `wpcom_json_api_request_as_user` will generate and overwrite these.
277        'method'  => \WP_REST_Server::READABLE,
278        'url'     => JETPACK__WPCOM_JSON_API_BASE . '/wpcom/v2' . $path,
279    );
280
281    if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
282        // This will load the library, but it may be too late to automatically load any endpoints using WPCOM_API_Direct::register_endpoints.
283        // In that case, call `wpcom_rest_api_v2_load_plugin_files( 'wp-content/rest-api-plugins/endpoints/blogging-prompts.php' )`
284        // on the `init` hook to load the blogging-prompts endpoint before calling this function.
285        require_once WP_CONTENT_DIR . '/lib/wpcom-api-direct/wpcom-api-direct.php';
286        $response = \WPCOM_API_Direct::do_request( $args );
287    } else {
288        $response = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user( $path, 'v2', $args, null, 'wpcom' );
289    }
290    $response_status = wp_remote_retrieve_response_code( $response );
291
292    if ( is_wp_error( $response ) || $response_status !== \WP_Http::OK ) {
293        return null;
294    }
295
296    $body = json_decode( wp_remote_retrieve_body( $response ) );
297
298    if ( ! $body || ! isset( $body->prompts ) ) {
299        return null;
300    }
301
302    $prompts = $body->prompts;
303    set_transient( $transient_key, $prompts, DAY_IN_SECONDS );
304
305    return $prompts;
306}
307
308/**
309 * Determines if the site has publish posts or plans to publish posts.
310 *
311 * @return bool
312 */
313function jetpack_has_or_will_publish_posts() {
314    // Lets count the posts.
315    $count_posts_object = wp_count_posts( 'post' );
316    $count_posts        = (int) $count_posts_object->publish + (int) $count_posts_object->future + (int) $count_posts_object->draft;
317
318    return $count_posts_object->publish >= 2 || $count_posts >= 100;
319}
320
321/**
322 * Determines if the site has a posts page or shows posts on the front page.
323 *
324 * @return bool
325 */
326function jetpack_has_posts_page() {
327    // The site is set up to be a blog.
328    if ( 'posts' === get_option( 'show_on_front' ) ) {
329        return true;
330    }
331
332    // There is a page set to show posts.
333    $is_posts_page_set = (int) get_option( 'page_for_posts' ) > 0;
334    if ( $is_posts_page_set ) {
335        return true;
336    }
337
338    return false;
339}
340
341/**
342 * Determines if site had the "Write" intent set when created.
343 *
344 * @return bool
345 */
346function jetpack_has_write_intent() {
347    return 'write' === get_option( 'site_intent', '' );
348}
349
350/**
351 * Determines if the current screen (in wp-admin) is creating a new post.
352 *
353 * /wp-admin/post-new.php
354 *
355 * @return bool
356 */
357function jetpack_is_new_post_screen() {
358    global $current_screen;
359
360    if (
361        $current_screen instanceof \WP_Screen &&
362        'add' === $current_screen->action &&
363        'post' === $current_screen->post_type
364    ) {
365        return true;
366    }
367
368    return false;
369}
370
371/**
372 * Determines if the site might have a blog.
373 *
374 * @return bool
375 */
376function jetpack_is_potential_blogging_site() {
377    return jetpack_has_write_intent() || jetpack_has_posts_page() || jetpack_has_or_will_publish_posts();
378}