[ 'name' => 'Yoast SEO', 'meta_prefix' => '_yoast_wpseo_', 'option_keys' => ['wpseo', 'wpseo_titles', 'wpseo_social'], 'plugin_files' => ['wordpress-seo/wp-seo.php', 'wordpress-seo-premium/wp-seo-premium.php'], ], 'rankmath' => [ 'name' => 'Rank Math', 'meta_prefix' => 'rank_math_', 'option_keys' => ['rank-math-options-general', 'rank-math-options-titles'], 'plugin_files' => ['seo-by-rank-math/rank-math.php', 'seo-by-rank-math-pro/rank-math-pro.php'], ], 'seopress' => [ 'name' => 'SEOPress', 'meta_prefix' => '_seopress_', // SEOPress option names all carry the `_option_name` suffix. 'option_keys' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name'], 'plugin_files' => ['wp-seopress/seopress.php', 'wp-seopress-pro/seopress-pro.php'], ], 'aioseo' => [ 'name' => 'All in One SEO', 'meta_prefix' => '', 'option_keys' => ['aioseo_options'], 'plugin_files' => ['all-in-one-seo-pack/all_in_one_seo_pack.php', 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php'], ], 'squirrly' => [ 'name' => 'Squirrly SEO', // Squirrly keeps SEO in its own `qss` table, not postmeta (#740); // `_sq_` only covers its few fallback meta keys. 'meta_prefix' => '_sq_', 'option_keys' => ['sq_options'], 'plugin_files' => ['squirrly-seo/squirrly.php'], ], ]; /** * Detect all source plugins present in the database * * @param bool $use_cache Whether to use cached results * @return array Detected plugins with item counts */ public function detect(bool $use_cache = true): array { if ($use_cache) { $cached = get_transient(self::CACHE_KEY); if ($cached !== false) { return $cached; } } $detected = []; foreach (self::PLUGINS as $slug => $config) { $result = $this->detect_plugin($slug, $config); if ($result !== null) { $detected[$slug] = $result; } } set_transient(self::CACHE_KEY, $detected, self::CACHE_TTL); return $detected; } /** * Detect ThinkRank's own exportable data. * * Deliberately NOT part of the PLUGINS registry that detect() walks. That * registry describes plugins to migrate FROM: every entry is gated on * is_source_active() (which would reject us), feeds the "import from" cards * in the UI, and — most importantly — drives cleanup()'s prefix maps, which * delete the listed plugin's live data. Keeping the native source on its * own path avoids all three problems instead of special-casing each. * * @param bool $use_cache Whether to use cached results * @return array|null Detection result, or null when there is nothing to export */ public function detect_native(bool $use_cache = true): ?array { if ($use_cache) { $cached = get_transient(self::NATIVE_CACHE_KEY); if ($cached !== false) { return is_array($cached) ? $cached : null; } } $exporter = new Thinkrank_Exporter(); $counts = $exporter->get_available_types(); $result = [ 'plugin' => $exporter->get_plugin_slug(), 'plugin_name' => $exporter->get_plugin_name(), 'counts' => $counts, 'total' => array_sum($counts), ]; set_transient(self::NATIVE_CACHE_KEY, $result, self::CACHE_TTL); return $result; } /** * Clear the detection cache * * @return void */ public function clear_cache(): void { delete_transient(self::CACHE_KEY); delete_transient(self::NATIVE_CACHE_KEY); } /** * Detect a single plugin's data presence * * @param string $slug Plugin slug * @param array $config Plugin configuration * @return array|null Detection result or null if not found */ private function detect_plugin(string $slug, array $config): ?array { global $wpdb; // Only surface source plugins that are currently installed AND active. // Migrating from a plugin the user no longer runs isn't actionable, so // leftover data from a deactivated/uninstalled plugin is intentionally // excluded from the migration screen. // // The one exception is Rank Math's FAQ / HowTo blocks. Those live in // post_content, and deactivating Rank Math is precisely what breaks // them: the blocks turn into "unsupported block" in the editor and lose // their schema on the front end. Hiding the only screen that can repair // them, at the exact moment they need repairing, left users with no way // out at all (#777) — so an inactive Rank Math stays listed whenever // convertible blocks remain, carrying that type and nothing else. if (!$this->is_source_active($config['plugin_files'] ?? [])) { return $this->detect_orphaned_blocks($slug, $config); } $counts = []; $total = 0; if ($slug === 'aioseo') { // AIOSEO uses a custom table $counts = $this->detect_aioseo(); } elseif ($slug === 'squirrly') { // Squirrly uses a custom table too; the exporter classifies rows. $counts = $this->detect_squirrly(); } else { // Standard postmeta-based plugins $prefix = $config['meta_prefix']; // Count posts with this plugin's meta $post_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s", $wpdb->esc_like($prefix) . '%' ) ); if ($post_count > 0) { $counts['postmeta'] = $post_count; } // Count terms with this plugin's meta $term_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s", $wpdb->esc_like($prefix) . '%' ) ); if ($term_count > 0) { $counts['termmeta'] = $term_count; } } // Redirections / 404 logs. The UI derives its type checkboxes from // these counts, so a type missing here is invisible to the user even // when the exporter supports it (Rank Math Pro's redirects + 404 // Monitor and Yoast Premium's redirects were never offered). $counts = array_merge($counts, $this->detect_redirect_data($slug)); // FAQ / HowTo blocks, offered while Rank Math is still active too, so // the wizard converts them on the way out rather than leaving the user // to discover the breakage after deactivation. if ('rankmath' === $slug) { $block_count = Block_Converter::count_posts(); if ($block_count > 0) { $counts[Block_Converter::TYPE] = $block_count; } } // Check for settings $has_settings = false; foreach ($config['option_keys'] as $option_key) { if (get_option($option_key, null) !== null) { $has_settings = true; break; } } if ($has_settings) { $counts['settings'] = 1; } // Only return if we found something if (empty($counts)) { return null; } foreach ($counts as $count) { $total += $count; } return [ 'plugin' => $slug, 'plugin_name' => $config['name'], 'counts' => $counts, 'total' => $total, ]; } /** * Detection result for a deactivated source that has left convertible * content blocks behind, or null when there is nothing to repair. * * Only the block type is reported. The plugin's postmeta and settings are * deliberately still withheld: migrating those from a plugin the site no * longer runs is the case the active-only rule exists to prevent, and it is * not what broke. * * @param string $slug Plugin slug. * @param array $config Plugin configuration. * @return array|null */ private function detect_orphaned_blocks(string $slug, array $config): ?array { if ('rankmath' !== $slug) { return null; } $count = Block_Converter::count_posts(); if ($count < 1) { return null; } return [ 'plugin' => $slug, 'plugin_name' => $config['name'], 'counts' => [Block_Converter::TYPE => $count], 'total' => $count, ]; } /** * Whether any of a source plugin's known main files is active. * * Checks both single-site and network activation. A plugin that is merely * installed but not active returns false. * * @param string[] $plugin_files Candidate plugin main files (free + pro). * @return bool */ private function is_source_active(array $plugin_files): bool { if (empty($plugin_files)) { return false; } if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ($plugin_files as $plugin_file) { if (is_plugin_active($plugin_file)) { return true; } } return false; } /** * Count a source plugin's redirect / 404-log stores, matching what its * exporter reads. * * @param string $slug Plugin slug * @return array Partial counts (redirections / 404_logs keys only) */ private function detect_redirect_data(string $slug): array { global $wpdb; $counts = []; $count_table = static function (string $suffix) use ($wpdb): int { $table = $wpdb->prefix . $suffix; if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) { return 0; } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); }; if ($slug === 'rankmath') { $redirects = $count_table('rank_math_redirections'); if ($redirects > 0) { $counts['redirections'] = $redirects; } $logs = $count_table('rank_math_404_logs'); if ($logs > 0) { $counts['404_logs'] = $logs; } } elseif ($slug === 'yoast') { $redirects = $count_table('yoast_seo_redirects'); if ($redirects > 0) { $counts['redirections'] = $redirects; } } elseif ($slug === 'seopress') { $redirects = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'seopress_404'" ); if ($redirects > 0) { $counts['redirections'] = $redirects; } } // Squirrly's Advanced Pack redirects are counted by the exporter in // detect_squirrly(), since only its `url` actions are redirects. return $counts; } /** * Detect Squirrly SEO data (custom `qss` table + Advanced Pack redirects). * * Delegated to the exporter: the table mixes posts, terms, author * profiles and the homepage, and only the exporter knows how to tell * them apart from the serialized `post` column. * * @return array Counts array */ private function detect_squirrly(): array { $exporter = new Squirrly_Exporter(); $counts = $exporter->get_available_types(); // Settings are added by the caller from option_keys. unset($counts['settings']); return $counts; } /** * Detect AIOSEO custom table data * * @return array Counts array */ private function detect_aioseo(): array { global $wpdb; $counts = []; $table_name = $wpdb->prefix . 'aioseo_posts'; // Check if table exists $table_exists = $wpdb->get_var( $wpdb->prepare("SHOW TABLES LIKE %s", $table_name) ); if ($table_exists) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement. $post_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}"); if ($post_count > 0) { $counts['postmeta'] = $post_count; } } // Check for redirections table $redirects_table = $wpdb->prefix . 'aioseo_redirects'; $redirects_exists = $wpdb->get_var( $wpdb->prepare("SHOW TABLES LIKE %s", $redirects_table) ); if ($redirects_exists) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement. $redirect_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$redirects_table}"); if ($redirect_count > 0) { $counts['redirections'] = $redirect_count; } } return $counts; } }