PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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 2.6.4 2.6.3 All 139 releases
metasync / metasync.php

metasync.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.0, at metasync.php

792 lines 30.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * This file is read by WordPress to generate the plugin information in the plugin
5 * admin area. This file also includes all of the dependencies used by the plugin,
6 * registers the activation and deactivation functions, and defines a function
7 * that starts the plugin.
8 *
9 * @package Search Atlas SEO
10 * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com
11 * @link https://searchatlas.com/
12 * @since 1.0.0
13 *
14 * @wordpress-plugin
15 * Plugin Name: Search Atlas: The Premier AI SEO Plugin for Instant Optimization
16 * Plugin URI: https://searchatlas.com/
17 * Description: Search Atlas SEO is an intuitive WordPress Plugin that transforms the most complicated, most labor-intensive SEO tasks into streamlined, straightforward processes. With a few clicks, the meta-bulk update feature automates the re-optimization of meta tags using AI to increase clicks. Stay up-to-date with the freshest Google Search data for your entire site or targeted URLs within the Meta Sync plug-in page.
18 * Version: 2.7.0
19 * Requires PHP: 8.1
20 * Author: Search Atlas
21 * Author URI: https://searchatlas.com
22 * License: GPL v3
23 * License URI: https://www.gnu.org/licenses/gpl-3.0.txt
24 * Text Domain: metasync
25 */
26
27 // If this file is called directly, abort.
28 if (!defined('WPINC')) {
29 die;
30 }
31
32 // Composer classmap autoloader — loads all plugin classes on demand.
33 require_once __DIR__ . '/vendor/autoload.php';
34
35 // Canonical sanitizer — required explicitly (not via the committed classmap)
36 // so it is guaranteed loadable everywhere canonicals are read or written.
37 require_once __DIR__ . '/includes/class-metasync-canonical-sanitizer.php';
38
39 // Editor-settings feature flags — required explicitly for the same reason: they
40 // gate output in the OTTO buffer and in third-party filter callbacks, both of
41 // which can run before the classmap has resolved anything else.
42 require_once __DIR__ . '/includes/class-metasync-feature-flags.php';
43 Metasync_Feature_Flags::register_invalidation();
44
45 // Consent gate and write-once backup for third-party SEO storage — required
46 // explicitly for the same reason: every writer into Yoast / Rank Math / AIOSEO
47 // consults it, and a class that fails to load would read as "no consent" and
48 // silently stop those writes.
49 require_once __DIR__ . '/includes/class-metasync-seo-backup.php';
50
51 // Loaded beside the backup class because it is the only reader of what that
52 // class writes. Restore is inert until something calls it, so loading it early
53 // costs nothing and keeps the pair together.
54 require_once __DIR__ . '/includes/class-metasync-seo-restore.php';
55
56 /**
57 * Currently plugin version.
58 * Start at version 1.0.0 and use SemVer - https://semver.org
59 * Rename this for your plugin and update it as you release new versions.
60 */
61 $metasync_version = '2.7.0';
62 define('METASYNC_VERSION', preg_match('/^\d+\.\d+/', $metasync_version) ? $metasync_version : '9.9.9');
63 /**
64 * Define the current required php version
65 * This will be used to validate whether the user can install the plugin or not
66 */
67 define('METASYNC_MIN_PHP', '8.1');
68
69 /**
70 * Define the current required php version
71 * This will be used to validate whether the user can install the plugin or not
72 */
73 define('METASYNC_MIN_WP', '5.2');
74
75 /**
76 * Telemetry Configuration Constants
77 * These replace the old database options for better security and consistency
78 */
79 define('METASYNC_SENTRY_PROJECT_ID', '4509950439849985');
80 define('METASYNC_SENTRY_ENVIRONMENT', 'production');
81 define('METASYNC_SENTRY_RELEASE', METASYNC_VERSION);
82 define('METASYNC_SENTRY_SAMPLE_RATE', 1.0);
83
84 /**
85 * GA4 Analytics Configuration
86 * Measurement ID for Google Analytics 4 event tracking (format: G-XXXXXXXX)
87 *
88 * IMPORTANT: This constant is REQUIRED for GA4 tracking to function.
89 * If not defined or empty, all GA4 analytics tracking will be disabled.
90 *
91 * GA4_API_SECRET is required for server-side Measurement Protocol events
92 * (Content Genius, OTTO optimization). Generate it in GA4:
93 * Admin → Data Streams → (stream) → Measurement Protocol → Create
94 */
95 define('METASYNC_GA4_MEASUREMENT_ID', 'G-SBLWW1EMTJ');
96 define('METASYNC_GA4_API_SECRET', 'nMGs22mxQ3qVUy-aInqfZA');
97
98 /**
99 * Define whether to show the plugin status in WordPress admin top navigation bar
100 * Set to false to hide the status indicator
101 */
102 define('METASYNC_SHOW_ADMIN_BAR_STATUS', true);
103
104 /**
105 * Sanitize POST/GET/REQUEST data recursively
106 *
107 * @param array $data Data to sanitize
108 * @return array Sanitized data
109 */
110 if (!function_exists('metasync_sanitize_input_array')) {
111 function metasync_sanitize_input_array($data) {
112 if (!is_array($data)) {
113 return sanitize_text_field($data);
114 }
115
116 $sanitized = [];
117 foreach ($data as $key => $value) {
118 if (is_array($value)) {
119 $sanitized[$key] = metasync_sanitize_input_array($value);
120 } else {
121 // Check if it's a URL
122 if (filter_var($value, FILTER_VALIDATE_URL)) {
123 $sanitized[$key] = esc_url_raw($value);
124 } else {
125 $sanitized[$key] = sanitize_text_field($value);
126 }
127 }
128 }
129 return $sanitized;
130 }
131 }
132
133 // Skip heavy MetaSync init on admin-ajax requests that don't target our own actions (Sentry issue 7441226449).
134 if (!function_exists('metasync_is_non_metasync_admin_ajax')) {
135 function metasync_is_non_metasync_admin_ajax() {
136 static $result = null;
137 if ($result !== null) {
138 return $result;
139 }
140 // The MCP bridge intentionally boots WordPress with DOING_AJAX set, but it
141 // is a MetaSync entry point rather than an unrelated admin AJAX request.
142 // The constant is only ever defined as true by the bridge bootstraps.
143 if (defined('METASYNC_MCP_BRIDGE')) {
144 return ($result = false);
145 }
146 if (!defined('DOING_AJAX') || !DOING_AJAX) {
147 return ($result = false);
148 }
149 $action = isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action']))
150 : (isset($_GET['action']) ? sanitize_text_field(wp_unslash($_GET['action'])) : '');
151 if ($action === '') {
152 return ($result = true);
153 }
154 if (strpos($action, 'metasync_') === 0 || strpos($action, 'meta_sync_') === 0 || $action === 'sample-permalink') {
155 return ($result = false);
156 }
157 return ($result = true);
158 }
159 }
160
161 // Lazy-load guard: only initialise the MCP server when the request is actually targeting
162 // the MCP REST route (or its sibling SEO-inventory route, which depends on $metasync_mcp_server
163 // for its permission callback). Saves ~2-5 MB / 10-50 ms on the 99.9% of requests that never
164 // touch MCP.
165 if (!function_exists('metasync_is_mcp_rest_request')) {
166 function metasync_is_mcp_rest_request(): bool {
167 $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
168 if ($uri === '') {
169 return false;
170 }
171 $prefix = function_exists('rest_get_url_prefix') ? rest_get_url_prefix() : 'wp-json';
172 $needles = [
173 '/' . $prefix . '/metasync/v1/mcp',
174 '/' . $prefix . '/metasync/v1/seo-inventory',
175 'rest_route=/metasync/v1/mcp',
176 'rest_route=/metasync/v1/seo-inventory',
177 'rest_route=%2Fmetasync%2Fv1%2Fmcp',
178 'rest_route=%2Fmetasync%2Fv1%2Fseo-inventory',
179 ];
180 foreach ($needles as $needle) {
181 if (stripos($uri, $needle) !== false) {
182 return true;
183 }
184 }
185 return false;
186 }
187 }
188
189 // Phase 2 — these files have file-level side effects (add_action outside class body)
190 // and must remain as explicit require_once until refactored.
191 require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-api-backoff-rest.php';
192 require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-error-logger.php';
193
194 // Shared helpers (custom/LPS page detection + query exclusion) — loaded in all
195 // request contexts (admin, REST, MCP, AJAX) so every SEO surface uses one rule.
196 require_once plugin_dir_path( __FILE__ ) . 'includes/metasync-helpers.php';
197
198 // Shared "results per page" selector helper used by the Redirections, 404
199 // Monitor, Changes Log and Media Library admin list tables. Loaded early so
200 // the helper class is available before any list table instantiates.
201 require_once plugin_dir_path( __FILE__ ) . 'includes/class-metasync-per-page-helper.php';
202
203 /**
204 * Include the Otto Pixel Php Code
205 */
206 if (!metasync_is_non_metasync_admin_ajax()) {
207 require_once plugin_dir_path( __FILE__ ) . '/otto/otto_pixel.php';
208 require_once plugin_dir_path( __FILE__ ) . '/otto/class-metasync-otto-clone-meta-cleaner.php';
209 Metasync_Otto_Clone_Meta_Cleaner::register();
210 }
211
212
213 /**
214 * Initialize OTTO Persistence Settings (registers REST API endpoints)
215 */
216 add_action('init', function() {
217 if (metasync_is_non_metasync_admin_ajax()) {
218 return;
219 }
220 Metasync_Otto_Persistence_Settings::init();
221 }, 5);
222
223 /**
224 * The code that runs during plugin activation.
225 * This action is documented in includes/class-metasync-activator.php
226 */
227 function activate_metasync()
228 {
229 # Include WordPress plugin functions to access plugin metadata
230 if (!function_exists('get_plugin_data')) {
231 require_once ABSPATH . 'wp-admin/includes/plugin.php';
232 }
233
234 # Get plugin data
235 $plugin_data = get_plugin_data(__FILE__);
236
237 # Get the plugin name
238 $plugin_name = $plugin_data['Name'];
239
240 # Get the current WordPress
241 global $wp_version;
242
243 # Get the current php version
244 $php_version = PHP_VERSION;
245
246 # Check for incompatible WordPress version
247 if (version_compare($wp_version, METASYNC_MIN_WP, '<')) {
248
249 #show error
250 wp_die(
251
252 #craft the message
253 sprintf(
254 '%s requires WordPress version %s or later. You are currently using version %s. Please update WordPress to activate this plugin.',
255 esc_html($plugin_name),
256 METASYNC_MIN_WP,
257 esc_html($wp_version)
258 ),
259
260 #the plugin title as page title
261 esc_html($plugin_name).'Plugin Activation Error',
262
263 #include the back link
264 ['back_link' => true]
265 );
266 }
267
268 # Check for incompatible PHP version
269 if (version_compare($php_version, METASYNC_MIN_PHP, '<')) {
270
271 #show error message
272 wp_die(
273
274 #craft the message
275 sprintf(
276 '%s requires PHP version %s or later. You are currently using version %s. Please update PHP to activate this plugin.',
277 esc_html($plugin_name),
278 METASYNC_MIN_PHP,
279 esc_html($php_version)
280 ),
281
282 #the plugin title as page title
283 esc_html($plugin_name).'Plugin Activation Error',
284
285 #include the back link
286 ['back_link' => true]
287 );
288 }
289
290 Metasync_Activator::activate();
291 // class name is changed at class-db-migrations.php
292 MetaSync_DBMigration::activation();
293
294 // Set initial version
295 update_option('metasync_version', METASYNC_VERSION);
296
297 // Record activation timestamp so Divi CSS fix transients
298 // auto-invalidate after plugin deactivate/reactivate cycles.
299 update_option('metasync_activated_at', (string) time());
300
301 // Clear all cache plugins to ensure fresh start
302 Metasync_Cache_Purge::purge_all('plugin_activation');
303
304 // Migrate physical sitemap files on activation.
305 metasync_migrate_physical_sitemaps();
306 }
307
308 // Log-sync removed - error monitoring now handled by Sentry
309 // require_once plugin_dir_path(__FILE__) . 'log-sync/log-sync.php';
310
311 /**
312 * Initialize telemetry system for error monitoring and Sentry integration
313 */
314 if (!metasync_is_non_metasync_admin_ajax()) {
315 require_once plugin_dir_path(__FILE__) . 'telemetry/telemetry-init.php';
316 }
317
318 /**
319 * The code that runs during plugin deactivation.
320 * This action is documented in includes/class-metasync-deactivator.php
321 */
322 function deactivate_metasync()
323 {
324 Metasync_Deactivator::deactivate();
325 // class name is changed at class-db-migrations.php
326 MetaSync_DBMigration::deactivation();
327
328 // Clear news/video sitemap caches
329 delete_transient('metasync_vsm_' . md5('news-sitemap.xml'));
330 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
331 delete_option('metasync_sitemap_virtual_index');
332
333 // Clear OTTO JS detection cache so re-activation gets a fresh check
334 delete_transient('metasync_otto_js_detected');
335 }
336
337 register_activation_hook(__FILE__, 'activate_metasync');
338 register_deactivation_hook(__FILE__, 'deactivate_metasync');
339
340 /**
341 * Migrate physical sitemap .xml files into transients and delete them.
342 *
343 * Physical files in ABSPATH cause nginx/Plesk to 403 before WordPress can
344 * serve them. Called from both the activation hook and the version-gate
345 * migration so it covers fresh activations and auto-updates.
346 */
347 function metasync_migrate_physical_sitemaps()
348 {
349 update_option('metasync_sitemap_virtual_mode', true, false);
350
351 $candidates = [];
352 $globbed = glob(ABSPATH . 'sitemap*.xml');
353 if (is_array($globbed)) {
354 foreach ($globbed as $file) {
355 $basename = basename($file);
356 if ($basename === 'sitemap_index.xml' || preg_match('/^sitemap\d*\.xml$/', $basename)) {
357 $candidates[] = $file;
358 }
359 }
360 }
361 foreach (['news-sitemap.xml', 'video-sitemap.xml'] as $extra) {
362 $path = ABSPATH . $extra;
363 if (file_exists($path)) {
364 $candidates[] = $path;
365 }
366 }
367
368 if (empty($candidates)) {
369 return;
370 }
371
372 // Ownership gate: other plugins (Yoast and friends) write files with the
373 // exact same names (sitemap_index.xml, sitemap1.xml, ...), so a name match
374 // alone must never authorize deleting a physical file. A file is only
375 // migrated when this plugin left evidence it wrote it.
376 $tracked_names = [];
377 $tracked_files = get_option('metasync_sitemap_files', []);
378 if (is_array($tracked_files)) {
379 foreach ($tracked_files as $entry) {
380 $name = is_array($entry) && isset($entry['filename']) ? $entry['filename'] : $entry;
381 if (is_string($name) && '' !== $name) {
382 $tracked_names[] = $name;
383 }
384 }
385 }
386 $virtual_index = get_option('metasync_sitemap_virtual_index', []);
387 if (!is_array($virtual_index)) {
388 $virtual_index = [];
389 }
390 $parked_names = array_keys($virtual_index);
391 $generator_ran = !empty($tracked_names) && false !== get_option('metasync_sitemap_last_generated');
392
393 $ours = [];
394 $metasync_files_found = false;
395 foreach ($candidates as $file) {
396 $bn = basename($file);
397
398 // A parked name proves MetaSync owned the file at an earlier point, but
399 // an expired transient no longer proves that a file currently on disk
400 // is ours. A different plugin may have recreated the same basename.
401 if (in_array($bn, $parked_names, true)) {
402 if (false !== get_transient('metasync_vsm_' . md5($bn))) {
403 continue;
404 }
405 continue;
406 }
407
408 // Chunk, index, news, and video files are migratable only when their
409 // exact basename is recorded by MetaSync's generator/configuration.
410 if (in_array($bn, $tracked_names, true) && $generator_ran) {
411 $ours[] = $file;
412 $metasync_files_found = true;
413 continue;
414 }
415
416 if ($bn === 'news-sitemap.xml' && null !== get_option('metasync_news_sitemap_settings', null)) {
417 $ours[] = $file;
418 $metasync_files_found = true;
419 continue;
420 }
421 if ($bn === 'video-sitemap.xml' && null !== get_option('metasync_video_sitemap_settings', null)) {
422 $ours[] = $file;
423 $metasync_files_found = true;
424 }
425 }
426
427 // Do not infer ownership of sitemap_index.xml from another sitemap: Yoast,
428 // Rank Math, and other SEO plugins commonly use the same root filename.
429 // It must be explicitly recorded in MetaSync's tracked file list.
430 $ours = array_values(array_unique($ours));
431
432 if (empty($ours)) {
433 return;
434 }
435
436 $migrated_files = [];
437
438 foreach ($ours as $file) {
439 $bn = basename($file);
440 $content = @file_get_contents($file);
441 if (false !== $content) {
442 $tkey = 'metasync_vsm_' . md5($bn);
443 // The news sitemap's entries are only valid inside Google News'
444 // 48-hour window, so it must not be migrated in under a 30-day
445 // TTL — that would re-introduce the staleness the generator's
446 // own bounded TTL exists to prevent. Regenerate-on-miss rebuilds
447 // it with a fresh date_query when this expires.
448 $ttl = ('news-sitemap.xml' === $bn) ? (int) (DAY_IN_SECONDS / 4) : 30 * DAY_IN_SECONDS;
449 set_transient($tkey, $content, $ttl);
450 if (false !== get_transient($tkey)) {
451 // The parked transient doubles as a 30-day backup of the
452 // content before the file is removed from disk.
453 @unlink($file);
454 $virtual_index[$bn] = $tkey;
455 if ($bn !== 'sitemap_index.xml' && $bn !== 'news-sitemap.xml' && $bn !== 'video-sitemap.xml') {
456 $migrated_files[] = [
457 'filename' => $bn,
458 'url' => home_url('/' . $bn),
459 'lastmod' => current_time('mysql', true),
460 ];
461 }
462 }
463 }
464 }
465
466 update_option('metasync_sitemap_virtual_index', $virtual_index, false);
467
468 if (!empty($migrated_files)) {
469 update_option('metasync_sitemap_files', $migrated_files);
470 update_option('metasync_sitemap_last_generated', current_time('mysql'));
471 }
472 }
473
474 /**
475 * Check for plugin updates and run migrations if needed
476 */
477 function check_metasync_updates()
478 {
479 static $checked = false;
480 if ($checked) return;
481 $checked = true;
482
483 $current_version = get_option('metasync_version', '0.0.0');
484 $plugin_version = METASYNC_VERSION;
485
486 // If versions don't match, run migration. Dev builds store the 9.9.9
487 // placeholder version, which compares greater than every real release —
488 // once stored, a plain "<" comparison never fires again and migrations
489 // are silently skipped forever. Also enter when the stored version is the
490 // placeholder but the running version is a real release, so the migration
491 // runner's dev-build escape hatch becomes reachable and the stored version
492 // is corrected to the real release.
493 if (version_compare($current_version, $plugin_version, '<')
494 || ('9.9.9' === $current_version && '9.9.9' !== $plugin_version)) {
495 // Import whitelabel settings only if the JSON file is new or changed
496 // (prevents overwriting admin UI changes on every version check)
497 Metasync_Activator::check_whitelabel_settings_update();
498
499 // Run version-specific migrations first
500 MetaSync_DBMigration::run_version_migrations($current_version, $plugin_version);
501
502 // Migration for v2.7.0+: Remove AI Agent, switch to plugin auth token, make MCP always-on
503 if (version_compare($current_version, '2.7.0', '<')) {
504 // Remove old MCP API key option
505 delete_option('metasync_mcp_api_key');
506
507 // Remove MCP enabled/disabled toggle option (MCP is now always enabled)
508 delete_option('metasync_mcp_enabled');
509
510 // Remove AI Agent settings (AI Agent has been removed)
511 delete_option('metasync_ai_agent_mcp_config');
512 delete_option('metasync_ai_agent_ai_config');
513 delete_option('metasync_ai_agent_enabled');
514
515 // Ensure plugin auth token exists
516 $options = get_option('metasync_options', []);
517 if (empty($options['general']['apikey'])) {
518 $options['general']['apikey'] = wp_generate_password(32, false, false);
519 update_option('metasync_options', $options);
520 }
521 }
522
523 // One-time purge of stale OTTO SEO cron backlog.
524 // Prior versions could accumulate thousands of metasync_process_seo_job and
525 // metasync_process_otto_crawl_url_job events due to unbounded rescheduling.
526 // Clear the backlog once on update; the new code prevents re-accumulation.
527 if (!get_option('metasync_wp299_cron_cleanup_done')) {
528 wp_unschedule_hook('metasync_process_seo_job');
529 wp_unschedule_hook('metasync_process_otto_crawl_url_job');
530 wp_unschedule_hook('metasync_process_otto_batch_cache_job');
531 update_option('metasync_wp299_cron_cleanup_done', true, false);
532 }
533
534 // One-time cleanup of canonical values corrupted to the literal
535 // "Array" (emitted as http://Array once the 2.6.16 canonical filters
536 // started reading them). The sanitizer prevents new corruption; this
537 // repairs the rows already in the database. Cache purge below pushes
538 // the clean pages live. Claimed via add_option() — it fails if the row
539 // already exists, so concurrent requests can't run the cleanup twice,
540 // and the claim lands BEFORE the work: everything inside is idempotent
541 // and the read-side sanitizer already protects output if a run is
542 // interrupted.
543 if (false === get_option('metasync_canonical_cleanup_done')
544 && add_option('metasync_canonical_cleanup_done', 'running', '', false)) {
545 MetaSync_DBMigration::cleanup_corrupted_canonicals();
546 update_option('metasync_canonical_cleanup_done', 'done', false);
547 }
548
549 // One-time repair of Local Business logos corrupted to "http://<id>".
550 // The sanitizer fix stopped new corruption; this restores the
551 // attachment ID on sites that saved a logo before it. The corrupted
552 // value encodes the original ID exactly, so the rewrite is lossless.
553 // Claimed via add_option() — it fails when the row already exists, so
554 // concurrent requests can't run the repair twice, and the claim lands
555 // BEFORE the work: the repair itself is idempotent, and the read-side
556 // normalisation in the schema output and admin preview already
557 // protects output if a run is interrupted.
558 if (false === get_option('metasync_local_seo_logo_repair_done')
559 && add_option('metasync_local_seo_logo_repair_done', 'running', '', false)) {
560 MetaSync_DBMigration::repair_corrupted_local_seo_logo();
561 update_option('metasync_local_seo_logo_repair_done', 'done', false);
562 }
563
564 // Migrate physical sitemap files on version update.
565 metasync_migrate_physical_sitemaps();
566
567 // Run full migration to ensure all tables are up to date
568 MetaSync_DBMigration::run_migrations();
569
570 // Update stored version
571 update_option('metasync_version', $plugin_version);
572
573 // Clear all cache plugins after update
574 Metasync_Cache_Purge::purge_all('plugin_update');
575
576 // Log the update
577 //error_log("MetaSync: Plugin updated from {$current_version} to {$plugin_version}. Database migration completed.");
578 }
579 }
580
581 // Hook into WordPress init to check for updates
582 add_action('init', 'check_metasync_updates', 1);
583
584 /**
585 * Handle whitelabel settings import after plugin is updated via WordPress admin
586 * This hook fires when plugins are installed/updated through the WordPress updater
587 *
588 * @param WP_Upgrader $upgrader WP_Upgrader instance
589 * @param array $hook_extra Extra arguments passed to hooked filters
590 */
591 function metasync_handle_plugin_upgrade($upgrader, $hook_extra)
592 {
593 // Only process plugin updates/installs
594 if (!isset($hook_extra['type']) || $hook_extra['type'] !== 'plugin') {
595 return;
596 }
597
598 // By the time upgrader_process_complete fires, the upgrader may have
599 // deleted the directory this (old, still-in-memory) copy of the plugin was
600 // loaded from — e.g. when the installed dir name differs from the package's
601 // root dir ('metasync-develop' vs 'metasync'). The Composer classmap then
602 // points at files that no longer exist, so autoloading Metasync_Activator
603 // below would fatal. Bail instead; the whitelabel re-import runs on the next
604 // request via check_metasync_updates() once the new copy is active.
605 if (!class_exists('Metasync_Activator', false)) {
606 $activator = __DIR__ . '/includes/class-metasync-activator.php';
607 if (!is_file($activator)) {
608 return;
609 }
610 require_once $activator;
611 }
612
613 // Only process install and update actions
614 if (!isset($hook_extra['action']) || !in_array($hook_extra['action'], ['install', 'update'], true)) {
615 return;
616 }
617
618 $this_plugin = plugin_basename(__FILE__);
619 $this_plugin_slug = dirname($this_plugin); // Get 'metasync' from 'metasync/metasync.php'
620 $should_import = false;
621
622 // Handle bulk updates
623 if (isset($hook_extra['bulk']) && $hook_extra['bulk'] === true && isset($hook_extra['plugins'])) {
624 foreach ($hook_extra['plugins'] as $plugin) {
625 // Match by exact path OR by plugin slug/directory
626 if ($plugin === $this_plugin || dirname($plugin) === $this_plugin_slug) {
627 $should_import = true;
628 break;
629 }
630 }
631 }
632
633 // Handle single plugin update/install
634 if (isset($hook_extra['plugin'])) {
635 $plugin = $hook_extra['plugin'];
636 // Match by exact path OR by plugin slug/directory
637 if ($plugin === $this_plugin || dirname($plugin) === $this_plugin_slug) {
638 $should_import = true;
639 }
640 }
641
642 // SPECIAL CASE: When uploading plugin via "Add New > Upload Plugin",
643 // WordPress doesn't set the 'plugin' parameter during 'install' action.
644 // Check if we can get the plugin info from the upgrader result or whitelabel file exists.
645 if (!$should_import && $hook_extra['action'] === 'install') {
646 // Check upgrader result for destination
647 if (isset($upgrader->result) && isset($upgrader->result['destination'])) {
648 $destination = $upgrader->result['destination'];
649 // Check if destination contains our plugin slug
650 if (strpos($destination, $this_plugin_slug) !== false) {
651 $should_import = true;
652 }
653 }
654
655 // Fallback: Check if whitelabel file exists in our plugin directory
656 // This means our plugin was just installed/updated with whitelabel settings
657 if (!$should_import) {
658 $whitelabel_file = Metasync_Activator::get_whitelabel_settings_file();
659 if ($whitelabel_file !== false) {
660 $should_import = true;
661 }
662 }
663 }
664
665 if ($should_import) {
666 Metasync_Activator::check_whitelabel_settings_update(true);
667 }
668 }
669
670 // Hook into WordPress upgrader to detect plugin updates
671 add_action('upgrader_process_complete', 'metasync_handle_plugin_upgrade', 10, 2);
672
673 /**
674 * Fallback: Check for whitelabel file changes on admin pages
675 * This handles edge cases where upgrader_process_complete doesn't fire
676 * (e.g., FTP uploads, manual file replacements)
677 * Only checks once per admin request to minimize performance impact
678 */
679 function metasync_check_whitelabel_on_admin()
680 {
681 static $checked = false;
682 if ($checked || !current_user_can('manage_options')) {
683 return;
684 }
685 $checked = true;
686
687 require_once plugin_dir_path(__FILE__) . 'includes/class-metasync-activator.php';
688
689 // Surface a persistently failing White Label import instead of
690 // retrying it silently on every admin request.
691 add_action('admin_notices', array('Metasync_Activator', 'render_whitelabel_import_failure_notice'));
692
693 Metasync_Activator::check_whitelabel_settings_update();
694 }
695
696 // Retry package imports on an authorized admin request when an update completed
697 // through the public version-check or stale-classmap fallback path.
698 add_action('admin_init', 'metasync_check_whitelabel_on_admin', 1);
699
700 // Include Media Optimization Module
701 if (!metasync_is_non_metasync_admin_ajax()) {
702 require_once plugin_dir_path(__FILE__) . 'media-optimization/media-optimization-loader.php';
703 }
704
705 // Include Code Minification & Delivery Module
706 if (!metasync_is_non_metasync_admin_ajax()) {
707 require_once plugin_dir_path(__FILE__) . 'code-minification/code-minification-loader.php';
708 }
709
710
711 function run_metasync()
712 {
713 $plugin = new Metasync();
714 $plugin->run();
715 }
716 run_metasync();
717
718 // MCP server bootstrap (server + tool registration) extracted to keep this entry file lean.
719 require_once plugin_dir_path( __FILE__ ) . 'includes/mcp-server-bootstrap.php';
720
721 /**
722 * Schedule a daily cron event to auto-purge Sync History records older than 90 days.
723 */
724 function metasync_schedule_sync_log_cleanup() {
725 if (!wp_next_scheduled('metasync_sync_log_daily_cleanup')) {
726 wp_schedule_event(time(), 'daily', 'metasync_sync_log_daily_cleanup');
727 }
728 }
729 add_action('wp', 'metasync_schedule_sync_log_cleanup');
730
731 /**
732 * Cron callback: delete Sync History records older than 90 days.
733 */
734 function metasync_sync_log_cleanup_handler() {
735 $sync_db = new Metasync_Sync_History_Database();
736 $sync_db->delete_older_than_days(90);
737 }
738 add_action('metasync_sync_log_daily_cleanup', 'metasync_sync_log_cleanup_handler');
739
740 /**
741 * Output DYO initialization flag to the frontend
742 * Makes window.__SA_DYO_INITIALIZED__ = true available in the DOM
743 * This indicates the Search Atlas plugin is active and initialized
744 */
745 function metasync_output_dyo_init_flag() {
746 echo '<script>window.__SA_DYO_INITIALIZED__=true;</script>' . "\n";
747 }
748 add_action('wp_head', 'metasync_output_dyo_init_flag', 1);
749
750 // Runtime feature initialisers (GA4, API backoff, review notice, JWT accessor, debug mode) extracted to keep this entry file lean.
751 require_once plugin_dir_path( __FILE__ ) . 'includes/metasync-runtime-init.php';
752
753 /**
754 * Append a "Website Studio" post state to LPS-synced / MetaSync custom pages in
755 * the admin Pages list, so site owners can tell at a glance which pages are
756 * managed by Website Studio and shouldn't be hand-edited.
757 *
758 * Hooks WordPress core's display_post_states filter — the same mechanism that
759 * renders the grey inline tags like "— Front Page" / "— Draft" — so the label
760 * is native-styled and only appears next to relevant page titles, with no
761 * custom admin column.
762 *
763 * @param string[] $post_states Existing post-state labels keyed by slug.
764 * @param WP_Post $post The post being listed.
765 * @return string[] Possibly-augmented post states.
766 */
767 function metasync_add_lps_post_state($post_states, $post) {
768 // metasync_is_custom_or_lps_page() lives in otto/otto_pixel.php, which is NOT
769 // loaded on non-MetaSync admin-ajax requests (e.g. Quick Edit's inline-save,
770 // where this filter still fires), so guard against the undefined function.
771 if (!function_exists('metasync_is_custom_or_lps_page')) {
772 return $post_states;
773 }
774 if (!metasync_is_custom_or_lps_page($post->ID)) {
775 return $post_states;
776 }
777 $post_states['metasync_website_studio'] = __('Website Studio', 'metasync');
778 return $post_states;
779 }
780
781
782 /**
783 * Oxygen Builder Compatibility
784 * Auto re-signs [oxygen] dynamic-data shortcodes when their HMAC signatures
785 * are invalid (e.g. after design-set import or site migration).
786 * Runs once on admin_init; skips entirely when Oxygen is inactive.
787 */
788 if (is_admin()) {
789 add_action('admin_init', ['Metasync_Oxygen_Compat', 'maybe_resign_shortcodes'], 20);
790 add_filter('display_post_states', 'metasync_add_lps_post_state', 10, 2);
791 }
792