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). /** * Scope — which posts Clusterlane analyses. * * Published posts only, and by default NOT password-protected ones: their * content is hidden from visitors and search engines, so they shouldn't * count towards cluster health, orphans or scores, or be offered as link * targets. Opt back in with: * * add_filter( 'clusterlane_include_protected_posts', '__return_true' ); * * Every post query in Clusterlane goes through these helpers, so the rule * lives in one place. */ final class Scope { public static function include_protected(): bool { return (bool) apply_filters( 'clusterlane_include_protected_posts', false ); } /** get_posts()/WP_Query args with the scope applied. */ public static function query_args( array $args ): array { if ( ! self::include_protected() ) { $args['has_password'] = false; } // Shops (0.98.172): products hidden from the catalogue are out of scope. if ( Shop::in_scope() ) { $tq = isset( $args['tax_query'] ) ? (array) $args['tax_query'] : []; $tq[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-catalog' ], 'operator' => 'NOT IN' ]; if ( count( $tq ) > 1 && ! isset( $tq['relation'] ) ) $tq['relation'] = 'AND'; $args['tax_query'] = $tq; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- WooCommerce catalog visibility, only on shops } return $args; } /** SQL condition for a posts table alias: published (+ not protected). */ public static function sql( string $alias = '' ): string { $a = $alias !== '' ? $alias . '.' : ''; return "{$a}post_status = 'publish'" . ( self::include_protected() ? '' : " AND {$a}post_password = ''" ) . Shop::hidden_sql( $alias ); } /** * The post types Clusterlane analyses (Settings → Content → Post types). * Clusters, Text Match, counts and lists all use this — before 0.98.114 * several places assumed 'post' only, so pages and custom post types * were scanned but never became cluster members or link targets. * @return string[] */ public static function post_types(): array { $s = Settings::get_instance(); $types = $s ? (array) $s->post_types() : [ 'post' ]; $types = array_values( array_unique( array_filter( array_map( 'sanitize_key', $types ) ) ) ); return $types ?: [ 'post' ]; } /** * SQL condition: post_type IN (…) for a posts table alias, prepared here * (one %s placeholder per post type). $with_pages adds 'page' — link * targets are often pages even when pages aren't scanned. */ public static function type_sql( string $alias = '', bool $with_pages = false ): string { global $wpdb; $types = self::post_types(); if ( $with_pages ) $types = array_values( array_unique( array_merge( $types, [ 'page' ] ) ) ); if ( $alias !== '' ) { return $wpdb->prepare( '%i.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', array_merge( [ $alias ], $types ) ); } return $wpdb->prepare( 'post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', $types ); } /** True if a post falls outside the scope. */ public static function excludes( ?\WP_Post $post ): bool { if ( ! $post || $post->post_status !== 'publish' ) return true; if ( $post->post_type === Shop::TYPE && Shop::hidden( (int) $post->ID ) ) return true; // hidden from the shop (0.98.172) return ! self::include_protected() && $post->post_password !== ''; } /** Forget one post: metrics, cluster roles and the links it contributes. */ public static function forget_post( int $post_id ): void { global $wpdb; $wpdb->delete( $wpdb->prefix . 'clusterlane_post_metrics', [ 'post_id' => $post_id ], [ '%d' ] ); $wpdb->delete( $wpdb->prefix . 'clusterlane_cluster_post_class', [ 'post_id' => $post_id ], [ '%d' ] ); $wpdb->delete( $wpdb->prefix . 'clusterlane_links', [ 'source_id' => $post_id ], [ '%d' ] ); } /** * Remove stored data for every post now outside the scope (e.g. a post * that got a password since the last scan). Runs at the start of a full * scan; the next Compute Metrics then recalculates the clusters. * * @return int number of posts forgotten */ public static function purge_out_of_scope(): int { global $wpdb; // 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(). $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT m.post_id FROM %i m LEFT JOIN %i p ON p.ID = m.post_id WHERE p.ID IS NULL OR NOT ( ' . self::sql( 'p' ) . ' )', $wpdb->prefix . 'clusterlane_post_metrics', $wpdb->posts ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared foreach ( $ids as $id ) self::forget_post( (int) $id ); return count( $ids ); } }