get_supported_post_types(); foreach ($post_types as $post_type) { // Add column header add_filter("manage_{$post_type}_posts_columns", [$this, 'add_column_header'], 10); // Add column content add_action("manage_{$post_type}_posts_custom_column", [$this, 'render_column_content'], 10, 2); // Make column sortable add_filter("manage_edit-{$post_type}_sortable_columns", [$this, 'make_column_sortable'], 10); } }); // Handle sorting add_action('pre_get_posts', [$this, 'handle_column_sorting']); // Enqueue admin styles for column add_action('admin_enqueue_scripts', [$this, 'enqueue_column_styles']); } /** * Get supported post types * * @return array Array of post type names */ private function get_supported_post_types(): array { $post_types = get_post_types(['public' => true], 'names'); // Remove unnecessary post types unset($post_types['attachment']); unset($post_types['elementor_library']); unset($post_types['oceanwp_library']); unset($post_types['ae_global_templates']); // Divi library + Theme Builder templates. unset($post_types['et_pb_layout']); unset($post_types['et_theme_builder']); unset($post_types['et_template']); unset($post_types['et_header_layout']); unset($post_types['et_body_layout']); unset($post_types['et_footer_layout']); /** * Filter supported post types for ThinkRank SEO Overview column * * @param array $post_types Array of post type names */ return apply_filters('thinkrank_seo_overview_post_types', $post_types); } /** * Add column header * * @param array $columns Existing columns * @return array Modified columns */ public function add_column_header(array $columns): array { // Insert column before date column $new_columns = []; foreach ($columns as $key => $value) { if ($key === 'date') { $new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank'); } $new_columns[$key] = $value; } // If date column doesn't exist, add at the end if (!isset($columns['date'])) { $new_columns[self::COLUMN_ID] = __('ThinkRank SEO Overview', 'thinkrank'); } return $new_columns; } /** * Render column content * * @param string $column_name Column name * @param int $post_id Post ID * @return void */ public function render_column_content(string $column_name, int $post_id): void { if ($column_name !== self::COLUMN_ID) { return; } // Get SEO data $seo_data = $this->get_seo_data($post_id); // Render the column content $this->render_seo_overview($seo_data, $post_id); } /** * Get SEO data for a post * * @param int $post_id Post ID * @return array SEO data */ private function get_seo_data(int $post_id): array { // Get SEO score and metrics from database table $seo_metrics = $this->get_seo_metrics_from_database($post_id); // Get focus keyword(s). The column shows only the primary keyword, but // the inline editor operates on the full comma-separated set so editing // never drops the secondary keywords. $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id); $focus_keyword = implode(', ', $focus_keywords); $focus_keyword_primary = $focus_keywords[0] ?? ''; // Get the effective meta title/description. When no custom per-post value // is set, ThinkRank still renders these from the Bulk SEO Optimization // (Global SEO) patterns, so resolve those inherited values here instead of // reporting "Not Set" for content that is actually output on the frontend. $raw_title = get_post_meta($post_id, '_thinkrank_seo_title', true); $meta_title = $raw_title !== '' ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_title, $post_id) : \ThinkRank\SEO\Pattern_Resolver::title($post_id); $raw_description = get_post_meta($post_id, '_thinkrank_meta_description', true); $meta_description = $raw_description !== '' ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($raw_description, $post_id) : \ThinkRank\SEO\Pattern_Resolver::description($post_id); // Get content quality and readability from database columns $content_quality = $seo_metrics['content_quality'] ?? 'Not Analyzed'; $readability = $seo_metrics['readability_score'] ?? 'N/A'; return [ 'seo_score' => $seo_metrics['seo_score'], 'seo_grade' => $seo_metrics['seo_grade'], 'content_quality' => $content_quality, 'readability' => $readability, 'focus_keyword' => !empty($focus_keyword), 'focus_keyword_value' => $focus_keyword, 'focus_keyword_primary' => $focus_keyword_primary, 'meta_title' => !empty($meta_title), 'meta_description' => !empty($meta_description), 'pillar_content' => $this->is_link_suggestions_enabled(get_post_type($post_id)) && get_post_meta($post_id, '_thinkrank_pillar_content', true) === '1', ]; } /** * Check if link suggestions are enabled for a post type * * @param string $post_type Post type * @return bool True if enabled */ private function is_link_suggestions_enabled(string $post_type): bool { $settings = get_option('thinkrank_global_seo_settings', []); if (isset($settings[$post_type]['link_suggestions'])) { return (bool) $settings[$post_type]['link_suggestions']; } return true; } /** * Cached result of the scores-table existence check. * * The post-list column renders this method once per row; the table schema * cannot change mid-request, so the `SHOW TABLES LIKE` probe is memoized for * the lifetime of the request instead of re-running per row. * * @since 1.16.2 * @var bool|null Null until first resolved, then the boolean result. */ private static ?bool $scores_table_exists = null; /** * Get SEO metrics from database table * * @param int $post_id Post ID * @return array SEO metrics (seo_score, content_quality, readability_score) */ private function get_seo_metrics_from_database(int $post_id): array { global $wpdb; $table_name = $wpdb->prefix . 'thinkrank_seo_scores'; // Default values $default_metrics = [ 'seo_score' => null, 'seo_grade' => null, 'content_quality' => null, 'readability_score' => null, ]; // Check if table exists (memoized per request — the column runs this // per row and the schema is stable within a single page load). if (null === self::$scores_table_exists) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access self::$scores_table_exists = (bool) $wpdb->get_var($wpdb->prepare( "SHOW TABLES LIKE %s", $table_name )); } if (!self::$scores_table_exists) { return $default_metrics; } // Get the most recent metrics from the database // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO score retrieval requires direct database access $result = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is properly escaped "SELECT overall_score, grade, content_quality, readability_score FROM `{$table_name}` WHERE post_id = %d ORDER BY created_at DESC LIMIT 1", $post_id ), ARRAY_A); if (!$result) { return $default_metrics; } return [ 'seo_score' => $result['overall_score'] !== null ? intval($result['overall_score']) : null, 'seo_grade' => $result['grade'] !== null ? $result['grade'] : null, 'content_quality' => $result['content_quality'], 'readability_score' => $result['readability_score'], ]; } /** * Render SEO overview content * * @param array $seo_data SEO data * @param int $post_id Post ID * @return void */ private function render_seo_overview(array $seo_data, int $post_id): void { ?>