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 { ?>
%
= 90) { return 'great'; } elseif ($score >= 70) { return 'good'; } elseif ($score >= 50) { return 'medium'; } else { return 'poor'; } } /** * Make column sortable * * @param array $columns Sortable columns * @return array Modified sortable columns */ public function make_column_sortable(array $columns): array { $columns[self::COLUMN_ID] = 'thinkrank_seo_score'; return $columns; } /** * Handle column sorting * * @param \WP_Query $query WordPress query object * @return void */ public function handle_column_sorting(\WP_Query $query): void { if (!is_admin() || !$query->is_main_query()) { return; } if ($query->get('orderby') !== 'thinkrank_seo_score') { return; } // Sort on the scores table — the same source the column renders. The // `_thinkrank_seo_score` meta cannot be used here: it is written only by // the AI analyze flow and reset to 0 on every save, and ordering by // meta_key INNER JOINs postmeta, which silently drops every post that // lacks the key from the list entirely. add_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10, 2); } /** * Order the posts list by each post's latest SEO score. * * LEFT JOIN so posts that have never been scored still appear (they order as * NULL: last when sorting DESC). The subquery collapses the append-only * score history to one row per post — the newest by primary key — so posts * are never duplicated by the join. * * @param array $clauses Query clauses * @param \WP_Query $query Query object * @return array Modified clauses */ public function add_seo_score_order_clauses(array $clauses, \WP_Query $query): array { global $wpdb; // Leave any other query untouched — and leave the filter attached, since // the query that asked for this ordering may not be the first to run. if ($query->get('orderby') !== 'thinkrank_seo_score') { return $clauses; } // Handled — don't touch anything that runs later in the request. remove_filter('posts_clauses', [$this, 'add_seo_score_order_clauses'], 10); $table_name = $wpdb->prefix . 'thinkrank_seo_scores'; $order = strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC'; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name derives from $wpdb->prefix and $order is whitelisted above $clauses['join'] .= " LEFT JOIN ( SELECT s.post_id, s.overall_score FROM `{$table_name}` s INNER JOIN ( SELECT post_id, MAX(id) AS latest_id FROM `{$table_name}` GROUP BY post_id ) latest ON latest.latest_id = s.id ) thinkrank_scores ON thinkrank_scores.post_id = {$wpdb->posts}.ID"; // Tie-break on ID so equal scores keep a stable, deterministic order. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $order is whitelisted above $clauses['orderby'] = "thinkrank_scores.overall_score {$order}, {$wpdb->posts}.ID DESC"; return $clauses; } /** * Enqueue column styles * * @param string $hook_suffix Current admin page hook * @return void */ public function enqueue_column_styles(string $hook_suffix): void { // Only load on post list pages (edit.php for all post types) $screen = get_current_screen(); if (!$screen || $screen->base !== 'edit') { return; } // Enqueue column styles wp_enqueue_style( 'thinkrank-admin-columns', THINKRANK_PLUGIN_URL . 'static/css/admin-columns.css', [], THINKRANK_VERSION ); // Enqueue inline editing script wp_enqueue_script( 'thinkrank-focus-keyword-inline-edit', THINKRANK_PLUGIN_URL . 'assets/focus-keyword-inline-edit.js', ['jquery'], THINKRANK_VERSION, true ); // Enqueue the Quick Edit SEO modal (style + script + config) wp_enqueue_style( 'thinkrank-seo-quick-edit', THINKRANK_PLUGIN_URL . 'static/css/seo-quick-edit.css', [], THINKRANK_VERSION ); wp_enqueue_script( 'thinkrank-seo-quick-edit', THINKRANK_PLUGIN_URL . 'assets/seo-quick-edit.js', [], THINKRANK_VERSION, true ); wp_localize_script( 'thinkrank-seo-quick-edit', 'thinkrankSeoQuickEdit', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(\ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_nonce_action()), 'actionGet' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_get(), 'actionSave' => \ThinkRank\Admin\Seo_Quick_Edit_Ajax::get_ajax_action_save(), 'titleMax' => 60, 'descriptionMax' => 160, 'keywordLimit' => \ThinkRank\SEO\Focus_Keywords::limit(), 'i18n' => [ 'modalTitle' => __('Quick Edit SEO', 'thinkrank'), 'seoTitle' => __('SEO Title', 'thinkrank'), 'seoTitleHelp' => __('Keep it under 60 characters for best results.', 'thinkrank'), 'metaDescription' => __('Meta Description', 'thinkrank'), 'metaDescriptionHelp' => __('Keep it under 160 characters for best results.', 'thinkrank'), 'focusKeywords' => __('Focus Keywords', 'thinkrank'), /* translators: %d: maximum number of focus keywords */ 'focusKeywordsHelp' => __('Separate keywords with commas. Up to %d keywords.', 'thinkrank'), 'inheritedHint' => __('Leave empty to inherit from your Global SEO pattern.', 'thinkrank'), 'save' => __('Save', 'thinkrank'), 'saving' => __('Saving…', 'thinkrank'), 'cancel' => __('Cancel', 'thinkrank'), 'close' => __('Close', 'thinkrank'), 'editFull' => __('Open full editor', 'thinkrank'), 'loadError' => __('Could not load SEO fields. Please try again.', 'thinkrank'), 'saveError' => __('Could not save. Please try again.', 'thinkrank'), ], ] ); // Localize script with AJAX data wp_localize_script( 'thinkrank-focus-keyword-inline-edit', 'thinkrankFocusKeyword', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(\ThinkRank\Admin\Focus_Keyword_Ajax::get_nonce_action()), 'action' => \ThinkRank\Admin\Focus_Keyword_Ajax::get_ajax_action(), 'i18n' => [ 'notSet' => __('Not Set', 'thinkrank'), 'clickToEdit' => __('Click to edit', 'thinkrank'), 'pressEnter' => __('Press Enter to save, Esc to cancel', 'thinkrank'), 'saving' => __('Saving...', 'thinkrank'), 'saved' => __('Saved!', 'thinkrank'), 'error' => __('Error updating focus keyword. Please try again.', 'thinkrank'), ] ] ); } }