PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 trunk 1.0.0 1.0.1 All 51 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 trunk, at includes/admin/importers/class-import-detector.php

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