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 . '%';
};
?>