\d+)/tree', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_tree' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ), array( 'methods' => 'POST', 'callback' => array( $this, 'save_tree' ), 'args' => array( 'id' => Args::id(), 'items' => Args::tree_items(), 'version' => array( 'type' => 'integer', 'minimum' => 0, 'description' => 'Structure version the client loaded; a stale one returns 409.', ), ), 'permission_callback' => $manage, ), ) ); register_rest_route( Routes::NS, '/categories', array( 'methods' => 'POST', 'callback' => array( $this, 'create_category' ), 'args' => array( 'kb' => Args::id(), 'name' => Args::text(), 'slug' => Args::text(), 'parent' => Args::id( false ), 'icon' => Args::icon(), 'description' => Args::textarea( 1000 ), 'color' => Args::color(), 'cover' => Args::cover(), ), 'permission_callback' => $manage, ) ); register_rest_route( Routes::NS, '/categories/(?P\d+)', array( array( 'methods' => 'PUT, PATCH, POST', 'callback' => array( $this, 'update_category' ), 'args' => array( 'id' => Args::id(), 'name' => Args::text(), 'slug' => Args::text(), 'description' => Args::textarea( 1000 ), 'icon' => Args::icon(), 'color' => Args::color(), 'cover' => Args::cover(), ), 'permission_callback' => $manage, ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_category' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $manage, ), ) ); register_rest_route( Routes::NS, '/articles', array( 'methods' => 'POST', 'callback' => array( $this, 'create_article' ), 'args' => array( 'kb' => Args::id(), 'parent' => Args::node(), 'title' => Args::text( 300 ), 'status' => Args::enum( array( 'draft', 'publish' ), false, 'draft' ), ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/articles/(?P\d+)', array( array( 'methods' => 'PUT, PATCH, POST', 'callback' => array( $this, 'update_article' ), 'args' => array( 'id' => Args::id(), 'title' => Args::text( 300 ), 'slug' => array( 'type' => 'string', 'maxLength' => 200, 'sanitize_callback' => 'sanitize_title', ), 'excerpt' => Args::textarea( 1000 ), 'status' => Args::enum( array( 'draft', 'publish', 'private', 'pending' ) ), 'icon' => Args::icon(), 'category' => Args::id( false ), ), 'permission_callback' => $edit, ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_article' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ), ) ); register_rest_route( Routes::NS, '/articles/(?P\d+)/duplicate', array( 'methods' => 'POST', 'callback' => array( $this, 'duplicate_article' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/bulk', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk_action' ), 'args' => array( 'kb' => Args::id(), 'action' => Args::enum( array( 'publish', 'draft', 'trash' ), true ), 'ids' => Args::nodes(), ), 'permission_callback' => $edit, ) ); } /** * Validates a KB id. * * @param int $id KB id. * @return \WP_Term|\WP_Error */ private function kb( $id ) { $term = get_term( (int) $id, Taxonomies::KB ); if ( ! $term || is_wp_error( $term ) ) { return new \WP_Error( 'kdocs_not_found', __( 'Knowledge base not found.', 'karma-docs' ), array( 'status' => 404 ) ); } return $term; } /** * Returns a tree response. * * @param int $kb_id KB. * @return \WP_REST_Response */ private function tree_response( $kb_id ) { return rest_ensure_response( array( 'kb' => \KarmaDocs\KnowledgeBase::format( get_term( $kb_id, Taxonomies::KB ) ), 'tree' => Tree::get( $kb_id, array( 'details' => true ) ), 'version' => TreeVersion::get( $kb_id ), ) ); } /** * GET tree. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function get_tree( $request ) { $kb = $this->kb( $request['id'] ); if ( is_wp_error( $kb ) ) { return $kb; } return $this->tree_response( $kb->term_id ); } /** * POST tree (drag & drop result). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function save_tree( $request ) { $kb = $this->kb( $request['id'] ); if ( is_wp_error( $kb ) ) { return $kb; } $fresh = TreeVersion::check( $kb->term_id, $request->get_param( 'version' ) ); if ( is_wp_error( $fresh ) ) { return $fresh; } $items = (array) $request->get_param( 'items' ); $result = Tree::save( $kb->term_id, $items ); if ( is_wp_error( $result ) ) { return $result; } return $this->tree_response( $kb->term_id ); } /** * Next order value among siblings. * * @param int $kb_id KB. * @param string|null $parent Parent node ref. * @return int */ private function next_order( $kb_id, $parent ) { $tree = Tree::get( $kb_id ); $siblings = $parent ? ( Tree::find( $tree, $parent )['children'] ?? array() ) : $tree; $max = -1; foreach ( $siblings as $sibling ) { $max = max( $max, (int) $sibling['order'] ); } return $max + 1; } /** * Creates a category. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create_category( $request ) { $kb = $this->kb( $request->get_param( 'kb' ) ); if ( is_wp_error( $kb ) ) { return $kb; } $name = sanitize_text_field( (string) $request->get_param( 'name' ) ); $parent = (int) $request->get_param( 'parent' ); if ( $parent && Taxonomies::category_kb( $parent ) !== (int) $kb->term_id ) { $parent = 0; } $slug = sanitize_title( $request->get_param( 'slug' ) ? $request->get_param( 'slug' ) : $name ); $exists = get_term_by( 'slug', $slug, Taxonomies::CATEGORY ); if ( $exists ) { $slug .= '-' . $kb->slug; } $result = wp_insert_term( $name ? $name : __( 'New Category', 'karma-docs' ), Taxonomies::CATEGORY, array( 'parent' => $parent, 'description' => (string) $request->get_param( 'description' ), 'slug' => wp_unique_term_slug( $slug, (object) array( 'taxonomy' => Taxonomies::CATEGORY, 'parent' => $parent, ) ), ) ); if ( is_wp_error( $result ) ) { $result->add_data( array( 'status' => 400 ) ); return $result; } $id = (int) $result['term_id']; update_term_meta( $id, 'kdocs_kb', (int) $kb->term_id ); update_term_meta( $id, 'kdocs_order', $this->next_order( $kb->term_id, $parent ? 'c' . $parent : null ) ); if ( $request->get_param( 'icon' ) ) { update_term_meta( $id, 'kdocs_icon', \KarmaDocs\Icons::sanitize( $request->get_param( 'icon' ) ) ); } if ( $request->get_param( 'color' ) ) { update_term_meta( $id, 'kdocs_color', (string) sanitize_hex_color( $request->get_param( 'color' ) ) ); } if ( $request->get_param( 'cover' ) ) { update_term_meta( $id, \KarmaDocs\Covers::META, $request->get_param( 'cover' ) ); } return $this->tree_response( $kb->term_id ); } /** * Updates a category. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function update_category( $request ) { $id = (int) $request['id']; $term = get_term( $id, Taxonomies::CATEGORY ); if ( ! $term || is_wp_error( $term ) ) { return new \WP_Error( 'kdocs_not_found', __( 'Category not found.', 'karma-docs' ), array( 'status' => 404 ) ); } $args = array(); foreach ( array( 'name', 'slug', 'description' ) as $field ) { if ( null !== $request->get_param( $field ) ) { $args[ $field ] = 'description' === $field ? sanitize_textarea_field( $request->get_param( $field ) ) : sanitize_text_field( $request->get_param( $field ) ); } } if ( $args ) { $result = wp_update_term( $id, Taxonomies::CATEGORY, $args ); if ( is_wp_error( $result ) ) { $result->add_data( array( 'status' => 400 ) ); return $result; } } if ( null !== $request->get_param( 'icon' ) ) { update_term_meta( $id, 'kdocs_icon', \KarmaDocs\Icons::sanitize( $request->get_param( 'icon' ) ) ); } if ( null !== $request->get_param( 'color' ) ) { update_term_meta( $id, 'kdocs_color', (string) sanitize_hex_color( $request->get_param( 'color' ) ) ); } if ( null !== $request->get_param( 'cover' ) ) { update_term_meta( $id, \KarmaDocs\Covers::META, $request->get_param( 'cover' ) ); } return $this->tree_response( Taxonomies::category_kb( $id ) ); } /** * Deletes a category; its articles move to the parent category. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function delete_category( $request ) { $id = (int) $request['id']; $term = get_term( $id, Taxonomies::CATEGORY ); if ( ! $term || is_wp_error( $term ) ) { return new \WP_Error( 'kdocs_not_found', __( 'Category not found.', 'karma-docs' ), array( 'status' => 404 ) ); } $kb_id = Taxonomies::category_kb( $id ); $posts = get_posts( array( 'post_type' => PostTypes::ARTICLE, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery array( 'taxonomy' => Taxonomies::CATEGORY, 'terms' => $id, 'include_children' => false, ), ), ) ); foreach ( $posts as $post_id ) { wp_set_object_terms( $post_id, $term->parent ? array( (int) $term->parent ) : array(), Taxonomies::CATEGORY ); if ( $kb_id ) { wp_set_object_terms( $post_id, array( $kb_id ), Taxonomies::KB ); } } wp_delete_term( $id, Taxonomies::CATEGORY ); return $this->tree_response( $kb_id ); } /** * Quick-creates an article from the builder. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create_article( $request ) { $kb = $this->kb( $request->get_param( 'kb' ) ); if ( is_wp_error( $kb ) ) { return $kb; } $parent_ref = (string) $request->get_param( 'parent' ); $parent_ref = preg_match( '/^[ca]\d+$/', $parent_ref ) ? $parent_ref : null; $status = 'publish' === $request->get_param( 'status' ) && current_user_can( 'publish_posts' ) ? 'publish' : 'draft'; $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); $post_parent = 0; $category = 0; if ( $parent_ref && 'a' === $parent_ref[0] ) { $post_parent = (int) substr( $parent_ref, 1 ); $cat = Taxonomies::article_category( $post_parent ); $category = $cat ? (int) $cat->term_id : 0; } elseif ( $parent_ref ) { $category = (int) substr( $parent_ref, 1 ); } $id = wp_insert_post( array( 'post_type' => PostTypes::ARTICLE, 'post_title' => $title ? $title : __( 'Untitled Article', 'karma-docs' ), 'post_status' => $status, 'post_parent' => $post_parent, 'menu_order' => $this->next_order( $kb->term_id, $parent_ref ), ), true ); if ( is_wp_error( $id ) ) { return $id; } if ( $category ) { wp_set_object_terms( $id, array( $category ), Taxonomies::CATEGORY ); } wp_set_object_terms( $id, array( (int) $kb->term_id ), Taxonomies::KB ); $response = $this->tree_response( $kb->term_id ); $data = $response->get_data(); $data['created'] = array( 'id' => (int) $id, 'editLink' => get_edit_post_link( $id, 'raw' ), ); $response->set_data( $data ); return $response; } /** * Inline updates (title, status, icon). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function update_article( $request ) { $id = (int) $request['id']; if ( PostTypes::ARTICLE !== get_post_type( $id ) || ! current_user_can( 'edit_post', $id ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot edit this article.', 'karma-docs' ), array( 'status' => 403 ) ); } $update = array( 'ID' => $id ); if ( null !== $request->get_param( 'title' ) ) { $update['post_title'] = sanitize_text_field( $request->get_param( 'title' ) ); } if ( $request->get_param( 'slug' ) ) { $update['post_name'] = $request->get_param( 'slug' ); } if ( null !== $request->get_param( 'excerpt' ) ) { $update['post_excerpt'] = $request->get_param( 'excerpt' ); } $status = $request->get_param( 'status' ); if ( in_array( $status, array( 'draft', 'publish', 'private', 'pending' ), true ) ) { if ( 'publish' === $status && ! current_user_can( 'publish_post', $id ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot publish this article.', 'karma-docs' ), array( 'status' => 403 ) ); } $update['post_status'] = $status; } if ( count( $update ) > 1 ) { wp_update_post( $update ); } if ( null !== $request->get_param( 'icon' ) ) { update_post_meta( $id, '_kdocs_icon', \KarmaDocs\Icons::sanitize( $request->get_param( 'icon' ) ) ); } $category = $request->get_param( 'category' ); if ( null !== $category ) { $moved = $this->set_article_category( $id, (int) $category ); if ( is_wp_error( $moved ) ) { return $moved; } } $kb = Taxonomies::article_kb( $id ); return $this->tree_response( $kb ? $kb->term_id : 0 ); } /** * Moves an article (with its nested articles) into a category of the same knowledge base. * A nested article leaves its parent article, since the category is now its position. * * @param int $id Article. * @param int $category Category id (0 = top level of the KB). * @return true|\WP_Error */ private function set_article_category( $id, $category ) { $kb = Taxonomies::article_kb( $id ); $current = Taxonomies::article_category( $id ); if ( ( $current ? (int) $current->term_id : 0 ) === $category ) { return true; } if ( $category && ( ! $kb || Taxonomies::category_kb( $category ) !== (int) $kb->term_id ) ) { return new \WP_Error( 'kdocs_invalid_category', __( 'The category belongs to another knowledge base.', 'karma-docs' ), array( 'status' => 400 ) ); } if ( get_post( $id )->post_parent ) { wp_update_post( array( 'ID' => $id, 'post_parent' => 0, ) ); } wp_set_object_terms( $id, $category ? array( $category ) : array(), Taxonomies::CATEGORY ); if ( $kb ) { wp_set_object_terms( $id, array( (int) $kb->term_id ), Taxonomies::KB ); } \KarmaDocs\Sync::cascade( $id ); return true; } /** * Trashes an article; its children move up one level. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function delete_article( $request ) { global $wpdb; $id = (int) $request['id']; $post = get_post( $id ); if ( ! $post || PostTypes::ARTICLE !== $post->post_type || ! current_user_can( 'delete_post', $id ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot delete this article.', 'karma-docs' ), array( 'status' => 403 ) ); } $kb = Taxonomies::article_kb( $id ); $children = get_children( array( 'post_parent' => $id, 'post_type' => PostTypes::ARTICLE, 'fields' => 'ids', ) ); \KarmaDocs\Trash::remember( $post, array_map( 'intval', array_values( $children ) ) ); foreach ( $children as $child_id ) { $wpdb->update( $wpdb->posts, array( 'post_parent' => (int) $post->post_parent ), array( 'ID' => $child_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery clean_post_cache( $child_id ); } wp_trash_post( $id ); return $this->tree_response( $kb ? $kb->term_id : 0 ); } /** * Duplicates an article (content, meta, terms) as a draft right below the original. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function duplicate_article( $request ) { $id = (int) $request['id']; $post = get_post( $id ); if ( ! $post || PostTypes::ARTICLE !== $post->post_type || ! current_user_can( 'edit_post', $id ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot duplicate this article.', 'karma-docs' ), array( 'status' => 403 ) ); } $new_id = wp_insert_post( wp_slash( array( 'post_type' => $post->post_type, /* translators: %s: article title */ 'post_title' => sprintf( __( '%s (Copy)', 'karma-docs' ), $post->post_title ), 'post_content' => $post->post_content, 'post_excerpt' => $post->post_excerpt, 'post_status' => 'draft', 'post_parent' => $post->post_parent, 'menu_order' => $post->menu_order + 1, ) ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } foreach ( array( Taxonomies::CATEGORY, Taxonomies::KB ) as $tax ) { wp_set_object_terms( $new_id, wp_get_object_terms( $id, $tax, array( 'fields' => 'ids' ) ), $tax ); } foreach ( get_post_meta( $id ) as $key => $values ) { if ( in_array( $key, array( '_kdocs_views', '_kdocs_helpful_yes', '_kdocs_helpful_no', '_edit_lock', '_edit_last' ), true ) ) { continue; } foreach ( $values as $value ) { add_post_meta( $new_id, $key, maybe_unserialize( $value ) ); } } $kb = Taxonomies::article_kb( $id ); return $this->tree_response( $kb ? $kb->term_id : 0 ); } /** * Bulk status actions from the builder: publish, draft or trash several nodes. * Moves go through POST /kbs/{id}/tree so they get the tree's validation and ordering. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function bulk_action( $request ) { $kb = $this->kb( $request->get_param( 'kb' ) ); if ( is_wp_error( $kb ) ) { return $kb; } $action = sanitize_key( (string) $request->get_param( 'action' ) ); if ( ! in_array( $action, array( 'publish', 'draft', 'trash' ), true ) ) { return new \WP_Error( 'kdocs_invalid', __( 'Invalid bulk action.', 'karma-docs' ), array( 'status' => 400 ) ); } $ids = array_filter( (array) $request->get_param( 'ids' ), static function ( $id ) { return is_string( $id ) && preg_match( '/^[ca]\d+$/', $id ); } ); if ( ! $ids ) { return new \WP_Error( 'kdocs_invalid', __( 'No items selected.', 'karma-docs' ), array( 'status' => 400 ) ); } foreach ( $ids as $ref ) { $id = (int) substr( $ref, 1 ); if ( 'c' === $ref[0] ) { // Categories only support deletion; their content moves up a level (see delete_category()). if ( 'trash' === $action && Routes::can_manage() && Taxonomies::category_kb( $id ) === (int) $kb->term_id ) { $category = new \WP_REST_Request( 'DELETE' ); $category->set_url_params( array( 'id' => $id ) ); $this->delete_category( $category ); } continue; } $article_kb = Taxonomies::article_kb( $id ); if ( PostTypes::ARTICLE !== get_post_type( $id ) || ! $article_kb || (int) $article_kb->term_id !== (int) $kb->term_id || ! current_user_can( 'edit_post', $id ) ) { continue; } if ( 'trash' === $action ) { if ( current_user_can( 'delete_post', $id ) ) { $article = new \WP_REST_Request( 'DELETE' ); $article->set_url_params( array( 'id' => $id ) ); $this->delete_article( $article ); } } elseif ( 'publish' !== $action || current_user_can( 'publish_post', $id ) ) { wp_update_post( array( 'ID' => $id, 'post_status' => $action, ) ); } } return $this->tree_response( $kb->term_id ); } }