*/ public static function technical_types(): array { /** * Filter the post types treated as templates / technical. * * @param array $types Post type names. */ return (array) apply_filters( 'airanksuite/inventory/technical_types', array( 'attachment', 'elementor_library', 'e-landing-page', 'e-floating-buttons', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', 'oembed_cache', 'custom_css', 'customize_changeset', 'user_request', 'nav_menu_item', 'revision', 'shop_order', 'shop_order_placehold', 'shop_order_refund', 'shop_coupon', 'shop_subscription', 'product_variation', 'scheduled-action', 'acf-field', 'acf-field-group', 'acf-post-type', 'acf-taxonomy', 'jet-engine', 'jet-theme-core', 'jet-menu', 'brizy_template', 'fl-builder-template', 'astra-advanced-hook', 'ct_template', 'oxy_user_library', 'bricks_template', 'tribe_events_cat', 'wpforms', 'wpcf7_contact_form', 'popup', 'popup_theme', 'jetpack_migration', 'trp_translation', ) ); } /** * Individual pages that exist for a transaction, not for a reader: the * WooCommerce cart, checkout and account pages, and WordPress' privacy * policy placeholder. Real URLs, but scoring their content is noise. * * @return array */ public static function technical_ids(): array { $ids = array(); if ( function_exists( 'wc_get_page_id' ) ) { foreach ( array( 'cart', 'checkout', 'myaccount', 'view_order', 'terms' ) as $key ) { $id = (int) wc_get_page_id( $key ); if ( $id > 0 ) { $ids[] = $id; } } } /** * Filter the individual post ids treated as technical. * * @param array $ids Post ids. */ return array_values( array_unique( array_map( 'intval', (array) apply_filters( 'airanksuite/inventory/technical_ids', $ids ) ) ) ); } /** * Post types whose posts are real, addressable pages. * * @return array */ public static function eligible_types(): array { $public = get_post_types( array( 'public' => true ), 'names' ); $types = array_values( array_diff( array_values( $public ), self::technical_types() ) ); /** * Filter the post types the plugin scores and audits. * * @param array $types Post type names. */ return (array) apply_filters( 'airanksuite/inventory/eligible_types', $types ); } /** * The display name of a post type, made unambiguous. * * The7, JetEngine and hand-written CPTs happily register two types both * called "Services" (`services` and `our_services`). Two tabs with the same * name and different numbers look like a bug and cannot be told apart in a * support ticket — so when a label collides with another registered type's * label, the technical name rides along: "Services (our_services)". */ public static function type_label( string $type ): string { $labels = array(); foreach ( get_post_types( array(), 'objects' ) as $obj ) { $labels[ (string) $obj->name ] = (string) ( $obj->labels->name ?? $obj->name ); } $label = $labels[ $type ] ?? $type; $dupes = array_keys( $labels, $label, true ); if ( count( $dupes ) > 1 && $label !== $type ) { return $label . ' (' . $type . ')'; } return $label; } /** * Technical types that actually exist on this install (registered), for the * "Templates & global elements" bucket. * * @return array */ public static function present_technical_types(): array { $all = get_post_types( array(), 'names' ); return array_values( array_intersect( self::technical_types(), array_values( $all ) ) ); } /** * The counts, cached five minutes and cleared on any post change. * * @param bool $fresh Bypass the cache. * @return array{ * types: array, all: int, published: int, drafts: int, indexable: int, * analyzed: int, unanalyzed: int, technical: int, by_type: array, * computed: int * } */ public static function counts( bool $fresh = false ): array { if ( ! $fresh ) { $cached = get_transient( self::CACHE ); if ( is_array( $cached ) && isset( $cached['computed'] ) ) { return $cached; } } global $wpdb; $types = self::eligible_types(); $out = array( 'types' => $types, 'all' => 0, 'published' => 0, 'drafts' => 0, 'indexable' => 0, 'analyzed' => 0, 'unanalyzed' => 0, 'technical' => 0, 'by_type' => array(), 'computed' => time(), ); if ( empty( $types ) ) { set_transient( self::CACHE, $out, self::CACHE_TTL ); return $out; } $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // One grouped query for status × type, one for scored, one for noindex. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_type, post_status, COUNT(*) AS n FROM {$wpdb->posts} WHERE post_type IN ({$in}) AND post_status IN ('publish','draft','pending','future','private') GROUP BY post_type, post_status", ...$types ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $scored = $wpdb->get_results( $wpdb->prepare( "SELECT p.post_type, COUNT(*) AS n FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_airanksuite_geo_score' WHERE p.post_type IN ({$in}) AND p.post_status = 'publish' GROUP BY p.post_type", ...$types ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- '%noindex%' is a literal constant pattern, no input involved. $noindex = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_airanksuite_robots' AND m.meta_value LIKE '%noindex%' WHERE p.post_type IN ({$in}) AND p.post_status = 'publish'", ...$types ) ); foreach ( $types as $t ) { $out['by_type'][ $t ] = array( 'label' => self::type_label( $t ), 'all' => 0, 'published' => 0, 'drafts' => 0, 'analyzed' => 0, 'unanalyzed' => 0, ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery } foreach ( (array) $rows as $r ) { $t = (string) $r->post_type; $n = (int) $r->n; if ( ! isset( $out['by_type'][ $t ] ) ) { continue; } $out['by_type'][ $t ]['all'] += $n; $out['all'] += $n; if ( 'publish' === $r->post_status ) { $out['by_type'][ $t ]['published'] += $n; $out['published'] += $n; } else { $out['by_type'][ $t ]['drafts'] += $n; $out['drafts'] += $n; } } foreach ( (array) $scored as $r ) { $t = (string) $r->post_type; if ( isset( $out['by_type'][ $t ] ) ) { $out['by_type'][ $t ]['analyzed'] = (int) $r->n; $out['analyzed'] += (int) $r->n; } } foreach ( $out['by_type'] as $t => $row ) { $out['by_type'][ $t ]['unanalyzed'] = max( 0, $row['published'] - $row['analyzed'] ); } $out['unanalyzed'] = max( 0, $out['published'] - $out['analyzed'] ); $out['indexable'] = max( 0, $out['published'] - $noindex ); $tech = self::present_technical_types(); if ( ! empty( $tech ) ) { $tin = implode( ',', array_fill( 0, count( $tech ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $out['technical'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ({$tin}) AND post_status = 'publish'", ...$tech ) ); } set_transient( self::CACHE, $out, self::CACHE_TTL ); return $out; } /** * Forget the cached counts — hooked to post changes. * * Screens that cache a payload *derived* from these counts (the dashboard * KPIs, for one) have to go with them: a five-minute-old "158 pages" next * to a fresh "161 items" is exactly the disagreement this class exists to * prevent. They subscribe to the action. */ public static function flush(): void { delete_transient( self::CACHE ); /** * Fires when the page counts change; derived caches must clear. */ do_action( 'airanksuite/inventory/flushed' ); } /** Register the invalidation hooks. Idempotent. */ public static function boot(): void { static $done = false; if ( $done ) { return; } $done = true; foreach ( array( 'save_post', 'deleted_post', 'transition_post_status', 'airanksuite/content_analyzer/analyzed' ) as $hook ) { add_action( $hook, array( self::class, 'flush' ) ); } } }