PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / redirections / class-metasync-redirection-importer.php

class-metasync-redirection-importer.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at redirections/class-metasync-redirection-importer.php

1,432 lines 53.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 redirections from other SEO plugins.
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 * @package Metasync
9 * @subpackage Metasync/redirections
10 * @author Engineering Team <[email protected]>
11 */
12
13 # Abort if this file is accessed directly.
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 if (!class_exists('Metasync_Redirection_Validator')) {
19 require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php';
20 }
21
22 class Metasync_Redirection_Importer
23 {
24 /**
25 * Maximum rows accepted from one CSV import. A malformed or oversized
26 * export must not turn into an unbounded loop or a memory blow-up.
27 */
28 const CSV_MAX_ROWS = 20000;
29
30 private $db_redirection;
31
32 private $redirection_helper = null;
33
34 /**
35 * @var array<string, true>|null Memoized exact-key index of stored sources.
36 */
37 private $source_index_cache = null;
38
39 /**
40 * Supported plugins for import
41 */
42 const SUPPORTED_PLUGINS = [
43 'redirection' => [
44 'name' => 'Redirection',
45 'slug' => 'redirection/redirection.php',
46 'table' => 'redirection_items'
47 ],
48 'yoast' => [
49 'name' => 'Yoast SEO Premium',
50 'slugs' => [
51 'wordpress-seo-premium/wp-seo-premium.php',
52 'wordpress-seo-premium-main/wp-seo-premium.php',
53 'wordpress-seo/wp-seo.php'
54 ],
55 'options' => [
56 'wpseo-premium-redirects-base',
57 'wpseo-premium-redirects-export-plain',
58 'wpseo-premium-redirects-export-regex',
59 'wpseo-premium-redirects',
60 'wpseo-premium-redirects-regex'
61 ]
62 ],
63 'rankmath' => [
64 'name' => 'Rank Math',
65 'slug' => 'seo-by-rank-math/rank-math.php',
66 'table' => 'rank_math_redirections'
67 ],
68 'aioseo' => [
69 'name' => 'All in One SEO',
70 'slug' => 'all-in-one-seo-pack/all_in_one_seo_pack.php',
71 'table' => 'aioseo_redirects'
72 ],
73 'simple301' => [
74 'name' => 'Simple 301 Redirects',
75 'slug' => 'simple-301-redirects/simple-301-redirects.php',
76 'option' => '301_redirects'
77 ]
78 ];
79
80 public function __construct(&$db_redirection)
81 {
82 $this->db_redirection = $db_redirection;
83 }
84
85 /**
86 * Lazy-load the Metasync_Redirection helper used for loop detection.
87 */
88 private function get_redirection_helper()
89 {
90 if ($this->redirection_helper === null) {
91 require_once dirname(__FILE__) . '/class-metasync-redirection.php';
92 $this->redirection_helper = new Metasync_Redirection($this->db_redirection);
93 }
94 return $this->redirection_helper;
95 }
96
97 /**
98 * Get list of available plugins for import
99 *
100 * @return array List of plugins with their status
101 */
102 public function get_available_plugins()
103 {
104 global $wpdb;
105 $available = [];
106
107 foreach (self::SUPPORTED_PLUGINS as $key => $plugin) {
108 $status = [
109 'key' => $key,
110 'name' => $plugin['name'],
111 'installed' => false,
112 'has_data' => false,
113 'count' => 0
114 ];
115
116 # Check if plugin is installed/active
117 if (isset($plugin['slug'])) {
118 $status['installed'] = is_plugin_active($plugin['slug']) || $this->plugin_exists($plugin['slug']);
119 } elseif (isset($plugin['slugs'])) {
120 # Check multiple possible slugs (for Yoast)
121 foreach ($plugin['slugs'] as $slug) {
122 if (is_plugin_active($slug) || $this->plugin_exists($slug)) {
123 $status['installed'] = true;
124 break;
125 }
126 }
127 }
128
129 # Check if data exists. Table names come only from the fixed
130 # SUPPORTED_PLUGINS allowlist above ('redirection_items',
131 # 'rank_math_redirections', 'aioseo_redirects').
132 if (isset($plugin['table']) && in_array($plugin['table'], ['redirection_items', 'rank_math_redirections', 'aioseo_redirects'], true)) {
133 $table_name = $wpdb->prefix . $plugin['table'];
134 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) === $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
135 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name verified against the literal allowlist above
136 $count = $wpdb->get_var('SELECT COUNT(*) FROM ' . $wpdb->prefix . $plugin['table']);
137 if ($count > 0) {
138 $status['has_data'] = true;
139 $status['count'] = (int)$count;
140 }
141 }
142 } elseif (isset($plugin['option'])) {
143 $option_data = get_option($plugin['option'], []);
144 if (!empty($option_data)) {
145 $status['has_data'] = true;
146 $status['count'] = is_array($option_data) ? count($option_data) : 0;
147 }
148 } elseif (isset($plugin['options'])) {
149 # Check multiple options (for Yoast)
150 $total_count = 0;
151 foreach ($plugin['options'] as $option_name) {
152 $option_data = get_option($option_name, []);
153 if (!empty($option_data)) {
154 $status['has_data'] = true;
155 if (is_array($option_data)) {
156 $total_count += count($option_data);
157 }
158 }
159 }
160 if ($total_count > 0) {
161 $status['count'] = $total_count;
162 }
163 }
164
165 $available[] = $status;
166 }
167
168 return $available;
169 }
170
171 /**
172 * Check if plugin exists (even if not active)
173 */
174 private function plugin_exists($plugin_path)
175 {
176 $plugin_file = WP_PLUGIN_DIR . '/' . $plugin_path;
177 return file_exists($plugin_file);
178 }
179
180 /**
181 * Import redirections from specified plugin
182 *
183 * @param string $plugin Plugin key
184 * @return array Result with success status and message
185 */
186 public function import_from_plugin($plugin)
187 {
188 if (!isset(self::SUPPORTED_PLUGINS[$plugin])) {
189 return [
190 'success' => false,
191 'message' => 'Unknown plugin specified.',
192 'imported' => 0,
193 'skipped' => 0
194 ];
195 }
196
197 $method = "import_from_{$plugin}";
198 if (method_exists($this, $method)) {
199 return $this->$method();
200 }
201
202 return [
203 'success' => false,
204 'message' => 'Import method not implemented.',
205 'imported' => 0,
206 'skipped' => 0
207 ];
208 }
209
210 /**
211 * Import from Redirection plugin
212 */
213 private function import_from_redirection()
214 {
215 global $wpdb;
216 $table_name = $wpdb->prefix . 'redirection_items';
217
218 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
219 return [
220 'success' => false,
221 'message' => 'Redirection plugin table not found.',
222 'imported' => 0,
223 'skipped' => 0,
224 'loop_skipped' => 0
225 ];
226 }
227
228 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
229 $redirects = $wpdb->get_results("
230 SELECT * FROM {$wpdb->prefix}redirection_items
231 WHERE status = 'enabled'
232 ");
233
234 $imported = 0;
235 $skipped = 0;
236 $loop_skipped = 0;
237 $parse_skipped = 0;
238 $unsafe_skipped = 0;
239 $conditional_skipped = 0;
240
241 # Exact-key index of every source already stored. One SELECT plus one
242 # unserialize pass replaces the per-row LIKE rescan (quadratic on big
243 # tables) and stops substring false-positives (/a "matching" /about).
244 $existing_sources = $this->get_existing_source_index();
245
246 foreach ($redirects as $redirect) {
247 # Conditional redirects (query/referrer/agent/… matches) only fire
248 # under conditions MetaSync cannot represent. Importing them as
249 # unconditional redirects would silently change their semantics.
250 if (isset($redirect->match_type) && $redirect->match_type !== '' && $redirect->match_type !== 'url') {
251 $conditional_skipped++;
252 continue;
253 }
254
255 $http_code = intval($redirect->action_code ?? 301);
256
257 # action_data is a JSON envelope ('{"url":"…"}'), never a plain URL.
258 # Storing it raw produced redirects to a literal '{"url":…}' string.
259 $target_url = $this->extract_destination($redirect->action_data ?? '');
260
261 # For 410 and 451, target URL can be empty (they don't redirect)
262 if ($target_url === null || ($target_url === '' && !in_array($http_code, [410, 451]))) {
263 $parse_skipped++;
264 continue;
265 }
266
267 # Check if already exists (exact source-key match)
268 if (isset($existing_sources[$redirect->url])) {
269 $skipped++;
270 continue;
271 }
272
273 # Map Redirection plugin format to MetaSync format
274 $pattern_type = 'exact';
275 $regex_pattern = null;
276
277 # Check if it's a regex. Foreign patterns arrive unvalidated —
278 # screen them with the same ReDoS guard the admin form applies.
279 if (isset($redirect->regex) && $redirect->regex == 1) {
280 if (!Metasync_Redirection_Validator::is_regex_safe((string) $redirect->url)) {
281 $parse_skipped++;
282 continue;
283 }
284 $pattern_type = 'regex';
285 $regex_pattern = $redirect->url;
286 }
287
288 $sources = [
289 $redirect->url => $pattern_type
290 ];
291
292 $args = [
293 'sources_from' => serialize($sources),
294 'url_redirect_to' => $target_url,
295 'http_code' => $http_code,
296 'hits_count' => $redirect->hits ?? 0,
297 'status' => 'active',
298 'pattern_type' => $pattern_type,
299 'regex_pattern' => $regex_pattern,
300 'description' => 'Imported from Redirection plugin',
301 'created_at' => current_time('mysql'),
302 'updated_at' => current_time('mysql')
303 ];
304
305 if (!$this->is_safe_destination($target_url, $http_code)) {
306 $unsafe_skipped++;
307 continue;
308 }
309 $args['url_redirect_to'] = $target_url;
310
311 if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->url, $target_url) !== null) {
312 $loop_skipped++;
313 continue;
314 }
315
316 if ($this->db_redirection->add($args)) {
317 $imported++;
318 $existing_sources[$redirect->url] = true;
319 $this->remember_imported_source($redirect->url);
320 }
321 }
322
323 $message = "Successfully imported $imported redirections from Redirection plugin.";
324 if ($skipped > 0) {
325 $message .= " Skipped $skipped already-existing redirection(s).";
326 }
327 if ($parse_skipped > 0) {
328 $message .= " Skipped $parse_skipped redirection(s) with an unreadable destination.";
329 }
330 if ($unsafe_skipped > 0) {
331 $message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination.";
332 }
333 if ($conditional_skipped > 0) {
334 $message .= " Skipped $conditional_skipped conditional redirection(s); conditions are not supported.";
335 }
336 if ($loop_skipped > 0) {
337 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
338 }
339
340 return [
341 'success' => true,
342 'message' => $message,
343 'imported' => $imported,
344 'skipped' => $skipped,
345 'loop_skipped' => $loop_skipped,
346 'skipped_parse' => $parse_skipped,
347 'skipped_unsafe' => $unsafe_skipped,
348 'skipped_conditional' => $conditional_skipped
349 ];
350 }
351
352 /**
353 * Import from Yoast SEO Premium
354 *
355 * Yoast stores redirects in multiple options:
356 * - wpseo-premium-redirects-base (main storage since v3.1)
357 * - wpseo-premium-redirects-export-plain (plain redirects)
358 * - wpseo-premium-redirects-export-regex (regex redirects)
359 * - wpseo-premium-redirects (legacy plain)
360 * - wpseo-premium-redirects-regex (legacy regex)
361 */
362 private function import_from_yoast()
363 {
364 $imported = 0;
365 $skipped = 0;
366 $errors = [];
367 $loop_skipped = 0;
368
369 # Try to import from base option first (preferred method)
370 $base_redirects = get_option('wpseo-premium-redirects-base', []);
371
372 if (!empty($base_redirects) && is_array($base_redirects)) {
373 foreach ($base_redirects as $redirect) {
374 $result = $this->process_yoast_redirect_base($redirect);
375 if ($result === true) {
376 $imported++;
377 } elseif ($result === 'skipped') {
378 $skipped++;
379 } elseif ($result === 'loop_skipped') {
380 $loop_skipped++;
381 } else {
382 $errors[] = $result;
383 }
384 }
385 }
386
387 # If no base redirects, try export options
388 if ($imported === 0 && $skipped === 0) {
389 # Import plain redirects
390 $plain_redirects = get_option('wpseo-premium-redirects-export-plain', []);
391 if (!empty($plain_redirects) && is_array($plain_redirects)) {
392 foreach ($plain_redirects as $origin => $redirect_data) {
393 $result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain');
394 if ($result === true) {
395 $imported++;
396 } elseif ($result === 'skipped') {
397 $skipped++;
398 } elseif ($result === 'loop_skipped') {
399 $loop_skipped++;
400 } else {
401 $errors[] = $result;
402 }
403 }
404 }
405
406 # Import regex redirects
407 $regex_redirects = get_option('wpseo-premium-redirects-export-regex', []);
408 if (!empty($regex_redirects) && is_array($regex_redirects)) {
409 foreach ($regex_redirects as $origin => $redirect_data) {
410 $result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex');
411 if ($result === true) {
412 $imported++;
413 } elseif ($result === 'skipped') {
414 $skipped++;
415 } elseif ($result === 'loop_skipped') {
416 $loop_skipped++;
417 } else {
418 $errors[] = $result;
419 }
420 }
421 }
422 }
423
424 # If still no redirects, try legacy options
425 if ($imported === 0 && $skipped === 0) {
426 # Legacy plain redirects
427 $legacy_plain = get_option('wpseo-premium-redirects', []);
428 if (!empty($legacy_plain) && is_array($legacy_plain)) {
429 foreach ($legacy_plain as $origin => $redirect_data) {
430 $result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain');
431 if ($result === true) {
432 $imported++;
433 } elseif ($result === 'skipped') {
434 $skipped++;
435 } elseif ($result === 'loop_skipped') {
436 $loop_skipped++;
437 } else {
438 $errors[] = $result;
439 }
440 }
441 }
442
443 # Legacy regex redirects
444 $legacy_regex = get_option('wpseo-premium-redirects-regex', []);
445 if (!empty($legacy_regex) && is_array($legacy_regex)) {
446 foreach ($legacy_regex as $origin => $redirect_data) {
447 $result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex');
448 if ($result === true) {
449 $imported++;
450 } elseif ($result === 'skipped') {
451 $skipped++;
452 } elseif ($result === 'loop_skipped') {
453 $loop_skipped++;
454 } else {
455 $errors[] = $result;
456 }
457 }
458 }
459 }
460
461 # Return results
462 if ($imported === 0 && $skipped === 0 && $loop_skipped === 0) {
463 return [
464 'success' => false,
465 'message' => 'No Yoast redirections found. ' . (!empty($errors) ? implode(' ', array_slice($errors, 0, 2)) : ''),
466 'imported' => 0,
467 'skipped' => 0,
468 'loop_skipped' => 0,
469 'errors' => $errors
470 ];
471 }
472
473 $message = $imported > 0
474 ? "Successfully imported $imported redirections from Yoast SEO."
475 : "All redirections already exist. Skipped $skipped duplicates.";
476 if ($loop_skipped > 0) {
477 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
478 }
479
480 return [
481 'success' => true,
482 'message' => $message,
483 'imported' => $imported,
484 'skipped' => $skipped,
485 'loop_skipped' => $loop_skipped,
486 'errors' => $errors
487 ];
488 }
489
490 /**
491 * Process a redirect from Yoast base option
492 *
493 * @param mixed $redirect Redirect data from base option
494 * @return bool|string True on success, 'skipped' if exists, error message on failure
495 */
496 private function process_yoast_redirect_base($redirect)
497 {
498 try {
499 # Base format: ['origin' => string, 'url' => string, 'type' => int, 'format' => string]
500 if (!is_array($redirect)) {
501 return 'Invalid redirect format';
502 }
503
504 $origin = isset($redirect['origin']) ? trim($redirect['origin']) : '';
505 $target = isset($redirect['url']) ? trim($redirect['url']) : '';
506 $type = isset($redirect['type']) ? $redirect['type'] : 301;
507 $format = isset($redirect['format']) ? $redirect['format'] : 'plain';
508
509 # Validate origin
510 if (empty($origin)) {
511 return 'Empty origin URL';
512 }
513
514 # For 410 and 451, target URL can be empty (they don't redirect)
515 if (empty($target) && !in_array($type, [410, 451])) {
516 return 'Empty target URL for non-410/451 redirect';
517 }
518
519 # Check if already exists
520 if ($this->redirection_exists($origin)) {
521 return 'skipped';
522 }
523
524 # Determine pattern type. Foreign regex patterns get the same
525 # ReDoS screen the admin form applies.
526 if ($format === 'regex' && !Metasync_Redirection_Validator::is_regex_safe($origin)) {
527 return 'skipped';
528 }
529 $pattern_type = ($format === 'regex') ? 'regex' : 'exact';
530 $regex_pattern = ($format === 'regex') ? $origin : null;
531
532 $sources = [
533 $origin => $pattern_type
534 ];
535
536 $args = [
537 'sources_from' => serialize($sources),
538 'url_redirect_to' => $target,
539 'http_code' => intval($type),
540 'hits_count' => 0,
541 'status' => 'active',
542 'pattern_type' => $pattern_type,
543 'regex_pattern' => $regex_pattern,
544 'description' => 'Imported from Yoast SEO',
545 'created_at' => current_time('mysql'),
546 'updated_at' => current_time('mysql')
547 ];
548
549 if (!$this->is_safe_destination($target, intval($type))) {
550 return 'skipped';
551 }
552 $args['url_redirect_to'] = $target;
553
554 if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) {
555 return 'loop_skipped';
556 }
557
558 if ($this->db_redirection->add($args)) {
559 $this->remember_imported_source($origin);
560 return true;
561 }
562
563 return 'Failed to insert redirect';
564
565 } catch (Exception $e) {
566 return 'Exception: ' . $e->getMessage();
567 }
568 }
569
570 /**
571 * Process a redirect from Yoast export options
572 *
573 * @param string $origin Source URL
574 * @param mixed $redirect_data Redirect data from export option
575 * @param string $format Format type ('plain' or 'regex')
576 * @return bool|string True on success, 'skipped' if exists, error message on failure
577 */
578 private function process_yoast_redirect_export($origin, $redirect_data, $format)
579 {
580 try {
581 $origin = trim($origin);
582
583 # Export format: ['url' => string, 'type' => int]
584 if (is_array($redirect_data)) {
585 $target = isset($redirect_data['url']) ? trim($redirect_data['url']) : '';
586 $type = isset($redirect_data['type']) ? $redirect_data['type'] : 301;
587 } elseif (is_string($redirect_data)) {
588 # Simple string format (target URL only)
589 $target = trim($redirect_data);
590 $type = 301;
591 } else {
592 return 'Invalid redirect data format';
593 }
594
595 # Validate origin
596 if (empty($origin)) {
597 return 'Empty origin URL';
598 }
599
600 # For 410 and 451, target URL can be empty (they don't redirect)
601 if (empty($target) && !in_array($type, [410, 451])) {
602 return 'Empty target URL for non-410/451 redirect';
603 }
604
605 # Check if already exists
606 if ($this->redirection_exists($origin)) {
607 return 'skipped';
608 }
609
610 # Determine pattern type. Foreign regex patterns get the same
611 # ReDoS screen the admin form applies.
612 if ($format === 'regex' && !Metasync_Redirection_Validator::is_regex_safe($origin)) {
613 return 'skipped';
614 }
615 $pattern_type = ($format === 'regex') ? 'regex' : 'exact';
616 $regex_pattern = ($format === 'regex') ? $origin : null;
617
618 $sources = [
619 $origin => $pattern_type
620 ];
621
622 $args = [
623 'sources_from' => serialize($sources),
624 'url_redirect_to' => $target,
625 'http_code' => intval($type),
626 'hits_count' => 0,
627 'status' => 'active',
628 'pattern_type' => $pattern_type,
629 'regex_pattern' => $regex_pattern,
630 'description' => 'Imported from Yoast SEO',
631 'created_at' => current_time('mysql'),
632 'updated_at' => current_time('mysql')
633 ];
634
635 if (!$this->is_safe_destination($target, intval($type))) {
636 return 'skipped';
637 }
638 $args['url_redirect_to'] = $target;
639
640 if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) {
641 return 'loop_skipped';
642 }
643
644 if ($this->db_redirection->add($args)) {
645 $this->remember_imported_source($origin);
646 return true;
647 }
648
649 return 'Failed to insert redirect';
650
651 } catch (Exception $e) {
652 return 'Exception: ' . $e->getMessage();
653 }
654 }
655
656 /**
657 * Import from Rank Math
658 */
659 private function import_from_rankmath()
660 {
661 global $wpdb;
662 $table_name = $wpdb->prefix . 'rank_math_redirections';
663
664 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
665 return [
666 'success' => false,
667 'message' => 'Rank Math redirections table not found.',
668 'imported' => 0,
669 'skipped' => 0,
670 'loop_skipped' => 0,
671 'errors' => ['Table not found: ' . $table_name]
672 ];
673 }
674
675 # Get all redirections (not just active ones, Rank Math uses different status field)
676 $redirects = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}rank_math_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
677
678 if (empty($redirects)) {
679 return [
680 'success' => false,
681 'message' => 'No redirections found in Rank Math.',
682 'imported' => 0,
683 'skipped' => 0,
684 'loop_skipped' => 0,
685 'errors' => []
686 ];
687 }
688
689 $imported = 0;
690 $skipped = 0;
691 $errors = [];
692 $loop_skipped = 0;
693 $parse_skipped = 0;
694 $unsafe_skipped = 0;
695 $inactive_skipped = 0;
696
697 # Exact-key index of every source already stored — one pass instead of a
698 # per-row LIKE rescan, with no substring false-positives.
699 $existing_sources = $this->get_existing_source_index();
700
701 foreach ($redirects as $redirect) {
702 try {
703 # Rank Math keeps disabled redirects in the same table; only
704 # enabled rows must come across.
705 if (isset($redirect->status) && !in_array((string) $redirect->status, ['active', 'enabled', '1', ''], true)) {
706 $inactive_skipped++;
707 continue;
708 }
709
710 # Rank Math uses 'sources' field - it's a serialized PHP array of
711 # ['pattern' => …, 'comparison' => …] entries. Keep ALL of them:
712 # taking only the first silently dropped every extra source.
713 $row_sources = [];
714
715 if (isset($redirect->sources)) {
716 $unserialized = @maybe_unserialize($redirect->sources);
717
718 if (!is_array($unserialized) || empty($unserialized)) {
719 # Try as JSON before giving up on the blob
720 $parsed = json_decode((string) $redirect->sources, true);
721 if (json_last_error() === JSON_ERROR_NONE && is_array($parsed) && !empty($parsed)) {
722 $unserialized = $parsed;
723 }
724 }
725
726 if (is_array($unserialized) && !empty($unserialized)) {
727 foreach ($unserialized as $source_entry) {
728 if (is_array($source_entry)) {
729 if (empty($source_entry['pattern']) || !is_string($source_entry['pattern'])) {
730 continue;
731 }
732 $row_sources[trim($source_entry['pattern'])] = isset($source_entry['comparison'])
733 ? (string) $source_entry['comparison']
734 : 'exact';
735 } elseif (is_string($source_entry) && trim($source_entry) !== '') {
736 $row_sources[trim($source_entry)] = 'exact';
737 }
738 }
739 } elseif (is_string($unserialized) && trim($unserialized) !== '') {
740 # Direct string value
741 $row_sources[trim($unserialized)] = 'exact';
742 }
743 }
744
745 if (empty($row_sources) && isset($redirect->url_from) && trim((string) $redirect->url_from) !== '') {
746 $row_sources[trim((string) $redirect->url_from)] = 'exact';
747 }
748
749 if (empty($row_sources)) {
750 $errors[] = 'Empty source URL in redirect ID: ' . ($redirect->id ?? 'unknown');
751 $parse_skipped++;
752 continue;
753 }
754
755 # Map Rank Math comparison types to MetaSync pattern types
756 # Rank Math: exact, regex, contains, starts, ends
757 # MetaSync: exact, regex, contain, start, end
758 $pattern_type_map = [
759 'exact' => 'exact',
760 'regex' => 'regex',
761 'contains' => 'contain',
762 'starts' => 'start',
763 'ends' => 'end'
764 ];
765
766 # Drop sources that already exist; if every source is a duplicate
767 # the whole row is one, otherwise import the remainder.
768 $sources = [];
769 foreach ($row_sources as $source_pattern => $comparison_type) {
770 if (isset($existing_sources[$source_pattern])) {
771 continue;
772 }
773 $sources[$source_pattern] = isset($pattern_type_map[$comparison_type])
774 ? $pattern_type_map[$comparison_type]
775 : 'exact';
776 }
777
778 if (empty($sources)) {
779 $skipped++;
780 continue;
781 }
782
783 # Row-level pattern type only matters when there is a single source;
784 # with several, per-source types in the serialized map drive matching.
785 $pattern_type = count($sources) === 1 ? reset($sources) : 'exact';
786
787 # For regex patterns, store the first regex source as the pattern
788 $regex_pattern = null;
789 if ($pattern_type === 'regex') {
790 $regex_pattern = (string) key($sources);
791 if (!Metasync_Redirection_Validator::is_regex_safe($regex_pattern)) {
792 $errors[] = 'Skipped unsafe regex pattern: ' . $regex_pattern;
793 $parse_skipped++;
794 continue;
795 }
796 }
797
798 $source_url = (string) key($sources);
799
800 $target_url = $redirect->url_to ?? '';
801 $http_code = intval($redirect->header_code ?? 301);
802
803 # For 410 and 451, target URL can be empty (they don't redirect)
804 if (empty($target_url) && !in_array($http_code, [410, 451])) {
805 $errors[] = 'Empty target URL for source: ' . $source_url;
806 $parse_skipped++;
807 continue;
808 }
809
810 $args = [
811 'sources_from' => serialize($sources),
812 'url_redirect_to' => $target_url,
813 'http_code' => $http_code,
814 'hits_count' => $redirect->hits ?? 0,
815 'status' => 'active',
816 'pattern_type' => $pattern_type,
817 'regex_pattern' => $regex_pattern,
818 'description' => 'Imported from Rank Math',
819 'created_at' => current_time('mysql'),
820 'updated_at' => current_time('mysql')
821 ];
822
823 if (!$this->is_safe_destination($target_url, $http_code)) {
824 $unsafe_skipped++;
825 continue;
826 }
827 $args['url_redirect_to'] = $target_url;
828
829 if (!in_array($http_code, [410, 451])) {
830 foreach (array_keys($sources) as $loop_source) {
831 if ($this->get_redirection_helper()->validate_no_loop($loop_source, $target_url) !== null) {
832 $loop_skipped++;
833 continue 2;
834 }
835 }
836 }
837
838 $result = $this->db_redirection->add($args);
839
840 if ($result !== false && $result > 0) {
841 $imported++;
842 foreach (array_keys($sources) as $imported_source) {
843 $existing_sources[$imported_source] = true;
844 $this->remember_imported_source($imported_source);
845 }
846 } else {
847 $errors[] = 'Failed to insert redirect: ' . $source_url . ' -> ' . $target_url;
848 if ($wpdb->last_error) {
849 $errors[] = 'DB Error: ' . $wpdb->last_error;
850 }
851 }
852
853 } catch (Exception $e) {
854 $errors[] = 'Exception: ' . $e->getMessage();
855 $skipped++;
856 }
857 }
858
859 # Return success if we processed redirections (even if all skipped)
860 $has_results = ($imported + $skipped + $loop_skipped + $parse_skipped + $unsafe_skipped + $inactive_skipped) > 0;
861
862 $message = $imported > 0
863 ? "Successfully imported $imported redirections from Rank Math."
864 : ($skipped > 0
865 ? "All redirections already exist. Skipped $skipped duplicates."
866 : "No redirections found to import. " . (count($errors) > 0 ? implode(' ', array_slice($errors, 0, 2)) : ''));
867 if ($inactive_skipped > 0) {
868 $message .= " Skipped $inactive_skipped disabled redirection(s).";
869 }
870 if ($parse_skipped > 0) {
871 $message .= " Skipped $parse_skipped redirection(s) with unreadable source or destination.";
872 }
873 if ($unsafe_skipped > 0) {
874 $message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination.";
875 }
876 if ($loop_skipped > 0) {
877 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
878 }
879
880 return [
881 'success' => $has_results,
882 'message' => $message,
883 'imported' => $imported,
884 'skipped' => $skipped,
885 'loop_skipped' => $loop_skipped,
886 'skipped_parse' => $parse_skipped,
887 'skipped_unsafe' => $unsafe_skipped,
888 'skipped_inactive' => $inactive_skipped,
889 'errors' => $errors
890 ];
891 }
892
893 /**
894 * Import from All in One SEO
895 */
896 private function import_from_aioseo()
897 {
898 global $wpdb;
899 $table_name = $wpdb->prefix . 'aioseo_redirects';
900
901 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
902 return [
903 'success' => false,
904 'message' => 'All in One SEO redirects table not found.',
905 'imported' => 0,
906 'skipped' => 0,
907 'loop_skipped' => 0
908 ];
909 }
910
911 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
912 $redirects = $wpdb->get_results("
913 SELECT * FROM {$wpdb->prefix}aioseo_redirects
914 WHERE enabled = 1
915 ");
916
917 $imported = 0;
918 $skipped = 0;
919 $loop_skipped = 0;
920 $parse_skipped = 0;
921
922 foreach ($redirects as $redirect) {
923 $http_code = intval($redirect->redirect_code ?? 301);
924 $target_url = $redirect->target_url ?? '';
925
926 # For 410 and 451, target URL can be empty (they don't redirect)
927 if (empty($target_url) && !in_array($http_code, [410, 451])) {
928 $skipped++;
929 continue;
930 }
931
932 # Check if already exists
933 if ($this->redirection_exists($redirect->source_url)) {
934 $skipped++;
935 continue;
936 }
937
938 # Determine pattern type
939 $pattern_type = 'exact';
940 $regex_pattern = null;
941
942 if (isset($redirect->regex) && $redirect->regex == 1) {
943 if (!Metasync_Redirection_Validator::is_regex_safe((string) $redirect->source_url)) {
944 $parse_skipped++;
945 continue;
946 }
947 $pattern_type = 'regex';
948 $regex_pattern = $redirect->source_url;
949 }
950
951 $sources = [
952 $redirect->source_url => $pattern_type
953 ];
954
955 $args = [
956 'sources_from' => serialize($sources),
957 'url_redirect_to' => $target_url,
958 'http_code' => $http_code,
959 'hits_count' => $redirect->hits ?? 0,
960 'status' => 'active',
961 'pattern_type' => $pattern_type,
962 'regex_pattern' => $regex_pattern,
963 'description' => 'Imported from All in One SEO',
964 'created_at' => current_time('mysql'),
965 'updated_at' => current_time('mysql')
966 ];
967
968 if (!$this->is_safe_destination($target_url, $http_code)) {
969 $skipped++;
970 continue;
971 }
972 $args['url_redirect_to'] = $target_url;
973
974 if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->source_url, $target_url) !== null) {
975 $loop_skipped++;
976 continue;
977 }
978
979 if ($this->db_redirection->add($args)) {
980 $imported++;
981 $this->remember_imported_source($redirect->source_url);
982 }
983 }
984
985 $message = "Successfully imported $imported redirections from All in One SEO.";
986 if ($parse_skipped > 0) {
987 $message .= " Skipped $parse_skipped redirection(s) with an unreadable source or destination.";
988 }
989 if ($loop_skipped > 0) {
990 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
991 }
992
993 return [
994 'success' => true,
995 'message' => $message,
996 'imported' => $imported,
997 'skipped' => $skipped,
998 'loop_skipped' => $loop_skipped,
999 'skipped_parse' => $parse_skipped
1000 ];
1001 }
1002
1003 /**
1004 * Import from Simple 301 Redirects
1005 */
1006 private function import_from_simple301()
1007 {
1008 # Simple 301 Redirects stores in options
1009 $redirects = get_option('301_redirects', []);
1010
1011 if (empty($redirects)) {
1012 return [
1013 'success' => false,
1014 'message' => 'No Simple 301 Redirects found.',
1015 'imported' => 0,
1016 'skipped' => 0,
1017 'loop_skipped' => 0
1018 ];
1019 }
1020
1021 $imported = 0;
1022 $skipped = 0;
1023 $loop_skipped = 0;
1024
1025 foreach ($redirects as $old_url => $new_url) {
1026 # Skip empty entries
1027 if (empty($old_url) || empty($new_url)) {
1028 $skipped++;
1029 continue;
1030 }
1031
1032 # Check if already exists
1033 if ($this->redirection_exists($old_url)) {
1034 $skipped++;
1035 continue;
1036 }
1037
1038 $sources = [
1039 $old_url => 'exact'
1040 ];
1041
1042 $args = [
1043 'sources_from' => serialize($sources),
1044 'url_redirect_to' => $new_url,
1045 'http_code' => 301,
1046 'hits_count' => 0,
1047 'status' => 'active',
1048 'pattern_type' => 'exact',
1049 'regex_pattern' => null,
1050 'description' => 'Imported from Simple 301 Redirects',
1051 'created_at' => current_time('mysql'),
1052 'updated_at' => current_time('mysql')
1053 ];
1054
1055 if (!$this->is_safe_destination($new_url, 301)) {
1056 $skipped++;
1057 continue;
1058 }
1059 $args['url_redirect_to'] = $new_url;
1060
1061 if ($this->get_redirection_helper()->validate_no_loop($old_url, $new_url) !== null) {
1062 $loop_skipped++;
1063 continue;
1064 }
1065
1066 if ($this->db_redirection->add($args)) {
1067 $imported++;
1068 $this->remember_imported_source($old_url);
1069 }
1070 }
1071
1072 $message = "Successfully imported $imported redirections from Simple 301 Redirects.";
1073 if ($loop_skipped > 0) {
1074 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
1075 }
1076
1077 return [
1078 'success' => true,
1079 'message' => $message,
1080 'imported' => $imported,
1081 'skipped' => $skipped,
1082 'loop_skipped' => $loop_skipped
1083 ];
1084 }
1085
1086 /**
1087 * Reject off-site destination URLs at import time so attacker-controlled
1088 * exports cannot smuggle external redirects in via plugin migration.
1089 *
1090 * 410 and 451 entries have no destination, so they are always safe.
1091 *
1092 * @param string $url Destination URL from the source plugin.
1093 * @param int $http_code HTTP status the redirect will serve.
1094 * @return bool True when the destination is empty-by-design or resolves on-site.
1095 */
1096 private function is_safe_destination(&$url, $http_code)
1097 {
1098 if (in_array(intval($http_code), [410, 451], true)) {
1099 $url = '';
1100 return true;
1101 }
1102
1103 $url = (string) $url;
1104 if ($url === '') {
1105 return false;
1106 }
1107
1108 # Backslashes never belong in a URL, but browsers read them as path
1109 # separators: '/\evil.com' slips past wp_validate_redirect (parse_url
1110 # sees no host) and still navigates off-site. Reject outright.
1111 if (!Metasync_Redirection_Validator::is_safe_destination_syntax($url)) {
1112 return false;
1113 }
1114
1115 if (get_option('metasync_allow_external_redirects', 0)) {
1116 return true;
1117 }
1118
1119 return wp_validate_redirect($url, '') !== '';
1120 }
1121
1122 /**
1123 * Extract the destination URL from the Redirection plugin's action_data column.
1124 *
1125 * action_data is normally a JSON envelope ('{"url":"https://…"}'); older rows
1126 * and other action types store plain strings, arrays or serialized values.
1127 * Storing the raw envelope as the destination makes the redirect point at a
1128 * literal '{"url":…}' string, so parse the envelope and only accept a URL.
1129 *
1130 * @param mixed $action_data Raw action_data value from the source table.
1131 * @return string|null Destination URL ('' when absent), or null when unparseable.
1132 */
1133 private function extract_destination($action_data)
1134 {
1135 if ($action_data === null || $action_data === '') {
1136 return '';
1137 }
1138
1139 $value = $action_data;
1140
1141 if (is_string($value)) {
1142 $trimmed = trim($value);
1143 if ($trimmed === '') {
1144 return '';
1145 }
1146 if ($trimmed[0] === '{' || $trimmed[0] === '[') {
1147 $decoded = json_decode($trimmed, true);
1148 if (json_last_error() !== JSON_ERROR_NONE) {
1149 return null;
1150 }
1151 $value = $decoded;
1152 } elseif (str_starts_with($trimmed, 'a:') || str_starts_with($trimmed, 's:')) {
1153 $unserialized = @unserialize($trimmed, ['allowed_classes' => false]);
1154 $value = ($unserialized !== false || $trimmed === 'b:0;') ? $unserialized : null;
1155 } else {
1156 return $trimmed;
1157 }
1158 }
1159
1160 if (is_array($value)) {
1161 if (isset($value['url']) && is_string($value['url'])) {
1162 return trim($value['url']);
1163 }
1164 $first = reset($value);
1165 if (is_array($first) && isset($first['url']) && is_string($first['url'])) {
1166 return trim($first['url']);
1167 }
1168 if (is_string($first)) {
1169 return trim($first);
1170 }
1171 return null;
1172 }
1173
1174 return is_string($value) ? trim($value) : null;
1175 }
1176
1177 /**
1178 * Build an exact-key index of every source URL already stored in the
1179 * redirections table.
1180 *
1181 * One SELECT plus one unserialize pass replaces the previous per-row
1182 * `sources_from LIKE '%…%'` lookup, which rescanned the whole table for
1183 * every imported row (O(N²) on large tables) and matched substrings —
1184 * importing /a reported the existing /about as a duplicate.
1185 *
1186 * Legacy rows that predate keyed sources store a numeric list; for those
1187 * the VALUE is the source URL.
1188 *
1189 * @return array<string, true> Source URL/path keys.
1190 */
1191 private function get_existing_source_index()
1192 {
1193 if ($this->source_index_cache !== null) {
1194 return $this->source_index_cache;
1195 }
1196
1197 $index = [];
1198 $rows = $this->db_redirection->getAllRecords();
1199 if (empty($rows)) {
1200 return $index;
1201 }
1202
1203 foreach ($rows as $row) {
1204 $sources = !empty($row->sources_from)
1205 ? @unserialize($row->sources_from, ['allowed_classes' => false])
1206 : [];
1207 if (!is_array($sources)) {
1208 continue;
1209 }
1210 foreach ($sources as $source_key => $source_value) {
1211 // Numeric keys mark legacy list rows; the value is the URL there.
1212 $key = is_int($source_key) ? (string) $source_value : (string) $source_key;
1213 if ($key !== '') {
1214 $index[$key] = true;
1215 }
1216 }
1217 }
1218
1219 $this->source_index_cache = $index;
1220
1221 return $index;
1222 }
1223
1224 /**
1225 * Record a source imported during this request so the memoized index —
1226 * and with it every later duplicate check — knows about it.
1227 *
1228 * @param string $source Source URL/path just stored.
1229 */
1230 private function remember_imported_source($source)
1231 {
1232 $source = (string) $source;
1233 if ($source !== '' && $this->source_index_cache !== null) {
1234 $this->source_index_cache[$source] = true;
1235 }
1236 }
1237
1238 /**
1239 * Import redirections from an uploaded CSV file.
1240 *
1241 * The readme has advertised ".csv file" import since the importer UI
1242 * shipped; this is the implementation. Expected columns per row:
1243 * source, destination, http code (optional, 301 by default). A header
1244 * row is detected and skipped, and every row passes the same dedup,
1245 * off-site, and loop guards the plugin imports use.
1246 *
1247 * @param string $file_path Path to the uploaded CSV temp file.
1248 * @return array Result with success status, message, and counters.
1249 */
1250 public function import_csv_file($file_path)
1251 {
1252 $handle = @fopen($file_path, 'r'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming file I/O; WP_Filesystem cannot return a raw stream handle
1253
1254 if (!$handle) {
1255 return [
1256 'success' => false,
1257 'message' => 'Could not read the uploaded CSV file.',
1258 'imported' => 0,
1259 'skipped' => 0,
1260 'loop_skipped' => 0
1261 ];
1262 }
1263
1264 $imported = 0;
1265 $skipped = 0;
1266 $parse_skipped = 0;
1267 $unsafe_skipped = 0;
1268 $loop_skipped = 0;
1269 $row_index = 0;
1270 $truncated = false;
1271
1272 $allowed_codes = [301, 302, 307, 410, 451];
1273 $existing_sources = $this->get_existing_source_index();
1274
1275 while (($raw = fgets($handle)) !== false) {
1276 # Bound the run: an oversized or malformed export must not turn
1277 # into an unbounded loop over the temp file.
1278 if ($row_index >= self::CSV_MAX_ROWS) {
1279 $truncated = true;
1280 break;
1281 }
1282
1283 $row = str_getcsv($raw, ',', '"', '\\');
1284
1285 # Header row ("source,destination,...") — skip it once.
1286 if ($row_index === 0 && isset($row[0]) && in_array(strtolower(trim((string) $row[0])), ['source', 'origin', 'from'], true)) {
1287 $row_index++;
1288 continue;
1289 }
1290
1291 $row_index++;
1292
1293 # str_getcsv() always returns an array, so a short row is the only
1294 # unusable shape to screen for here.
1295 if (count($row) < 2) {
1296 if (trim((string) $raw) !== '') {
1297 $parse_skipped++;
1298 }
1299 continue;
1300 }
1301
1302 $source = trim((string) $row[0]);
1303 $target = trim((string) $row[1]);
1304 $http_code = isset($row[2]) && trim((string) $row[2]) !== '' ? (int) trim((string) $row[2]) : 301;
1305
1306 if ($source === '' || !in_array($http_code, $allowed_codes, true) || ($target === '' && !in_array($http_code, [410, 451], true))) {
1307 $parse_skipped++;
1308 continue;
1309 }
1310
1311 # Skip rows whose source already has a rule.
1312 if (isset($existing_sources[(string) $source])) {
1313 $skipped++;
1314 continue;
1315 }
1316
1317 $sources = [
1318 $source => 'exact'
1319 ];
1320
1321 $args = [
1322 'sources_from' => serialize($sources),
1323 'url_redirect_to' => $target,
1324 'http_code' => $http_code,
1325 'hits_count' => 0,
1326 'status' => 'active',
1327 'pattern_type' => 'exact',
1328 'regex_pattern' => null,
1329 'description' => 'Imported from CSV file',
1330 'created_at' => current_time('mysql'),
1331 'updated_at' => current_time('mysql')
1332 ];
1333
1334 if (!$this->is_safe_destination($target, $http_code)) {
1335 $unsafe_skipped++;
1336 continue;
1337 }
1338 $args['url_redirect_to'] = $target;
1339
1340 if (!in_array($http_code, [410, 451], true) && $this->get_redirection_helper()->validate_no_loop($source, $target) !== null) {
1341 $loop_skipped++;
1342 continue;
1343 }
1344
1345 if ($this->db_redirection->add($args)) {
1346 $imported++;
1347 $existing_sources[(string) $source] = true;
1348 $this->remember_imported_source($source);
1349 }
1350 }
1351 fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
1352
1353 if ($imported === 0 && $skipped === 0 && $parse_skipped === 0 && $unsafe_skipped === 0 && $loop_skipped === 0) {
1354 return [
1355 'success' => false,
1356 'message' => 'No redirections found in the CSV file. Expected columns: source, destination, http code (optional).',
1357 'imported' => 0,
1358 'skipped' => 0,
1359 'loop_skipped' => 0
1360 ];
1361 }
1362
1363 $message = "Successfully imported $imported redirections from CSV.";
1364 if ($skipped > 0) {
1365 $message .= " Skipped $skipped already-existing redirection(s).";
1366 }
1367 if ($parse_skipped > 0) {
1368 $message .= " Skipped $parse_skipped invalid redirection(s).";
1369 }
1370 if ($unsafe_skipped > 0) {
1371 $message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination.";
1372 }
1373 if ($loop_skipped > 0) {
1374 $message .= " Skipped $loop_skipped redirect(s) that would have created loops.";
1375 }
1376 if ($truncated) {
1377 $message .= ' Import stopped after ' . self::CSV_MAX_ROWS . ' rows; split larger files and import them separately.';
1378 }
1379
1380 return [
1381 'success' => true,
1382 'message' => $message,
1383 'imported' => $imported,
1384 'skipped' => $skipped,
1385 'loop_skipped' => $loop_skipped,
1386 'skipped_parse' => $parse_skipped,
1387 'skipped_unsafe' => $unsafe_skipped,
1388 'truncated' => $truncated
1389 ];
1390 }
1391
1392 /**
1393 * Check whether a redirection already exists for a source.
1394 *
1395 * Exact key match against the memoized index; never a substring test,
1396 * so importing /a no longer "matches" an existing /about.
1397 *
1398 * @param string $source_url Source URL to check.
1399 * @return bool True if a redirect already stores this exact source.
1400 */
1401 private function redirection_exists($source_url)
1402 {
1403 $index = $this->get_existing_source_index();
1404 return isset($index[(string) $source_url]);
1405 }
1406
1407 /**
1408 * Get import statistics
1409 *
1410 * @return array Statistics about imports
1411 */
1412 public function get_import_stats()
1413 {
1414 global $wpdb;
1415 $table_name = $wpdb->prefix . Metasync_Redirection_Database::$table_name;
1416
1417 $stats = [
1418 'total_redirections' => 0,
1419 'imported_redirections' => 0
1420 ];
1421
1422 $stats['total_redirections'] = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aioseo_redirects"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
1423
1424 $stats['imported_redirections'] = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists
1425 "SELECT COUNT(*) FROM {$wpdb->prefix}aioseo_redirects WHERE description LIKE %s",
1426 'Imported from%'
1427 ));
1428
1429 return $stats;
1430 }
1431 }
1432