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 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. $meta_title = \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id); $meta_description = \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id); // Raw per-post values (what the Quick Edit inputs actually edit — the // effective values above are what the placeholders show). Both reads hit // the already-primed meta cache. $seo_title_raw = (string) get_post_meta($post_id, '_thinkrank_seo_title', true); $meta_description_raw = (string) get_post_meta($post_id, '_thinkrank_meta_description', true); // Get SEO score and metrics — the stored analysis when it is still // current, otherwise one calculated on the spot. $seo_metrics = $this->get_seo_metrics($post_id, $meta_title, $meta_description, $focus_keywords); // 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), 'seo_title_raw' => $seo_title_raw, 'meta_description_raw' => $meta_description_raw, 'effective_title' => $meta_title, 'effective_description' => $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; /** * Resolve the metrics to display for a post. * * Prefers the stored analysis, but only while it still describes the post as * it stands: a score saved before the last edit is stale, and showing it is * how a rewritten post could keep advertising the score of the draft it * replaced. When there is no usable stored score the metrics are calculated * here instead. * * That fallback is the difference between a column that reads "N/A / Not * Analyzed" forever and one that reports a real score straight away. A score * row is only ever written by an explicit analysis (the editor panel, the * bulk analyzer, an MCP call or the importer) — nothing scores a post on * save — so before this fallback existed, editing the SEO title and * description left the column empty with no indication that a separate, * manual step was what it was waiting for. * * @since 1.28.0 * * @param int $post_id Post ID. * @param string $title Effective meta title. * @param string $description Effective meta description. * @param string[] $keywords Focus keywords. * @return array SEO metrics (seo_score, seo_grade, content_quality, readability_score) */ private function get_seo_metrics(int $post_id, string $title, string $description, array $keywords): array { $stored = $this->get_seo_metrics_from_database($post_id); if ($stored['seo_score'] !== null && !$this->is_score_stale($post_id, $stored['calculated_at'] ?? null)) { return $stored; } return $this->calculate_seo_metrics($post_id, $title, $description, $keywords); } /** * Whether a stored score predates the post's last modification. * * Both timestamps are site-local `Y-m-d H:i:s` — `post_modified` from * WordPress and `created_at` written with `current_time('mysql')` — so they * are directly comparable as strings. A missing timestamp counts as stale; * calculating is cheap and cached, and a score we cannot date is one we * cannot vouch for. * * @since 1.28.0 * * @param int $post_id Post ID. * @param string|null $calculated_at When the stored score was written. * @return bool True when the post changed after the score was calculated. */ private function is_score_stale(int $post_id, ?string $calculated_at): bool { if (empty($calculated_at)) { return true; } $post = get_post($post_id); if (!$post instanceof \WP_Post || empty($post->post_modified)) { return false; } return $post->post_modified > $calculated_at; } /** * Calculate a post's metrics locally, caching the result against the inputs * that produced it. * * The scoring engine is pure PHP — no AI provider and no HTTP calls — so the * list table can run it directly. The cache is keyed on a fingerprint of * everything the score depends on (last modification, effective title and * description, focus keywords, plugin version), which means a Global SEO * pattern change or an inline focus-keyword edit invalidates it on its own, * with nothing to hook and no cache to sweep. * * @since 1.28.0 * * @param int $post_id Post ID. * @param string $title Effective meta title. * @param string $description Effective meta description. * @param string[] $keywords Focus keywords. * @return array SEO metrics; score/grade null when the post could not be scored. */ private function calculate_seo_metrics(int $post_id, string $title, string $description, array $keywords): array { $empty = [ 'seo_score' => null, 'seo_grade' => null, 'content_quality' => null, 'readability_score' => null, ]; $post = get_post($post_id); if (!$post instanceof \WP_Post) { return $empty; } $fingerprint = md5(implode('|', [ (string) $post->post_modified_gmt, $title, $description, implode(',', $keywords), THINKRANK_VERSION, ])); $cached = get_post_meta($post_id, self::METRICS_CACHE_META, true); if (is_array($cached) && ($cached['fingerprint'] ?? '') === $fingerprint && isset($cached['metrics'])) { return $cached['metrics']; } // Past the ceiling the column falls back to the un-scored display rather // than holding up the page; the next load picks these up. if (self::$scored_this_request >= self::MAX_SCORED_PER_REQUEST) { return $empty; } self::$scored_this_request++; try { $calculator = new \ThinkRank\AI\SEOScoreCalculator(new \ThinkRank\Core\Database()); $content_data = $calculator->analyze_post_content($post_id); if (empty($content_data)) { return $empty; } // Score against the effective title/description so a post inheriting // either from a Global pattern is scored the way it actually renders, // matching the editor panel rather than counting the field as empty. $score_data = $calculator->calculate_score( $content_data, ['title' => $title, 'description' => $description], ['target_keywords' => $keywords] ); } catch (\Throwable $e) { // A broken shortcode in one post must not take down the whole list. return $empty; } if (!isset($score_data['overall_score'])) { return $empty; } $metrics = [ 'seo_score' => (int) $score_data['overall_score'], 'seo_grade' => $score_data['grade'] ?? null, 'content_quality' => $score_data['content_quality'] ?? null, 'readability_score' => $score_data['readability_score'] ?? null, ]; update_post_meta($post_id, self::METRICS_CACHE_META, [ 'fingerprint' => $fingerprint, 'metrics' => $metrics, ]); return $metrics; } /** * 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, 'calculated_at' => 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, created_at 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'], 'calculated_at' => $result['created_at'], ]; } /** * 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 { ?>