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 / metasync.php

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

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