'c12' | 'a34', * 'type' => 'category' | 'article', * 'objectId' => 12, * 'title' => '...', * 'children' => [ ... ], * ... * ] */ class Tree { /** * Per-request cache. * * @var array */ private static $cache = array(); /** * Returns the tree of a knowledge base. * * @param int $kb_id KB term id. * @param array $args { * Optional. Tree options. * * @type bool $public Only published + viewable content (frontend). Default false. * @type bool $details Include admin details (edit links, stats). Default false. * } * @return array */ public static function get( $kb_id, $args = array() ) { $args = wp_parse_args( $args, array( 'public' => false, 'details' => false, ) ); // The visible tree depends on the user (access rules, private posts). $key = 'tree:' . md5( $kb_id . wp_json_encode( $args ) . get_current_user_id() . Cache::version() ); if ( isset( self::$cache[ $key ] ) ) { return self::$cache[ $key ]; } // Admin "details" trees carry live stats (views, votes), so only public trees persist. $persist = ! $args['details']; if ( $persist ) { $cached = Cache::get( $key ); if ( is_array( $cached ) ) { self::$cache[ $key ] = $cached; return $cached; } } $nodes = array(); // Categories. $cats = get_terms( array( 'taxonomy' => Taxonomies::CATEGORY, 'hide_empty' => false, 'meta_key' => 'kdocs_kb', // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_value' => (int) $kb_id, // phpcs:ignore WordPress.DB.SlowDBQuery ) ); $cats = is_wp_error( $cats ) ? array() : $cats; foreach ( $cats as $cat ) { $nodes[ 'c' . $cat->term_id ] = array( 'id' => 'c' . $cat->term_id, 'type' => 'category', 'objectId' => (int) $cat->term_id, 'title' => html_entity_decode( $cat->name, ENT_QUOTES, 'UTF-8' ), 'slug' => $cat->slug, 'description' => $cat->description, 'icon' => (string) get_term_meta( $cat->term_id, 'kdocs_icon', true ), 'color' => (string) get_term_meta( $cat->term_id, 'kdocs_color', true ), 'cover' => Covers::get( $cat->term_id ), 'link' => get_term_link( $cat ), 'order' => (int) get_term_meta( $cat->term_id, 'kdocs_order', true ), 'parentRef' => $cat->parent ? 'c' . $cat->parent : null, 'children' => array(), ); } // Articles. $statuses = $args['public'] ? array( 'publish' ) : array( 'publish', 'draft', 'pending', 'private', 'future' ); if ( $args['public'] && current_user_can( 'read_private_posts' ) ) { $statuses[] = 'private'; } $posts = get_posts( array( 'post_type' => PostTypes::ARTICLE, 'post_status' => $statuses, 'posts_per_page' => -1, 'orderby' => array( 'menu_order' => 'ASC', 'title' => 'ASC', ), 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery array( 'taxonomy' => Taxonomies::KB, 'terms' => (int) $kb_id, ), ), 'update_post_term_cache' => true, 'update_post_meta_cache' => true, 'suppress_filters' => false, ) ); $article_ids = wp_list_pluck( $posts, 'ID' ); foreach ( $posts as $post ) { if ( $args['public'] && ! apply_filters( 'kdocs_can_view_article', true, $post ) ) { continue; } $cat = Taxonomies::article_category( $post ); $parent_ref = null; if ( $post->post_parent && in_array( $post->post_parent, $article_ids, true ) ) { $parent_ref = 'a' . $post->post_parent; } elseif ( $cat && isset( $nodes[ 'c' . $cat->term_id ] ) ) { $parent_ref = 'c' . $cat->term_id; } $node = array( 'id' => 'a' . $post->ID, 'type' => 'article', 'objectId' => (int) $post->ID, 'title' => '' !== $post->post_title ? html_entity_decode( $post->post_title, ENT_QUOTES, 'UTF-8' ) : __( '(no title)', 'karma-docs' ), 'slug' => $post->post_name, 'status' => $post->post_status, 'icon' => (string) get_post_meta( $post->ID, '_kdocs_icon', true ), 'link' => get_permalink( $post ), 'order' => (int) $post->menu_order, 'parentRef' => $parent_ref, 'children' => array(), ); if ( $args['details'] ) { $node['editLink'] = get_edit_post_link( $post->ID, 'raw' ); $node['views'] = (int) get_post_meta( $post->ID, '_kdocs_views', true ); $node['yes'] = (int) get_post_meta( $post->ID, '_kdocs_helpful_yes', true ); $node['no'] = (int) get_post_meta( $post->ID, '_kdocs_helpful_no', true ); $node['modified'] = get_post_modified_time( 'U', true, $post ); $node['author'] = get_the_author_meta( 'display_name', $post->post_author ); } $nodes[ $node['id'] ] = $node; } // Drop dangling article branches (parent not viewable). foreach ( $nodes as $id => $node ) { if ( 'article' === $node['type'] && $node['parentRef'] && ! isset( $nodes[ $node['parentRef'] ] ) ) { $nodes[ $id ]['parentRef'] = null; } } $tree = self::nest( $nodes ); if ( $args['public'] ) { $tree = self::prune_empty_categories( $tree ); } self::$cache[ $key ] = $tree; if ( $persist ) { Cache::set( $key, $tree ); } return $tree; } /** * Converts the flat node map into a sorted nested array. * * @param array $nodes Flat nodes keyed by id. * @return array */ private static function nest( array $nodes ) { $children = array(); foreach ( $nodes as $id => $node ) { $parent = ( $node['parentRef'] && isset( $nodes[ $node['parentRef'] ] ) ) ? $node['parentRef'] : '__root'; $children[ $parent ][] = $id; } $build = static function ( $parent ) use ( &$build, &$nodes, $children ) { if ( empty( $children[ $parent ] ) ) { return array(); } $list = array(); foreach ( $children[ $parent ] as $id ) { $node = $nodes[ $id ]; $node['children'] = $build( $id ); $node['count'] = self::count_articles( $node['children'] ); unset( $node['parentRef'] ); $list[] = $node; } usort( $list, static function ( $a, $b ) { if ( $a['order'] !== $b['order'] ) { return $a['order'] <=> $b['order']; } if ( $a['type'] !== $b['type'] ) { return 'category' === $a['type'] ? -1 : 1; } return strcasecmp( $a['title'], $b['title'] ); } ); return $list; }; return $build( '__root' ); } /** * Counts articles in a branch. * * @param array $children Nodes. * @return int */ public static function count_articles( array $children ) { $count = 0; foreach ( $children as $child ) { if ( 'article' === $child['type'] ) { ++$count; } $count += self::count_articles( $child['children'] ); } return $count; } /** * Removes categories without any (visible) article. * * @param array $nodes Nodes. * @return array */ private static function prune_empty_categories( array $nodes ) { $out = array(); foreach ( $nodes as $node ) { $node['children'] = self::prune_empty_categories( $node['children'] ); if ( 'category' === $node['type'] && 0 === self::count_articles( $node['children'] ) ) { continue; } $out[] = $node; } return $out; } /** * Flattens articles in reading order (used for prev/next). * * @param array $nodes Nodes. * @return array */ public static function flatten_articles( array $nodes ) { $out = array(); foreach ( $nodes as $node ) { if ( 'article' === $node['type'] ) { $out[] = $node; } $out = array_merge( $out, self::flatten_articles( $node['children'] ) ); } return $out; } /** * Finds a node by id. * * @param array $nodes Nodes. * @param string $id Node id. * @return array|null */ public static function find( array $nodes, $id ) { foreach ( $nodes as $node ) { if ( $node['id'] === $id ) { return $node; } $found = self::find( $node['children'], $id ); if ( $found ) { return $found; } } return null; } /** * Persists a full tree structure coming from the drag & drop builder. * * @param int $kb_id KB id. * @param array $items Flat list of [ 'id' => 'c1'|'a2', 'parent' => 'c1'|'a2'|null, 'order' => int ]. * @return true|\WP_Error */ public static function save( $kb_id, array $items ) { global $wpdb; $map = array(); foreach ( $items as $item ) { if ( empty( $item['id'] ) || ! preg_match( '/^[ca]\d+$/', $item['id'] ) ) { return new \WP_Error( 'kdocs_invalid_node', __( 'Invalid node id.', 'karma-docs' ), array( 'status' => 400 ) ); } $parent = isset( $item['parent'] ) && $item['parent'] ? (string) $item['parent'] : null; if ( $parent && ! preg_match( '/^[ca]\d+$/', $parent ) ) { return new \WP_Error( 'kdocs_invalid_node', __( 'Invalid parent id.', 'karma-docs' ), array( 'status' => 400 ) ); } $map[ $item['id'] ] = array( 'type' => 'c' === $item['id'][0] ? 'category' : 'article', 'oid' => (int) substr( $item['id'], 1 ), 'parent' => $parent, 'order' => isset( $item['order'] ) ? (int) $item['order'] : 0, ); } // Validation: types, ownership and cycles. foreach ( $map as $id => $node ) { if ( $node['parent'] && ! isset( $map[ $node['parent'] ] ) ) { return new \WP_Error( 'kdocs_invalid_parent', __( 'Parent not found in tree.', 'karma-docs' ), array( 'status' => 400 ) ); } if ( 'category' === $node['type'] && $node['parent'] && 'category' !== $map[ $node['parent'] ]['type'] ) { return new \WP_Error( 'kdocs_invalid_parent', __( 'Categories can only be nested inside categories.', 'karma-docs' ), array( 'status' => 400 ) ); } if ( 'category' === $node['type'] ) { $term = get_term( $node['oid'], Taxonomies::CATEGORY ); if ( ! $term || is_wp_error( $term ) ) { return new \WP_Error( 'kdocs_missing', __( 'Category not found.', 'karma-docs' ), array( 'status' => 404 ) ); } } elseif ( PostTypes::ARTICLE !== get_post_type( $node['oid'] ) || ! current_user_can( 'edit_post', $node['oid'] ) ) { return new \WP_Error( 'kdocs_forbidden', __( 'You cannot edit one of the articles.', 'karma-docs' ), array( 'status' => 403 ) ); } // Cycle check. $seen = array( $id => true ); $cursor = $node['parent']; while ( $cursor ) { if ( isset( $seen[ $cursor ] ) ) { return new \WP_Error( 'kdocs_cycle', __( 'An item cannot be nested inside itself.', 'karma-docs' ), array( 'status' => 400 ) ); } $seen[ $cursor ] = true; $cursor = $map[ $cursor ]['parent']; } } Sync::suspend( true ); foreach ( $map as $id => $node ) { if ( 'category' === $node['type'] ) { $term = get_term( $node['oid'], Taxonomies::CATEGORY ); $new_parent = $node['parent'] ? (int) substr( $node['parent'], 1 ) : 0; if ( (int) $term->parent !== $new_parent ) { wp_update_term( $node['oid'], Taxonomies::CATEGORY, array( 'parent' => $new_parent ) ); } if ( Taxonomies::category_kb( $node['oid'] ) !== (int) $kb_id ) { update_term_meta( $node['oid'], 'kdocs_kb', (int) $kb_id ); } if ( (int) get_term_meta( $node['oid'], 'kdocs_order', true ) !== $node['order'] ) { update_term_meta( $node['oid'], 'kdocs_order', $node['order'] ); } continue; } // Article: nearest category ancestor becomes its category. $category = 0; $cursor = $node['parent']; while ( $cursor ) { if ( 'category' === $map[ $cursor ]['type'] ) { $category = $map[ $cursor ]['oid']; break; } $cursor = $map[ $cursor ]['parent']; } $parent_post = ( $node['parent'] && 'article' === $map[ $node['parent'] ]['type'] ) ? $map[ $node['parent'] ]['oid'] : 0; $post = get_post( $node['oid'] ); $changes = array(); if ( (int) $post->post_parent !== $parent_post ) { $changes['post_parent'] = $parent_post; $unique = wp_unique_post_slug( $post->post_name, $post->ID, $post->post_status, $post->post_type, $parent_post ); if ( $post->post_name && $unique !== $post->post_name ) { $changes['post_name'] = $unique; } } if ( (int) $post->menu_order !== $node['order'] ) { $changes['menu_order'] = $node['order']; } if ( $changes ) { $wpdb->update( $wpdb->posts, $changes, array( 'ID' => $post->ID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery clean_post_cache( $post->ID ); } $current_cat = Taxonomies::article_category( $post->ID ); if ( ( $current_cat ? (int) $current_cat->term_id : 0 ) !== $category ) { wp_set_object_terms( $post->ID, $category ? array( $category ) : array(), Taxonomies::CATEGORY ); } $current_kb = Taxonomies::article_kb( $post->ID ); if ( ! $current_kb || (int) $current_kb->term_id !== (int) $kb_id ) { wp_set_object_terms( $post->ID, array( (int) $kb_id ), Taxonomies::KB ); } } Sync::suspend( false ); wp_cache_set_last_changed( 'posts' ); wp_cache_set_last_changed( 'terms' ); Cache::flush(); self::$cache = array(); /** * Fires after the builder saved a knowledge base structure. * * @param int $kb_id KB id. * @param array $map Normalised nodes. */ do_action( 'kdocs_tree_saved', (int) $kb_id, $map ); return true; } }