PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.24
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.24
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / database / class-db-migrations.php

class-db-migrations.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.24, at database/class-db-migrations.php

696 lines 28.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The database migration for the plugin.
5 *
6 * @since 1.0.0
7 * @package Metasync
8 * @subpackage Metasync/database
9 * @author Engineering Team <support@searchatlas.com>
10 */
11 // Some Plugins declare class name DBMigration to avoid conflict, renamed the class
12 class MetaSync_DBMigration
13 {
14
15 /**
16 * activation of migration.
17 */
18 public static function activation()
19 {
20 self::run_migrations();
21 }
22
23 /**
24 * Run all database migrations
25 */
26 public static function run_migrations()
27 {
28 global $wpdb;
29 $collate = $wpdb->get_charset_collate();
30
31 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
32
33 // Create 404 Monitor Table
34 require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
35 $tableName = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
36
37 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $tableName)) != $tableName) {
38 $table_sql = "CREATE TABLE {$tableName} (
39 id BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
40 uri VARCHAR(255) NOT NULL,
41 date_time DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
42 hits_count BIGINT(20) unsigned NOT NULL DEFAULT 1,
43 user_agent VARCHAR(255) NOT NULL DEFAULT '',
44 PRIMARY KEY id (id),
45 KEY uri (uri(191))
46 ) $collate;";
47
48 dbDelta($table_sql);
49 }
50
51 // Create Redirections Table
52 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
53 $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
54
55 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameRedirection)) != $tableNameRedirection) {
56 $table_sql = "CREATE TABLE {$tableNameRedirection} (
57 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
58 sources_from TEXT NOT NULL,
59 url_redirect_to TEXT NOT NULL,
60 http_code SMALLINT(4) unsigned NOT NULL DEFAULT 301,
61 hits_count BIGINT(20) unsigned NOT NULL DEFAULT '0',
62 status VARCHAR(25) NOT NULL DEFAULT 'active',
63 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact',
64 regex_pattern TEXT NULL,
65 description TEXT NULL,
66 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
67 updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
68 last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
69 PRIMARY KEY id (id),
70 KEY status (status),
71 KEY pattern_type (pattern_type),
72 KEY created_at (created_at),
73 KEY idx_active_redirects (status, sources_from(191))
74 ) $collate;";
75
76 dbDelta($table_sql);
77 } else {
78 // Check if new columns exist and add them if they don't
79 $columns = $wpdb->get_col("DESCRIBE {$tableNameRedirection}");
80
81 if (!in_array('pattern_type', $columns)) {
82 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact' AFTER status");
83 }
84
85 if (!in_array('regex_pattern', $columns)) {
86 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type");
87 }
88
89 if (!in_array('description', $columns)) {
90 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN description TEXT NULL AFTER regex_pattern");
91 }
92
93 // Add indexes if they don't exist
94 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameRedirection}");
95 $index_names = array_column($indexes, 'Key_name');
96
97 if (!in_array('pattern_type', $index_names)) {
98 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY pattern_type (pattern_type)");
99 }
100
101 if (!in_array('created_at', $index_names)) {
102 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY created_at (created_at)");
103 }
104
105 // PERFORMANCE OPTIMIZATION: Add composite index for active redirects lookup
106 if (!in_array('idx_active_redirects', $index_names)) {
107 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY idx_active_redirects (status, sources_from(191))");
108 }
109
110 // Set default pattern_type for existing records
111 $wpdb->query("UPDATE {$tableNameRedirection} SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''");
112 }
113
114 // One-time migration: auto-enable external redirects if the site already has any
115 if (!get_option('metasync_external_redirects_migrated')) {
116 // Compare against every home-URL variant (http/https, www/non-www),
117 // not just the exact home_url() string — otherwise same-site
118 // destinations such as 'https://example.com/about' on a
119 // www-prefixed site count as external and silently flip the setting.
120 $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
121 $home_host = is_string($home_host) ? strtolower(preg_replace('/^www\./i', '', $home_host)) : '';
122 $params = ['http%'];
123 if ($home_host !== '') {
124 $not_like = '';
125 foreach (['http://', 'https://'] as $scheme) {
126 foreach ([$home_host, 'www.' . $home_host] as $host) {
127 $not_like .= ' AND url_redirect_to NOT LIKE %s';
128 $params[] = $wpdb->esc_like($scheme . $host) . '%';
129 }
130 }
131 } else {
132 $not_like = ' AND url_redirect_to NOT LIKE %s';
133 $params[] = $wpdb->esc_like(trailingslashit(home_url())) . '%';
134 }
135 $external_count = $wpdb->get_var(
136 $wpdb->prepare(
137 "SELECT COUNT(*) FROM {$tableNameRedirection} WHERE url_redirect_to LIKE %s{$not_like}",
138 ...$params
139 )
140 );
141 if ($external_count > 0) {
142 update_option('metasync_allow_external_redirects', 1, true);
143 }
144 update_option('metasync_external_redirects_migrated', 1, true);
145 }
146
147 // Create HeartBeat Error Monitor Table
148 require_once dirname(__FILE__, 2) . '/heartbeat-error-monitor/class-metasync-heartbeat-error-monitor-database.php';
149 $tableNameHeartBeatErrorMonitor = esc_sql($wpdb->prefix . Metasync_HeartBeat_Error_Monitor_Database::$table_name);
150
151 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameHeartBeatErrorMonitor)) != $tableNameHeartBeatErrorMonitor) {
152 $table_sql = "CREATE TABLE {$tableNameHeartBeatErrorMonitor} (
153 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
154 attribute_name VARCHAR(25) NOT NULL DEFAULT '',
155 object_count VARCHAR(25) NOT NULL DEFAULT '',
156 error_description TEXT NULL,
157 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
158 PRIMARY KEY id (id)
159 ) $collate;";
160
161 dbDelta($table_sql);
162 }
163
164 // Create Sync History Table
165 require_once dirname(__FILE__, 2) . '/sync-history/class-metasync-sync-history-database.php';
166 $tableNameSyncHistory = esc_sql($wpdb->prefix . Metasync_Sync_History_Database::$table_name);
167
168 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameSyncHistory)) != $tableNameSyncHistory) {
169 $table_sql = "CREATE TABLE {$tableNameSyncHistory} (
170 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
171 title VARCHAR(255) NOT NULL DEFAULT '',
172 source VARCHAR(50) NOT NULL DEFAULT '',
173 status VARCHAR(25) NOT NULL DEFAULT 'draft',
174 content_type VARCHAR(50) NOT NULL DEFAULT '',
175 url TEXT NULL,
176 meta_data TEXT NULL,
177 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
178 PRIMARY KEY id (id),
179 KEY source (source),
180 KEY status (status),
181 KEY created_at (created_at),
182 KEY idx_dedup (source, created_at),
183 KEY idx_search (title(50), source, created_at)
184 ) $collate;";
185
186 dbDelta($table_sql);
187 } else {
188 // PERFORMANCE OPTIMIZATION: Add composite indexes to existing tables
189 // Check and add indexes if they don't exist
190 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameSyncHistory}");
191 $index_names = array_column($indexes, 'Key_name');
192
193 // Add deduplication index (source, created_at)
194 if (!in_array('idx_dedup', $index_names)) {
195 $wpdb->query("ALTER TABLE {$tableNameSyncHistory} ADD KEY idx_dedup (source, created_at)");
196 }
197
198 // Add search index (title(50), source, created_at)
199 if (!in_array('idx_search', $index_names)) {
200 $wpdb->query("ALTER TABLE {$tableNameSyncHistory} ADD KEY idx_search (title(50), source, created_at)");
201 }
202 }
203
204 // Create OTTO Excluded URLs Table
205 require_once dirname(__FILE__, 2) . '/otto/class-metasync-otto-excluded-urls-database.php';
206 $tableNameOttoExcludedURLs = esc_sql($wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name);
207
208 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameOttoExcludedURLs)) != $tableNameOttoExcludedURLs) {
209 $table_sql = "CREATE TABLE {$tableNameOttoExcludedURLs} (
210 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
211 url_pattern TEXT NOT NULL,
212 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact',
213 description TEXT NULL,
214 status VARCHAR(25) NOT NULL DEFAULT 'active',
215 is_permanent TINYINT(1) NOT NULL DEFAULT 0,
216 auto_excluded TINYINT(1) NOT NULL DEFAULT 0,
217 recheck_after DATETIME NULL DEFAULT NULL,
218 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
219 PRIMARY KEY id (id),
220 KEY status (status),
221 KEY pattern_type (pattern_type),
222 KEY created_at (created_at),
223 KEY is_permanent (is_permanent),
224 KEY auto_excluded (auto_excluded),
225 KEY recheck_after (recheck_after),
226 UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)
227 ) $collate;";
228
229 dbDelta($table_sql);
230 }
231
232 // Create Robots.txt Backups Table
233 require_once dirname(__FILE__, 2) . '/robots-txt/class-metasync-robots-txt-database.php';
234 $robots_db = Metasync_Robots_Txt_Database::get_instance();
235 $table_name_robots = esc_sql($wpdb->prefix . 'metasync_robots_txt_backups');
236 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name_robots)) != $table_name_robots) {
237 $robots_db->create_table();
238 }
239
240 }
241
242 /**
243 * deactivation of migration.
244 */
245 public static function deactivation()
246 {
247 global $wpdb;
248 // require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
249 // $tableName = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
250
251 /* drop wp_metasync_404_logs table */
252 // $sql = "DROP TABLE IF EXISTS `$tableName` ";
253 // $wpdb->query($sql);
254
255 // require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
256 // $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
257
258 /* drop wp_metasync_redirections table */
259 // $sql = "DROP TABLE IF EXISTS `$tableNameRedirection` ";
260 // $wpdb->query($sql);
261
262 require_once dirname(__FILE__, 2) . '/heartbeat-error-monitor/class-metasync-heartbeat-error-monitor-database.php';
263 $tableNameHeartBeatErrorMonitor = esc_sql($wpdb->prefix . Metasync_HeartBeat_Error_Monitor_Database::$table_name);
264 /* drop wp_metasync_redirections table */
265 $sql = "DROP TABLE IF EXISTS `$tableNameHeartBeatErrorMonitor` ";
266 $wpdb->query($sql);
267 }
268
269 /**
270 * Run version-specific migrations
271 */
272 public static function run_version_migrations($from_version, $to_version)
273 {
274 // If from_version is 9.9.9, always run all migrations
275 $force_run = ($from_version === '9.9.9');
276
277 // Migration for versions 2.5.4+ - Enhanced 404 monitor and redirections
278 if ($force_run || version_compare($to_version, '2.5.4', '>=')) {
279 self::migrate_enhanced_features_v2_5_4();
280 }
281
282 // Migration for versions 2.5.6+ - Robots.txt management
283 if ($force_run || version_compare($to_version, '2.5.6', '>=')) {
284 self::migrate_robots_txt_v2_5_6();
285 }
286
287 // Migration for versions 2.5.9+ - OTTO Excluded URLs
288 if ($force_run || version_compare($to_version, '2.5.9', '>=')) {
289 self::migrate_otto_excluded_urls_v2_5_9();
290 }
291
292 // Add more version-specific migrations here as needed
293 // if (version_compare($from_version, '1.1.0', '<')) {
294 // self::migrate_something_v1_1();
295 // }
296 }
297
298 /**
299 * Migrate enhanced features for version 2.5.4+
300 */
301 private static function migrate_enhanced_features_v2_5_4()
302 {
303 global $wpdb;
304 $collate = $wpdb->get_charset_collate();
305
306 // Load WordPress upgrade functions for dbDelta
307 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
308
309 // Enhanced 404 Error Monitor Table
310 require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
311 $tableName404Monitor = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
312
313 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableName404Monitor)) != $tableName404Monitor) {
314 // Table doesn't exist, create enhanced version
315 $table_sql = "CREATE TABLE {$tableName404Monitor} (
316 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
317 uri TEXT NOT NULL,
318 hits_count BIGINT(20) unsigned NOT NULL DEFAULT '1',
319 date_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
320 user_agent TEXT NULL,
321 referer TEXT NULL,
322 ip_address VARCHAR(45) NULL,
323 PRIMARY KEY id (id),
324 KEY uri (uri(191)),
325 KEY hits_count (hits_count),
326 KEY date_time (date_time)
327 ) $collate;";
328
329 dbDelta($table_sql);
330 } else {
331 // Table exists, check for missing columns and add them
332 $columns = $wpdb->get_col("DESCRIBE {$tableName404Monitor}");
333
334 // Add referer column if it doesn't exist
335 if (!in_array('referer', $columns)) {
336 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD COLUMN referer TEXT NULL AFTER user_agent");
337 }
338
339 // Add ip_address column if it doesn't exist
340 if (!in_array('ip_address', $columns)) {
341 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD COLUMN ip_address VARCHAR(45) NULL AFTER referer");
342 }
343
344 // Update uri column to TEXT if it's VARCHAR(255)
345 $uri_column = $wpdb->get_row("SHOW COLUMNS FROM {$tableName404Monitor} LIKE 'uri'");
346 if ($uri_column && strpos($uri_column->Type, 'varchar') !== false) {
347 $wpdb->query("ALTER TABLE {$tableName404Monitor} MODIFY COLUMN uri TEXT NOT NULL");
348 }
349
350 // Update user_agent column to TEXT if it's VARCHAR(255)
351 $ua_column = $wpdb->get_row("SHOW COLUMNS FROM {$tableName404Monitor} LIKE 'user_agent'");
352 if ($ua_column && strpos($ua_column->Type, 'varchar') !== false) {
353 $wpdb->query("ALTER TABLE {$tableName404Monitor} MODIFY COLUMN user_agent TEXT NULL");
354 }
355
356 // Add missing indexes
357 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableName404Monitor}");
358 $index_names = array_column($indexes, 'Key_name');
359
360 if (!in_array('hits_count', $index_names)) {
361 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD KEY hits_count (hits_count)");
362 }
363
364 if (!in_array('date_time', $index_names)) {
365 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD KEY date_time (date_time)");
366 }
367 }
368
369 // Enhanced Redirections Table with new columns
370 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
371 $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
372
373 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameRedirection)) == $tableNameRedirection) {
374 // Table exists, check for new columns
375 $columns = $wpdb->get_col("DESCRIBE {$tableNameRedirection}");
376
377 // Add pattern_type column if it doesn't exist
378 if (!in_array('pattern_type', $columns)) {
379 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact' AFTER status");
380 }
381
382 // Add regex_pattern column if it doesn't exist
383 if (!in_array('regex_pattern', $columns)) {
384 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type");
385 }
386
387 // Add description column if it doesn't exist
388 if (!in_array('description', $columns)) {
389 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN description TEXT NULL AFTER regex_pattern");
390 }
391
392 // Add timestamp columns if they don't exist
393 if (!in_array('created_at', $columns)) {
394 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER description");
395 }
396
397 if (!in_array('updated_at', $columns)) {
398 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER created_at");
399 }
400
401 if (!in_array('last_accessed_at', $columns)) {
402 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER updated_at");
403 }
404
405 // Add indexes if they don't exist
406 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameRedirection}");
407 $index_names = array_column($indexes, 'Key_name');
408
409 if (!in_array('pattern_type', $index_names)) {
410 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY pattern_type (pattern_type)");
411 }
412
413 if (!in_array('created_at', $index_names)) {
414 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY created_at (created_at)");
415 }
416
417 // Set default pattern_type for existing records
418 $wpdb->query("UPDATE {$tableNameRedirection} SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''");
419 }
420 }
421
422 /**
423 * Migrate robots.txt management for version 2.5.6+
424 */
425 private static function migrate_robots_txt_v2_5_6()
426 {
427 global $wpdb;
428
429 // Create Robots.txt Backups Table
430 require_once dirname(__FILE__, 2) . '/robots-txt/class-metasync-robots-txt-database.php';
431 $robots_db = Metasync_Robots_Txt_Database::get_instance();
432 $table_name = esc_sql($wpdb->prefix . 'metasync_robots_txt_backups');
433
434 // Check if table already exists
435 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
436 // Table doesn't exist, create it
437 $robots_db->create_table();
438 }
439 }
440
441 /**
442 * Migrate OTTO Excluded URLs for version 2.5.9+
443 */
444 private static function migrate_otto_excluded_urls_v2_5_9()
445 {
446 global $wpdb;
447 $collate = $wpdb->get_charset_collate();
448
449 // Load WordPress upgrade functions for dbDelta
450 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
451
452 // Create OTTO Excluded URLs Table
453 require_once dirname(__FILE__, 2) . '/otto/class-metasync-otto-excluded-urls-database.php';
454 $tableNameOttoExcludedURLs = esc_sql($wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name);
455
456 // Check if table already exists
457 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $tableNameOttoExcludedURLs)) != $tableNameOttoExcludedURLs) {
458 // Table doesn't exist, create it
459 $table_sql = "CREATE TABLE {$tableNameOttoExcludedURLs} (
460 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
461 url_pattern TEXT NOT NULL,
462 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact',
463 description TEXT NULL,
464 status VARCHAR(25) NOT NULL DEFAULT 'active',
465 is_permanent TINYINT(1) NOT NULL DEFAULT 0,
466 auto_excluded TINYINT(1) NOT NULL DEFAULT 0,
467 recheck_after DATETIME NULL DEFAULT NULL,
468 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
469 PRIMARY KEY id (id),
470 KEY status (status),
471 KEY pattern_type (pattern_type),
472 KEY created_at (created_at),
473 KEY is_permanent (is_permanent),
474 KEY auto_excluded (auto_excluded),
475 KEY status_auto_excluded (status, auto_excluded),
476 KEY recheck_after (recheck_after),
477 UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)
478 ) $collate;";
479
480 dbDelta($table_sql);
481
482 // Log successful migration
483 // error_log('MetaSync: OTTO Excluded URLs table created successfully (v2.5.9)');
484 } else {
485 // Table exists, verify structure and add any missing columns if needed
486 $columns = $wpdb->get_col("DESCRIBE {$tableNameOttoExcludedURLs}");
487
488 // Check for required columns and add if missing
489 $missing_columns = false;
490
491 if (!in_array('pattern_type', $columns)) {
492 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact' AFTER url_pattern");
493 $missing_columns = true;
494 }
495
496 if (!in_array('description', $columns)) {
497 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN description TEXT NULL AFTER pattern_type");
498 $missing_columns = true;
499 }
500
501 if (!in_array('status', $columns)) {
502 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN status VARCHAR(25) NOT NULL DEFAULT 'active' AFTER description");
503 $missing_columns = true;
504 }
505
506 if (!in_array('is_permanent', $columns)) {
507 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN is_permanent TINYINT(1) NOT NULL DEFAULT 0 AFTER status");
508 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY is_permanent (is_permanent)");
509 }
510
511 if (!in_array('auto_excluded', $columns)) {
512 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN auto_excluded TINYINT(1) NOT NULL DEFAULT 0 AFTER is_permanent");
513 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY auto_excluded (auto_excluded)");
514 // Backfill: mark existing 404 exclusions as auto_excluded
515 $wpdb->query("UPDATE {$tableNameOttoExcludedURLs} SET auto_excluded = 1 WHERE description = 'Auto-excluded: 404'");
516 }
517
518 if (!in_array('recheck_after', $columns)) {
519 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN recheck_after DATETIME NULL DEFAULT NULL AFTER auto_excluded");
520 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY recheck_after (recheck_after)");
521 // Backfill: set recheck_after = created_at + 7 days for auto-excluded URLs
522 $wpdb->query("UPDATE {$tableNameOttoExcludedURLs} SET recheck_after = DATE_ADD(created_at, INTERVAL 7 DAY) WHERE auto_excluded = 1 AND (recheck_after IS NULL OR recheck_after = '0000-00-00 00:00:00')");
523 }
524
525 // Check and add indexes if they don't exist
526 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameOttoExcludedURLs}");
527 $index_names = array_column($indexes, 'Key_name');
528
529 if (!in_array('status', $index_names)) {
530 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY status (status)");
531 }
532
533 if (!in_array('pattern_type', $index_names)) {
534 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY pattern_type (pattern_type)");
535 }
536
537 if (!in_array('created_at', $index_names)) {
538 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY created_at (created_at)");
539 }
540
541 // Add unique index on url_pattern + pattern_type to prevent duplicates at database level
542 // Note: TEXT columns need a prefix length for indexing (767 is max for UTF8)
543 if (!in_array('url_pattern_type_unique', $index_names)) {
544 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)");
545 }
546
547 // Composite index for the cache-miss path in metasync_is_otto_url_manually_excluded()
548 if (!in_array('status_auto_excluded', $index_names)) {
549 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY status_auto_excluded (status, auto_excluded)");
550 }
551
552 // if ($missing_columns) {
553 // error_log('MetaSync: OTTO Excluded URLs table structure updated (v2.5.9)');
554 // }
555 }
556 }
557
558 /**
559 * One-time cleanup of canonical meta corrupted to the literal
560 * "Array" (and its esc_url'd forms "http://Array" / "https://Array").
561 *
562 * Deletions are exact-match only — a legitimate URL can never match.
563 * Also repairs legacy rows still stored as (possibly nested) serialized
564 * arrays, and clears the mirrored corruption from Yoast / RankMath /
565 * AIOSEO storage that plugin-sync propagated, so cleaned sites are not
566 * re-polluted by stale third-party caches. Idempotent by construction.
567 */
568 public static function cleanup_corrupted_canonicals()
569 {
570 global $wpdb;
571
572 $meta_keys = array('meta_canonical', '_metasync_canonical_url', '_yoast_wpseo_canonical', 'rank_math_canonical_url');
573 $bad_values = array('array', 'http://array', 'https://array');
574
575 $keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
576 $vals_placeholders = implode(',', array_fill(0, count($bad_values), '%s'));
577
578 // Normalized comparison: trailing slashes stripped in SQL so
579 // "http://Array/" and "http://Array//" both match the literals.
580 $norm_meta = "LOWER(TRIM(TRAILING '/' FROM TRIM(meta_value)))";
581
582 // 1. Post meta + term meta: delete exact-match corrupted rows.
583 // Batched and deleted by primary key so huge postmeta tables aren't
584 // range-locked in one statement, with per-object meta-cache
585 // invalidation — raw SQL alone would leave persistent object caches
586 // (Redis/Memcached) serving the deleted value to Yoast/RankMath
587 // readers indefinitely.
588 $meta_targets = array(
589 array($wpdb->postmeta, 'post_id', 'post_meta'),
590 array($wpdb->termmeta, 'term_id', 'term_meta'),
591 );
592 foreach ($meta_targets as $target) {
593 list($table, $object_col, $cache_group) = $target;
594 for ($batch = 0; $batch < 50; $batch++) {
595 $rows = $wpdb->get_results($wpdb->prepare(
596 "SELECT meta_id, {$object_col} AS object_id FROM {$table} WHERE meta_key IN ({$keys_placeholders}) AND {$norm_meta} IN ({$vals_placeholders}) ORDER BY meta_id LIMIT 500",
597 array_merge($meta_keys, $bad_values)
598 ));
599 if (empty($rows)) {
600 break;
601 }
602 $meta_ids = implode(',', array_map('intval', wp_list_pluck($rows, 'meta_id')));
603 $wpdb->query("DELETE FROM {$table} WHERE meta_id IN ({$meta_ids})");
604 foreach ($rows as $row) {
605 wp_cache_delete((int) $row->object_id, $cache_group);
606 }
607 if (count($rows) < 500) {
608 break;
609 }
610 }
611 }
612
613 // 2. Rows still stored as serialized arrays (the raw material the
614 // "Array" casts came from): repair MetaSync's own keys to the first
615 // usable URL inside; third-party keys are delete-only (never invent
616 // a value inside another plugin's storage). Written with direct SQL
617 // by meta_id so the updated_post_meta plugin-sync cascade, Yoast
618 // indexable rebuilds, and sitemap cache busts don't fire once per
619 // row; loops until exhausted (repaired rows stop matching LIKE).
620 if (class_exists('Metasync_Canonical_Sanitizer')) {
621 $own_keys = array('meta_canonical', '_metasync_canonical_url');
622 for ($batch = 0; $batch < 50; $batch++) {
623 $rows = $wpdb->get_results($wpdb->prepare(
624 "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key IN ({$keys_placeholders}) AND meta_value LIKE 'a:%%' ORDER BY meta_id LIMIT 500",
625 $meta_keys
626 ));
627 if (empty($rows)) {
628 break;
629 }
630 foreach ($rows as $row) {
631 $repaired = '';
632 if (in_array($row->meta_key, $own_keys, true)) {
633 $repaired = Metasync_Canonical_Sanitizer::sanitize(maybe_unserialize($row->meta_value));
634 }
635 if ($repaired !== '') {
636 $wpdb->update($wpdb->postmeta, array('meta_value' => $repaired), array('meta_id' => (int) $row->meta_id));
637 } else {
638 $wpdb->delete($wpdb->postmeta, array('meta_id' => (int) $row->meta_id));
639 }
640 wp_cache_delete((int) $row->post_id, 'post_meta');
641 }
642 if (count($rows) < 500) {
643 break;
644 }
645 }
646 }
647
648 // 3. Yoast indexable cache: null corrupted canonical columns so the
649 // frontend and sitemaps stop serving the bad value immediately.
650 $indexable_table = $wpdb->prefix . 'yoast_indexable';
651 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($indexable_table))) === $indexable_table) {
652 $wpdb->query($wpdb->prepare(
653 "UPDATE {$indexable_table} SET canonical = NULL WHERE LOWER(TRIM(TRAILING '/' FROM TRIM(canonical))) IN ({$vals_placeholders})",
654 $bad_values
655 ));
656 }
657
658 // 4. AIOSEO custom tables: null corrupted canonical_url columns.
659 foreach (array('aioseo_posts', 'aioseo_terms') as $aioseo_table) {
660 $table = $wpdb->prefix . $aioseo_table;
661 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) {
662 $wpdb->query($wpdb->prepare(
663 "UPDATE {$table} SET canonical_url = NULL WHERE LOWER(TRIM(TRAILING '/' FROM TRIM(canonical_url))) IN ({$vals_placeholders})",
664 $bad_values
665 ));
666 }
667 }
668
669 // 5. Yoast stores term canonicals in the wpseo_taxonomy_meta option,
670 // not termmeta. Strip only exact corruption literals.
671 if (class_exists('Metasync_Canonical_Sanitizer')) {
672 $tax_meta = get_option('wpseo_taxonomy_meta');
673 if (is_array($tax_meta)) {
674 $changed = false;
675 foreach ($tax_meta as $taxonomy => $terms) {
676 if (!is_array($terms)) {
677 continue;
678 }
679 foreach ($terms as $term_id => $fields) {
680 if (is_array($fields) && isset($fields['wpseo_canonical'])
681 && Metasync_Canonical_Sanitizer::is_corrupted($fields['wpseo_canonical'])) {
682 unset($tax_meta[$taxonomy][$term_id]['wpseo_canonical']);
683 $changed = true;
684 }
685 }
686 }
687 if ($changed) {
688 update_option('wpseo_taxonomy_meta', $tax_meta);
689 }
690 }
691 }
692 }
693
694
695 }
696