prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * ClusterSections (0.98.160) — groups the cluster list into the site's own * sections, the way visitors see them: the main menu. * * For each cluster, in this order: * 1. The editor's own choice (option clusterlane_cluster_sections, set from the * cluster's detail panel). * 2. A category cluster: the top-level menu item its category sits under in * the main menu (the category itself, or its nearest parent that is in * the menu). The menu wins over the category tree: on a site where * Churches is a child of Museums but has its own menu item, Churches is * its own section. * 3. Not in the menu: its top-level parent category. * 4. Tag and custom clusters (no category of their own): the section most of * their articles belong to, through the articles' categories — when at * least 40 % agree. Otherwise "Other". * * Main menu: a classic menu assigned to a theme location (primary / main / * header first), else the classic menu with the most category items; on block * themes the first Navigation (wp_navigation) with category links. * Nothing here is stored except the editor's choices — sections follow the * menu and the categories as they change. */ final class ClusterSections { public const OPTION = 'clusterlane_cluster_sections'; /** The editor's own groups, in their order: [ ['key' => 'grp_…', 'label' => '…'], … ] (0.98.161). */ public const GROUPS = 'clusterlane_cluster_groups'; public const OTHER = 'other'; private const MAJORITY = 0.4; /** * @param object[] $clusters rows of clusterlane_clusters (cluster_key, cluster_type, term_id) * @param array $members cluster_key → member post IDs * @return array{sections: array, map: array, menu: string} */ public static function compute( array $clusters, array $members ): array { $menu = self::menu(); // term_id → [key,label,order] $sections = []; // key → [key,label,order,source] $cat_cache = []; // $tax: 'category', or 'product_cat' for WooCommerce product categories (0.98.172). // Term IDs are unique across taxonomies, so one menu map serves both. $resolve = function ( int $term_id, string $tax = 'category' ) use ( &$menu, &$sections, &$cat_cache ): ?string { if ( isset( $cat_cache[ $term_id ] ) ) return $cat_cache[ $term_id ]; $sec = null; // Nearest ancestor-or-self that the menu lists. $t = $term_id; $guard = 0; while ( $t && $guard++ < 20 ) { if ( isset( $menu['terms'][ $t ] ) ) { $sec = $menu['terms'][ $t ]; break; } $term = get_term( $t, $tax ); $t = ( $term && ! is_wp_error( $term ) ) ? (int) $term->parent : 0; } if ( $sec ) { $sections[ $sec['key'] ] ??= $sec + [ 'source' => 'menu' ]; return $cat_cache[ $term_id ] = $sec['key']; } // The top-level parent category. $top = get_term( $term_id, $tax ); $guard = 0; while ( $top && ! is_wp_error( $top ) && (int) $top->parent && $guard++ < 20 ) $top = get_term( (int) $top->parent, $tax ); if ( ! $top || is_wp_error( $top ) || $top->slug === 'uncategorized' ) return $cat_cache[ $term_id ] = null; $key = 'cat_' . (int) $top->term_id; $sections[ $key ] ??= [ 'key' => $key, 'label' => html_entity_decode( $top->name, ENT_QUOTES ), 'order' => 1000, 'source' => 'category' ]; return $cat_cache[ $term_id ] = $key; }; $manual = (array) get_option( self::OPTION, [] ); $map = []; $later = []; // Pages as clusters (0.98.181): grouped by the page at the top of their // hierarchy — an island with the clusters of its areas. A top page with // only its own cluster joins the others of its kind ("Islands"). $tops = []; foreach ( $clusters as $c ) { if ( HubClusters::is_hub_type( (string) $c->cluster_type ) && (int) $c->term_id ) $tops[ $c->cluster_key ] = HubClusters::top_of( (int) $c->term_id ); } $per_top = array_count_values( $tops ); // Other taxonomies (0.98.181): a top-level term whose cluster is alone // there doesn't make a section of its own; its taxonomy does ("Types"). $tax_top = []; foreach ( $clusters as $c ) { $tx = (string) $c->cluster_type; if ( in_array( $tx, ClusterTypes::BUILT_IN, true ) || isset( $tops[ $c->cluster_key ] ) || ! (int) $c->term_id || ! is_taxonomy_hierarchical( $tx ) ) continue; $t = get_term( (int) $c->term_id, $tx ); $guard = 0; while ( $t && ! is_wp_error( $t ) && (int) $t->parent && $guard++ < 20 ) $t = get_term( (int) $t->parent, $tx ); if ( $t && ! is_wp_error( $t ) ) $tax_top[ $c->cluster_key ] = $tx . ':' . (int) $t->term_id; } $per_tax_top = array_count_values( $tax_top ); foreach ( $clusters as $c ) { $k = $c->cluster_key; if ( isset( $tax_top[ $k ] ) && $per_tax_top[ $tax_top[ $k ] ] === 1 && ! isset( $menu['terms'][ (int) $c->term_id ] ) ) { $tx = (string) $c->cluster_type; $obj = get_taxonomy( $tx ); $sk = 'tax_' . $tx; $sections[ $sk ] ??= [ 'key' => $sk, 'label' => $obj ? html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ) : $tx, 'order' => 1000, 'source' => 'category' ]; $map[ $k ] = [ 'section' => $sk, 'how' => 'category' ]; continue; } if ( isset( $tops[ $k ] ) ) { $top = $tops[ $k ]; if ( $per_top[ $top ] > 1 ) { $sk = 'page_' . $top; $sections[ $sk ] ??= [ 'key' => $sk, 'label' => html_entity_decode( get_the_title( $top ), ENT_QUOTES, 'UTF-8' ), 'order' => 1000, 'source' => 'pages' ]; } else { $pt = get_post_type_object( (string) get_post_type( $top ) ); $sk = 'pages_' . ( $pt ? $pt->name : 'x' ); $sections[ $sk ] ??= [ 'key' => $sk, 'label' => $pt ? html_entity_decode( (string) $pt->labels->name, ENT_QUOTES, 'UTF-8' ) : __( 'Pages', 'clusterlane' ), 'order' => 1000, 'source' => 'pages' ]; } $map[ $k ] = [ 'section' => $sk, 'how' => 'pages' ]; continue; } if ( ( in_array( $c->cluster_type, [ 'category', 'product_cat' ], true ) || ( $c->cluster_type !== 'custom' && is_taxonomy_hierarchical( (string) $c->cluster_type ) ) ) && (int) $c->term_id ) { // any hierarchical taxonomy resolves by its top term (0.98.177) $s = $resolve( (int) $c->term_id, (string) $c->cluster_type ); if ( $s ) { $map[ $k ] = [ 'section' => $s, 'how' => $sections[ $s ]['source'] ]; continue; } } $later[] = $c; } // Tags and custom clusters: where their articles live. if ( $later ) { $ids = []; foreach ( $later as $c ) foreach ( $members[ $c->cluster_key ] ?? [] as $id ) $ids[ (int) $id ] = true; $post_cats = self::post_categories( array_keys( $ids ) ); foreach ( $later as $c ) { $votes = []; $n = 0; foreach ( $members[ $c->cluster_key ] ?? [] as $id ) { $secs = []; foreach ( $post_cats[ (int) $id ] ?? [] as $tid ) { $s = $resolve( $tid ); if ( $s ) $secs[ $s ] = true; } if ( ! $secs ) continue; $n++; foreach ( array_keys( $secs ) as $s ) $votes[ $s ] = ( $votes[ $s ] ?? 0 ) + 1; } arsort( $votes ); $best = (string) array_key_first( $votes ); // Share of ALL the cluster's articles, not only those with a category: // 2 blog posts among 40 pages without categories made a "Blog" cluster (0.98.181). $all_n = count( $members[ $c->cluster_key ] ?? [] ); $map[ $c->cluster_key ] = ( $best !== '' && $votes[ $best ] >= max( 1, self::MAJORITY * max( $n, $all_n ) ) ) ? [ 'section' => $best, 'how' => 'articles' ] : [ 'section' => self::OTHER, 'how' => 'none' ]; } } // The editor's choices last — they win. A choice of a section that no // longer exists (menu item removed) is ignored. $all = self::choices( $menu ); foreach ( $manual as $k => $s ) { if ( ! isset( $map[ $k ] ) ) continue; if ( $s === self::OTHER || isset( $all[ $s ] ) ) { if ( isset( $all[ $s ] ) ) $sections[ $s ] ??= $all[ $s ]; $map[ $k ] = [ 'section' => (string) $s, 'how' => 'manual' ]; } } // The editor's own groups are always listed — even empty, so clusters can be moved in. foreach ( $all as $k => $sec ) if ( $sec['source'] === 'group' ) $sections[ $k ] ??= $sec; if ( in_array( self::OTHER, array_column( $map, 'section' ), true ) ) { $sections[ self::OTHER ] = [ 'key' => self::OTHER, 'label' => __( 'Other', 'clusterlane' ), 'order' => 99999, 'source' => 'none' ]; } // Menu order first, then category sections A–Z, Other last. Only sections in use. $used = array_flip( array_column( $map, 'section' ) ); $sections = array_filter( $sections, fn( $s ) => isset( $used[ $s['key'] ] ) || $s['source'] === 'group' ); uasort( $sections, fn( $a, $b ) => ( $a['order'] <=> $b['order'] ) ?: strcasecmp( $a['label'], $b['label'] ) ); return [ 'sections' => array_values( array_map( fn( $s ) => [ 'key' => $s['key'], 'label' => $s['label'], 'source' => $s['source'] ], $sections ) ), 'choices' => array_values( array_map( fn( $s ) => [ 'key' => $s['key'], 'label' => $s['label'], 'source' => $s['source'] ], $all ) ), 'map' => $map, 'menu' => $menu['name'], ]; } /** Every section an editor can pick: their own groups, the menu's top items, then top-level categories. */ private static function choices( array $menu ): array { $out = []; foreach ( self::groups() as $i => $g ) $out[ $g['key'] ] = $g + [ 'order' => -1000 + $i, 'source' => 'group' ]; foreach ( $menu['tops'] as $s ) $out[ $s['key'] ] = $s + [ 'source' => 'menu' ]; $cats = get_terms( [ 'taxonomy' => 'category', 'parent' => 0, 'hide_empty' => false ] ); if ( ! is_wp_error( $cats ) ) { foreach ( $cats as $t ) { if ( $t->slug === 'uncategorized' ) continue; if ( isset( $menu['terms'][ (int) $t->term_id ] ) ) continue; // already a menu section $out[ 'cat_' . $t->term_id ] = [ 'key' => 'cat_' . $t->term_id, 'label' => html_entity_decode( $t->name, ENT_QUOTES ), 'order' => 1000, 'source' => 'category' ]; } } return $out; } /** @return array */ public static function groups(): array { $out = []; foreach ( (array) get_option( self::GROUPS, [] ) as $g ) { if ( is_array( $g ) && ! empty( $g['key'] ) && isset( $g['label'] ) ) $out[] = [ 'key' => (string) $g['key'], 'label' => (string) $g['label'] ]; } return $out; } /** * Change the editor's own groups: add (label) → the new key, rename (key, label), * delete (key; its clusters go back to Automatic), move (key, dir -1 | 1). */ public static function edit_group( string $action, string $key = '', string $label = '', int $dir = 0 ): ?string { $groups = self::groups(); $label = trim( mb_substr( sanitize_text_field( $label ), 0, 60 ) ); $at = array_search( $key, array_column( $groups, 'key' ), true ); switch ( $action ) { case 'add': if ( $label === '' ) return null; $key = 'grp_' . substr( md5( uniqid( '', true ) ), 0, 8 ); $groups[] = [ 'key' => $key, 'label' => $label ]; break; case 'rename': if ( $at === false || $label === '' ) return null; $groups[ $at ]['label'] = $label; break; case 'delete': if ( $at === false ) return null; array_splice( $groups, $at, 1 ); $manual = (array) get_option( self::OPTION, [] ); update_option( self::OPTION, array_filter( $manual, fn( $s ) => $s !== $key ), false ); break; case 'move': $to = $at === false ? false : $at + ( $dir < 0 ? -1 : 1 ); if ( $to === false || $to < 0 || $to >= count( $groups ) ) return null; [ $groups[ $at ], $groups[ $to ] ] = [ $groups[ $to ], $groups[ $at ] ]; break; default: return null; } update_option( self::GROUPS, array_values( $groups ), false ); return $key; } /** Save (or clear with '') the editor's section for one cluster. */ public static function set( string $cluster_key, string $section ): void { $all = (array) get_option( self::OPTION, [] ); if ( $section === '' ) unset( $all[ $cluster_key ] ); else $all[ $cluster_key ] = sanitize_key( $section ); update_option( self::OPTION, $all, false ); } /** * The main menu, reduced to what sections need. * @return array{name:string, tops: array, terms: array} */ public static function menu(): array { $empty = [ 'name' => '', 'tops' => [], 'terms' => [] ]; $items = null; $name = ''; // Classic menus: a menu in a theme location — primary/main/header first. if ( function_exists( 'wp_get_nav_menu_items' ) ) { $locs = (array) get_nav_menu_locations(); $ranked = []; foreach ( $locs as $loc => $menu_id ) { if ( ! $menu_id ) continue; $ranked[] = [ preg_match( '/primary|main|header|top/i', (string) $loc ) ? 0 : 1, (int) $menu_id ]; } sort( $ranked ); $candidates = array_column( $ranked, 1 ); if ( ! $candidates ) { // no locations: the menu with the most category items $best = 0; $bestn = 0; foreach ( (array) wp_get_nav_menus() as $m ) { $n = count( array_filter( (array) wp_get_nav_menu_items( $m->term_id ), fn( $i ) => in_array( $i->object, [ 'category', 'product_cat' ], true ) ) ); if ( $n > $bestn ) { $best = (int) $m->term_id; $bestn = $n; } } if ( $best ) $candidates = [ $best ]; } foreach ( $candidates as $menu_id ) { $list = (array) wp_get_nav_menu_items( $menu_id ); if ( ! array_filter( $list, fn( $i ) => in_array( $i->object, [ 'category', 'product_cat' ], true ) ) ) continue; $obj = wp_get_nav_menu_object( $menu_id ); $name = $obj ? $obj->name : ''; $items = []; foreach ( $list as $i ) { $items[] = [ 'id' => (int) $i->ID, 'parent' => (int) $i->menu_item_parent, 'label' => (string) $i->title, 'order' => (int) $i->menu_order, 'term' => in_array( $i->object, [ 'category', 'product_cat' ], true ) ? (int) $i->object_id : 0 ]; } break; } } // Block themes: the first Navigation with category links. if ( $items === null && post_type_exists( 'wp_navigation' ) ) { $navs = get_posts( [ 'post_type' => 'wp_navigation', 'post_status' => 'publish', 'numberposts' => 5, 'orderby' => 'date', 'order' => 'ASC' ] ); foreach ( $navs as $nav ) { $flat = []; $order = 0; $walk = function ( array $blocks, int $parent ) use ( &$walk, &$flat, &$order ) { foreach ( $blocks as $b ) { if ( ! in_array( $b['blockName'] ?? '', [ 'core/navigation-link', 'core/navigation-submenu' ], true ) ) continue; $a = $b['attrs'] ?? []; $id = ++$order; $flat[] = [ 'id' => $id, 'parent' => $parent, 'label' => wp_strip_all_tags( (string) ( $a['label'] ?? '' ) ), 'order' => $id, 'term' => ( ( $a['type'] ?? '' ) === 'category' && ! empty( $a['id'] ) ) ? (int) $a['id'] : 0 ]; if ( ! empty( $b['innerBlocks'] ) ) $walk( $b['innerBlocks'], $id ); } }; $walk( parse_blocks( (string) $nav->post_content ), 0 ); if ( array_filter( $flat, fn( $i ) => $i['term'] ) ) { $items = $flat; $name = $nav->post_title; break; } } } if ( ! $items ) return $empty; $by = []; foreach ( $items as $i ) $by[ $i['id'] ] = $i; $tops = []; $terms = []; foreach ( $items as $i ) { // Walk up to the top-level item. $top = $i; $guard = 0; while ( $top['parent'] && isset( $by[ $top['parent'] ] ) && $guard++ < 20 ) $top = $by[ $top['parent'] ]; $label = trim( html_entity_decode( wp_strip_all_tags( $top['label'] ), ENT_QUOTES ) ); if ( $label === '' && $top['term'] ) { $t = get_term( $top['term'], 'category' ); $label = $t && ! is_wp_error( $t ) ? $t->name : ''; } $sec = [ 'key' => 'menu_' . $top['id'], 'label' => $label, 'order' => $top['order'] ]; if ( ! $i['parent'] ) $tops[ $sec['key'] ] = $sec; if ( $i['term'] && ! isset( $terms[ $i['term'] ] ) ) $terms[ $i['term'] ] = $sec; } // Only top items that lead to a category can hold clusters. $with_terms = array_flip( array_column( $terms, 'key' ) ); $tops = array_values( array_filter( $tops, fn( $s ) => isset( $with_terms[ $s['key'] ] ) ) ); return [ 'name' => $name, 'tops' => $tops, 'terms' => $terms ]; } /** @return array post ID → category term IDs */ private static function post_categories( array $ids ): array { global $wpdb; $out = []; foreach ( array_chunk( array_map( 'intval', $ids ), 500 ) as $chunk ) { if ( ! $chunk ) continue; $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT tr.object_id, tt.term_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = 'category' AND tr.object_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', $chunk ) ); foreach ( (array) $rows as $r ) $out[ (int) $r->object_id ][] = (int) $r->term_id; } return $out; } }