| @@ -1,0 +1,378 @@ | ||
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Used by the blogging prompt feature. | |
| 4 | + * | |
| 5 | + * @package automattic/jetpack | |
| 6 | + */ | |
| 7 | + | |
| 8 | +if ( ! 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 | + */ | |
| 23 | +function jetpack_blogging_prompts_add_meta_data( $keys ) { | |
| 24 | + $keys[] = '_jetpack_blogging_prompt_key'; | |
| 25 | + return $keys; | |
| 26 | +} | |
| 27 | + | |
| 28 | +add_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 | + */ | |
| 46 | +function 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 | + | |
| 62 | +add_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 | + */ | |
| 75 | +function 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 | + | |
| 87 | +add_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 | + */ | |
| 99 | +function 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 | + */ | |
| 123 | +function 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 | + | |
| 184 | +add_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 | + */ | |
| 209 | +function 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 | + */ | |
| 225 | +function 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 | + */ | |
| 253 | +function 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 | + */ | |
| 313 | +function 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 | + */ | |
| 326 | +function 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 | + */ | |
| 346 | +function 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 | + */ | |
| 357 | +function 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 | + */ | |
| 376 | +function jetpack_is_potential_blogging_site() { | |
| 377 | + return jetpack_has_write_intent() || jetpack_has_posts_page() || jetpack_has_or_will_publish_posts(); | |
| 378 | +} | |