diff --git a/lib/compat/wordpress-6.2/rest-api.php b/lib/compat/wordpress-6.2/rest-api.php index a504be4dca2a6..58597e16d9928 100644 --- a/lib/compat/wordpress-6.2/rest-api.php +++ b/lib/compat/wordpress-6.2/rest-api.php @@ -84,15 +84,6 @@ function gutenberg_pattern_directory_collection_params_6_2( $query_params ) { } add_filter( 'rest_pattern_directory_collection_params', 'gutenberg_pattern_directory_collection_params_6_2' ); -/** - * Registers the Global Styles REST API routes. - */ -function gutenberg_register_global_styles_endpoints() { - $editor_settings = new Gutenberg_REST_Global_Styles_Controller_6_2(); - $editor_settings->register_routes(); -} -add_action( 'rest_api_init', 'gutenberg_register_global_styles_endpoints' ); - /** * Updates REST API response for the sidebars and marks them as 'inactive'. * diff --git a/lib/compat/wordpress-6.3/class-gutenberg-rest-global-styles-controller-6-3.php b/lib/compat/wordpress-6.3/class-gutenberg-rest-global-styles-controller-6-3.php new file mode 100644 index 0000000000000..5eeb0a1014aed --- /dev/null +++ b/lib/compat/wordpress-6.3/class-gutenberg-rest-global-styles-controller-6-3.php @@ -0,0 +1,51 @@ +namespace, $this->rest_base ); + + $links = array( + 'self' => array( + 'href' => rest_url( trailingslashit( $base ) . $id ), + ), + ); + + if ( post_type_supports( $this->post_type, 'revisions' ) ) { + $revisions = wp_get_latest_revision_id_and_total_count( $id ); + $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0; + $revisions_base = sprintf( '/%s/%s/%d/revisions', $this->namespace, $this->rest_base, $id ); + $links['version-history'] = array( + 'href' => rest_url( $revisions_base ), + 'count' => $revisions_count, + ); + } + + return $links; + } +} diff --git a/lib/compat/wordpress-6.3/rest-api.php b/lib/compat/wordpress-6.3/rest-api.php index f8f5fc91fed3d..e111980887ef5 100644 --- a/lib/compat/wordpress-6.3/rest-api.php +++ b/lib/compat/wordpress-6.3/rest-api.php @@ -28,3 +28,37 @@ function gutenberg_update_templates_template_parts_rest_controller( $args, $post return $args; } add_filter( 'register_post_type_args', 'gutenberg_update_templates_template_parts_rest_controller', 10, 2 ); + + +/** + * Registers the Global Styles Revisions REST API routes. + */ +function gutenberg_register_global_styles_revisions_endpoints() { + $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller(); + $global_styles_revisions_controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); + +/** + * Registers the Global Styles REST API routes. + */ +function gutenberg_register_global_styles_endpoints() { + $global_styles_controller = new Gutenberg_REST_Global_Styles_Controller_6_3(); + $global_styles_controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_global_styles_endpoints' ); + +/** + * Update `wp_global_styles` post type to use Gutenberg's REST controller. + * + * @param array $args Array of arguments for registering a post type. + * @param string $post_type Post type key. + */ +function gutenberg_update_global_styles_rest_controller( $args, $post_type ) { + if ( in_array( $post_type, array( 'wp_global_styles' ), true ) ) { + $args['rest_controller_class'] = 'Gutenberg_REST_Templates_Controller_6_3'; + $args['rest_base'] = 'global-styles'; + } + return $args; +} +add_filter( 'register_post_type_args', 'gutenberg_update_global_styles_rest_controller', 10, 2 ); diff --git a/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php b/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php new file mode 100644 index 0000000000000..19e5262a1d351 --- /dev/null +++ b/lib/experimental/class-gutenberg-rest-global-styles-revisions-controller.php @@ -0,0 +1,352 @@ +parent_post_type = 'wp_global_styles'; + $this->rest_base = 'revisions'; + $this->parent_base = 'global-styles'; + $this->namespace = 'wp/v2'; + } + + /** + * Registers the controllers routes. + * + * @return void + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, + array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Returns revisions of the given global styles config custom post type. + * + * @since 6.3.0 + * + * @param WP_REST_Request $request The request instance. + * + * @return WP_REST_Response|WP_Error + */ + public function get_items( $request ) { + $parent = $this->get_parent( $request['parent'] ); + + if ( is_wp_error( $parent ) ) { + return $parent; + } + $response = array(); + $raw_config = json_decode( $parent->post_content, true ); + $is_global_styles_user_theme_json = isset( $raw_config['isGlobalStylesUserThemeJSON'] ) && true === $raw_config['isGlobalStylesUserThemeJSON']; + + if ( $is_global_styles_user_theme_json ) { + $user_theme_revisions = wp_get_post_revisions( + $parent->ID, + array( + 'posts_per_page' => 100, + ) + ); + + if ( ! empty( $user_theme_revisions ) ) { + foreach ( $user_theme_revisions as $revision ) { + $revision = $this->prepare_item_for_response( $revision, $request ); + $response[] = $this->prepare_response_for_collection( $revision ); + } + } + } + + return rest_ensure_response( $response ); + } + + /** + * Prepares the revision for the REST response. + * + * @since 6.3.0 + * + * @param WP_Post $item Post revision object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $parent = $this->get_parent( $request['parent'] ); + // Retrieves global styles config as JSON. + $raw_revision_config = json_decode( $item->post_content, true ); + $config = ( new WP_Theme_JSON_Gutenberg( $raw_revision_config, 'custom' ) )->get_raw_data(); + + // Builds human-friendly date. + $now_gmt = time(); + $modified = strtotime( $item->post_modified ); + $modified_gmt = strtotime( $item->post_modified_gmt . ' +0000' ); + /* translators: %s: Human-readable time difference. */ + $time_ago = sprintf( __( '%s ago', 'gutenberg' ), human_time_diff( $modified_gmt, $now_gmt ) ); + $date_short = date_i18n( _x( 'j M @ H:i', 'revision date short format', 'gutenberg' ), $modified ); + + // Prepares item data. + $data = array(); + $fields = $this->get_fields_for_response( $request ); + + if ( rest_is_field_included( 'author', $fields ) ) { + $data['author'] = (int) $parent->post_author; + } + + if ( rest_is_field_included( 'author_avatar_url', $fields ) ) { + $data['author_avatar_url'] = get_avatar_url( + $parent->post_author, + array( + 'size' => 24, + ) + ); + } + + if ( rest_is_field_included( 'author_display_name', $fields ) ) { + $data['author_display_name'] = get_the_author_meta( 'display_name', $parent->post_author ); + } + + if ( rest_is_field_included( 'date', $fields ) ) { + $data['date'] = $parent->post_date; + } + + if ( rest_is_field_included( 'date_display', $fields ) ) { + /* translators: 1: Human-readable time difference, 2: short date combined to show rendered revision date. */ + $data['date_display'] = sprintf( __( '%1$s (%2$s)', 'gutenberg' ), $time_ago, $date_short ); + } + + if ( rest_is_field_included( 'date_gmt', $fields ) ) { + $data['date_gmt'] = $parent->post_date_gmt; + } + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = (int) $item->ID; + } + + if ( rest_is_field_included( 'modified', $fields ) ) { + $data['modified'] = $parent->post_modified; + } + + if ( rest_is_field_included( 'modified_gmt', $fields ) ) { + $data['modified_gmt'] = $parent->post_modified_gmt; + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $data['parent'] = (int) $parent->ID; + } + + if ( rest_is_field_included( 'settings', $fields ) ) { + $data['settings'] = ! empty( $config['settings'] ) ? $config['settings'] : new stdClass(); + } + + if ( rest_is_field_included( 'styles', $fields ) ) { + $data['styles'] = ! empty( $config['styles'] ) ? $config['styles'] : new stdClass(); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + return rest_ensure_response( $data ); + } + + /** + * Retrieves the revision's schema, conforming to JSON Schema. + * + * @since 6.3.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => "{$this->parent_post_type}-revision", + 'type' => 'object', + // Base properties for every Revision. + 'properties' => array( + + /* + * Adds settings and styles from the WP_REST_Revisions_Controller item fields. + * Leaves out GUID as global styles shouldn't be accessible via URL. + */ + 'author' => array( + 'description' => __( 'The ID for the author of the revision.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'date' => array( + 'description' => __( "The date the revision was published, in the site's timezone.", 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'date_gmt' => array( + 'description' => __( 'The date the revision was published, as GMT.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + ), + 'id' => array( + 'description' => __( 'Unique identifier for the revision.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'modified' => array( + 'description' => __( "The date the revision was last modified, in the site's timezone.", 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + ), + 'modified_gmt' => array( + 'description' => __( 'The date the revision was last modified, as GMT.', 'gutenberg' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + ), + 'parent' => array( + 'description' => __( 'The ID for the parent of the revision.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + + // Adds custom global styles revisions schema. + 'author_display_name' => array( + 'description' => __( 'The display name of the author.', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + + 'author_avatar_url' => array( + 'description' => __( 'A URL to the avatar image of the author', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + + 'date_display' => array( + 'description' => __( 'A human-friendly rendering of the date', 'gutenberg' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + ), + // Adds settings and styles from the WP_REST_Global_Styles_Controller parent schema. + 'styles' => array( + 'description' => __( 'Global styles.', 'gutenberg' ), + 'type' => array( 'object' ), + 'context' => array( 'view', 'edit' ), + ), + 'settings' => array( + 'description' => __( 'Global settings.', 'gutenberg' ), + 'type' => array( 'object' ), + 'context' => array( 'view', 'edit' ), + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Checks if a given request has access to read a single global style. + * + * @since 6.3.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $post = $this->get_parent( $request['parent'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to view revisions for this global style.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Get the parent post, if the ID is valid. Copied from WP_REST_Revisions_Controller. + * + * @since 6.3.0 + * + * @param int $parent_post_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent( $parent_post_id ) { + $error = new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid post parent ID.', 'gutenberg' ), + array( 'status' => 404 ) + ); + + if ( (int) $parent_post_id <= 0 ) { + return $error; + } + + $parent_post = get_post( (int) $parent_post_id ); + + if ( empty( $parent_post ) || empty( $parent_post->ID ) + || $this->parent_post_type !== $parent_post->post_type + ) { + return $error; + } + + return $parent_post; + } +} diff --git a/lib/load.php b/lib/load.php index 0a7afaee026ea..84ba7a18453a8 100644 --- a/lib/load.php +++ b/lib/load.php @@ -51,6 +51,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.3 compat. require_once __DIR__ . '/compat/wordpress-6.3/class-gutenberg-rest-pattern-directory-controller-6-3.php'; require_once __DIR__ . '/compat/wordpress-6.3/class-gutenberg-rest-templates-controller-6-3.php'; + require_once __DIR__ . '/compat/wordpress-6.3/class-gutenberg-rest-global-styles-controller-6-3.php'; require_once __DIR__ . '/compat/wordpress-6.3/rest-api.php'; // Experimental. @@ -58,6 +59,7 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/experimental/class-wp-rest-customizer-nonces.php'; } + require_once __DIR__ . '/experimental/class-gutenberg-rest-global-styles-revisions-controller.php'; require_once __DIR__ . '/experimental/class-wp-rest-navigation-fallback-controller.php'; require_once __DIR__ . '/experimental/rest-api.php'; } diff --git a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php new file mode 100644 index 0000000000000..e74b1c2dbe7de --- /dev/null +++ b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php @@ -0,0 +1,185 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + // This creates the global styles for the current theme. + self::$global_styles_id = wp_insert_post( + array( + 'post_content' => '{"version": ' . WP_Theme_JSON_Gutenberg::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'post_status' => 'publish', + 'post_title' => __( 'Custom Styles', 'default' ), + 'post_type' => 'wp_global_styles', + 'post_name' => 'wp-global-styles-emptytheme', + 'tax_input' => array( + 'wp_theme' => 'emptytheme', + ), + ), + true + ); + } + + /** + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/global-styles/(?P[\d]+)/revisions', + $routes, + 'Global style revisions based on the given parentID route does not exist' + ); + } + + /** + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + // Update post to create a new revision. + $config = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'isGlobalStylesUserThemeJSON' => true, + 'styles' => array( + 'color' => array( + 'background' => 'hotpink', + ), + ), + ); + $new_styles_post = array( + 'ID' => self::$global_styles_id, + 'post_content' => wp_json_encode( $config ), + ); + + $post_id = wp_update_post( $new_styles_post, true, false ); + $post = get_post( $post_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data, 'Check that only one revision exists' ); + $this->assertArrayHasKey( 'id', $data[0], 'Check that an id key exists' ); + $this->assertEquals( self::$global_styles_id, $data[0]['parent'], 'Check that an id for the parent exists' ); + + // Dates. + $this->assertArrayHasKey( 'date', $data[0], 'Check that an date key exists' ); + $this->assertArrayHasKey( 'date_gmt', $data[0], 'Check that an date_gmt key exists' ); + $this->assertArrayHasKey( 'date_display', $data[0], 'Check that an date_display key exists' ); + $this->assertArrayHasKey( 'modified', $data[0], 'Check that an modified key exists' ); + $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); + $this->assertArrayHasKey( 'modified_gmt', $data[0], 'Check that an modified_gmt key exists' ); + + // Author information. + $this->assertEquals( $post->post_author, $data[0]['author'], 'Check that author id returns expected value' ); + $this->assertEquals( get_the_author_meta( 'display_name', $post->post_author ), $data[0]['author_display_name'], 'Check that author display_name returns expected value' ); + $this->assertIsString( + $data[0]['author_avatar_url'], + 'Check that author avatar_url returns expected value type' + ); + + // Global styles. + $this->assertEquals( + $data[0]['settings'], + new stdClass(), + 'Check that the revision settings exist in the response.' + ); + $this->assertEquals( + $data[0]['styles'], + array( + 'color' => array( + 'background' => 'hotpink', + ), + ), + 'Check that the revision styles match the last updated styles.' + ); + } + + /** + * @covers Gutenberg_REST_Global_Styles_Revisions_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertCount( 12, $properties, 'Schema properties array does not have exactly 4 elements' ); + $this->assertArrayHasKey( 'id', $properties, 'Schema properties array does not have "id" key' ); + $this->assertArrayHasKey( 'styles', $properties, 'Schema properties array does not have "styles" key' ); + $this->assertArrayHasKey( 'settings', $properties, 'Schema properties array does not have "settings" key' ); + $this->assertArrayHasKey( 'parent', $properties, 'Schema properties array does not have "parent" key' ); + $this->assertArrayHasKey( 'author', $properties, 'Schema properties array does not have "author" key' ); + $this->assertArrayHasKey( 'author_display_name', $properties, 'Schema properties array does not have "author_display_name" key' ); + $this->assertArrayHasKey( 'author_avatar_url', $properties, 'Schema properties array does not have "author_avatar_url" key' ); + $this->assertArrayHasKey( 'date', $properties, 'Schema properties array does not have "date" key' ); + $this->assertArrayHasKey( 'date_gmt', $properties, 'Schema properties array does not have "date_gmt" key' ); + $this->assertArrayHasKey( 'date_display', $properties, 'Schema properties array does not have "date_display" key' ); + $this->assertArrayHasKey( 'modified', $properties, 'Schema properties array does not have "modified" key' ); + $this->assertArrayHasKey( 'modified_gmt', $properties, 'Schema properties array does not have "modified_gmt" key' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not use get_context_param(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Controller does not implement get_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not implement create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not implement delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not implement prepare_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not implement update_item(). + } +}