PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 54 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.13.0, at includes/admin/importers/class-import-detector.php

457 lines 15.5 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 'squirrly' => [
73 'name' => 'Squirrly SEO',
74 // Squirrly keeps SEO in its own `qss` table, not postmeta (#740);
75 // `_sq_` only covers its few fallback meta keys.
76 'meta_prefix' => '_sq_',
77 'option_keys' => ['sq_options'],
78 'plugin_files' => ['squirrly-seo/squirrly.php'],
79 ],
80 'slimseo' => [
81 'name' => 'Slim SEO',
82 // One `slim_seo` meta array per post/term, plus primary-term keys
83 // and option-stored redirects (#886) — counted by the exporter.
84 'meta_prefix' => 'slim_seo',
85 'option_keys' => ['slim_seo', 'ss_redirects', 'slim_seo_pro', 'slim_seo_schemas'],
86 'plugin_files' => ['slim-seo/slim-seo.php', 'slim-seo-pro/slim-seo-pro.php'],
87 ],
88 ];
89
90 /**
91 * Detect all source plugins present in the database
92 *
93 * @param bool $use_cache Whether to use cached results
94 * @return array Detected plugins with item counts
95 */
96 public function detect(bool $use_cache = true): array {
97 if ($use_cache) {
98 $cached = get_transient(self::CACHE_KEY);
99 if ($cached !== false) {
100 return $cached;
101 }
102 }
103
104 $detected = [];
105
106 foreach (self::PLUGINS as $slug => $config) {
107 $result = $this->detect_plugin($slug, $config);
108 if ($result !== null) {
109 $detected[$slug] = $result;
110 }
111 }
112
113 set_transient(self::CACHE_KEY, $detected, self::CACHE_TTL);
114
115 return $detected;
116 }
117
118 /**
119 * Detect ThinkRank's own exportable data.
120 *
121 * Deliberately NOT part of the PLUGINS registry that detect() walks. That
122 * registry describes plugins to migrate FROM: every entry is gated on
123 * is_source_active() (which would reject us), feeds the "import from" cards
124 * in the UI, and — most importantly — drives cleanup()'s prefix maps, which
125 * delete the listed plugin's live data. Keeping the native source on its
126 * own path avoids all three problems instead of special-casing each.
127 *
128 * @param bool $use_cache Whether to use cached results
129 * @return array|null Detection result, or null when there is nothing to export
130 */
131 public function detect_native(bool $use_cache = true): ?array {
132 if ($use_cache) {
133 $cached = get_transient(self::NATIVE_CACHE_KEY);
134 if ($cached !== false) {
135 return is_array($cached) ? $cached : null;
136 }
137 }
138
139 $exporter = new Thinkrank_Exporter();
140 $counts = $exporter->get_available_types();
141
142 $result = [
143 'plugin' => $exporter->get_plugin_slug(),
144 'plugin_name' => $exporter->get_plugin_name(),
145 'counts' => $counts,
146 'total' => array_sum($counts),
147 ];
148
149 set_transient(self::NATIVE_CACHE_KEY, $result, self::CACHE_TTL);
150
151 return $result;
152 }
153
154 /**
155 * Clear the detection cache
156 *
157 * @return void
158 */
159 public function clear_cache(): void {
160 delete_transient(self::CACHE_KEY);
161 delete_transient(self::NATIVE_CACHE_KEY);
162 }
163
164 /**
165 * Detect a single plugin's data presence
166 *
167 * @param string $slug Plugin slug
168 * @param array $config Plugin configuration
169 * @return array|null Detection result or null if not found
170 */
171 private function detect_plugin(string $slug, array $config): ?array {
172 global $wpdb;
173
174 // Only surface source plugins that are currently installed AND active.
175 // Migrating from a plugin the user no longer runs isn't actionable, so
176 // leftover data from a deactivated/uninstalled plugin is intentionally
177 // excluded from the migration screen.
178 //
179 // The one exception is Rank Math's FAQ / HowTo blocks. Those live in
180 // post_content, and deactivating Rank Math is precisely what breaks
181 // them: the blocks turn into "unsupported block" in the editor and lose
182 // their schema on the front end. Hiding the only screen that can repair
183 // them, at the exact moment they need repairing, left users with no way
184 // out at all (#777) — so an inactive Rank Math stays listed whenever
185 // convertible blocks remain, carrying that type and nothing else.
186 if (!$this->is_source_active($config['plugin_files'] ?? [])) {
187 return $this->detect_orphaned_blocks($slug, $config);
188 }
189
190 $counts = [];
191 $total = 0;
192
193 if ($slug === 'aioseo') {
194 // AIOSEO uses a custom table
195 $counts = $this->detect_aioseo();
196 } elseif ($slug === 'squirrly') {
197 // Squirrly uses a custom table too; the exporter classifies rows.
198 $counts = $this->detect_squirrly();
199 } elseif ($slug === 'slimseo') {
200 // Slim SEO's redirects and 404 log live outside postmeta, and its
201 // primary terms under a second key; the exporter counts all of it.
202 $counts = $this->detect_slimseo();
203 } else {
204 // Standard postmeta-based plugins
205 $prefix = $config['meta_prefix'];
206
207 // Count posts with this plugin's meta
208 $post_count = (int) $wpdb->get_var(
209 $wpdb->prepare(
210 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
211 $wpdb->esc_like($prefix) . '%'
212 )
213 );
214
215 if ($post_count > 0) {
216 $counts['postmeta'] = $post_count;
217 }
218
219 // Count terms with this plugin's meta
220 $term_count = (int) $wpdb->get_var(
221 $wpdb->prepare(
222 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
223 $wpdb->esc_like($prefix) . '%'
224 )
225 );
226
227 if ($term_count > 0) {
228 $counts['termmeta'] = $term_count;
229 }
230 }
231
232 // Redirections / 404 logs. The UI derives its type checkboxes from
233 // these counts, so a type missing here is invisible to the user even
234 // when the exporter supports it (Rank Math Pro's redirects + 404
235 // Monitor and Yoast Premium's redirects were never offered).
236 $counts = array_merge($counts, $this->detect_redirect_data($slug));
237
238 // FAQ / HowTo blocks, offered while Rank Math is still active too, so
239 // the wizard converts them on the way out rather than leaving the user
240 // to discover the breakage after deactivation.
241 if ('rankmath' === $slug) {
242 $block_count = Block_Converter::count_posts();
243 if ($block_count > 0) {
244 $counts[Block_Converter::TYPE] = $block_count;
245 }
246 }
247
248 // Check for settings
249 $has_settings = false;
250 foreach ($config['option_keys'] as $option_key) {
251 if (get_option($option_key, null) !== null) {
252 $has_settings = true;
253 break;
254 }
255 }
256
257 if ($has_settings) {
258 $counts['settings'] = 1;
259 }
260
261 // Only return if we found something
262 if (empty($counts)) {
263 return null;
264 }
265
266 foreach ($counts as $count) {
267 $total += $count;
268 }
269
270 return [
271 'plugin' => $slug,
272 'plugin_name' => $config['name'],
273 'counts' => $counts,
274 'total' => $total,
275 ];
276 }
277
278 /**
279 * Detection result for a deactivated source that has left convertible
280 * content blocks behind, or null when there is nothing to repair.
281 *
282 * Only the block type is reported. The plugin's postmeta and settings are
283 * deliberately still withheld: migrating those from a plugin the site no
284 * longer runs is the case the active-only rule exists to prevent, and it is
285 * not what broke.
286 *
287 * @param string $slug Plugin slug.
288 * @param array $config Plugin configuration.
289 * @return array|null
290 */
291 private function detect_orphaned_blocks(string $slug, array $config): ?array {
292 if ('rankmath' !== $slug) {
293 return null;
294 }
295
296 $count = Block_Converter::count_posts();
297 if ($count < 1) {
298 return null;
299 }
300
301 return [
302 'plugin' => $slug,
303 'plugin_name' => $config['name'],
304 'counts' => [Block_Converter::TYPE => $count],
305 'total' => $count,
306 ];
307 }
308
309 /**
310 * Whether any of a source plugin's known main files is active.
311 *
312 * Checks both single-site and network activation. A plugin that is merely
313 * installed but not active returns false.
314 *
315 * @param string[] $plugin_files Candidate plugin main files (free + pro).
316 * @return bool
317 */
318 private function is_source_active(array $plugin_files): bool {
319 if (empty($plugin_files)) {
320 return false;
321 }
322
323 if (!function_exists('is_plugin_active')) {
324 require_once ABSPATH . 'wp-admin/includes/plugin.php';
325 }
326
327 foreach ($plugin_files as $plugin_file) {
328 if (is_plugin_active($plugin_file)) {
329 return true;
330 }
331 }
332
333 return false;
334 }
335
336 /**
337 * Count a source plugin's redirect / 404-log stores, matching what its
338 * exporter reads.
339 *
340 * @param string $slug Plugin slug
341 * @return array Partial counts (redirections / 404_logs keys only)
342 */
343 private function detect_redirect_data(string $slug): array {
344 global $wpdb;
345
346 $counts = [];
347
348 $count_table = static function (string $suffix) use ($wpdb): int {
349 $table = $wpdb->prefix . $suffix;
350 if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) {
351 return 0;
352 }
353 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
354 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
355 };
356
357 if ($slug === 'rankmath') {
358 $redirects = $count_table('rank_math_redirections');
359 if ($redirects > 0) {
360 $counts['redirections'] = $redirects;
361 }
362 $logs = $count_table('rank_math_404_logs');
363 if ($logs > 0) {
364 $counts['404_logs'] = $logs;
365 }
366 } elseif ($slug === 'yoast') {
367 $redirects = $count_table('yoast_seo_redirects');
368 if ($redirects > 0) {
369 $counts['redirections'] = $redirects;
370 }
371 } elseif ($slug === 'seopress') {
372 $redirects = (int) $wpdb->get_var(
373 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'seopress_404'"
374 );
375 if ($redirects > 0) {
376 $counts['redirections'] = $redirects;
377 }
378 }
379 // Squirrly's Advanced Pack redirects are counted by the exporter in
380 // detect_squirrly(), since only its `url` actions are redirects.
381
382 return $counts;
383 }
384
385 /**
386 * Detect Squirrly SEO data (custom `qss` table + Advanced Pack redirects).
387 *
388 * Delegated to the exporter: the table mixes posts, terms, author
389 * profiles and the homepage, and only the exporter knows how to tell
390 * them apart from the serialized `post` column.
391 *
392 * @return array Counts array
393 */
394 private function detect_squirrly(): array {
395 $exporter = new Squirrly_Exporter();
396 $counts = $exporter->get_available_types();
397 // Settings are added by the caller from option_keys.
398 unset($counts['settings']);
399
400 return $counts;
401 }
402
403 /**
404 * Detect Slim SEO data: per-object meta, redirects and the 404 log.
405 *
406 * @return array Counts array
407 */
408 private function detect_slimseo(): array {
409 $counts = (new Slim_SEO_Exporter())->get_available_types();
410 // Settings are added by the caller from option_keys.
411 unset($counts['settings']);
412
413 return $counts;
414 }
415
416 /**
417 * Detect AIOSEO custom table data
418 *
419 * @return array Counts array
420 */
421 private function detect_aioseo(): array {
422 global $wpdb;
423
424 $counts = [];
425 $table_name = $wpdb->prefix . 'aioseo_posts';
426
427 // Check if table exists
428 $table_exists = $wpdb->get_var(
429 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
430 );
431
432 if ($table_exists) {
433 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
434 $post_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
435 if ($post_count > 0) {
436 $counts['postmeta'] = $post_count;
437 }
438 }
439
440 // Check for redirections table
441 $redirects_table = $wpdb->prefix . 'aioseo_redirects';
442 $redirects_exists = $wpdb->get_var(
443 $wpdb->prepare("SHOW TABLES LIKE %s", $redirects_table)
444 );
445
446 if ($redirects_exists) {
447 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
448 $redirect_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$redirects_table}");
449 if ($redirect_count > 0) {
450 $counts['redirections'] = $redirect_count;
451 }
452 }
453
454 return $counts;
455 }
456 }
457