diff -r 34716fd837a4 -r be944660c56a wp/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php --- a/wp/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php Tue Dec 15 15:52:01 2020 +0100 +++ b/wp/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php Wed Sep 21 18:19:35 2022 +0200 @@ -32,6 +32,14 @@ protected $meta; /** + * Passwordless post access permitted. + * + * @since 5.7.1 + * @var int[] + */ + protected $password_check_passed = array(); + + /** * Constructor. * * @since 4.7.0 @@ -48,7 +56,7 @@ } /** - * Registers the routes for the objects of the controller. + * Registers the routes for posts. * * @since 4.7.0 * @@ -92,7 +100,7 @@ array( 'args' => array( 'id' => array( - 'description' => __( 'Unique identifier for the object.' ), + 'description' => __( 'Unique identifier for the post.' ), 'type' => 'integer', ), ), @@ -149,6 +157,38 @@ } /** + * Override the result of the post password check for REST requested posts. + * + * Allow users to read the content of password protected posts if they have + * previously passed a permission check or if they have the `edit_post` capability + * for the post being checked. + * + * @since 5.7.1 + * + * @param bool $required Whether the post requires a password check. + * @param WP_Post $post The post been password checked. + * @return bool Result of password check taking in to account REST API considerations. + */ + public function check_password_required( $required, $post ) { + if ( ! $required ) { + return $required; + } + + $post = get_post( $post ); + + if ( ! $post ) { + return $required; + } + + if ( ! empty( $this->password_check_passed[ $post->ID ] ) ) { + // Password previously checked and approved. + return false; + } + + return ! current_user_can( 'edit_post', $post->ID ); + } + + /** * Retrieves a collection of posts. * * @since 4.7.0 @@ -216,14 +256,32 @@ // Check for & assign any parameters which require special handling or setting. $args['date_query'] = array(); - // Set before into date query. Date query must be specified as an array of an array. if ( isset( $registered['before'], $request['before'] ) ) { - $args['date_query'][0]['before'] = $request['before']; + $args['date_query'][] = array( + 'before' => $request['before'], + 'column' => 'post_date', + ); + } + + if ( isset( $registered['modified_before'], $request['modified_before'] ) ) { + $args['date_query'][] = array( + 'before' => $request['modified_before'], + 'column' => 'post_modified', + ); } - // Set after into date query. Date query must be specified as an array of an array. if ( isset( $registered['after'], $request['after'] ) ) { - $args['date_query'][0]['after'] = $request['after']; + $args['date_query'][] = array( + 'after' => $request['after'], + 'column' => 'post_date', + ); + } + + if ( isset( $registered['modified_after'], $request['modified_after'] ) ) { + $args['date_query'][] = array( + 'after' => $request['modified_after'], + 'column' => 'post_modified', + ); } // Ensure our per_page parameter overrides any provided posts_per_page filter. @@ -262,60 +320,41 @@ } } + $args = $this->prepare_tax_query( $args, $request ); + // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; /** - * Filters the query arguments for a request. + * Filters WP_Query arguments when querying posts via the REST API. + * + * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. + * + * Possible hook names include: + * + * - `rest_post_query` + * - `rest_page_query` + * - `rest_attachment_query` * * Enables adding extra arguments or setting defaults for a post collection request. * * @since 4.7.0 + * @since 5.7.0 Moved after the `tax_query` query arg is generated. * * @link https://developer.wordpress.org/reference/classes/wp_query/ * - * @param array $args Key value array of query var to query value. - * @param WP_REST_Request $request The request used. + * @param array $args Array of arguments for WP_Query. + * @param WP_REST_Request $request The REST API request. */ $args = apply_filters( "rest_{$this->post_type}_query", $args, $request ); $query_args = $this->prepare_items_query( $args, $request ); - $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); - - if ( ! empty( $request['tax_relation'] ) ) { - $query_args['tax_query'] = array( 'relation' => $request['tax_relation'] ); - } - - foreach ( $taxonomies as $taxonomy ) { - $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - $tax_exclude = $base . '_exclude'; - - if ( ! empty( $request[ $base ] ) ) { - $query_args['tax_query'][] = array( - 'taxonomy' => $taxonomy->name, - 'field' => 'term_id', - 'terms' => $request[ $base ], - 'include_children' => false, - ); - } - - if ( ! empty( $request[ $tax_exclude ] ) ) { - $query_args['tax_query'][] = array( - 'taxonomy' => $taxonomy->name, - 'field' => 'term_id', - 'terms' => $request[ $tax_exclude ], - 'include_children' => false, - 'operator' => 'NOT IN', - ); - } - } - $posts_query = new WP_Query(); $query_result = $posts_query->query( $query_args ); // Allow access to all password protected posts if the context is edit. if ( 'edit' === $request['context'] ) { - add_filter( 'post_password_required', '__return_false' ); + add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 ); } $posts = array(); @@ -331,7 +370,7 @@ // Reset filter. if ( 'edit' === $request['context'] ) { - remove_filter( 'post_password_required', '__return_false' ); + remove_filter( 'post_password_required', array( $this, 'check_password_required' ) ); } $page = (int) $query_args['paged']; @@ -417,7 +456,7 @@ * @since 4.7.0 * * @param WP_REST_Request $request Full details about the request. - * @return bool|WP_Error True if the request has read access for the item, WP_Error object otherwise. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ public function get_item_permissions_check( $request ) { $post = $this->get_post( $request['id'] ); @@ -446,7 +485,7 @@ // Allow access to all password protected posts if the context is edit. if ( 'edit' === $request['context'] ) { - add_filter( 'post_password_required', '__return_false' ); + add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 ); } if ( $post ) { @@ -474,8 +513,14 @@ return false; } - // Edit context always gets access to password-protected posts. - if ( 'edit' === $request['context'] ) { + /* + * Users always gets access to password protected content in the edit + * context if they have the `edit_post` meta capability. + */ + if ( + 'edit' === $request['context'] && + current_user_can( 'edit_post', $post->ID ) + ) { return true; } @@ -591,7 +636,7 @@ $prepared_post->post_type = $this->post_type; - $post_id = wp_insert_post( wp_slash( (array) $prepared_post ), true ); + $post_id = wp_insert_post( wp_slash( (array) $prepared_post ), true, false ); if ( is_wp_error( $post_id ) ) { @@ -611,6 +656,12 @@ * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * + * Possible hook names include: + * + * - `rest_insert_post` + * - `rest_insert_page` + * - `rest_insert_attachment` + * * @since 4.7.0 * * @param WP_Post $post Inserted or updated post object. @@ -669,6 +720,12 @@ * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * + * Possible hook names include: + * + * - `rest_after_insert_post` + * - `rest_after_insert_page` + * - `rest_after_insert_attachment` + * * @since 5.0.0 * * @param WP_Post $post Inserted or updated post object. @@ -677,6 +734,8 @@ */ do_action( "rest_after_insert_{$this->post_type}", $post, $request, true ); + wp_after_insert_post( $post, false, null ); + $response = $this->prepare_item_for_response( $post, $request ); $response = rest_ensure_response( $response ); @@ -751,14 +810,15 @@ return $valid_check; } - $post = $this->prepare_item_for_database( $request ); + $post_before = get_post( $request['id'] ); + $post = $this->prepare_item_for_database( $request ); if ( is_wp_error( $post ) ) { return $post; } // Convert the post object to an array, otherwise wp_update_post() will expect non-escaped input. - $post_id = wp_update_post( wp_slash( (array) $post ), true ); + $post_id = wp_update_post( wp_slash( (array) $post ), true, false ); if ( is_wp_error( $post_id ) ) { if ( 'db_update_error' === $post_id->get_error_code() ) { @@ -828,6 +888,8 @@ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ do_action( "rest_after_insert_{$this->post_type}", $post, $request, false ); + wp_after_insert_post( $post, true, $post_before ); + $response = $this->prepare_item_for_response( $post, $request ); return rest_ensure_response( $response ); @@ -886,6 +948,12 @@ * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * + * Possible hook names include: + * + * - `rest_post_trashable` + * - `rest_page_trashable` + * - `rest_attachment_trashable` + * * Pass false to disable Trash support for the post. * * @since 4.7.0 @@ -1048,7 +1116,8 @@ * @return stdClass|WP_Error Post object or WP_Error. */ protected function prepare_item_for_database( $request ) { - $prepared_post = new stdClass(); + $prepared_post = new stdClass(); + $current_status = ''; // Post ID. if ( isset( $request['id'] ) ) { @@ -1058,6 +1127,7 @@ } $prepared_post->ID = $existing_post->ID; + $current_status = $existing_post->post_status; } $schema = $this->get_item_schema(); @@ -1101,7 +1171,11 @@ $post_type = get_post_type_object( $prepared_post->post_type ); // Post status. - if ( ! empty( $schema['properties']['status'] ) && isset( $request['status'] ) ) { + if ( + ! empty( $schema['properties']['status'] ) && + isset( $request['status'] ) && + ( ! $current_status || $current_status !== $request['status'] ) + ) { $status = $this->handle_status_param( $request['status'], $post_type ); if ( is_wp_error( $status ) ) { @@ -1241,6 +1315,12 @@ * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * + * Possible hook names include: + * + * - `rest_pre_insert_post` + * - `rest_pre_insert_page` + * - `rest_pre_insert_attachment` + * * @since 4.7.0 * * @param stdClass $prepared_post An object representing a single post prepared @@ -1252,6 +1332,32 @@ } /** + * Checks whether the status is valid for the given post. + * + * Allows for sending an update request with the current status, even if that status would not be acceptable. + * + * @since 5.6.0 + * + * @param string $status The provided status. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return true|WP_Error True if the status is valid, or WP_Error if not. + */ + public function check_status( $status, $request, $param ) { + if ( $request['id'] ) { + $post = $this->get_post( $request['id'] ); + + if ( ! is_wp_error( $post ) && $post->post_status === $status ) { + return true; + } + } + + $args = $request->get_attributes()['args'][ $param ]; + + return rest_validate_value_from_schema( $status, $args, $param ); + } + + /** * Determines validity and normalizes the given status parameter. * * @since 4.7.0 @@ -1340,8 +1446,10 @@ } if ( $request['id'] ) { + $post = get_post( $request['id'] ); $current_template = get_page_template_slug( $request['id'] ); } else { + $post = null; $current_template = ''; } @@ -1351,7 +1459,7 @@ } // If this is a create request, get_post() will return null and wp theme will fallback to the passed post type. - $allowed_templates = wp_get_theme()->get_page_templates( get_post( $request['id'] ), $this->post_type ); + $allowed_templates = wp_get_theme()->get_page_templates( $post, $this->post_type ); if ( isset( $allowed_templates[ $template ] ) ) { return true; @@ -1370,9 +1478,9 @@ * @since 4.7.0 * @since 4.9.0 Added the `$validate` parameter. * - * @param string $template Page template filename. - * @param integer $post_id Post ID. - * @param bool $validate Whether to validate that the template selected is valid. + * @param string $template Page template filename. + * @param int $post_id Post ID. + * @param bool $validate Whether to validate that the template selected is valid. */ public function handle_template( $template, $post_id, $validate = false ) { @@ -1666,8 +1774,9 @@ $has_password_filter = false; if ( $this->can_access_password_content( $post, $request ) ) { + $this->password_check_passed[ $post->ID ] = true; // Allow access to the post, permissions already checked before. - add_filter( 'post_password_required', '__return_false' ); + add_filter( 'post_password_required', array( $this, 'check_password_required' ), 10, 2 ); $has_password_filter = true; } @@ -1705,7 +1814,7 @@ if ( $has_password_filter ) { // Reset filter. - remove_filter( 'post_password_required', '__return_false' ); + remove_filter( 'post_password_required', array( $this, 'check_password_required' ) ); } if ( rest_is_field_included( 'author', $fields ) ) { @@ -1812,10 +1921,16 @@ } /** - * Filters the post data for a response. + * Filters the post data for a REST API response. * * The dynamic portion of the hook name, `$this->post_type`, refers to the post type slug. * + * Possible hook names include: + * + * - `rest_prepare_post` + * - `rest_prepare_page` + * - `rest_prepare_attachment` + * * @since 4.7.0 * * @param WP_REST_Response $response The response object. @@ -2038,31 +2153,31 @@ // Base properties for every Post. 'properties' => array( 'date' => array( - 'description' => __( "The date the object was published, in the site's timezone." ), + 'description' => __( "The date the post was published, in the site's timezone." ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view', 'edit', 'embed' ), ), 'date_gmt' => array( - 'description' => __( 'The date the object was published, as GMT.' ), + 'description' => __( 'The date the post was published, as GMT.' ), 'type' => array( 'string', 'null' ), 'format' => 'date-time', 'context' => array( 'view', 'edit' ), ), 'guid' => array( - 'description' => __( 'The globally unique identifier for the object.' ), + 'description' => __( 'The globally unique identifier for the post.' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => array( 'raw' => array( - 'description' => __( 'GUID for the object, as it exists in the database.' ), + 'description' => __( 'GUID for the post, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), 'readonly' => true, ), 'rendered' => array( - 'description' => __( 'GUID for the object, transformed for display.' ), + 'description' => __( 'GUID for the post, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -2070,34 +2185,34 @@ ), ), 'id' => array( - 'description' => __( 'Unique identifier for the object.' ), + 'description' => __( 'Unique identifier for the post.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), 'link' => array( - 'description' => __( 'URL to the object.' ), + 'description' => __( 'URL to the post.' ), 'type' => 'string', 'format' => 'uri', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, ), 'modified' => array( - 'description' => __( "The date the object was last modified, in the site's timezone." ), + 'description' => __( "The date the post was last modified, in the site's timezone." ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'modified_gmt' => array( - 'description' => __( 'The date the object was last modified, as GMT.' ), + 'description' => __( 'The date the post was last modified, as GMT.' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'slug' => array( - 'description' => __( 'An alphanumeric identifier for the object unique to its type.' ), + 'description' => __( 'An alphanumeric identifier for the post unique to its type.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( @@ -2105,13 +2220,16 @@ ), ), 'status' => array( - 'description' => __( 'A named status for the object.' ), + 'description' => __( 'A named status for the post.' ), 'type' => 'string', 'enum' => array_keys( get_post_stati( array( 'internal' => false ) ) ), 'context' => array( 'view', 'edit' ), + 'arg_options' => array( + 'validate_callback' => array( $this, 'check_status' ), + ), ), 'type' => array( - 'description' => __( 'Type of Post for the object.' ), + 'description' => __( 'Type of post.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -2127,14 +2245,14 @@ $post_type_obj = get_post_type_object( $this->post_type ); if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { $schema['properties']['permalink_template'] = array( - 'description' => __( 'Permalink template for the object.' ), + 'description' => __( 'Permalink template for the post.' ), 'type' => 'string', 'context' => array( 'edit' ), 'readonly' => true, ); $schema['properties']['generated_slug'] = array( - 'description' => __( 'Slug automatically generated from the object title.' ), + 'description' => __( 'Slug automatically generated from the post title.' ), 'type' => 'string', 'context' => array( 'edit' ), 'readonly' => true, @@ -2143,7 +2261,7 @@ if ( $post_type_obj->hierarchical ) { $schema['properties']['parent'] = array( - 'description' => __( 'The ID for the parent of the object.' ), + 'description' => __( 'The ID for the parent of the post.' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), ); @@ -2204,7 +2322,7 @@ case 'title': $schema['properties']['title'] = array( - 'description' => __( 'The title for the object.' ), + 'description' => __( 'The title for the post.' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( @@ -2213,12 +2331,12 @@ ), 'properties' => array( 'raw' => array( - 'description' => __( 'Title for the object, as it exists in the database.' ), + 'description' => __( 'Title for the post, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), ), 'rendered' => array( - 'description' => __( 'HTML title for the object, transformed for display.' ), + 'description' => __( 'HTML title for the post, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -2229,7 +2347,7 @@ case 'editor': $schema['properties']['content'] = array( - 'description' => __( 'The content for the object.' ), + 'description' => __( 'The content for the post.' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'arg_options' => array( @@ -2238,18 +2356,18 @@ ), 'properties' => array( 'raw' => array( - 'description' => __( 'Content for the object, as it exists in the database.' ), + 'description' => __( 'Content for the post, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), ), 'rendered' => array( - 'description' => __( 'HTML content for the object, transformed for display.' ), + 'description' => __( 'HTML content for the post, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'block_version' => array( - 'description' => __( 'Version of the content block format used by the object.' ), + 'description' => __( 'Version of the content block format used by the post.' ), 'type' => 'integer', 'context' => array( 'edit' ), 'readonly' => true, @@ -2266,7 +2384,7 @@ case 'author': $schema['properties']['author'] = array( - 'description' => __( 'The ID for the author of the object.' ), + 'description' => __( 'The ID for the author of the post.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), ); @@ -2274,7 +2392,7 @@ case 'excerpt': $schema['properties']['excerpt'] = array( - 'description' => __( 'The excerpt for the object.' ), + 'description' => __( 'The excerpt for the post.' ), 'type' => 'object', 'context' => array( 'view', 'edit', 'embed' ), 'arg_options' => array( @@ -2283,12 +2401,12 @@ ), 'properties' => array( 'raw' => array( - 'description' => __( 'Excerpt for the object, as it exists in the database.' ), + 'description' => __( 'Excerpt for the post, as it exists in the database.' ), 'type' => 'string', 'context' => array( 'edit' ), ), 'rendered' => array( - 'description' => __( 'HTML excerpt for the object, transformed for display.' ), + 'description' => __( 'HTML excerpt for the post, transformed for display.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true, @@ -2305,7 +2423,7 @@ case 'thumbnail': $schema['properties']['featured_media'] = array( - 'description' => __( 'The ID of the featured media for the object.' ), + 'description' => __( 'The ID of the featured media for the post.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), ); @@ -2313,13 +2431,13 @@ case 'comments': $schema['properties']['comment_status'] = array( - 'description' => __( 'Whether or not comments are open on the object.' ), + 'description' => __( 'Whether or not comments are open on the post.' ), 'type' => 'string', 'enum' => array( 'open', 'closed' ), 'context' => array( 'view', 'edit' ), ); $schema['properties']['ping_status'] = array( - 'description' => __( 'Whether or not the object can be pinged.' ), + 'description' => __( 'Whether or not the post can be pinged.' ), 'type' => 'string', 'enum' => array( 'open', 'closed' ), 'context' => array( 'view', 'edit' ), @@ -2328,7 +2446,7 @@ case 'page-attributes': $schema['properties']['menu_order'] = array( - 'description' => __( 'The order of the object in relation to other object of its type.' ), + 'description' => __( 'The order of the post in relation to other posts.' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), ); @@ -2339,7 +2457,7 @@ $formats = array_values( get_post_format_slugs() ); $schema['properties']['format'] = array( - 'description' => __( 'The format for the object.' ), + 'description' => __( 'The format for the post.' ), 'type' => 'string', 'enum' => $formats, 'context' => array( 'view', 'edit' ), @@ -2355,14 +2473,14 @@ if ( 'post' === $this->post_type ) { $schema['properties']['sticky'] = array( - 'description' => __( 'Whether or not the object should be treated as sticky.' ), + 'description' => __( 'Whether or not the post should be treated as sticky.' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), ); } $schema['properties']['template'] = array( - 'description' => __( 'The theme file to use to display the object.' ), + 'description' => __( 'The theme file to use to display the post.' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'arg_options' => array( @@ -2380,7 +2498,7 @@ _doing_it_wrong( 'register_taxonomy', sprintf( - /* translators: 1. The taxonomy name, 2. The property name, either 'rest_base' or 'name', 3. The conflicting value. */ + /* translators: 1: The taxonomy name, 2: The property name, either 'rest_base' or 'name', 3: The conflicting value. */ __( 'The "%1$s" taxonomy "%2$s" property (%3$s) conflicts with an existing property on the REST API Posts Controller. Specify a custom "rest_base" when registering the taxonomy to avoid this error.' ), $taxonomy->name, $taxonomy_field_name_with_conflict, @@ -2392,7 +2510,7 @@ $schema['properties'][ $base ] = array( /* translators: %s: Taxonomy name. */ - 'description' => sprintf( __( 'The terms assigned to the object in the %s taxonomy.' ), $taxonomy->name ), + 'description' => sprintf( __( 'The terms assigned to the post in the %s taxonomy.' ), $taxonomy->name ), 'type' => 'array', 'items' => array( 'type' => 'integer', @@ -2411,7 +2529,7 @@ $schema_fields = array_keys( $schema['properties'] ); /** - * Filter the post's schema. + * Filters the post's schema. * * The dynamic portion of the filter, `$this->post_type`, refers to the * post type slug for the controller. @@ -2571,6 +2689,8 @@ * Retrieves the query params for the posts collection. * * @since 4.7.0 + * @since 5.4.0 The `tax_relation` query parameter was added. + * @since 5.7.0 The `modified_after` and `modified_before` query parameters were added. * * @return array Collection parameters. */ @@ -2585,6 +2705,12 @@ 'format' => 'date-time', ); + $query_params['modified_after'] = array( + 'description' => __( 'Limit response to posts modified after a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + if ( post_type_supports( $this->post_type, 'author' ) ) { $query_params['author'] = array( 'description' => __( 'Limit result set to posts assigned to specific authors.' ), @@ -2610,6 +2736,12 @@ 'format' => 'date-time', ); + $query_params['modified_before'] = array( + 'description' => __( 'Limit response to posts modified before a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + $query_params['exclude'] = array( 'description' => __( 'Ensure result set excludes specific IDs.' ), 'type' => 'array', @@ -2648,7 +2780,7 @@ ); $query_params['orderby'] = array( - 'description' => __( 'Sort collection by object attribute.' ), + 'description' => __( 'Sort collection by post attribute.' ), 'type' => 'string', 'default' => 'date', 'enum' => array( @@ -2710,39 +2842,7 @@ 'sanitize_callback' => array( $this, 'sanitize_post_statuses' ), ); - $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); - - if ( ! empty( $taxonomies ) ) { - $query_params['tax_relation'] = array( - 'description' => __( 'Limit result set based on relationship between multiple taxonomies.' ), - 'type' => 'string', - 'enum' => array( 'AND', 'OR' ), - ); - } - - foreach ( $taxonomies as $taxonomy ) { - $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - - $query_params[ $base ] = array( - /* translators: %s: Taxonomy name. */ - 'description' => sprintf( __( 'Limit result set to all items that have the specified term assigned in the %s taxonomy.' ), $base ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - 'default' => array(), - ); - - $query_params[ $base . '_exclude' ] = array( - /* translators: %s: Taxonomy name. */ - 'description' => sprintf( __( 'Limit result set to all items except those that have the specified term assigned in the %s taxonomy.' ), $base ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - 'default' => array(), - ); - } + $query_params = $this->prepare_taxonomy_limit_schema( $query_params ); if ( 'post' === $this->post_type ) { $query_params['sticky'] = array( @@ -2752,7 +2852,7 @@ } /** - * Filter collection parameters for the posts controller. + * Filters collection parameters for the posts controller. * * The dynamic part of the filter `$this->post_type` refers to the post * type slug for the controller. @@ -2810,4 +2910,182 @@ return $statuses; } + + /** + * Prepares the 'tax_query' for a collection of posts. + * + * @since 5.7.0 + * + * @param array $args WP_Query arguments. + * @param WP_REST_Request $request Full details about the request. + * @return array Updated query arguments. + */ + private function prepare_tax_query( array $args, WP_REST_Request $request ) { + $relation = $request['tax_relation']; + + if ( $relation ) { + $args['tax_query'] = array( 'relation' => $relation ); + } + + $taxonomies = wp_list_filter( + get_object_taxonomies( $this->post_type, 'objects' ), + array( 'show_in_rest' => true ) + ); + + foreach ( $taxonomies as $taxonomy ) { + $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + + $tax_include = $request[ $base ]; + $tax_exclude = $request[ $base . '_exclude' ]; + + if ( $tax_include ) { + $terms = array(); + $include_children = false; + $operator = 'IN'; + + if ( rest_is_array( $tax_include ) ) { + $terms = $tax_include; + } elseif ( rest_is_object( $tax_include ) ) { + $terms = empty( $tax_include['terms'] ) ? array() : $tax_include['terms']; + $include_children = ! empty( $tax_include['include_children'] ); + + if ( isset( $tax_include['operator'] ) && 'AND' === $tax_include['operator'] ) { + $operator = 'AND'; + } + } + + if ( $terms ) { + $args['tax_query'][] = array( + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $terms, + 'include_children' => $include_children, + 'operator' => $operator, + ); + } + } + + if ( $tax_exclude ) { + $terms = array(); + $include_children = false; + + if ( rest_is_array( $tax_exclude ) ) { + $terms = $tax_exclude; + } elseif ( rest_is_object( $tax_exclude ) ) { + $terms = empty( $tax_exclude['terms'] ) ? array() : $tax_exclude['terms']; + $include_children = ! empty( $tax_exclude['include_children'] ); + } + + if ( $terms ) { + $args['tax_query'][] = array( + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $terms, + 'include_children' => $include_children, + 'operator' => 'NOT IN', + ); + } + } + } + + return $args; + } + + /** + * Prepares the collection schema for including and excluding items by terms. + * + * @since 5.7.0 + * + * @param array $query_params Collection schema. + * @return array Updated schema. + */ + private function prepare_taxonomy_limit_schema( array $query_params ) { + $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); + + if ( ! $taxonomies ) { + return $query_params; + } + + $query_params['tax_relation'] = array( + 'description' => __( 'Limit result set based on relationship between multiple taxonomies.' ), + 'type' => 'string', + 'enum' => array( 'AND', 'OR' ), + ); + + $limit_schema = array( + 'type' => array( 'object', 'array' ), + 'oneOf' => array( + array( + 'title' => __( 'Term ID List' ), + 'description' => __( 'Match terms with the listed IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ), + array( + 'title' => __( 'Term ID Taxonomy Query' ), + 'description' => __( 'Perform an advanced term query.' ), + 'type' => 'object', + 'properties' => array( + 'terms' => array( + 'description' => __( 'Term IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ), + 'include_children' => array( + 'description' => __( 'Whether to include child terms in the terms limiting the result set.' ), + 'type' => 'boolean', + 'default' => false, + ), + ), + 'additionalProperties' => false, + ), + ), + ); + + $include_schema = array_merge( + array( + /* translators: %s: Taxonomy name. */ + 'description' => __( 'Limit result set to items with specific terms assigned in the %s taxonomy.' ), + ), + $limit_schema + ); + // 'operator' is supported only for 'include' queries. + $include_schema['oneOf'][1]['properties']['operator'] = array( + 'description' => __( 'Whether items must be assigned all or any of the specified terms.' ), + 'type' => 'string', + 'enum' => array( 'AND', 'OR' ), + 'default' => 'OR', + ); + + $exclude_schema = array_merge( + array( + /* translators: %s: Taxonomy name. */ + 'description' => __( 'Limit result set to items except those with specific terms assigned in the %s taxonomy.' ), + ), + $limit_schema + ); + + foreach ( $taxonomies as $taxonomy ) { + $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + $base_exclude = $base . '_exclude'; + + $query_params[ $base ] = $include_schema; + $query_params[ $base ]['description'] = sprintf( $query_params[ $base ]['description'], $base ); + + $query_params[ $base_exclude ] = $exclude_schema; + $query_params[ $base_exclude ]['description'] = sprintf( $query_params[ $base_exclude ]['description'], $base ); + + if ( ! $taxonomy->hierarchical ) { + unset( $query_params[ $base ]['oneOf'][1]['properties']['include_children'] ); + unset( $query_params[ $base_exclude ]['oneOf'][1]['properties']['include_children'] ); + } + } + + return $query_params; + } }