diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 028e61ec414a8..326fffc567525 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1365,6 +1365,102 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { return $allcaps; } +/** + * Filters the user capabilities to grant the `wp_knowledge` post type capabilities as necessary. + * + * The `wp_knowledge` post type uses a `knowledge`-prefixed capability set that is + * granted dynamically rather than stored on roles. Administrators (users with + * `manage_options`) receive every knowledge capability. Contributors, authors, + * and editors (users with `edit_posts`) may list and create knowledge rows and + * fully manage their own private rows. Publishing knowledge and acting on other + * users' rows is reserved for administrators. Subscribers receive nothing and + * are stopped at the post-type door by the `read_knowledge_items` mapping. + * + * @since 7.1.0 + * + * @param bool[] $allcaps An array of all the user's capabilities. + * @param string[] $caps Required primitive capabilities for the requested capability. + * @param array $args { + * Arguments that accompany the requested capability check. + * + * @type string $0 Requested capability. + * @type int $1 Concerned user ID. + * @type mixed ...$2 Optional second and further parameters, typically object ID. + * } + * @param WP_User $user The user object. + * @return bool[] Filtered array of the user's capabilities. + */ +function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { + if ( ! empty( $allcaps['manage_options'] ) ) { + $allcaps['read_knowledge_items'] = true; + $allcaps['edit_knowledge_items'] = true; + $allcaps['edit_others_knowledge_items'] = true; + $allcaps['edit_published_knowledge_items'] = true; + $allcaps['edit_private_knowledge_items'] = true; + $allcaps['publish_knowledge_items'] = true; + $allcaps['delete_knowledge_items'] = true; + $allcaps['delete_others_knowledge_items'] = true; + $allcaps['delete_published_knowledge_items'] = true; + $allcaps['delete_private_knowledge_items'] = true; + $allcaps['read_private_knowledge_items'] = true; + + return $allcaps; + } + + if ( empty( $allcaps['edit_posts'] ) ) { + return $allcaps; + } + + /* + * Ambient floor for contributors and above: `read_knowledge_items` clears the + * post-type read check; `edit_knowledge_items` clears the create and ownership + * checks that do not pass a post ID. Per-post primitives are granted only + * in the per-post branch below. + */ + $allcaps['read_knowledge_items'] = true; + $allcaps['edit_knowledge_items'] = true; + + if ( ! isset( $args[0], $args[2] ) ) { + return $allcaps; + } + + if ( ! in_array( $args[0], array( 'edit_post', 'delete_post', 'read_post' ), true ) ) { + return $allcaps; + } + + $post = get_post( $args[2] ); + if ( + ! $post instanceof WP_Post || + 'wp_knowledge' !== $post->post_type || + (int) $post->post_author !== (int) $user->ID + ) { + return $allcaps; + } + + /* + * A trashed row keeps its pre-trash status in `_wp_trash_meta_status`. + * Resolve that effective status so the author keeps the ability to restore + * or permanently delete their own row once it is in the trash. A row trashed + * from a non-private status (only reachable for administrators) still falls + * outside the grant. + */ + $status = $post->post_status; + if ( 'trash' === $status ) { + $status = get_post_meta( $post->ID, '_wp_trash_meta_status', true ); + } + + if ( 'private' !== $status ) { + return $allcaps; + } + + $allcaps['edit_private_knowledge_items'] = true; + $allcaps['delete_knowledge_items'] = true; + $allcaps['delete_private_knowledge_items'] = true; + $allcaps['read_private_knowledge_items'] = true; + + return $allcaps; +} + return; // Dummy gettext calls to get strings in the catalog. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 5581828a10b61..d0b784f8cef3a 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -773,6 +773,7 @@ add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_site_health_caps', 1, 4 ); +add_filter( 'user_has_cap', 'wp_maybe_grant_knowledge_caps', 1, 4 ); // Block templates post type and rendering. add_filter( 'render_block_context', '_block_template_render_without_post_block_context' ); @@ -786,6 +787,10 @@ // wp_navigation post type. add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); +// wp_knowledge post type. +add_action( 'save_post_wp_knowledge', 'wp_knowledge_ensure_default_type_term' ); +add_filter( 'wp_insert_term_data', 'wp_knowledge_maybe_map_term_label', 10, 2 ); + // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php new file mode 100644 index 0000000000000..e646ec55a4901 --- /dev/null +++ b/src/wp-includes/knowledge.php @@ -0,0 +1,142 @@ + + */ +function wp_knowledge_types(): array { + /** + * Filters the knowledge types available on this site. + * + * @since 7.1.0 + * + * @param array $types { + * Slug-keyed map of knowledge types. + * + * @type array ...$0 { + * Data for a single knowledge type. + * + * @type string $title The human-readable label for the type. + * } + * } + * @phpstan-param array $types + */ + return apply_filters( + 'wp_knowledge_types', + array( + 'guideline' => array( + 'title' => _x( 'Guideline', 'knowledge type' ), + ), + 'memory' => array( + 'title' => _x( 'Memory', 'knowledge type' ), + ), + 'note' => array( + 'title' => _x( 'Note', 'knowledge type' ), + ), + ) + ); +} + +/** + * Assigns the `note` fallback term when a knowledge post is saved without a type. + * + * Hooked to the `save_post_wp_knowledge` action so that every knowledge row has + * at least one `wp_knowledge_type` term. Uses get_the_terms() so the check is + * served by the object term cache. + * + * @since 7.1.0 + * @access private + * + * @param int $post_id Saved post ID. + */ +function wp_knowledge_ensure_default_type_term( int $post_id ): void { + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + $terms = get_the_terms( $post_id, 'wp_knowledge_type' ); + if ( is_wp_error( $terms ) || ! empty( $terms ) ) { + return; + } + + /* + * Resolve to a term ID up front, creating the term on first use: + * wp_set_object_terms() interprets strings as names for hierarchical + * taxonomies, not slugs. + */ + $term = term_exists( 'note', 'wp_knowledge_type' ); + if ( ! $term ) { + $term = wp_insert_term( 'note', 'wp_knowledge_type' ); + if ( is_wp_error( $term ) ) { + return; + } + } + + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); +} + +/** + * Swaps a raw knowledge-type slug for its registered label on term creation. + * + * Hooked to the `wp_insert_term_data` filter. When wp_set_object_terms() is + * called with a slug that does not yet exist, wp_insert_term() fires and this + * filter runs after WordPress has computed both `name` and `slug`. A `name` + * equal to `slug` indicates the term was created from a raw slug (for example by + * wp_set_object_terms()) rather than from a user-provided label, so the label is + * replaced with the title from wp_knowledge_types(). Because term names are + * persisted in the database, the translated title is stored in the locale active + * when the term is created. + * + * @since 7.1.0 + * @access private + * + * @param array $data Term data to be inserted (keyed by column name). + * @param string $taxonomy Taxonomy slug. + * @return array Possibly modified term data. + * + * @phpstan-param array $data + * @phpstan-return array + */ +function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { + if ( 'wp_knowledge_type' !== $taxonomy ) { + return $data; + } + + if ( $data['name'] !== $data['slug'] ) { + return $data; + } + + $types = wp_knowledge_types(); + if ( isset( $types[ $data['slug'] ] ) ) { + $data['name'] = $types[ $data['slug'] ]['title']; + } + + return $data; +} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..a462eca779510 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -16,6 +16,7 @@ * See {@see 'init'}. * * @since 2.9.0 + * @since 7.1.0 Added the `wp_knowledge` post type. */ function create_initial_post_types() { WP_Post_Type::reset_default_labels(); @@ -657,6 +658,47 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_knowledge', + array( + 'labels' => array( + 'name' => _x( 'Knowledge', 'post type general name' ), + 'singular_name' => _x( 'Knowledge Item', 'post type singular name' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + /* + * Knowledge rows have no native post-type screens. They are managed + * through the REST API and consuming features, not the wp-admin UI. + */ + 'show_ui' => false, + 'map_meta_cap' => true, + 'capability_type' => array( 'knowledge_item', 'knowledge_items' ), + /* + * `read` is remapped so that subscribers (who hold the base `read` + * capability) are stopped at the post-type door. Every other + * primitive defaults to a `knowledge_items`-suffixed capability granted + * by `wp_maybe_grant_knowledge_caps()`. + */ + 'capabilities' => array( + 'read' => 'read_knowledge_items', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'knowledge', + 'rest_controller_class' => 'WP_REST_Knowledge_Controller', + 'supports' => array( 'title', 'editor', 'excerpt', 'author', 'revisions' ), + ) + ); + /* + * Disable autosave endpoints for knowledge. 'editor' support implies + * 'autosave', but knowledge is headless storage with no editor session, so + * the autosave REST routes have no consumer. Revision history is retained. + */ + remove_post_type_support( 'wp_knowledge', 'autosave' ); + register_post_status( 'publish', array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php new file mode 100644 index 0000000000000..8f4e2d435e34f --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -0,0 +1,131 @@ +post_type ); + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to view knowledge.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return parent::get_items_permissions_check( $request ); + } + + /** + * Determines the allowed query_vars for a get_items() response and prepares + * them for WP_Query. + * + * Scopes the collection to rows readable by the current user so that the + * total count and pagination headers reflect per-user visibility. + * + * @since 7.1.0 + * + * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. + * @param WP_REST_Request $request Optional. Full details about the request. + * @return array Items query arguments. + */ + protected function prepare_items_query( $prepared_args = array(), $request = null ) { + $query_args = parent::prepare_items_query( $prepared_args, $request ); + $query_args['perm'] = 'readable'; + + return $query_args; + } + + /** + * Checks if a knowledge row can be read. + * + * A row is readable only when the current user passes the `read_post` + * capability check, which accounts for the row's author and status. + * + * @since 7.1.0 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + public function check_read_permission( $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return false; + } + + return parent::check_read_permission( $post ); + } + + /** + * Determines validity and normalizes the given status parameter. + * + * Callers without the publish capability may only set the `private` status. + * + * @since 7.1.0 + * + * @param string $post_status The post status. + * @param WP_Post_Type $post_type The post type object. + * @return string|WP_Error Post status or WP_Error if not allowed. + */ + protected function handle_status_param( $post_status, $post_type ) { + if ( ! current_user_can( $post_type->cap->publish_posts ) ) { + if ( 'private' !== $post_status ) { + return new WP_Error( + 'rest_cannot_publish', + __( 'Sorry, you are only allowed to set knowledge to a private status.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return $post_status; + } + + return parent::handle_status_param( $post_status, $post_type ); + } + + /** + * Prepares a single knowledge row for create or update. + * + * New rows default to the `private` status when no status is supplied. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + if ( ! isset( $request['id'] ) && null === $request['status'] ) { + $request->set_param( 'status', 'private' ); + } + + return parent::prepare_item_for_database( $request ); + } +} diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 80f457de0e6f7..fa9767135865a 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -19,6 +19,7 @@ * * @since 2.8.0 * @since 5.9.0 Added `'wp_template_part_area'` taxonomy. + * @since 7.1.0 Added `'wp_knowledge_type'` taxonomy. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ @@ -261,6 +262,38 @@ function create_initial_taxonomies() { 'show_tagcloud' => false, ) ); + + register_taxonomy( + 'wp_knowledge_type', + array( 'wp_knowledge' ), + array( + 'public' => false, + 'publicly_queryable' => false, + 'hierarchical' => true, + 'labels' => array( + 'name' => _x( 'Knowledge Types', 'taxonomy general name' ), + 'singular_name' => _x( 'Knowledge Type', 'taxonomy singular name' ), + ), + /* + * Editing and assigning terms reuse the `wp_knowledge` primitive + * `edit_knowledge_items` so that anyone who can edit a knowledge row + * can also lazily create and assign its type. Managing or deleting the + * type vocabulary itself stays an administrator task. + */ + 'capabilities' => array( + 'manage_terms' => 'manage_options', + 'edit_terms' => 'edit_knowledge_items', + 'delete_terms' => 'manage_options', + 'assign_terms' => 'edit_knowledge_items', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_ui' => false, + '_builtin' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => true, + ) + ); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index ef5c7784ee561..f10b71095aa28 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -254,6 +254,7 @@ require ABSPATH . WPINC . '/class-wp-term.php'; require ABSPATH . WPINC . '/class-wp-term-query.php'; require ABSPATH . WPINC . '/class-wp-tax-query.php'; +require ABSPATH . WPINC . '/knowledge.php'; require ABSPATH . WPINC . '/update.php'; require ABSPATH . WPINC . '/canonical.php'; require ABSPATH . WPINC . '/shortcodes.php'; @@ -356,6 +357,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-knowledge-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php new file mode 100644 index 0000000000000..2dbe7af7ecdb8 --- /dev/null +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -0,0 +1,224 @@ +user->create( array( 'role' => $role ) ); + } + + self::$own_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$own_published = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$others_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['author'], + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_has_every_primitive() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'read_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'edit_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'edit_others_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'publish_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'delete_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'delete_others_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'read_private_knowledge_items' ) ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_can_act_on_others_rows() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_subscriber_has_no_access() { + wp_set_current_user( self::$users['subscriber'] ); + + $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge_items' ) ); + } + + /** + * @ticket 65476 + */ + public function test_anonymous_has_no_access() { + wp_set_current_user( 0 ); + + $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge_items' ) ); + } + + /** + * @ticket 65476 + * + * @dataProvider data_contributor_level_roles + * + * @param string $role Role slug. + */ + public function test_contributor_level_ambient_floor( $role ) { + wp_set_current_user( self::$users[ $role ] ); + + // May list and create knowledge. + $this->assertTrue( current_user_can( 'read_knowledge_items' ), "$role should read_knowledge_items" ); + $this->assertTrue( current_user_can( 'edit_knowledge_items' ), "$role should edit_knowledge_items" ); + + // May not publish or act on other users' rows. + $this->assertFalse( current_user_can( 'publish_knowledge_items' ), "$role should not publish_knowledge_items" ); + $this->assertFalse( current_user_can( 'edit_others_knowledge_items' ), "$role should not edit_others_knowledge_items" ); + $this->assertFalse( current_user_can( 'delete_others_knowledge_items' ), "$role should not delete_others_knowledge_items" ); + } + + public function data_contributor_level_roles(): array { + return array( + 'contributor' => array( 'contributor' ), + 'author' => array( 'author' ), + 'editor' => array( 'editor' ), + ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_can_manage_own_private_row() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$own_private ) ); + } + + /** + * A contributor keeps control of their own row after trashing it. + * + * Trashing flips the status to `trash`, but the pre-trash `private` status is + * preserved in `_wp_trash_meta_status`, so the per-post grant must still apply + * and let the author permanently delete (or restore) their own trashed row. + * + * @ticket 65476 + */ + public function test_contributor_can_delete_own_trashed_row() { + wp_set_current_user( self::$users['contributor'] ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + wp_trash_post( $post_id ); + $this->assertSame( 'trash', get_post_status( $post_id ) ); + + $this->assertTrue( current_user_can( 'delete_post', $post_id ) ); + $this->assertTrue( current_user_can( 'edit_post', $post_id ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_edit_own_published_row() { + wp_set_current_user( self::$users['contributor'] ); + + // Publishing is reserved for administrators, so an already-published + // row falls outside the per-post grant. + $this->assertFalse( current_user_can( 'edit_post', self::$own_published ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_act_on_others_rows() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertFalse( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'read_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_grant_does_not_apply_to_other_post_types() { + wp_set_current_user( self::$users['contributor'] ); + + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + // The knowledge per-post grant must not leak into other post types. + $this->assertFalse( current_user_can( 'edit_post', $page_id ) ); + } +} diff --git a/tests/phpunit/tests/knowledge/postType.php b/tests/phpunit/tests/knowledge/postType.php new file mode 100644 index 0000000000000..64b71dcf222a9 --- /dev/null +++ b/tests/phpunit/tests/knowledge/postType.php @@ -0,0 +1,141 @@ +assertTrue( post_type_exists( 'wp_knowledge' ) ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_is_builtin_and_private() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertInstanceOf( 'WP_Post_Type', $post_type ); + $this->assertTrue( $post_type->_builtin, '_builtin should be true' ); + $this->assertFalse( $post_type->public, 'public should be false' ); + $this->assertFalse( $post_type->show_ui, 'show_ui should be false' ); + $this->assertFalse( $post_type->hierarchical, 'hierarchical should be false' ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_rest_configuration() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertTrue( $post_type->show_in_rest, 'show_in_rest should be true' ); + $this->assertSame( 'knowledge', $post_type->rest_base ); + $this->assertSame( 'WP_REST_Knowledge_Controller', $post_type->rest_controller_class ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'title' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'editor' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'excerpt' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'author' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + } + + /** + * Revisions are supported and served by the default revisions controller. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports_revisions_with_default_controller() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + + $controller = get_post_type_object( 'wp_knowledge' )->get_revisions_rest_controller(); + $this->assertInstanceOf( 'WP_REST_Revisions_Controller', $controller ); + } + + /** + * Autosave support is removed, so no autosave endpoints are registered. + * + * Knowledge is headless storage with no editor session; `editor` support + * implies `autosave`, which is explicitly removed at registration. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_does_not_support_autosaves() { + $this->assertFalse( post_type_supports( 'wp_knowledge', 'autosave' ) ); + $this->assertNull( get_post_type_object( 'wp_knowledge' )->get_autosave_rest_controller() ); + } + + /** + * The `read` capability is remapped so that the base `read` cap (held by + * subscribers) does not grant access to the post type. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_read_capability_is_remapped() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertSame( 'read_knowledge_items', $post_type->cap->read ); + } + + /** + * The per-post meta capabilities (derived from the singular `knowledge_item` + * base) must not collide with the primitive capabilities (derived from the + * plural `knowledge_items` base). A collision would make checks such as + * `current_user_can( 'edit_knowledge_items' )` ambiguous. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_meta_caps_do_not_collide_with_primitives() { + $cap = get_post_type_object( 'wp_knowledge' )->cap; + + // Meta capabilities are derived from the singular `knowledge_item` base. + $this->assertSame( 'edit_knowledge_item', $cap->edit_post ); + $this->assertSame( 'read_knowledge_item', $cap->read_post ); + $this->assertSame( 'delete_knowledge_item', $cap->delete_post ); + + // Primitive capabilities are derived from the plural `knowledge_items` base. + $this->assertSame( 'edit_knowledge_items', $cap->edit_posts ); + $this->assertSame( 'edit_others_knowledge_items', $cap->edit_others_posts ); + $this->assertSame( 'publish_knowledge_items', $cap->publish_posts ); + $this->assertSame( 'read_private_knowledge_items', $cap->read_private_posts ); + + // The meta and primitive forms must be distinct. + $this->assertNotSame( $cap->edit_post, $cap->edit_posts ); + $this->assertNotSame( $cap->read_post, $cap->read_private_posts ); + $this->assertNotSame( $cap->delete_post, $cap->delete_posts ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_taxonomies + */ + public function test_knowledge_type_taxonomy_is_attached() { + $this->assertTrue( taxonomy_exists( 'wp_knowledge_type' ) ); + $this->assertContains( 'wp_knowledge_type', get_object_taxonomies( 'wp_knowledge' ) ); + + $taxonomy = get_taxonomy( 'wp_knowledge_type' ); + $this->assertTrue( $taxonomy->hierarchical, 'taxonomy should be hierarchical' ); + $this->assertFalse( $taxonomy->public, 'taxonomy should not be public' ); + $this->assertTrue( $taxonomy->show_in_rest, 'taxonomy should be shown in REST' ); + } +} diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php new file mode 100644 index 0000000000000..f2b26b2272dd6 --- /dev/null +++ b/tests/phpunit/tests/knowledge/types.php @@ -0,0 +1,142 @@ +assertArrayHasKey( 'guideline', $types ); + $this->assertArrayHasKey( 'memory', $types ); + $this->assertArrayHasKey( 'note', $types ); + + $this->assertSame( 'Guideline', $types['guideline']['title'] ); + $this->assertSame( 'Memory', $types['memory']['title'] ); + $this->assertSame( 'Note', $types['note']['title'] ); + } + + /** + * @ticket 65476 + * @covers ::wp_knowledge_types + */ + public function test_types_are_filterable() { + $callback = static function ( $types ) { + $types['skill'] = array( 'title' => 'Skill' ); + return $types; + }; + + add_filter( 'wp_knowledge_types', $callback ); + $types = wp_knowledge_types(); + remove_filter( 'wp_knowledge_types', $callback ); + + $this->assertArrayHasKey( 'skill', $types ); + $this->assertSame( 'Skill', $types['skill']['title'] ); + } + + /** + * A knowledge row saved without a type term should fall back to `note`. + * + * @ticket 65476 + * @covers ::wp_knowledge_ensure_default_type_term + */ + public function test_default_type_term_is_assigned_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'note' ), $terms ); + } + + /** + * A row that already carries a type term should keep it, not gain `note`. + * + * @ticket 65476 + * @covers ::wp_knowledge_ensure_default_type_term + */ + public function test_existing_type_term_is_preserved_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + // Assign a non-default term, replacing the `note` fallback from creation. + $term = wp_insert_term( 'memory', 'wp_knowledge_type' ); + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); + + // A subsequent save must not re-add the `note` fallback. + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated knowledge', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'memory' ), $terms ); + } + + /** + * A term lazily created from a registered slug gets the registered label. + * + * @ticket 65476 + * @covers ::wp_knowledge_maybe_map_term_label + */ + public function test_registered_slug_term_gets_mapped_label() { + $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'Guideline', $created->name ); + } + + /** + * A user-provided label (where name differs from the slug) is left intact. + * + * @ticket 65476 + * @covers ::wp_knowledge_maybe_map_term_label + */ + public function test_custom_label_is_not_overwritten() { + $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'My Custom Type', $created->name ); + } + + /** + * The label mapping must not touch terms in other taxonomies. + * + * @ticket 65476 + * @covers ::wp_knowledge_maybe_map_term_label + */ + public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { + $term = wp_insert_term( 'guideline', 'category' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'category' ); + + $this->assertSame( 'guideline', $created->name ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..3c2faa87ab159 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -108,6 +108,10 @@ public function test_expected_routes_in_schema() { '/wp/v2/pages/(?P[\\d]+)/autosaves', '/wp/v2/pages/(?P[\\d]+)/autosaves/(?P[\\d]+)', '/wp/v2/pattern-directory/patterns', + '/wp/v2/knowledge', + '/wp/v2/knowledge/(?P[\\d]+)', + '/wp/v2/knowledge/(?P[\\d]+)/revisions', + '/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)', '/wp/v2/media', '/wp/v2/media/(?P[\\d]+)', '/wp/v2/media/(?P[\\d]+)/post-process', @@ -192,6 +196,8 @@ public function test_expected_routes_in_schema() { '/wp-site-health/v1/tests/authorization-header', '/wp-site-health/v1/tests/page-cache', '/wp-site-health/v1/directory-sizes', + '/wp/v2/wp_knowledge_type', + '/wp/v2/wp_knowledge_type/(?P[\d]+)', '/wp/v2/wp_pattern_category', '/wp/v2/wp_pattern_category/(?P[\d]+)', '/wp/v2/font-collections', diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php new file mode 100644 index 0000000000000..0069c503bfba6 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -0,0 +1,354 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$admin_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$admin_id, + 'post_title' => 'Admin private knowledge', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$contributor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$admin_private, true ); + } + + /** + * Creates a private knowledge row for the given author. + * + * @param int $author_id Author user ID. + * @return int Post ID. + */ + private function create_knowledge_post( int $author_id ): int { + return self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => $author_id, + 'post_title' => 'Knowledge row', + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)', $routes ); + + // Revisions are supported. + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions/(?P[\d]+)', $routes ); + + // Autosave support is removed, so the autosaves routes are not registered. + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves', $routes ); + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves/(?P[\d]+)', $routes ); + } + + /** + * @ticket 65476 + */ + public function test_context_param() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * @ticket 65476 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + // The collection defaults to the `publish` status; knowledge rows are + // private by default, so list a published row here. + self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + 'post_title' => 'Published knowledge', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $response->get_data() ); + } + + /** + * @ticket 65476 + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + + wp_set_current_user( self::$subscriber_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * @ticket 65476 + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( self::$admin_private, $response->get_data()['id'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_read_others_private_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + // The contributor is authenticated, so reading another user's private row is forbidden. + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 65476 + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by admin' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + // With no status supplied, new rows default to private rather than draft. + $this->assertSame( 'private', $data['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_create_defaults_to_private() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by contributor' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'private', $response->get_data()['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_publish() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( + array( + 'title' => 'Attempted publish', + 'status' => 'publish', + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_publish', $response, 403 ); + } + + /** + * @ticket 65476 + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'title' => 'Updated title' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated title', $response->get_data()['title']['raw'] ); + } + + /** + * A contributor may edit their own private row. + * + * @ticket 65476 + */ + public function test_contributor_can_update_own_row() { + wp_set_current_user( self::$contributor_id ); + + $post_id = $this->create_knowledge_post( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'title' => 'Updated by contributor' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated by contributor', $response->get_data()['title']['raw'] ); + } + + /** + * A contributor may not edit another user's row. + * + * @ticket 65476 + */ + public function test_contributor_cannot_update_others_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_body_params( array( 'title' => 'Attempted update' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 65476 + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * A contributor may delete their own private row. + * + * @ticket 65476 + */ + public function test_contributor_can_delete_own_row() { + wp_set_current_user( self::$contributor_id ); + + $post_id = $this->create_knowledge_post( self::$contributor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * A contributor may not delete another user's row. + * + * @ticket 65476 + */ + public function test_contributor_cannot_delete_others_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertInstanceOf( WP_Post::class, get_post( self::$admin_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertArrayHasKey( 'status', $data ); + $this->assertArrayHasKey( 'author', $data ); + } + + /** + * @ticket 65476 + */ + public function test_get_item_schema() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $properties = $response->get_data()['schema']['properties']; + + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'excerpt', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..cf1edba25e3c5 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -8426,6 +8426,874 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/knowledge": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "after": { + "description": "Limit response to posts published after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_after": { + "description": "Limit response to posts modified after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "author": { + "description": "Limit result set to posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "author_exclude": { + "description": "Ensure result set excludes posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "before": { + "description": "Limit response to posts published before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_before": { + "description": "Limit response to posts modified before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "search_semantics": { + "description": "How to interpret the search input.", + "type": "string", + "enum": [ + "exact" + ], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by post attribute.", + "type": "string", + "default": "date", + "enum": [ + "author", + "date", + "id", + "include", + "modified", + "parent", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, + "slug": { + "description": "Limit result set to posts with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "status": { + "default": "publish", + "description": "Limit result set to posts assigned one or more statuses.", + "type": "array", + "items": { + "enum": [ + "publish", + "future", + "draft", + "pending", + "private", + "trash", + "auto-draft", + "inherit", + "request-pending", + "request-confirmed", + "request-failed", + "request-completed", + "any" + ], + "type": "string" + }, + "required": false + }, + "tax_relation": { + "description": "Limit result set based on relationship between multiple taxonomies.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "required": false + }, + "wp_knowledge_type": { + "description": "Limit result set to items with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + }, + "operator": { + "description": "Whether items must be assigned all or any of the specified terms.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "default": "OR" + } + }, + "additionalProperties": false + } + ], + "required": false + }, + "wp_knowledge_type_exclude": { + "description": "Limit result set to items except those with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ], + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ] + } + }, + "/wp/v2/knowledge/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "excerpt_length": { + "description": "Override the default excerpt length.", + "type": "integer", + "required": false + }, + "password": { + "description": "The password for the post if it is password protected.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, "/wp/v2/types": { "namespace": "wp/v2", "methods": [ @@ -8577,23 +9445,219 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + }, + "/wp/v2/taxonomies/(?P[\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "taxonomy": { + "description": "An alphanumeric identifier for the taxonomy.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, + "/wp/v2/categories": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by term attribute.", + "type": "string", + "default": "name", + "enum": [ + "id", + "include", + "name", + "slug", + "include_slugs", + "term_group", + "description", + "count" + ], + "required": false + }, + "hide_empty": { + "description": "Whether to hide terms not assigned to any posts.", + "type": "boolean", + "default": false, + "required": false + }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, + "post": { + "description": "Limit result set to terms assigned to a specific post.", + "type": "integer", + "default": null, + "required": false + }, + "slug": { + "description": "Limit result set to terms with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": true + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + } + ], + "_links": { + "self": "http://example.org/index.php?rest_route=/wp/v2/categories" } }, - "/wp/v2/taxonomies/(?P[\\w-]+)": { + "/wp/v2/categories/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ - "GET" + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" ], "endpoints": [ { "methods": [ "GET" ], + "allow_batch": { + "v1": true + }, "args": { - "taxonomy": { - "description": "An alphanumeric identifier for the taxonomy.", - "type": "string", + "id": { + "description": "Unique identifier for the term.", + "type": "integer", "required": false }, "context": { @@ -8608,10 +9672,74 @@ mockedApiResponse.Schema = { "required": false } } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as terms do not support trashing.", + "required": false + } + } } ] }, - "/wp/v2/categories": { + "/wp/v2/tags": { "namespace": "wp/v2", "methods": [ "GET", @@ -8675,6 +9803,11 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -8707,11 +9840,6 @@ mockedApiResponse.Schema = { "default": false, "required": false }, - "parent": { - "description": "Limit result set to terms assigned to a specific parent.", - "type": "integer", - "required": false - }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -8751,11 +9879,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8766,10 +9889,10 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/categories" + "self": "http://example.org/index.php?rest_route=/wp/v2/tags" } }, - "/wp/v2/categories/(?P[\\d]+)": { + "/wp/v2/tags/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -8835,11 +9958,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8871,7 +9989,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/tags": { + "/wp/v2/menus": { "namespace": "wp/v2", "methods": [ "GET", @@ -9016,15 +10134,32 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/tags" + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + } + ] } }, - "/wp/v2/tags/(?P[\\d]+)": { + "/wp/v2/menus/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9095,6 +10230,19 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } }, @@ -9121,7 +10269,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/menus": { + "/wp/v2/wp_pattern_category": { "namespace": "wp/v2", "methods": [ "GET", @@ -9266,19 +10414,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } } @@ -9286,12 +10421,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" } ] } }, - "/wp/v2/menus/(?P[\\d]+)": { + "/wp/v2/wp_pattern_category/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9362,19 +10497,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } }, @@ -9401,7 +10523,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/wp_pattern_category": { + "/wp/v2/wp_knowledge_type": { "namespace": "wp/v2", "methods": [ "GET", @@ -9465,11 +10587,6 @@ mockedApiResponse.Schema = { "default": [], "required": false }, - "offset": { - "description": "Offset the result set by a specific number of items.", - "type": "integer", - "required": false - }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -9502,6 +10619,11 @@ mockedApiResponse.Schema = { "default": false, "required": false }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -9541,6 +10663,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9553,12 +10680,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" } ] } }, - "/wp/v2/wp_pattern_category/(?P[\\d]+)": { + "/wp/v2/wp_knowledge_type/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9624,6 +10751,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9801,7 +10933,8 @@ mockedApiResponse.Schema = { "wp_global_styles": "wp_global_styles", "wp_navigation": "wp_navigation", "wp_font_family": "wp_font_family", - "wp_font_face": "wp_font_face" + "wp_font_face": "wp_font_face", + "wp_knowledge": "wp_knowledge" } }, "required": false @@ -14065,6 +15198,40 @@ mockedApiResponse.TypesCollection = { } ] } + }, + "wp_knowledge": { + "description": "", + "hierarchical": false, + "has_archive": false, + "name": "Knowledge", + "slug": "wp_knowledge", + "icon": null, + "taxonomies": [ + "wp_knowledge_type" + ], + "rest_base": "knowledge", + "rest_namespace": "wp/v2", + "template": [], + "template_lock": false, + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/types" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; @@ -14300,6 +15467,36 @@ mockedApiResponse.TaxonomiesCollection = { } ] } + }, + "wp_knowledge_type": { + "name": "Knowledge Types", + "slug": "wp_knowledge_type", + "description": "", + "types": [ + "wp_knowledge" + ], + "hierarchical": true, + "rest_base": "wp_knowledge_type", + "rest_namespace": "wp/v2", + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } };