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). /** * ClusterSettingsPage * * One place to decide which individual categories and tags should * become clusters, plus custom (manually assembled) clusters — pulled * together from what used to be scattered across the main Settings * page (whole-taxonomy on/off) and a form on the Dashboard (custom * clusters), matching how the person's previous plugin organized this. */ class ClusterSettingsPage { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function render(): void { $settings = $this->plugin->get( 'settings' ); $engine = $this->plugin->get( 'cluster_engine' ); if ( isset( $_POST['clusterlane_save_cluster_settings'] ) && \Clusterlane\Core\Capabilities::can_manage() && check_admin_referer( 'clusterlane_cluster_settings_save' ) ) { $this->save( $settings, $engine ); echo '

' . esc_html__( 'Cluster settings saved — clusters have been rebuilt with your new selection.', 'clusterlane' ) . '

'; } $min_count = max( 2, $settings->min_term_count() ); $categories = $this->get_clusterable_categories( $min_count ); $tags = $this->get_clusterable_tags( $min_count ); $unscanned = $this->unscanned_terms( array_merge( $categories, $tags ) ); // WooCommerce (0.98.172): product categories as clusters. $shop = \Clusterlane\Core\Shop::active(); $pcats = $shop ? $this->get_clusterable_product_cats( $min_count ) : []; $selected_pcats = $settings->enabled_product_cats(); $overlap = $this->get_tag_category_overlap( $tags ); // Other taxonomies of the site's content (0.98.177): attraction categories, // regions, genres … — one card each, the hierarchical ones first. $extra_taxes = \Clusterlane\Core\ClusterTypes::extra_taxonomies( $this->public_post_types() ); $extra_terms = []; foreach ( $extra_taxes as $tax_name => $tax_obj ) { $list = get_terms( [ 'taxonomy' => $tax_name, 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] ); $list = is_wp_error( $list ) ? [] : array_values( array_filter( $list, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) ); if ( $list ) $extra_terms[ $tax_name ] = $list; } $selected_extra = $settings->enabled_terms(); if ( $extra_terms ) $unscanned += $this->unscanned_terms( array_merge( ...array_values( $extra_terms ) ) ); // Most promising cluster candidates first: cross-cutting tags // (low overlap with any single category) before redundant ones, // and — for similar overlap — bigger tags before smaller ones. usort( $tags, function ( $a, $b ) use ( $overlap ) { $oa = $overlap[ $a->term_id ]['overlap_pct'] ?? 0; $ob = $overlap[ $b->term_id ]['overlap_pct'] ?? 0; if ( $oa !== $ob ) return $oa <=> $ob; return (int) $b->count <=> (int) $a->count; } ); $configured = $settings->cluster_terms_configured(); // Before the user has ever saved a selection here, default to // "everything that currently qualifies" so the page reflects // the plugin's existing out-of-the-box behavior rather than // looking like nothing is selected. $selected_categories = $configured ? $settings->enabled_categories() : array_map( fn( $t ) => (int) $t->term_id, $categories ); // Unconfigured: tags only count as selected if tag clusters are actually // being built (Tags ticked under Settings → Scan Settings), so the page // shows what really happens. $selected_tags = $configured ? $settings->enabled_tags() : ( in_array( 'post_tag', $settings->taxonomies(), true ) ? array_map( fn( $t ) => (int) $t->term_id, $tags ) : [] ); $custom_clusters = array_values( array_filter( $engine ? $engine->get_clusters() : [], fn( $c ) => $c->cluster_type === 'custom' ) ); // Current state of every cluster (for the status next to each term). $by_key = []; foreach ( ( $engine ? $engine->get_clusters() : [] ) as $c ) $by_key[ $c->cluster_key ] = $c; global $wpdb; $orphans_by_key = []; foreach ( (array) $wpdb->get_results( "SELECT cluster_key, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE content_class = 'orphan' GROUP BY cluster_key" ) as $r ) { // phpcs:ignore $orphans_by_key[ $r->cluster_key ] = (int) $r->n; } $band = fn( int $h ): string => $h >= 70 ? 'good' : ( $h >= 50 ? 'fair' : 'weak' ); $status = function ( string $key, bool $selected ) use ( $by_key, $band ): string { $c = $by_key[ $key ] ?? null; if ( $selected && ( ! $c || ! (int) $c->is_active ) ) return '' . esc_html__( 'Not built yet — save to add', 'clusterlane' ) . ''; if ( ! $selected || ! $c || ! (int) $c->is_active ) return '' . '' . ''; $h = (int) $c->health_score; return '' . $h . '%'; }; ?>

·

term_id, $selected_categories, true ); ?>

·

term_id, $selected_tags, true ); $ov = $overlap[ $term->term_id ] ?? null; $fit = ''; if ( $ov && $ov['dominant_category'] ) { $pct = (int) $ov['overlap_pct']; $cat = $ov['dominant_category']->name; if ( $pct >= 80 ) $fit = '' . esc_html( sprintf( /* translators: %1$s: text, %2$d: number */ __( 'Mostly overlaps “%1$s” (%2$d%%)', 'clusterlane' ), $cat, $pct ) ) . ''; elseif ( $pct >= 50 ) $fit = '' . esc_html( sprintf( /* translators: %1$s: text, %2$d: number */ __( 'Partly overlaps “%1$s” (%2$d%%)', 'clusterlane' ), $cat, $pct ) ) . ''; else $fit = '' . esc_html( sprintf( /* translators: %d: number */ _n( 'Spans %d category', 'Spans %d categories', (int) $ov['spans'], 'clusterlane' ), (int) $ov['spans'] ) ) . ''; } ?>

·

term_id, $selected_pcats, true ); ?>
labels->name, ENT_QUOTES, 'UTF-8' ) : $pt; }; ?>

$cand ): $sel = in_array( $hub_type, $hub_on, true ); ?> $c->cluster_type === \Clusterlane\Core\HubClusters::PREFIX . $hub_type && (int) $c->is_active ) : []; if ( $hub_rows ): usort( $hub_rows, fn( $x, $y ) => strcasecmp( $x->cluster_label, $y->cluster_label ) ); ?>
cluster_label ); ?> cluster_key, true ) ); ?> total_members, 'clusterlane' ), (int) $c->total_members ) ); ?>
$list ): $tax_obj = $extra_taxes[ $tax_name ]; $sel_ids = $selected_extra[ $tax_name ] ?? []; ?>

hierarchical ? 'folder' : 'tag' ); ?>labels->name, ENT_QUOTES, 'UTF-8' ) ); ?>

·

labels->name : ''; }, (array) $tax_obj->object_type ) ); /* translators: 1: taxonomy name, e.g. "Regions", 2: post type names, e.g. "Attractions" */ printf( esc_html__( 'Tick the %1$s that should be clusters (used by %2$s).', 'clusterlane' ), esc_html( mb_strtolower( html_entity_decode( (string) $tax_obj->labels->name, ENT_QUOTES, 'UTF-8' ) ) ), esc_html( implode( ', ', $types ) ) ); ?>

term_id, $sel_ids, true ); ?>

> health_score; $o = $orphans_by_key[ $c->cluster_key ] ?? 0; ?>
cluster_label ); ?> total_members; ?> %

>

set( 'enabled_categories', array_values( array_unique( array_filter( $cats ) ) ) ); // WooCommerce: product categories, and products among the scanned post types when any is picked. if ( \Clusterlane\Core\Shop::active() ) { $pcats = array_values( array_unique( array_filter( array_map( 'intval', (array) ( $_POST['cluster_product_cats'] ?? [] ) ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called $settings->set( 'enabled_product_cats', $pcats ); if ( $pcats && ! in_array( \Clusterlane\Core\Shop::TYPE, $settings->post_types(), true ) ) { $settings->set( 'post_types', array_values( array_unique( array_merge( $settings->post_types(), [ \Clusterlane\Core\Shop::TYPE ] ) ) ) ); } } $settings->set( 'enabled_tags', array_values( array_unique( array_filter( $tags ) ) ) ); // Keep Scan Settings → Taxonomies in step with what was picked here. if ( $tags && ! in_array( 'post_tag', $settings->taxonomies(), true ) ) { $settings->set( 'taxonomies', array_values( array_unique( array_merge( $settings->taxonomies(), [ 'post_tag' ] ) ) ) ); } // Other taxonomies (0.98.177): only those whose card was on the page, so an // empty card means "none of these" and a taxonomy not shown keeps its selection. $extra = $settings->enabled_terms(); $posted = []; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified by check_admin_referer(); taxonomy => term IDs, sanitized in the loop foreach ( (array) wp_unslash( $_POST['cluster_terms'] ?? [] ) as $tax => $ids ) { $posted[ sanitize_key( (string) $tax ) ] = array_map( 'absint', (array) $ids ); } foreach ( array_map( 'sanitize_key', (array) ( $_POST['cluster_terms_shown'] ?? [] ) ) as $tax ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() if ( ! taxonomy_exists( $tax ) || in_array( $tax, \Clusterlane\Core\ClusterTypes::BUILT_IN, true ) ) continue; $ids = array_values( array_unique( array_filter( array_map( 'intval', (array) ( $posted[ $tax ] ?? [] ) ), fn( $id ) => $id > 0 && term_exists( $id, $tax ) ) ) ); $extra[ $tax ] = $ids; // Articles of a taxonomy picked here are scanned (as with WooCommerce products). if ( $ids ) $this->scan_types_of( $settings, $tax ); } $settings->set( 'enabled_terms', $extra ); // Pages as clusters (0.98.180). if ( isset( $_POST['cluster_hubs_shown'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() $cands = array_keys( \Clusterlane\Core\HubClusters::candidates() ); $hubs = array_values( array_intersect( array_map( 'sanitize_key', (array) ( $_POST['cluster_hubs'] ?? [] ) ), $cands ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() $settings->set( 'enabled_hubs', $hubs ); } $settings->set( 'cluster_terms_configured', true ); if ( isset( $_POST['cluster_min_count'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called $settings->set( 'min_term_count', min( 100, max( 2, (int) $_POST['cluster_min_count'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called } // Rebuild immediately so the new selection is fully reflected — // health/archetype/etc. — rather than showing zeroed-out // clusters until the next manual scan. if ( $engine ) { $engine->sync_all(); $engine->compute_all(); } } /** Every post type with its own pages (as Settings → Content lists them). @return string[] */ private function public_post_types(): array { $types = array_keys( get_post_types( [ 'public' => true ] ) + get_post_types( [ 'publicly_queryable' => true ] ) ); return array_values( array_diff( $types, [ 'attachment' ] ) ); } /** Add a taxonomy's public post types to the scanned ones, unless one already is. */ private function scan_types_of( \Clusterlane\Core\Settings $settings, string $tax ): void { $obj = get_taxonomy( $tax ); if ( ! $obj ) return; $public = $this->public_post_types(); $types = array_values( array_intersect( (array) $obj->object_type, $public ) ); $scanned = $settings->post_types(); if ( ! $types || array_intersect( $types, $scanned ) ) return; $settings->set( 'post_types', array_values( array_unique( array_merge( $scanned, $types ) ) ) ); } /** WooCommerce product categories with at least $min_count products, by name. */ private function get_clusterable_product_cats( int $min_count = 2 ): array { $terms = get_terms( [ 'taxonomy' => \Clusterlane\Core\Shop::TAXONOMY, 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] ); if ( is_wp_error( $terms ) || empty( $terms ) ) return []; return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count && $t->slug !== 'uncategorized' ) ); } private function get_clusterable_categories( int $min_count = 2 ): array { $terms = get_categories( [ 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] ); if ( is_wp_error( $terms ) || empty( $terms ) ) return []; return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) ); } /** Tags with at least $min_count published posts, alphabetical (re-sorted by overlap later). */ private function get_clusterable_tags( int $min_count = 2 ): array { $terms = get_terms( [ 'taxonomy' => 'post_tag', 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] ); if ( is_wp_error( $terms ) || empty( $terms ) ) return []; return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) ); } /** * For each clusterable tag, how much its posts overlap with a * single category — the signal for deciding which tags are worth * turning into clusters. A tag whose posts are nearly all in ONE * category is largely redundant as a cluster; one spread across * many categories ("cross-cutting") usually makes a more useful * cluster, since it surfaces connections category browsing alone * wouldn't reveal. * * @return array */ /** * Terms whose published articles are all of post types Clusterlane doesn't * scan (e.g. a Docs category while only Posts are scanned) — they would * become empty clusters. @return array term_id → post type labels */ private function unscanned_terms( array $terms ): array { global $wpdb; $ids = array_map( fn( $t ) => (int) $t->term_taxonomy_id, $terms ); if ( ! $ids ) return []; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT tt.term_id, p.post_type, COUNT(*) AS n FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND p.post_status = 'publish' WHERE tr.term_taxonomy_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY tt.term_id, p.post_type", $ids ) ); $scanned = \Clusterlane\Core\Scope::post_types(); $by = []; foreach ( (array) $rows as $r ) $by[ (int) $r->term_id ][ $r->post_type ] = (int) $r->n; $out = []; foreach ( $by as $tid => $types ) { if ( array_intersect( array_keys( $types ), $scanned ) ) continue; // at least some are scanned $labels = array_map( function ( $pt ) { $o = get_post_type_object( $pt ); return $o ? $o->labels->name : $pt; }, array_keys( $types ) ); $out[ $tid ] = implode( ', ', $labels ); } return $out; } private function get_tag_category_overlap( array $tags ): array { // Two queries for all tags (0.98.171) — was one query per tag plus a category // lookup per article, thousands of queries with hundreds of tags. global $wpdb; $result = []; $ids = array_values( array_filter( array_map( fn( $t ) => (int) $t->term_id, $tags ) ) ); if ( ! $ids ) return $result; // Scope::sql() has no values; Scope::type_sql() is prepared; the tag IDs are placeholders. $from = "{$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'post_tag' AND tt.term_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND " . \Clusterlane\Core\Scope::sql( 'p' ) . ' AND ' . \Clusterlane\Core\Scope::type_sql( 'p' ); $totals = []; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT tt.term_id AS tag, COUNT(DISTINCT p.ID) AS n FROM ' . $from . ' GROUP BY tt.term_id', $ids ) ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $totals[ (int) $r->tag ] = (int) $r->n; } $counts = []; // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). foreach ( (array) $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery 'SELECT tt.term_id AS tag, ct.term_id AS cat, COUNT(DISTINCT p.ID) AS n FROM ' . $from . " INNER JOIN {$wpdb->term_relationships} cr ON cr.object_id = p.ID INNER JOIN {$wpdb->term_taxonomy} ct ON ct.term_taxonomy_id = cr.term_taxonomy_id AND ct.taxonomy = 'category' GROUP BY tt.term_id, ct.term_id ORDER BY tt.term_id, n DESC, ct.term_id ASC", $ids ) ) as $r ) { // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $counts[ (int) $r->tag ][ (int) $r->cat ] = (int) $r->n; } foreach ( $tags as $tag ) { $tid = (int) $tag->term_id; $total = $totals[ $tid ] ?? 0; if ( $total === 0 ) continue; $category_counts = $counts[ $tid ] ?? []; $overlap_pct = 0; $dominant_category = null; if ( $category_counts ) { $max_count = max( $category_counts ); $dominant_cat_id = array_search( $max_count, $category_counts, true ); // most articles; ties → lowest term ID $overlap_pct = (int) round( ( $max_count / $total ) * 100 ); $term = get_term( $dominant_cat_id, 'category' ); $dominant_category = ( $term && ! is_wp_error( $term ) ) ? $term : null; } $result[ $tid ] = [ 'overlap_pct' => $overlap_pct, 'dominant_category' => $dominant_category, 'spans' => count( $category_counts ), ]; } return $result; } }