[ 'inbound' => int, 'outbound' => int, 'external' => int ] ]`. * * @since 1.2.3 * * @var array */ private array $stats = array(); /** * Tracks whether the batch preload has already run for the current * `the_posts` call, preventing duplicate queries on screens that * fire the filter more than once. * * @since 1.2.3 * * @var bool */ private bool $preloaded = false; /** * General settings, loaded on first use. See {@see general()}. * * @since 1.18.4 * * @var GeneralSettings|null */ private ?GeneralSettings $general = null; /** * Returns the WordPress hooks this service registers. * * @since 1.2.3 * * @return array> */ public function get_hooks(): array { return array( array( 'init', array( $this, 'register_column_hooks' ), 999 ), array( 'the_posts', array( $this, 'preload_stats' ) ), array( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_assets' ) ), ); } /** * Registers the column-add and column-render callbacks for each * configured post type. * * Runs on `init` at priority 999 so custom post types registered by * other plugins/themes on the default `init` priority are already * available. * * @since 1.2.3 * @hook init * * @return void */ public function register_column_hooks(): void { $post_types = $this->get_configured_post_types(); foreach ( $post_types as $post_type ) { if ( 'attachment' === $post_type ) { continue; } add_filter( "manage_{$post_type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$post_type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); } } /** * Adds the Link Profile column to the end of the column list. * * @since 1.2.3 * @hook manage_{$post_type}_posts_columns * * @param array $columns Existing columns. * * @return array Modified columns. */ public function add_column( array $columns ): array { $columns[ self::COLUMN_KEY ] = __( 'Link Profile', 'postlinkly' ); return $columns; } /** * Renders the Link Profile column content for a single post. * * Reads from the runtime cache populated by `preload_stats()`. If * the post has no cached entry it has not been scanned yet — a * "Not scanned" placeholder is shown. * * @since 1.2.3 * @hook manage_{$post_type}_posts_custom_column * * @param string $column_name Column key being rendered. * @param int $post_id Post ID for the current row. * * @return void */ public function render_column( string $column_name, int $post_id ): void { if ( self::COLUMN_KEY !== $column_name ) { return; } if ( ! isset( $this->stats[ $post_id ] ) ) { // A draft or scheduled post under the default settings is never // scanned, so "Scan" would point at a button that cannot help. $post = get_post( $post_id ); if ( $post instanceof \WP_Post && ! in_array( $post->post_status, $this->general()->post_statuses, true ) ) { $this->render_excluded(); return; } $this->render_unscanned(); return; } $stats = $this->stats[ $post_id ]; $this->render_badges( $post_id, $stats['inbound'], $stats['outbound'], $stats['external'] ); } /** * Batch-loads link stats for every post on the current list page. * * Fires on the `the_posts` filter. Runs two GROUP-BY queries * (outbound/external by source post, inbound by target post) and * merges the results into the runtime cache. The filter value is * returned unchanged. * * @since 1.2.3 * @hook the_posts * * @param array $posts Posts about to be displayed. * * @return array Posts, unchanged. */ public function preload_stats( array $posts ): array { if ( $this->preloaded || array() === $posts ) { return $posts; } $this->preloaded = true; global $wpdb; if ( ! isset( $wpdb ) ) { return $posts; } $table = TableNames::report_links(); // Bail silently if the table doesn't exist yet (pre-migration). if ( ! $this->table_exists( $table ) ) { return $posts; } $ids = array(); foreach ( $posts as $post ) { $ids[] = (int) $post->ID; } $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Batch load for admin list table; IN-clause placeholders are built from sanitised integers. // Outbound internal + external, grouped by source post. $outbound_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, SUM(CASE WHEN is_internal = 1 THEN 1 ELSE 0 END) AS outbound, SUM(CASE WHEN is_internal = 0 THEN 1 ELSE 0 END) AS external FROM %i WHERE post_id IN ({$placeholders}) GROUP BY post_id", array_merge( array( $table ), $ids ) ), ARRAY_A ); // Inbound internal, grouped by target post. $inbound_rows = $wpdb->get_results( $wpdb->prepare( "SELECT target_id, COUNT(*) AS inbound FROM %i WHERE is_internal = 1 AND target_id IN ({$placeholders}) GROUP BY target_id", array_merge( array( $table ), $ids ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared // Build lookup maps. $outbound_map = array(); if ( is_array( $outbound_rows ) ) { foreach ( $outbound_rows as $row ) { if ( ! is_array( $row ) ) { continue; } $pid = (int) ( $row['post_id'] ?? 0 ); if ( $pid <= 0 ) { continue; } $outbound_map[ $pid ] = array( 'outbound' => (int) ( $row['outbound'] ?? 0 ), 'external' => (int) ( $row['external'] ?? 0 ), ); } } $inbound_map = array(); if ( is_array( $inbound_rows ) ) { foreach ( $inbound_rows as $row ) { if ( ! is_array( $row ) ) { continue; } $pid = (int) ( $row['target_id'] ?? 0 ); if ( $pid <= 0 ) { continue; } $inbound_map[ $pid ] = (int) ( $row['inbound'] ?? 0 ); } } // Merge into the runtime cache. A post has been scanned when it has // outgoing links or carries the index marker: a scanned post with no // links, or with only incoming ones, has no outgoing rows, so rows // alone cannot tell it from one never scanned. A post that is // neither is left out of $this->stats and renders as not scanned. foreach ( $ids as $pid ) { if ( ! isset( $outbound_map[ $pid ] ) && ! IndexedMarker::is_indexed( $pid ) ) { continue; } $this->stats[ $pid ] = array( 'inbound' => $inbound_map[ $pid ] ?? 0, 'outbound' => $outbound_map[ $pid ]['outbound'] ?? 0, 'external' => $outbound_map[ $pid ]['external'] ?? 0, ); } return $posts; } /** * Enqueues the column stylesheet on post-list screens only. * * @since 1.2.3 * @hook admin_enqueue_scripts * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ public function maybe_enqueue_assets( string $hook_suffix ): void { if ( 'edit.php' !== $hook_suffix ) { return; } $url = defined( 'POSTLINKLY_PLUGIN_URL' ) ? (string) POSTLINKLY_PLUGIN_URL : ''; $version = defined( 'POSTLINKLY_VERSION' ) ? (string) POSTLINKLY_VERSION : '1.0.0'; wp_enqueue_style( self::HANDLE_CSS, $url . 'assets/css/admin-post-columns.css', array(), $version ); } // ------------------------------------------------------------------------- // Render helpers // ------------------------------------------------------------------------- /** * Outputs the badge HTML for a post with known stats. * * @since 1.2.3 * * @param int $post_id Post ID (for the AI-count filter). * @param int $inbound Inbound internal link count. * @param int $outbound Outbound internal link count. * @param int $external External link count. * * @return void */ private function render_badges( int $post_id, int $inbound, int $outbound, int $external ): void { $is_orphaned = 0 === $inbound; echo '
'; // Health dot. $health_class = 'pls-health--good'; if ( $is_orphaned ) { $health_class = 'pls-health--orphan'; } elseif ( 0 === $inbound && 0 === $outbound && 0 === $external ) { $health_class = 'pls-health--warning'; } echo ''; // Inbound (blue, arrow-in). $this->render_badge( 'inbound', $inbound, __( 'Inbound internal links', 'postlinkly' ), $this->icon_arrow_in() ); // Outbound (green, arrow-out). $this->render_badge( 'outbound', $outbound, __( 'Outbound internal links', 'postlinkly' ), $this->icon_arrow_out() ); // External (red, diagonal-arrow). $this->render_badge( 'external', $external, __( 'External links', 'postlinkly' ), $this->icon_external() ); // AI anchors (purple) — shown only when AI-generated links exist. // The count is filterable so future schema additions or cache // providers can supply it without changing this class. $ai_count = (int) apply_filters( 'postlinkly_link_profile_ai_count', 0, $post_id ); if ( $ai_count > 0 ) { $this->render_badge( 'ai', $ai_count, __( 'AI-generated anchor links', 'postlinkly' ), $this->icon_ai() ); } // Orphan warning. if ( $is_orphaned ) { echo ''; echo wp_kses( $this->icon_warning(), Icons::allowed_html() ); echo ''; } echo '
'; } /** * Outputs a single stat badge. * * @since 1.2.3 * * @param string $type Badge type modifier (inbound|outbound|external). * @param int $count Link count. * @param string $title Tooltip text. * @param string $svg SVG icon markup; escaped against Icons::allowed_html(). * * @return void */ private function render_badge( string $type, int $count, string $title, string $svg ): void { $zero_class = 0 === $count ? ' pls-stat--zero' : ''; $tip = $title . ( 0 === $count ? ' — none' : '' ); echo ''; echo wp_kses( $svg, Icons::allowed_html() ); echo esc_html( (string) $count ); echo ''; } /** * Outputs the "Not scanned" placeholder. * * @since 1.2.3 * * @return void */ private function render_unscanned(): void { echo '
'; echo ''; echo ''; echo wp_kses( $this->icon_scan(), Icons::allowed_html() ); echo esc_html__( 'Scan', 'postlinkly' ); echo ''; echo '
'; } /** * Outputs the placeholder for a post whose status is not scanned. * * @since 1.18.4 * * @return void */ private function render_excluded(): void { echo '
'; echo ''; echo esc_html__( 'Not included', 'postlinkly' ); echo ''; echo '
'; } // ------------------------------------------------------------------------- // SVG icons // ------------------------------------------------------------------------- /** * Returns the arrow-in SVG (mirrored arrow-out). * * @since 1.2.3 * * @return string */ private function icon_arrow_in(): string { return ''; } /** * Returns the arrow-out SVG. * * @since 1.2.3 * * @return string */ private function icon_arrow_out(): string { return ''; } /** * Returns the external-link SVG — a clean diagonal arrow. * * @since 1.2.3 * * @return string */ private function icon_external(): string { return ''; } /** * Returns the warning triangle SVG. * * @since 1.2.3 * * @return string */ private function icon_warning(): string { return ''; } /** * Returns the scan/refresh SVG. * * @since 1.2.3 * * @return string */ private function icon_scan(): string { return ''; } /** * Returns the AI-anchor SVG (info-circle). * * @since 1.2.3 * * @return string */ private function icon_ai(): string { return ''; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Returns the tooltip title for a given health-dot class. * * @since 1.2.3 * * @param string $health_class One of the pls-health--* class names. * * @return string */ private function health_title( string $health_class ): string { return match ( $health_class ) { 'pls-health--orphan' => __( 'Orphaned — no inbound links', 'postlinkly' ), 'pls-health--warning' => __( 'Warning — no links found', 'postlinkly' ), default => __( 'Good — this post has inbound links', 'postlinkly' ), }; } /** * Returns the list of post types the column should appear on. * * Reads from the plugin's General settings. Falls back to `['post']` * if settings are unavailable. * * @since 1.2.3 * * @return array */ private function get_configured_post_types(): array { return $this->general()->post_types; } /** * Returns the General settings, read once per request. * * The status check runs for every row of the posts list, so the settings * are not re-read per row. * * @since 1.18.4 * * @return GeneralSettings */ private function general(): GeneralSettings { if ( null === $this->general ) { $this->general = ( new Settings() )->general(); } return $this->general; } /** * Checks whether a database table exists. * * Uses SHOW TABLES so it never triggers a DB error on a fresh * install before migrations have run. * * @since 1.2.3 * * @param string $table Fully-qualified table name. * * @return bool */ private function table_exists( string $table ): bool { global $wpdb; if ( ! isset( $wpdb ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Existence check; result not worth caching separately. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); return $found === $table; } }