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). class RestClusters { public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register_routes(): void { // List all clusters. register_rest_route( 'clusterlane/v1', '/clusters', [ 'methods' => 'GET', 'callback' => [ $this, 'get_clusters' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Sync clusters from taxonomy. register_rest_route( 'clusterlane/v1', '/clusters/sync', [ 'methods' => 'POST', 'callback' => [ $this, 'sync_clusters' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Compute metrics for all clusters. register_rest_route( 'clusterlane/v1', '/clusters/compute', [ 'methods' => 'POST', 'callback' => [ $this, 'compute_clusters' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Compute metrics for one cluster. register_rest_route( 'clusterlane/v1', '/clusters/compute/(?P[a-z0-9_\-]+)', [ 'methods' => 'POST', 'callback' => [ $this, 'compute_one' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // --- Custom clusters --- // Create a new custom cluster. register_rest_route( 'clusterlane/v1', '/clusters/custom', [ 'methods' => 'POST', 'callback' => [ $this, 'create_custom' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); // Update custom cluster (rename / add / remove members). register_rest_route( 'clusterlane/v1', '/clusters/custom/(?P[a-z0-9_\-]+)', [ 'methods' => 'POST,DELETE', 'callback' => [ $this, 'update_custom' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); // Search posts for the member picker. register_rest_route( 'clusterlane/v1', '/posts/search', [ 'methods' => 'GET,POST', 'callback' => [ $this, 'search_posts' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Get members of a cluster. register_rest_route( 'clusterlane/v1', '/clusters/members/(?P[a-z0-9_\\-]+)', [ 'methods' => 'GET,POST', 'callback' => [ $this, 'get_members' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); // Deactivate a taxonomy cluster. register_rest_route( 'clusterlane/v1', '/clusters/deactivate/(?P[a-z0-9_\\-]+)', [ 'methods' => 'POST', 'callback' => [ $this, 'deactivate_cluster' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); // Reset — delete all Clusterlane data. register_rest_route( 'clusterlane/v1', '/onboarding/setup', [ 'methods' => 'POST', 'callback' => [ $this, 'onboarding_setup' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); register_rest_route( 'clusterlane/v1', '/reset', [ 'methods' => 'POST', 'callback' => [ $this, 'reset_all' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(), ] ); } public function get_clusters(): \WP_REST_Response { $engine = $this->plugin->get('cluster_engine'); $clusters = $engine->get_clusters(); $summary = $engine->get_summary(); // Role counts per cluster (Site map on the Dashboard colours by orphan share). global $wpdb; $role_counts = []; foreach ( (array) $wpdb->get_results( "SELECT cluster_key, content_class, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class GROUP BY cluster_key, content_class" // phpcs:ignore ) as $rc ) { $role_counts[ $rc->cluster_key ][ $rc->content_class ] = (int) $rc->n; } foreach ( $clusters as $c ) { $c->orphans = (int) ( $role_counts[ $c->cluster_key ]['orphan'] ?? 0 ); $c->pillars = (int) ( $role_counts[ $c->cluster_key ]['pillar'] ?? 0 ); } return rest_ensure_response( [ 'summary' => $summary, 'clusters' => $clusters, ] ); } public function sync_clusters(): \WP_REST_Response { $created = $this->plugin->get('cluster_engine')->sync_all(); return rest_ensure_response( [ 'created' => $created ] ); } public function compute_clusters(): \WP_REST_Response { $count = $this->plugin->get('cluster_engine')->compute_all(); return rest_ensure_response( [ 'computed' => $count ] ); } public function compute_one( \WP_REST_Request $request ): \WP_REST_Response { $key = sanitize_key( $request['key'] ); $this->plugin->get('cluster_engine')->compute_cluster( $key ); return rest_ensure_response( [ 'computed' => $key ] ); } /* ------------------------------------------------------------------ Custom clusters ------------------------------------------------------------------ */ public function create_custom( \WP_REST_Request $request ): \WP_REST_Response { $label = sanitize_text_field( $request->get_json_params()['label'] ?? '' ); $post_ids = array_map( 'intval', $request->get_json_params()['post_ids'] ?? [] ); if ( ! $label ) { return new \WP_REST_Response( [ 'error' => 'Label is required' ], 400 ); } global $wpdb; // Generate unique key from label. $key = 'custom_' . sanitize_title( $label ) . '_' . time(); // Create cluster row. $wpdb->insert( $wpdb->prefix . 'clusterlane_clusters', [ 'cluster_key' => $key, 'cluster_label' => $label, 'cluster_type' => 'custom', 'term_id' => null, 'is_active' => 1, ] ); // Add members. $now = current_time( 'mysql' ); foreach ( $post_ids as $post_id ) { if ( ! $post_id ) continue; $wpdb->replace( $wpdb->prefix . 'clusterlane_custom_members', [ 'cluster_key' => $key, 'post_id' => $post_id, 'added_at' => $now, ] ); } // Compute metrics immediately. $this->plugin->get('cluster_engine')->compute_cluster( $key ); return rest_ensure_response( [ 'cluster_key' => $key, 'label' => $label, 'members' => count( $post_ids ), 'summary' => $this->custom_summary( $key ), ] ); } public function update_custom( \WP_REST_Request $request ): \WP_REST_Response { $key = sanitize_key( $request['key'] ); $method = $request->get_method(); global $wpdb; // DELETE — remove entire custom cluster. if ( $method === 'DELETE' ) { $wpdb->delete( $wpdb->prefix . 'clusterlane_custom_members', [ 'cluster_key' => $key ] ); $wpdb->delete( $wpdb->prefix . 'clusterlane_clusters', [ 'cluster_key' => $key ] ); return rest_ensure_response( [ 'deleted' => $key ] ); } // POST — update label and/or members. $params = $request->get_json_params(); $label = sanitize_text_field( $params['label'] ?? '' ); $add_ids = array_map( 'intval', $params['add_ids'] ?? [] ); $remove_ids = array_map( 'intval', $params['remove_ids'] ?? [] ); if ( $label ) { $wpdb->update( $wpdb->prefix . 'clusterlane_clusters', [ 'cluster_label' => $label ], [ 'cluster_key' => $key ] ); } $now = current_time( 'mysql' ); foreach ( $add_ids as $post_id ) { if ( ! $post_id ) continue; $wpdb->replace( $wpdb->prefix . 'clusterlane_custom_members', [ 'cluster_key' => $key, 'post_id' => $post_id, 'added_at' => $now, ] ); } foreach ( $remove_ids as $post_id ) { if ( ! $post_id ) continue; $wpdb->delete( $wpdb->prefix . 'clusterlane_custom_members', [ 'cluster_key' => $key, 'post_id' => $post_id, ] ); } // Recompute metrics. $this->plugin->get('cluster_engine')->compute_cluster( $key ); return rest_ensure_response( [ 'updated' => $key, 'summary' => $this->custom_summary( $key ) ] ); } /** Health, size and orphans of one cluster — for Cluster Admin's table after an edit. */ private function custom_summary( string $key ): array { global $wpdb; $c = $wpdb->get_row( $wpdb->prepare( "SELECT cluster_label, health_score, total_members FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) ); $orphans = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE cluster_key = %s AND content_class = 'orphan'", $key ) ); return [ 'label' => $c ? html_entity_decode( $c->cluster_label, ENT_QUOTES, 'UTF-8' ) : '', 'health' => $c ? (int) $c->health_score : 0, 'members' => $c ? (int) $c->total_members : 0, 'orphans' => $orphans, ]; } public function search_posts( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $query = sanitize_text_field( $request->get_param('q') ?? '' ); $limit = min( 20, (int) ( $request->get_param('limit') ?? 10 ) ); if ( strlen( $query ) < 2 ) { return rest_ensure_response( [ 'posts' => [] ] ); } $settings = $this->plugin->get('settings'); $post_types = $settings ? $settings->post_types() : ['post']; $like = '%' . $wpdb->esc_like( $query ) . '%'; // same_as= (0.98.168): multilingual sites — only articles in that article's language. $lang_sql = \Clusterlane\Core\Lang::sql( \Clusterlane\Core\Lang::of( (int) $request->get_param( 'same_as' ) ), '' ); // prepared in Lang::sql() (or 1=1) // 1. Title matches — always shown first, sorted alphabetically. // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $title_rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s AND " . $lang_sql . " AND post_status = %s AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ") ORDER BY post_title ASC LIMIT %d", array_merge( [ $like, 'publish' ], $post_types, [ $limit ] ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared $found_ids = array_column( $title_rows, 'ID' ); // 2. Content matches — fill remaining slots, exclude already-found posts. $content_rows = []; $remaining = $limit - count( $title_rows ); if ( $remaining > 0 ) { $exclude = $found_ids ? array_values( array_map( 'intval', $found_ids ) ) : [ 0 ]; // no post has ID 0 // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $content_rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_content LIKE %s AND " . $lang_sql . " AND post_status = %s AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ") AND ID NOT IN (" . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ") ORDER BY post_title ASC LIMIT %d", array_merge( [ $like, 'publish' ], $post_types, $exclude, [ $remaining ] ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared } $title_results = array_map( fn( $p ) => [ 'id' => (int) $p->ID, 'title' => $p->post_title, 'url' => get_permalink( $p->ID ), 'match_type' => 'title', ], $title_rows ); $content_results = array_map( fn( $p ) => [ 'id' => (int) $p->ID, 'title' => $p->post_title, 'url' => get_permalink( $p->ID ), 'match_type' => 'content', ], $content_rows ); $posts = array_merge( $title_results, $content_results ); if ( \Clusterlane\Core\Lang::active() ) { // a language tag per result, so same titles in two languages can be told apart $langs = \Clusterlane\Core\Lang::map( array_column( $posts, 'id' ) ); foreach ( $posts as &$p ) $p['lang'] = $langs[ $p['id'] ] ?? ''; unset( $p ); } return rest_ensure_response( [ 'posts' => \Clusterlane\Core\Thumbs::add( $posts ) ] ); } public function get_members( \WP_REST_Request $request ): \WP_REST_Response { $key = sanitize_key( $request['key'] ); $cluster = null; global $wpdb; $cluster = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) ); if ( ! $cluster ) { return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 ); } // Get member post IDs. if ( \Clusterlane\Core\HubClusters::is_hub_type( (string) $cluster->cluster_type ) ) { $ids = \Clusterlane\Core\HubClusters::members( (int) $cluster->term_id ); // pages as clusters (0.98.180) } elseif ( $cluster->cluster_type === 'custom' ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->prefix}clusterlane_custom_members WHERE cluster_key = %s ORDER BY added_at DESC", $key ) ); } else { $ids = get_posts( \Clusterlane\Core\Scope::query_args( [ 'post_type' => \Clusterlane\Core\Scope::post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => [[ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- members of one term, ids only 'taxonomy' => $cluster->cluster_type, 'field' => 'term_id', 'terms' => (int) $cluster->term_id, 'include_children' => false, ]], 'no_found_rows' => true, ] ) ); } $members = array_map( function( $id ) { $post = get_post( (int) $id ); return $post ? [ 'id' => $post->ID, 'title' => $post->post_title ] : null; }, $ids ); $members = array_filter( $members ); usort( $members, fn( $a, $b ) => strcmp( $a['title'], $b['title'] ) ); return rest_ensure_response( [ 'members' => $members ] ); } public function deactivate_cluster( \WP_REST_Request $request ): \WP_REST_Response { $key = sanitize_key( $request['key'] ); global $wpdb; $wpdb->update( $wpdb->prefix . 'clusterlane_clusters', [ 'is_active' => 0 ], [ 'cluster_key' => $key ] ); return rest_ensure_response( [ 'deactivated' => $key ] ); } public function onboarding_setup( \WP_REST_Request $request ): \WP_REST_Response { $settings = $this->plugin->get( 'settings' ); $raw_cats = array_map( 'intval', (array) ( $request->get_json_params()['categories'] ?? [] ) ); $raw_tags = array_map( 'intval', (array) ( $request->get_json_params()['tags'] ?? [] ) ); // Validate that every submitted ID is a real term on this site. // Prevents arbitrary integers ending up in settings if the request // is crafted or the JS sends stale data after a term was deleted. $cats = array_values( array_filter( $raw_cats, fn( $id ) => $id > 0 && term_exists( $id, 'category' ) ) ); $tags = array_values( array_filter( $raw_tags, fn( $id ) => $id > 0 && term_exists( $id, 'post_tag' ) ) ); $settings->set( 'enabled_categories', array_unique( $cats ) ); $settings->set( 'enabled_tags', array_unique( $tags ) ); // 0.98.177: what to scan, whose published pages to read, and terms of any // other taxonomy (attraction categories, regions …) as clusters. $p = (array) $request->get_json_params(); $public = array_diff( array_keys( get_post_types( [ 'public' => true ] ) + get_post_types( [ 'publicly_queryable' => true ] ) ), [ 'attachment' ] ); $types = array_values( array_intersect( array_map( 'sanitize_key', (array) ( $p['post_types'] ?? [] ) ), $public ) ); if ( $types ) $settings->set( 'post_types', $types ); if ( isset( $p['rendered_types'] ) ) { $settings->set( 'rendered_types', array_values( array_intersect( array_map( 'sanitize_key', (array) $p['rendered_types'] ), $settings->post_types() ) ) ); } $extra = []; foreach ( (array) ( $p['terms'] ?? [] ) as $tax => $ids ) { $tax = sanitize_key( (string) $tax ); if ( ! taxonomy_exists( $tax ) || in_array( $tax, \Clusterlane\Core\ClusterTypes::BUILT_IN, true ) ) continue; $extra[ $tax ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ), fn( $id ) => $id > 0 && term_exists( $id, $tax ) ) ) ); } $settings->set( 'enabled_terms', $extra ); // Pages as clusters (0.98.180), among what the saved post types point to. $settings->set( 'enabled_hubs', array_values( array_intersect( array_map( 'sanitize_key', (array) ( $p['hubs'] ?? [] ) ), array_keys( \Clusterlane\Core\HubClusters::candidates() ) ) ) ); $settings->set( 'cluster_terms_configured', true ); // Sync clusters from the saved selection. $engine = $this->plugin->get( 'cluster_engine' ); $created = $engine ? $engine->sync_all() : 0; return rest_ensure_response( [ 'ok' => true, 'clusters_created' => $created ] ); } public function reset_all(): \WP_REST_Response { global $wpdb; $tables = [ $wpdb->prefix . 'clusterlane_links', $wpdb->prefix . 'clusterlane_clusters', $wpdb->prefix . 'clusterlane_post_metrics', $wpdb->prefix . 'clusterlane_ai_cache', $wpdb->prefix . 'clusterlane_custom_members', $wpdb->prefix . 'clusterlane_tms_skipped', $wpdb->prefix . 'clusterlane_cluster_post_class', ]; foreach ( $tables as $table ) { $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) ); } // AI reviews of SEO titles (post meta) go with the AI cache. delete_post_meta_by_key( \Clusterlane\Core\SeoMeta::CHECK_META ); delete_post_meta_by_key( \Clusterlane\Core\SeoMeta::CHECK_MIN ); delete_option( \Clusterlane\Core\ClusterSections::OPTION ); delete_option( \Clusterlane\Core\ClusterSections::GROUPS ); // clusters are gone, so are their sections // Clear data-related settings (link templates) but keep API key and other config // so the user doesn't have to re-enter credentials after a reset. // Reset cluster_terms_configured so the onboarding modal reappears. $settings = get_option( 'clusterlane_settings', [] ); unset( $settings['link_templates'] ); $settings['cluster_terms_configured'] = false; $settings['enabled_categories'] = []; $settings['enabled_tags'] = []; $settings['enabled_product_cats'] = []; $settings['enabled_terms'] = []; $settings['enabled_hubs'] = []; update_option( 'clusterlane_settings', $settings ); \Clusterlane\Core\PageReader::reset_log(); \Clusterlane\Core\PageReader::reset_queue(); delete_post_meta_by_key( \Clusterlane\Core\PageReader::STATS_META ); delete_option( \Clusterlane\Core\PageReader::LOCK_OPTION ); wp_clear_scheduled_hook( \Clusterlane\Core\PageReader::CRON_HOOK ); return rest_ensure_response( [ 'reset' => true ] ); } }