# thinkrank/trunk/includes/admin/importers/class-import-detector.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version trunk. 432 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/trunk/code/includes/admin/importers/class-import-detector.php
- Raw: https://pluginprobe.com/plugins/thinkrank/trunk/raw/includes/admin/importers/class-import-detector.php
- Modified: 2026-09-27T11:16:58+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/trunk/code/includes/admin/importers/class-import-detector.php#L10-L20`.

```php
<?php

/**
 * Import Detector
 *
 * Database-level detection of SEO plugins. Works even when source plugins
 * are deactivated by querying meta tables and custom tables directly.
 *
 * @package ThinkRank\Admin\Importers
 * @since 2.0.0
 */

declare(strict_types=1);

namespace ThinkRank\Admin\Importers;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * Import Detector Class
 *
 * @since 2.0.0
 */
class Import_Detector {

    /**
     * Transient key for caching detection results
     */
    private const CACHE_KEY = 'thinkrank_import_detection';

    /**
     * Transient key for caching the native (ThinkRank's own data) counts
     */
    private const NATIVE_CACHE_KEY = 'thinkrank_export_detection';

    /**
     * Cache TTL in seconds (1 hour)
     */
    private const CACHE_TTL = 3600;

    /**
     * Plugin detection configurations
     */
    private const PLUGINS = [
        'yoast' => [
            '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;
    }
}

```
