'GET', 'callback' => array( $this, 'index' ), 'args' => Args::pagination( 200 ) + array( 'kb' => Args::id( false ), 'category' => Args::id( false ), 'status' => Args::enum( array_merge( array( 'any' ), self::STATUSES ), false, 'any' ), 'author' => array( 'type' => 'string', 'pattern' => '^(me|[0-9]+)?$', ), 'search' => Args::text( 200 ), 'view' => Args::enum( array( 'all', 'needs_review' ), false, 'all' ), 'orderby' => Args::enum( array( 'title', 'modified', 'date', 'views', 'helpful', 'reviewed' ), false, 'modified' ), 'order' => Args::enum( array( 'asc', 'desc' ), false, 'desc' ), ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/articles/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/articles/(?P\d+)/restore', array( 'methods' => 'POST', 'callback' => array( $this, 'restore' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/articles/(?P\d+)/permanent', array( 'methods' => 'DELETE', 'callback' => array( $this, 'destroy' ), 'args' => array( 'id' => Args::id() ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/articles/bulk', array( 'methods' => 'POST', 'callback' => array( $this, 'bulk' ), 'args' => array( 'action' => Args::enum( array( 'publish', 'draft', 'pending', 'trash', 'restore', 'delete' ), true ), 'ids' => array( 'type' => 'array', 'required' => true, 'minItems' => 1, 'maxItems' => 500, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), ), ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/trash', array( 'methods' => 'GET', 'callback' => array( $this, 'trash' ), 'args' => Args::pagination( 200 ) + array( 'kb' => Args::id( false ), 'search' => Args::text( 200 ), ), 'permission_callback' => $edit, ) ); register_rest_route( Routes::NS, '/admin-search', array( 'methods' => 'GET', 'callback' => array( $this, 'search' ), 'args' => array( 'q' => Args::text( 200, true ) ), 'permission_callback' => $edit, ) ); } /* ------------------------------------------------------------------ list */ /** * GET /articles. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function index( $request ) { $per_page = (int) $request['per_page']; $status = $request['status']; $args = array( 'post_type' => PostTypes::ARTICLE, 'post_status' => 'any' === $status ? self::STATUSES : $status, 'posts_per_page' => $per_page, 'paged' => (int) $request['page'], 'order' => strtoupper( $request['order'] ), ); $tax = array(); if ( $request['kb'] ) { $tax[] = array( 'taxonomy' => Taxonomies::KB, 'terms' => (int) $request['kb'], ); } if ( $request['category'] ) { $tax[] = array( 'taxonomy' => Taxonomies::CATEGORY, 'terms' => (int) $request['category'], ); } if ( $tax ) { $args['tax_query'] = $tax; // phpcs:ignore WordPress.DB.SlowDBQuery } if ( $request['author'] ) { $args['author'] = 'me' === $request['author'] ? get_current_user_id() : (int) $request['author']; } if ( $request['search'] ) { $args['s'] = $request['search']; } $clauses = $this->clauses( $request['orderby'], $request['order'], 'needs_review' === $request['view'] ); if ( in_array( $request['orderby'], array( 'title', 'modified', 'date' ), true ) ) { $args['orderby'] = $request['orderby']; } add_filter( 'posts_clauses', $clauses ); $query = new \WP_Query( $args ); remove_filter( 'posts_clauses', $clauses ); $items = $this->format_many( $query->posts ); return Args::paginate( rest_ensure_response( array( 'items' => $items ) ), $query->found_posts, $per_page ); } /** * Builds the SQL for sorting by stats and the "needs review" view. * * @param string $orderby Sort key. * @param string $order asc / desc. * @param bool $needs_review Only articles past their review date. * @return callable posts_clauses filter. */ private function clauses( $orderby, $order, $needs_review ) { return static function ( $clauses ) use ( $orderby, $order, $needs_review ) { global $wpdb; $dir = 'asc' === $order ? 'ASC' : 'DESC'; $meta = static function ( $alias, $key ) use ( $wpdb ) { return $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} {$alias} ON {$alias}.post_id = {$wpdb->posts}.ID AND {$alias}.meta_key = %s", $key ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared }; $checked = "GREATEST({$wpdb->posts}.post_modified_gmt, COALESCE(kd_rev.meta_value, '1970-01-01 00:00:00'))"; if ( $needs_review || 'reviewed' === $orderby ) { $clauses['join'] .= $meta( 'kd_rev', '_kdocs_reviewed_at' ); } if ( $needs_review ) { $cutoff = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * max( 1, (int) Settings::get( 'review_interval', 180 ) ) ); $clauses['where'] .= $wpdb->prepare( " AND {$checked} < %s", $cutoff ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } if ( 'views' === $orderby ) { $clauses['join'] .= $meta( 'kd_views', '_kdocs_views' ); $clauses['orderby'] = "CAST(COALESCE(kd_views.meta_value, 0) AS UNSIGNED) {$dir}, {$wpdb->posts}.ID {$dir}"; } elseif ( 'helpful' === $orderby ) { $clauses['join'] .= $meta( 'kd_yes', '_kdocs_helpful_yes' ) . $meta( 'kd_no', '_kdocs_helpful_no' ); $yes = 'CAST(COALESCE(kd_yes.meta_value, 0) AS UNSIGNED)'; $no = 'CAST(COALESCE(kd_no.meta_value, 0) AS UNSIGNED)'; $clauses['orderby'] = "({$yes} / NULLIF({$yes} + {$no}, 0)) {$dir}, ({$yes} + {$no}) {$dir}, {$wpdb->posts}.ID {$dir}"; } elseif ( 'reviewed' === $orderby ) { $clauses['orderby'] = "{$checked} {$dir}"; } return $clauses; }; } /** * Formats a page of articles (primes caches and asks Pro for period stats once). * * @param \WP_Post[] $posts Posts. * @return array */ private function format_many( array $posts ) { if ( ! $posts ) { return array(); } $ids = wp_list_pluck( $posts, 'ID' ); update_meta_cache( 'post', $ids ); update_object_term_cache( $ids, PostTypes::ARTICLE ); cache_users( array_unique( wp_list_pluck( $posts, 'post_author' ) ) ); /** * Views in the last 30 days per article, when a views log is available (Pro analytics). * * @param array|null $views Article id => views, or null when unknown. * @param int[] $ids Article ids. */ $recent = apply_filters( 'kdocs_article_recent_views', null, $ids ); return array_map( function ( $post ) use ( $recent ) { $item = $this->format( $post ); $item['views30'] = is_array( $recent ) ? (int) ( $recent[ $post->ID ] ?? 0 ) : null; return $item; }, $posts ); } /** * Table row shape. * * @param \WP_Post $post Article. * @return array */ private function format( $post ) { $kb = Taxonomies::article_kb( $post ); $category = Taxonomies::article_category( $post ); $author = get_userdata( (int) $post->post_author ); $yes = (int) get_post_meta( $post->ID, '_kdocs_helpful_yes', true ); $no = (int) get_post_meta( $post->ID, '_kdocs_helpful_no', true ); $reviewed = (string) get_post_meta( $post->ID, '_kdocs_reviewed_at', true ); $checked = max( strtotime( $post->post_modified_gmt . ' UTC' ), $reviewed ? strtotime( $reviewed . ' UTC' ) : 0 ); $row = array( 'id' => (int) $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ), 'status' => $post->post_status, 'icon' => (string) get_post_meta( $post->ID, '_kdocs_icon', true ), 'kb' => $kb ? array( 'id' => (int) $kb->term_id, 'name' => html_entity_decode( $kb->name, ENT_QUOTES, 'UTF-8' ), ) : null, 'category' => $category ? (int) $category->term_id : 0, 'path' => $this->path( $post, $category ), 'author' => array( 'id' => (int) $post->post_author, 'name' => $author ? $author->display_name : '', ), 'views' => (int) get_post_meta( $post->ID, '_kdocs_views', true ), 'helpful' => array( 'yes' => $yes, 'no' => $no, 'score' => $yes + $no ? (int) round( 100 * $yes / ( $yes + $no ) ) : null, ), 'modified' => strtotime( $post->post_modified_gmt . ' UTC' ), 'reviewed' => $checked, 'health' => $this->health( $post, $yes, $no, $checked ), 'editLink' => get_edit_post_link( $post->ID, 'raw' ), 'link' => get_permalink( $post ), 'canEdit' => current_user_can( 'edit_post', $post->ID ), 'canDelete' => current_user_can( 'delete_post', $post->ID ), ); /** * Filters an article row of the admin Articles table (Pro adds the summary status). * * @param array $row Row. * @param \WP_Post $post Article. */ return apply_filters( 'kdocs_article_row', $row, $post ); } /** * Category trail and parent articles, e.g. ["Getting started", "Install"]. * * @param \WP_Post $post Article. * @param \WP_Term|null $category Category. * @return string[] */ private function path( $post, $category ) { $path = array(); if ( $category ) { foreach ( array_reverse( get_ancestors( $category->term_id, Taxonomies::CATEGORY, 'taxonomy' ) ) as $ancestor ) { $term = get_term( $ancestor, Taxonomies::CATEGORY ); if ( $term && ! is_wp_error( $term ) ) { $path[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); } } $path[] = html_entity_decode( $category->name, ENT_QUOTES, 'UTF-8' ); } foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor ) { $path[] = html_entity_decode( get_the_title( $ancestor ), ENT_QUOTES, 'UTF-8' ); } return $path; } /** * Simple per-article health: 100 minus a penalty for each problem found. * * @param \WP_Post $post Article. * @param int $yes Helpful votes. * @param int $no Unhelpful votes. * @param int $checked Last edit or review (Unix time). * @return array{score:int, issues:string[]} */ private function health( $post, $yes, $no, $checked ) { $penalties = array(); if ( 'publish' === $post->post_status && $checked < time() - DAY_IN_SECONDS * max( 1, (int) Settings::get( 'review_interval', 180 ) ) ) { $penalties['stale'] = 30; } if ( $yes + $no >= 5 && $yes / ( $yes + $no ) < 0.5 ) { $penalties['unhelpful'] = 30; } if ( str_word_count( wp_strip_all_tags( $post->post_content ) ) < 150 ) { $penalties['thin'] = 20; } if ( '' === trim( $post->post_excerpt ) ) { $penalties['no_excerpt'] = 10; } if ( 'draft' === $post->post_status && strtotime( $post->post_modified_gmt . ' UTC' ) < time() - 14 * DAY_IN_SECONDS ) { $penalties['old_draft'] = 10; } return array( 'score' => max( 0, 100 - array_sum( $penalties ) ), 'issues' => array_keys( $penalties ), ); } /* --------------------------------------------------------------- details */ /** * Loads an article the current user may edit. * * @param int $id Id. * @return \WP_Post|\WP_Error */ private function article( $id ) { $post = get_post( (int) $id ); if ( ! $post || PostTypes::ARTICLE !== $post->post_type ) { return new \WP_Error( 'kdocs_not_found', __( 'Article not found.', 'karma-docs' ), array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_post', $post->ID ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot edit this article.', 'karma-docs' ), array( 'status' => 403 ) ); } return $post; } /** * GET /articles/{id}: everything the article drawer shows. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function show( $request ) { $post = $this->article( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } $item = $this->format_many( array( $post ) )[0]; $categories = array(); if ( $item['kb'] ) { $walk = static function ( $nodes, $depth ) use ( &$walk, &$categories ) { foreach ( $nodes as $node ) { if ( 'category' === $node['type'] ) { $categories[] = array( 'id' => $node['objectId'], 'title' => $node['title'], 'depth' => $depth, ); $walk( $node['children'], $depth + 1 ); } } }; $walk( Tree::get( $item['kb']['id'] ), 0 ); } $details = array( 'slug' => $post->post_name, 'excerpt' => $post->post_excerpt, 'words' => str_word_count( wp_strip_all_tags( $post->post_content ) ), 'parent' => $post->post_parent ? array( 'id' => (int) $post->post_parent, 'title' => get_the_title( $post->post_parent ), ) : null, 'created' => strtotime( $post->post_date_gmt . ' UTC' ), 'reviewNotes' => (string) get_post_meta( $post->ID, '_kdocs_review_notes', true ), 'categories' => $categories, 'trashed' => 'trash' === $post->post_status, 'reasons' => $this->reasons( $post->ID ), ); /** * Extra drawer data (Pro: daily views, recent feedback, AI summary state). * * @param array $details Details. * @param \WP_Post $post Article. */ $details = apply_filters( 'kdocs_article_details', $details, $post ); return rest_ensure_response( array_merge( $item, $details ) ); } /** * "Not helpful" reasons given for an article, most frequent first. * * @param int $post_id Article. * @return array */ private function reasons( $post_id ) { $out = array(); foreach ( \KarmaDocs\Frontend\View::feedback_reasons() as $reason => $label ) { $count = (int) get_post_meta( $post_id, '_kdocs_reason_' . $reason, true ); if ( $count ) { $out[] = array( 'reason' => $reason, 'label' => $label, 'count' => $count, ); } } usort( $out, static function ( $a, $b ) { return $b['count'] - $a['count']; } ); return $out; } /* ----------------------------------------------------------------- trash */ /** * GET /trash. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function trash( $request ) { $per_page = (int) $request['per_page']; $args = array( 'post_type' => PostTypes::ARTICLE, 'post_status' => 'trash', 'posts_per_page' => $per_page, 'paged' => (int) $request['page'], 'orderby' => 'modified', 'order' => 'DESC', ); if ( $request['kb'] ) { $args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery array( 'taxonomy' => Taxonomies::KB, 'terms' => (int) $request['kb'], ), ); } if ( $request['search'] ) { $args['s'] = $request['search']; } $query = new \WP_Query( $args ); $items = array_map( function ( $post ) { $item = $this->format( $post ); $item['trashedAt'] = (int) get_post_meta( $post->ID, '_wp_trash_meta_time', true ); $item['wasStatus'] = (string) get_post_meta( $post->ID, '_wp_trash_meta_status', true ); $position = get_post_meta( $post->ID, \KarmaDocs\Trash::META, true ); $item['restoreTo'] = is_array( $position ) && $position['parent'] ? get_the_title( $position['parent'] ) : ''; return $item; }, $query->posts ); return Args::paginate( rest_ensure_response( array( 'items' => $items ) ), $query->found_posts, $per_page ); } /** * POST /articles/{id}/restore. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function restore( $request ) { $post = $this->article( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } if ( 'trash' !== $post->post_status ) { return new \WP_Error( 'kdocs_not_trashed', __( 'This article is not in the trash.', 'karma-docs' ), array( 'status' => 400 ) ); } if ( ! current_user_can( 'delete_post', $post->ID ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot restore this article.', 'karma-docs' ), array( 'status' => 403 ) ); } wp_untrash_post( $post->ID ); return rest_ensure_response( $this->format( get_post( $post->ID ) ) ); } /** * DELETE /articles/{id}/permanent: only for articles already in the trash. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function destroy( $request ) { $post = $this->article( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } if ( 'trash' !== $post->post_status ) { return new \WP_Error( 'kdocs_not_trashed', __( 'Move the article to the trash first.', 'karma-docs' ), array( 'status' => 400 ) ); } if ( ! current_user_can( 'delete_post', $post->ID ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot delete this article.', 'karma-docs' ), array( 'status' => 403 ) ); } wp_delete_post( $post->ID, true ); return rest_ensure_response( array( 'deleted' => (int) $post->ID ) ); } /** * POST /articles/bulk: status changes, trash, restore and permanent delete by id. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function bulk( $request ) { $action = $request['action']; $done = array(); $failed = array(); foreach ( array_unique( array_map( 'intval', (array) $request['ids'] ) ) as $id ) { $single = new \WP_REST_Request( 'POST' ); $single->set_url_params( array( 'id' => $id ) ); switch ( $action ) { case 'restore': $result = $this->restore( $single ); break; case 'delete': $result = $this->destroy( $single ); break; case 'trash': $post = $this->article( $id ); $result = $post; if ( ! is_wp_error( $post ) ) { $result = current_user_can( 'delete_post', $id ) ? ( new TreeController() )->delete_article( $single ) : new \WP_Error( 'kdocs_forbidden', '', array( 'status' => 403 ) ); } break; default: $post = $this->article( $id ); $result = $post; if ( ! is_wp_error( $post ) ) { if ( 'publish' === $action && ! current_user_can( 'publish_post', $id ) ) { $result = new \WP_Error( 'kdocs_forbidden', '', array( 'status' => 403 ) ); } else { $result = wp_update_post( array( 'ID' => $id, 'post_status' => $action, ), true ); } } } if ( is_wp_error( $result ) ) { $failed[] = $id; } else { $done[] = $id; } } return rest_ensure_response( array( 'done' => $done, 'failed' => $failed, ) ); } /* -------------------------------------------------------- admin search */ /** * GET /admin-search: knowledge bases, categories and articles for the command palette. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function search( $request ) { $q = trim( (string) $request['q'] ); $needle = function_exists( 'mb_strtolower' ) ? mb_strtolower( $q ) : strtolower( $q ); $kbs = array(); foreach ( Taxonomies::get_kbs() as $kb ) { $name = html_entity_decode( $kb->name, ENT_QUOTES, 'UTF-8' ); if ( false !== stripos( $name, $needle ) ) { $kbs[] = array( 'id' => (int) $kb->term_id, 'title' => $name, 'icon' => (string) get_term_meta( $kb->term_id, 'kdocs_icon', true ), ); } } $categories = array(); $terms = get_terms( array( 'taxonomy' => Taxonomies::CATEGORY, 'hide_empty' => false, 'search' => $q, 'number' => 6, ) ); foreach ( is_wp_error( $terms ) ? array() : $terms as $term ) { $kb_id = Taxonomies::category_kb( $term->term_id ); $kb = $kb_id ? get_term( $kb_id, Taxonomies::KB ) : null; $categories[] = array( 'id' => (int) $term->term_id, 'title' => html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ), 'icon' => (string) get_term_meta( $term->term_id, 'kdocs_icon', true ), 'kb' => $kb_id, 'kbName' => $kb && ! is_wp_error( $kb ) ? html_entity_decode( $kb->name, ENT_QUOTES, 'UTF-8' ) : '', ); } $posts = get_posts( array( 'post_type' => PostTypes::ARTICLE, 'post_status' => self::STATUSES, 's' => $q, 'posts_per_page' => 8, ) ); $articles = array(); foreach ( $posts as $post ) { if ( ! current_user_can( 'edit_post', $post->ID ) ) { continue; } $kb = Taxonomies::article_kb( $post ); $articles[] = array( 'id' => (int) $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ), 'status' => $post->post_status, 'icon' => (string) get_post_meta( $post->ID, '_kdocs_icon', true ), 'kb' => $kb ? (int) $kb->term_id : 0, 'kbName' => $kb ? html_entity_decode( $kb->name, ENT_QUOTES, 'UTF-8' ) : '', 'editLink' => get_edit_post_link( $post->ID, 'raw' ), 'link' => get_permalink( $post ), ); } return rest_ensure_response( compact( 'kbs', 'categories', 'articles' ) ); } }