PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / importers / class-import-detector.php

class-import-detector.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/admin/importers/class-import-detector.php

352 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Import Detector
5 *
6 * Database-level detection of SEO plugins. Works even when source plugins
7 * are deactivated by querying meta tables and custom tables directly.
8 *
9 * @package ThinkRank\Admin\Importers
10 * @since 2.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin\Importers;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Import Detector Class
23 *
24 * @since 2.0.0
25 */
26 class Import_Detector {
27
28 /**
29 * Transient key for caching detection results
30 */
31 private const CACHE_KEY = 'thinkrank_import_detection';
32
33 /**
34 * Transient key for caching the native (ThinkRank's own data) counts
35 */
36 private const NATIVE_CACHE_KEY = 'thinkrank_export_detection';
37
38 /**
39 * Cache TTL in seconds (1 hour)
40 */
41 private const CACHE_TTL = 3600;
42
43 /**
44 * Plugin detection configurations
45 */
46 private const PLUGINS = [
47 'yoast' => [
48 'name' => 'Yoast SEO',
49 'meta_prefix' => '_yoast_wpseo_',
50 'option_keys' => ['wpseo', 'wpseo_titles', 'wpseo_social'],
51 'plugin_files' => ['wordpress-seo/wp-seo.php', 'wordpress-seo-premium/wp-seo-premium.php'],
52 ],
53 'rankmath' => [
54 'name' => 'Rank Math',
55 'meta_prefix' => 'rank_math_',
56 'option_keys' => ['rank-math-options-general', 'rank-math-options-titles'],
57 'plugin_files' => ['seo-by-rank-math/rank-math.php', 'seo-by-rank-math-pro/rank-math-pro.php'],
58 ],
59 'seopress' => [
60 'name' => 'SEOPress',
61 'meta_prefix' => '_seopress_',
62 // SEOPress option names all carry the `_option_name` suffix.
63 'option_keys' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name'],
64 'plugin_files' => ['wp-seopress/seopress.php', 'wp-seopress-pro/seopress-pro.php'],
65 ],
66 'aioseo' => [
67 'name' => 'All in One SEO',
68 'meta_prefix' => '',
69 'option_keys' => ['aioseo_options'],
70 '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'],
71 ],
72 ];
73
74 /**
75 * Detect all source plugins present in the database
76 *
77 * @param bool $use_cache Whether to use cached results
78 * @return array Detected plugins with item counts
79 */
80 public function detect(bool $use_cache = true): array {
81 if ($use_cache) {
82 $cached = get_transient(self::CACHE_KEY);
83 if ($cached !== false) {
84 return $cached;
85 }
86 }
87
88 $detected = [];
89
90 foreach (self::PLUGINS as $slug => $config) {
91 $result = $this->detect_plugin($slug, $config);
92 if ($result !== null) {
93 $detected[$slug] = $result;
94 }
95 }
96
97 set_transient(self::CACHE_KEY, $detected, self::CACHE_TTL);
98
99 return $detected;
100 }
101
102 /**
103 * Detect ThinkRank's own exportable data.
104 *
105 * Deliberately NOT part of the PLUGINS registry that detect() walks. That
106 * registry describes plugins to migrate FROM: every entry is gated on
107 * is_source_active() (which would reject us), feeds the "import from" cards
108 * in the UI, and — most importantly — drives cleanup()'s prefix maps, which
109 * delete the listed plugin's live data. Keeping the native source on its
110 * own path avoids all three problems instead of special-casing each.
111 *
112 * @param bool $use_cache Whether to use cached results
113 * @return array|null Detection result, or null when there is nothing to export
114 */
115 public function detect_native(bool $use_cache = true): ?array {
116 if ($use_cache) {
117 $cached = get_transient(self::NATIVE_CACHE_KEY);
118 if ($cached !== false) {
119 return is_array($cached) ? $cached : null;
120 }
121 }
122
123 $exporter = new Thinkrank_Exporter();
124 $counts = $exporter->get_available_types();
125
126 $result = [
127 'plugin' => $exporter->get_plugin_slug(),
128 'plugin_name' => $exporter->get_plugin_name(),
129 'counts' => $counts,
130 'total' => array_sum($counts),
131 ];
132
133 set_transient(self::NATIVE_CACHE_KEY, $result, self::CACHE_TTL);
134
135 return $result;
136 }
137
138 /**
139 * Clear the detection cache
140 *
141 * @return void
142 */
143 public function clear_cache(): void {
144 delete_transient(self::CACHE_KEY);
145 delete_transient(self::NATIVE_CACHE_KEY);
146 }
147
148 /**
149 * Detect a single plugin's data presence
150 *
151 * @param string $slug Plugin slug
152 * @param array $config Plugin configuration
153 * @return array|null Detection result or null if not found
154 */
155 private function detect_plugin(string $slug, array $config): ?array {
156 global $wpdb;
157
158 // Only surface source plugins that are currently installed AND active.
159 // Migrating from a plugin the user no longer runs isn't actionable, so
160 // leftover data from a deactivated/uninstalled plugin is intentionally
161 // excluded from the migration screen.
162 if (!$this->is_source_active($config['plugin_files'] ?? [])) {
163 return null;
164 }
165
166 $counts = [];
167 $total = 0;
168
169 if ($slug === 'aioseo') {
170 // AIOSEO uses a custom table
171 $counts = $this->detect_aioseo();
172 } else {
173 // Standard postmeta-based plugins
174 $prefix = $config['meta_prefix'];
175
176 // Count posts with this plugin's meta
177 $post_count = (int) $wpdb->get_var(
178 $wpdb->prepare(
179 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
180 $wpdb->esc_like($prefix) . '%'
181 )
182 );
183
184 if ($post_count > 0) {
185 $counts['postmeta'] = $post_count;
186 }
187
188 // Count terms with this plugin's meta
189 $term_count = (int) $wpdb->get_var(
190 $wpdb->prepare(
191 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
192 $wpdb->esc_like($prefix) . '%'
193 )
194 );
195
196 if ($term_count > 0) {
197 $counts['termmeta'] = $term_count;
198 }
199 }
200
201 // Redirections / 404 logs. The UI derives its type checkboxes from
202 // these counts, so a type missing here is invisible to the user even
203 // when the exporter supports it (Rank Math Pro's redirects + 404
204 // Monitor and Yoast Premium's redirects were never offered).
205 $counts = array_merge($counts, $this->detect_redirect_data($slug));
206
207 // Check for settings
208 $has_settings = false;
209 foreach ($config['option_keys'] as $option_key) {
210 if (get_option($option_key, null) !== null) {
211 $has_settings = true;
212 break;
213 }
214 }
215
216 if ($has_settings) {
217 $counts['settings'] = 1;
218 }
219
220 // Only return if we found something
221 if (empty($counts)) {
222 return null;
223 }
224
225 foreach ($counts as $count) {
226 $total += $count;
227 }
228
229 return [
230 'plugin' => $slug,
231 'plugin_name' => $config['name'],
232 'counts' => $counts,
233 'total' => $total,
234 ];
235 }
236
237 /**
238 * Whether any of a source plugin's known main files is active.
239 *
240 * Checks both single-site and network activation. A plugin that is merely
241 * installed but not active returns false.
242 *
243 * @param string[] $plugin_files Candidate plugin main files (free + pro).
244 * @return bool
245 */
246 private function is_source_active(array $plugin_files): bool {
247 if (empty($plugin_files)) {
248 return false;
249 }
250
251 if (!function_exists('is_plugin_active')) {
252 require_once ABSPATH . 'wp-admin/includes/plugin.php';
253 }
254
255 foreach ($plugin_files as $plugin_file) {
256 if (is_plugin_active($plugin_file)) {
257 return true;
258 }
259 }
260
261 return false;
262 }
263
264 /**
265 * Count a source plugin's redirect / 404-log stores, matching what its
266 * exporter reads.
267 *
268 * @param string $slug Plugin slug
269 * @return array Partial counts (redirections / 404_logs keys only)
270 */
271 private function detect_redirect_data(string $slug): array {
272 global $wpdb;
273
274 $counts = [];
275
276 $count_table = static function (string $suffix) use ($wpdb): int {
277 $table = $wpdb->prefix . $suffix;
278 if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) {
279 return 0;
280 }
281 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
282 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
283 };
284
285 if ($slug === 'rankmath') {
286 $redirects = $count_table('rank_math_redirections');
287 if ($redirects > 0) {
288 $counts['redirections'] = $redirects;
289 }
290 $logs = $count_table('rank_math_404_logs');
291 if ($logs > 0) {
292 $counts['404_logs'] = $logs;
293 }
294 } elseif ($slug === 'yoast') {
295 $redirects = $count_table('yoast_seo_redirects');
296 if ($redirects > 0) {
297 $counts['redirections'] = $redirects;
298 }
299 } elseif ($slug === 'seopress') {
300 $redirects = (int) $wpdb->get_var(
301 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'seopress_404'"
302 );
303 if ($redirects > 0) {
304 $counts['redirections'] = $redirects;
305 }
306 }
307
308 return $counts;
309 }
310
311 /**
312 * Detect AIOSEO custom table data
313 *
314 * @return array Counts array
315 */
316 private function detect_aioseo(): array {
317 global $wpdb;
318
319 $counts = [];
320 $table_name = $wpdb->prefix . 'aioseo_posts';
321
322 // Check if table exists
323 $table_exists = $wpdb->get_var(
324 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
325 );
326
327 if ($table_exists) {
328 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
329 $post_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
330 if ($post_count > 0) {
331 $counts['postmeta'] = $post_count;
332 }
333 }
334
335 // Check for redirections table
336 $redirects_table = $wpdb->prefix . 'aioseo_redirects';
337 $redirects_exists = $wpdb->get_var(
338 $wpdb->prepare("SHOW TABLES LIKE %s", $redirects_table)
339 );
340
341 if ($redirects_exists) {
342 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
343 $redirect_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$redirects_table}");
344 if ($redirect_count > 0) {
345 $counts['redirections'] = $redirect_count;
346 }
347 }
348
349 return $counts;
350 }
351 }
352