PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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
← All changes | admin/class-metasync-admin.php +544 -141 2.6.10trunk View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -198,13 +196,18 @@
198 196 $data = Metasync::get_option('general');
199 197
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 - if(!isset( $data['white_label_plugin_menu_slug'])){
203 - self::$page_slug = "searchatlas";
204 - }else{
205 - self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
206 - }
200 + $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 + $clean_slug = sanitize_title($raw_slug);
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 + # reader that builds admin links from the stored option gets a valid WP menu slug.
204 + if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 + $options = Metasync::get_option();
206 + $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 + Metasync::set_option($options);
208 + }
209 + self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
207 210
208 211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
@@ -281,8 +284,11 @@
281 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
282 285
283 286 // Add AJAX for saving hosting cache settings
284 287 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
288 +
289 + // Add AJAX for saving OTTO Cache TTL
290 + add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
285 291 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
286 292
287 293 // Add AJAX for saving edge cache / CDN settings
288 294 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -307,8 +313,9 @@
307 313 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
308 314 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
309 315 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
310 316 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
317 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
311 318
312 319 // Add AJAX handlers for host blocking test
313 320 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
314 321 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -362,8 +369,9 @@
362 369 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
363 370 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
364 371 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
365 372 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
373 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
366 374 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
367 375
368 376 # Add AJAX handlers for Google Instant Indexing
369 377 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
@@ -458,9 +466,11 @@
458 466
459 467 $data= Metasync::get_option('general');
460 468
461 469 # Get white label menu slug
462 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
470 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
471 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
472 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
463 473
464 474 ?>
465 475 <style>
466 476 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -481,19 +491,125 @@
481 491 if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
482 492 return;
483 493 }
484 494 ?>
485 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
495 + <style>
496 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
497 + /*
498 + * WP prints admin notices at the top of #wpbody-content, then core's
499 + * common.js relocates them next to .wp-header-end inside our wrap on
500 + * DOM-ready. Keep them out of the flow until that move so they don't
501 + * flash above the dashboard header and shove the page down.
502 + * Once relocated they're no longer a direct child here, so this rule
503 + * stops matching and they fade in with the wrap. We mirror core's
504 + * relocation set exactly (.notice/.updated/.error, minus .inline and
505 + * .below-h2); .update-nag is left alone since core never moves it.
506 + */
507 + #wpbody-content > .notice:not(.inline):not(.below-h2),
508 + #wpbody-content > .updated:not(.inline):not(.below-h2),
509 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
510 + </style>
486 511 <?php
487 512 }
488 513
489 514 public function suppress_notices_on_wizard_page() {
490 - if ( isset( $_GET['page'] ) && strpos( $_GET['page'], '-setup-wizard' ) !== false ) {
515 + if ( ! isset( $_GET['page'] ) ) {
516 + return;
517 + }
518 +
519 + $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
520 +
521 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
522 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
491 523 remove_all_actions( 'admin_notices' );
492 524 remove_all_actions( 'all_admin_notices' );
525 + return;
493 526 }
527 +
528 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
529 + if ( strpos( $page, self::$page_slug ) === 0 ) {
530 + $this->remove_third_party_admin_notices();
531 + }
494 532 }
495 533
534 + /**
535 + * Remove admin-notice callbacks that don't belong to this plugin.
536 + *
537 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
538 + * global admin_notices / all_admin_notices actions, which WordPress fires on
539 + * every admin screen — so their notices leak onto our settings pages. We walk
540 + * the registered callbacks and drop any whose code lives outside this plugin's
541 + * directory, leaving every one of our own notices intact.
542 + */
543 + private function remove_third_party_admin_notices() {
544 + global $wp_filter;
545 +
546 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
547 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
548 + continue;
549 + }
550 +
551 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
552 + $to_remove = array();
553 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
554 + foreach ( $callbacks as $callback ) {
555 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
556 + $to_remove[] = array( $callback['function'], $priority );
557 + }
558 + }
559 + }
560 +
561 + foreach ( $to_remove as $entry ) {
562 + remove_action( $hook, $entry[0], $entry[1] );
563 + }
564 + }
565 + }
566 +
567 + /**
568 + * Determine whether an admin-notice callback is defined by this plugin.
569 + *
570 + * Ownership is decided by where the callback's code physically lives, not by
571 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
572 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
573 + * rename the plugin folder. Resolving the declaring file and checking it sits
574 + * inside this plugin's directory is robust against all of that. If a callback
575 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
576 + *
577 + * @param string|array|Closure $function The registered callback.
578 + * @return bool True when the callback's code lives inside this plugin.
579 + */
580 + private function is_own_admin_notice_callback( $function ) {
581 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
582 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
583 +
584 + try {
585 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
586 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
587 + $ref = new ReflectionMethod( $class, $function[1] );
588 + } elseif ( $function instanceof Closure ) {
589 + $ref = new ReflectionFunction( $function );
590 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
591 + list( $class, $method ) = explode( '::', $function, 2 );
592 + $ref = new ReflectionMethod( $class, $method );
593 + } elseif ( is_string( $function ) ) {
594 + $ref = new ReflectionFunction( $function );
595 + } else {
596 + return false;
597 + }
598 + } catch ( \Throwable $e ) {
599 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
600 + return true;
601 + }
602 +
603 + $file = $ref->getFileName();
604 + if ( ! $file ) {
605 + // Internal/built-in callback (no source file) — not ours.
606 + return false;
607 + }
608 +
609 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
610 + }
611 +
496 612 #---------fixes issue : #95 ----------
497 613 #This function is to redirect in case client changes slug on fresh install
498 614 #It is called by the add_option hook
499 615
@@ -853,10 +969,41 @@
853 969 array('jquery'),
854 970 $this->version,
855 971 true
856 972 );
973 +
974 + // Keeps core's admin-menu pinning in step with pages whose height
975 + // changes after load. Depends on 'common' so core has bound its
976 + // own handlers before this runs.
977 + wp_enqueue_script(
978 + $this->plugin_name . '-admin-menu-height',
979 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
980 + array('jquery', 'common'),
981 + $this->version,
982 + true
983 + );
857 984 }
858 985
986 + $per_page_screens = [
987 + self::$page_slug . '-redirections',
988 + self::$page_slug . '-404-monitor',
989 + self::$page_slug . '-sync-log',
990 + self::$page_slug . '-media-optimization',
991 + ];
992 +
993 + if (in_array($current_page, $per_page_screens, true)) {
994 + // Results-per-page selectors use this external handler instead of
995 + // an inline event attribute so changing a page size always reloads
996 + // the current admin page.
997 + wp_enqueue_script(
998 + $this->plugin_name . '-per-page',
999 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1000 + array(),
1001 + $this->version,
1002 + true
1003 + );
1004 + }
1005 +
859 1006 // Dashboard iframe height (only on dashboard page)
860 1007 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
861 1008 wp_enqueue_script(
862 1009 $this->plugin_name . '-iframe',
@@ -1041,9 +1188,8 @@
1041 1188 true
1042 1189 );
1043 1190 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1044 1191 'optionKey' => self::option_key,
1045 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1046 1192 'adminPostUrl' => admin_url('admin-post.php'),
1047 1193 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1048 1194 'ajaxUrl' => admin_url('admin-ajax.php'),
1049 1195 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1131,8 +1277,11 @@
1131 1277 array('jquery'),
1132 1278 $this->version,
1133 1279 true
1134 1280 );
1281 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1282 + 'nonce' => wp_create_nonce('metasync_nonce'),
1283 + ));
1135 1284 }
1136 1285
1137 1286 // Add redirection form
1138 1287 if ($current_page === self::$page_slug . '-redirections') {
@@ -1327,14 +1476,44 @@
1327 1476 }
1328 1477
1329 1478 /**
1330 1479 * Settings of HeartBeat API for admin area.
1331 - * Set time interval of send request.
1480 + *
1481 + * The only thing the plugin rides the beat for is a periodic admin
1482 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1483 + * so it slows the beat down rather than speeding it up. Two contexts are
1484 + * left on whatever core decided, because core leans on the beat there:
1485 + *
1486 + * - The front end, where the beat drives wp-auth-check — the "your session
1487 + * has expired, log in again" prompt.
1488 + * - The editor screens, where each tick renews the post lock. A lock only
1489 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1490 + * interval longer than that drops the lock between beats and lets a
1491 + * second editor silently take over a post someone still has open.
1492 + *
1493 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1494 + * over-long interval is not corrected on our behalf.
1495 + *
1496 + * @param array $settings Heartbeat settings array.
1497 + * @return array Heartbeat settings array.
1332 1498 */
1333 1499 function metasync_heartbeat_settings($settings)
1334 1500 {
1335 - global $heartbeat_frequency;
1336 - $settings['interval'] = 300;
1501 + if (!is_admin()) {
1502 + return $settings;
1503 + }
1504 +
1505 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1506 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1507 + global $pagenow;
1508 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1509 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1510 + return $settings;
1511 + }
1512 +
1513 + # 60s matches heartbeat.js's own default and stays well inside the
1514 + # 150s post-lock window, so nothing core does can fall through a gap.
1515 + $settings['interval'] = 60;
1337 1516 return $settings;
1338 1517 }
1339 1518
1340 1519 /**
@@ -1503,8 +1682,47 @@
1503 1682 // Get cache count
1504 1683 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1505 1684
1506 1685 ?>
1686 + <!-- OTTO Cache TTL Setting -->
1687 + <div style="margin-bottom: 30px; padding-top: 20px;">
1688 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1689 + <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1690 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1691 + </p>
1692 +
1693 + <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1694 + <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1695 + <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1696 + Cache TTL (minutes):
1697 + </label>
1698 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1699 + <input type="number"
1700 + id="metasync-otto-cache-ttl"
1701 + value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1702 + min="30"
1703 + max="1440"
1704 + step="1"
1705 + style="width: 100px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg, rgba(255,255,255,0.05)); color: var(--dashboard-text-primary);" />
1706 + <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1707 + </div>
1708 +
1709 + <div style="display: flex; align-items: center; gap: 12px;">
1710 + <button type="button"
1711 + id="metasync-otto-ttl-save-btn"
1712 + class="metasync-btn-primary"
1713 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1714 + onmouseover="this.style.transform='translateY(-1px)';"
1715 + onmouseout="this.style.transform='translateY(0)';">
1716 + Save TTL
1717 + </button>
1718 + <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1719 + </div>
1720 +
1721 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1722 + </div>
1723 + </div>
1724 +
1507 1725 <!-- Cache Plugin Management -->
1508 1726 <div style="margin-bottom: 30px;">
1509 1727 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1510 1728 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1622,8 +1840,9 @@
1622 1840 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1623 1841 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1624 1842 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1625 1843 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1844 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1626 1845 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1627 1846 </label>
1628 1847
1629 1848 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1632,8 +1851,9 @@
1632 1851 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1633 1852 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1634 1853 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1635 1854 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1855 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1636 1856 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1637 1857 </label>
1638 1858
1639 1859 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1647,9 +1867,9 @@
1647 1867 </button>
1648 1868 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1649 1869 </div>
1650 1870
1651 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1871 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1652 1872 </div>
1653 1873
1654 1874 <!-- Purge button -->
1655 1875 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1724,8 +1944,46 @@
1724 1944 setTimeout(function() { $msg.fadeOut(); }, 4000);
1725 1945 }
1726 1946 });
1727 1947 });
1948 +
1949 + // OTTO Cache TTL save
1950 + $('#metasync-otto-ttl-save-btn').on('click', function() {
1951 + var $btn = $(this);
1952 + var $msg = $('#metasync-otto-ttl-save-msg');
1953 + var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1954 +
1955 + if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1956 + $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1957 + return;
1958 + }
1959 +
1960 + $btn.prop('disabled', true).text('Saving…');
1961 +
1962 + $.ajax({
1963 + url: ajaxurl,
1964 + type: 'POST',
1965 + data: {
1966 + action: 'metasync_save_otto_cache_ttl',
1967 + otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1968 + otto_cache_ttl: ttl,
1969 + },
1970 + success: function(response) {
1971 + if (response.success) {
1972 + $msg.text('✅ Saved').css('color', '#22c55e').show();
1973 + } else {
1974 + $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
1975 + }
1976 + },
1977 + error: function() {
1978 + $msg.text('❌ Request failed').css('color', '#ef4444').show();
1979 + },
1980 + complete: function() {
1981 + $btn.prop('disabled', false).text('Save TTL');
1982 + setTimeout(function() { $msg.fadeOut(); }, 4000);
1983 + }
1984 + });
1985 + });
1728 1986 });
1729 1987 </script>
1730 1988 </div>
1731 1989
@@ -1743,9 +2001,9 @@
1743 2001 id="metasync-targeted-object-cache"
1744 2002 <?php checked('1', $targeted_cache_enabled); ?>
1745 2003 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1746 2004 <span>
1747 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2005 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1748 2006 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1749 2007 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1750 2008 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1751 2009 </span>
@@ -1983,8 +2241,46 @@
1983 2241 wp_send_json_success(array('message' => 'Object cache settings saved'));
1984 2242 }
1985 2243
1986 2244 /**
2245 + * Get OTTO Cache TTL value in minutes from execution settings.
2246 + *
2247 + * @return int
2248 + */
2249 + private function get_otto_cache_ttl_minutes() {
2250 + $execution_settings = get_option('metasync_execution_settings', array());
2251 + return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2252 + }
2253 +
2254 + /**
2255 + * AJAX handler for saving OTTO Cache TTL
2256 + */
2257 + public function ajax_save_otto_cache_ttl() {
2258 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce($_POST['otto_cache_ttl_nonce'], 'metasync_otto_cache_ttl_nonce')) {
2259 + wp_send_json_error(array('message' => 'Security check failed.'));
2260 + return;
2261 + }
2262 +
2263 + if (!current_user_can('manage_options')) {
2264 + wp_send_json_error(array('message' => 'Insufficient permissions.'));
2265 + return;
2266 + }
2267 +
2268 + $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2269 +
2270 + if ($ttl < 30 || $ttl > 1440) {
2271 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2272 + return;
2273 + }
2274 +
2275 + $settings = get_option('metasync_execution_settings', array());
2276 + $settings['otto_cache_ttl'] = $ttl;
2277 + update_option('metasync_execution_settings', $settings);
2278 +
2279 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2280 + }
2281 +
2282 + /**
1987 2283 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1988 2284 */
1989 2285 public function handle_purge_hosting_cache() {
1990 2286 if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
@@ -2472,8 +2768,15 @@
2472 2768 // Handle form submissions
2473 2769 if (isset($_POST['metasync_media_optimization_nonce'])) {
2474 2770 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2475 2771
2772 + // Settings shape the whole site's conversion pipeline — restrict
2773 + // the save (and the reset) to site admins, not just anyone who can
2774 + // open the page via plugin_access_roles.
2775 + if (!current_user_can('manage_options')) {
2776 + wp_die(__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2777 + }
2778 +
2476 2779 // Handle reset to defaults
2477 2780 if (!empty($_POST['metasync_media_reset'])) {
2478 2781 $defaults = Metasync_Media_Settings::get_defaults();
2479 2782 Metasync_Media_Settings::save_settings($defaults);
@@ -2563,8 +2866,15 @@
2563 2866 if (!$attachment_id) {
2564 2867 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2565 2868 }
2566 2869
2870 + // Ownership: under the replace strategy this permanently deletes the
2871 + // attachment's original file, so upload_files alone is not enough —
2872 + // the caller must be allowed to edit this specific attachment.
2873 + if (!current_user_can('edit_post', $attachment_id)) {
2874 + wp_send_json_error(__('Permission denied.', 'metasync'));
2875 + }
2876 +
2567 2877 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2568 2878 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2569 2879 $settings = Metasync_Media_Settings::get_settings();
2570 2880
@@ -2616,8 +2926,20 @@
2616 2926 if (!$attachment_id) {
2617 2927 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2618 2928 }
2619 2929
2930 + // Ownership: reverting deletes the attachment's converted files, so
2931 + // restrict it to users who may edit this specific attachment.
2932 + if (!current_user_can('edit_post', $attachment_id)) {
2933 + wp_send_json_error(__('Permission denied.', 'metasync'));
2934 + }
2935 +
2936 + // Replace-strategy conversions have no original to restore — reverting
2937 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2938 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2939 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2940 + }
2941 +
2620 2942 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2621 2943
2622 2944 if ($success) {
2623 2945 wp_send_json_success();
@@ -2669,8 +2991,15 @@
2669 2991 public function ajax_batch_progress()
2670 2992 {
2671 2993 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2672 2994
2995 + // Read-only progress/stats polling — the same capability that can
2996 + // open the media optimization page is enough, but a nonce alone
2997 + // never was.
2998 + if (!current_user_can('upload_files')) {
2999 + wp_send_json_error(__('Permission denied.', 'metasync'));
3000 + }
3001 +
2673 3002 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2674 3003 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2675 3004
2676 3005 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2701,10 +3030,19 @@
2701 3030 $settings = Metasync_Media_Settings::get_settings();
2702 3031
2703 3032 $success = 0;
2704 3033 $failed = 0;
3034 + $denied = 0;
2705 3035
2706 3036 foreach ($ids as $id) {
3037 + // Per-attachment ownership: replace mode deletes originals, so
3038 + // upload_files alone must not allow converting images the caller
3039 + // does not own/cannot edit.
3040 + if (!current_user_can('edit_post', $id)) {
3041 + $denied++;
3042 + continue;
3043 + }
3044 +
2707 3045 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2708 3046 $success++;
2709 3047 } else {
2710 3048 $failed++;
@@ -2713,8 +3051,9 @@
2713 3051
2714 3052 wp_send_json_success([
2715 3053 'success' => $success,
2716 3054 'failed' => $failed,
3055 + 'denied' => $denied,
2717 3056 ]);
2718 3057 }
2719 3058
2720 3059 /**
@@ -2737,11 +3076,19 @@
2737 3076
2738 3077 $success = 0;
2739 3078 $failed = 0;
2740 3079 $skipped = 0;
3080 + $denied = 0;
2741 3081 $errors = [];
2742 3082
2743 3083 foreach ($ids as $id) {
3084 + // Per-attachment ownership: reverting deletes the attachment's
3085 + // converted files, so restrict it per image, not per role.
3086 + if (!current_user_can('edit_post', $id)) {
3087 + $denied++;
3088 + continue;
3089 + }
3090 +
2744 3091 $format = get_post_meta($id, '_metasync_converted_format', true);
2745 3092
2746 3093 if (!$format) {
2747 3094 $skipped++;
@@ -2769,8 +3116,9 @@
2769 3116 wp_send_json_success([
2770 3117 'success' => $success,
2771 3118 'failed' => $failed,
2772 3119 'skipped' => $skipped,
3120 + 'denied' => $denied,
2773 3121 'errors' => $errors,
2774 3122 ]);
2775 3123 }
2776 3124
@@ -2794,8 +3142,49 @@
2794 3142 wp_send_json_success($progress);
2795 3143 }
2796 3144
2797 3145 /**
3146 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3147 + *
3148 + * Only deletes when the file is confirmed missing on disk, so valid
3149 + * attachments can never be removed through this endpoint.
3150 + */
3151 + public function ajax_delete_orphaned_image()
3152 + {
3153 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3154 +
3155 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3156 + if (!$attachment_id) {
3157 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3158 + }
3159 +
3160 + if (!current_user_can('delete_post', $attachment_id)) {
3161 + wp_send_json_error(__('Permission denied.', 'metasync'));
3162 + }
3163 +
3164 + if (get_post_type($attachment_id) !== 'attachment') {
3165 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3166 + }
3167 +
3168 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3169 +
3170 + // Guard: only orphaned records (missing file) may be deleted here.
3171 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3172 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3173 + }
3174 +
3175 + $deleted = wp_delete_attachment($attachment_id, true);
3176 +
3177 + if ($deleted) {
3178 + wp_send_json_success([
3179 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3180 + ]);
3181 + }
3182 +
3183 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3184 + }
3185 +
3186 + /**
2798 3187 * Cron handler: Process batch optimization tick.
2799 3188 */
2800 3189 public function handle_media_batch_cron()
2801 3190 {
@@ -2852,8 +3241,10 @@
2852 3241 if (is_wp_error($result)) {
2853 3242 echo '<div class="notice notice-error"><p>' . esc_html(
2854 3243 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2855 3244 ) . '</p></div>';
3245 + } elseif (false === $result) {
3246 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2856 3247 } else {
2857 3248 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2858 3249 }
2859 3250 }
@@ -2916,8 +3307,12 @@
2916 3307 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2917 3308 'auto_detect' => isset($_POST['auto_detect']),
2918 3309 'taxonomies' => $video_taxonomies,
2919 3310 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3311 + // This array replaces the stored option wholesale, so every
3312 + // setting must be represented here or it is wiped on save.
3313 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3314 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
2920 3315 ];
2921 3316
2922 3317 // Always invalidate old cache before saving new settings
2923 3318 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2975,8 +3370,17 @@
2975 3370 if ($sitemap_generator->generate_news_sitemap()) {
2976 3371 $extras[] = 'news';
2977 3372 }
2978 3373 }
3374 +
3375 + // Reclaim unreferenced memory between the news and video passes
3376 + // so the video generator starts with more headroom (they share
3377 + // one request). Each generator already releases its own object
3378 + // cache via clean_post_cache(), so no cache flush is needed here.
3379 + if (function_exists('gc_collect_cycles')) {
3380 + gc_collect_cycles();
3381 + }
3382 +
2979 3383 if (!empty($video_opts['enabled'])) {
2980 3384 if ($sitemap_generator->generate_video_sitemap()) {
2981 3385 $extras[] = 'video';
2982 3386 }
@@ -2981,8 +3385,13 @@
2981 3385 $extras[] = 'video';
2982 3386 }
2983 3387 }
2984 3388
3389 + // Same inter-pass GC before the main sitemap pass begins.
3390 + if (function_exists('gc_collect_cycles')) {
3391 + gc_collect_cycles();
3392 + }
3393 +
2985 3394 // Generate main sitemap (its index will include news/video since they now exist)
2986 3395 $result = $sitemap_generator->generate_sitemap();
2987 3396
2988 3397 if (is_wp_error($result)) {
@@ -2990,8 +3399,10 @@
2990 3399 error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
2991 3400 echo '<div class="notice notice-error"><p>' . esc_html(
2992 3401 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
2993 3402 ) . '</p></div>';
3403 + } elseif (false === $result) {
3404 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2994 3405 } else {
2995 3406 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2996 3407 if (!empty($extras)) {
2997 3408 $message .= ' ' . sprintf(
@@ -3030,8 +3441,12 @@
3030 3441
3031 3442 if ($deleted) {
3032 3443 // Disable auto-update only when the general sitemap is removed
3033 3444 update_option('metasync_sitemap_auto_update', false);
3445 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3446 + if (!$sitemap_generator->sitemap_exists()) {
3447 + delete_option('metasync_disable_wp_sitemap');
3448 + }
3034 3449 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3035 3450 } else {
3036 3451 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete general sitemap. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3037 3452 }
@@ -3057,8 +3472,10 @@
3057 3472
3058 3473 if ($deleted) {
3059 3474 // Also disable auto-update when deleting
3060 3475 update_option('metasync_sitemap_auto_update', false);
3476 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3477 + delete_option('metasync_disable_wp_sitemap');
3061 3478 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3062 3479 } else {
3063 3480 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3064 3481 }
@@ -3206,8 +3623,9 @@
3206 3623 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3207 3624 $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3208 3625 ?>
3209 3626 <form method="POST" action="">
3627 + <?php wp_nonce_field('metasync_instant_indexing_settings', 'metasync_instant_indexing_nonce'); ?>
3210 3628 <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3211 3629 <div class="dashboard-card" style="padding: 20px;">
3212 3630 <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3213 3631 </div>
@@ -3241,16 +3659,8 @@
3241 3659 $this->render_layout_close();
3242 3660 }
3243 3661
3244 3662 /**
3245 - * General Options page callback
3246 - */
3247 - public function create_admin_optimal_settings_page()
3248 - {
3249 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3250 - }
3251 -
3252 - /**
3253 3663 * Global Options page callback
3254 3664 */
3255 3665 public function create_admin_global_settings_page()
3256 3666 {
@@ -3282,16 +3692,8 @@
3282 3692 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3283 3693 }
3284 3694
3285 3695 /**
3286 - * Site Optimal Settings page callback
3287 - */
3288 - public function optimization_settings_options()
3289 - {
3290 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3291 - }
3292 -
3293 - /**
3294 3696 * redirection page callback with tabs
3295 3697 */
3296 3698 public function create_admin_redirections_page()
3297 3699 {
@@ -3318,10 +3720,11 @@
3318 3720 }
3319 3721 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3320 3722 echo '<div class="notice notice-warning is-dismissible"><p>';
3321 3723 printf(
3322 - /* translators: 1: current load, 2: threshold, 3: core count */
3323 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3724 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3725 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3726 + esc_html( Metasync::get_effective_plugin_name() ),
3324 3727 esc_html( $data['load'] ),
3325 3728 esc_html( $data['threshold'] ),
3326 3729 esc_html( $data['cores'] )
3327 3730 );
@@ -3339,9 +3742,9 @@
3339 3742 if (!get_transient('metasync_llms_conflict')) {
3340 3743 return;
3341 3744 }
3342 3745 echo '<div class="notice notice-info is-dismissible"><p>';
3343 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3746 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3344 3747 echo '</p></div>';
3345 3748 }
3346 3749
3347 3750 /**
@@ -3410,8 +3813,17 @@
3410 3813 public function ajax_restore_robots_backup()
3411 3814 {
3412 3815 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3413 3816 }
3817 +
3818 + /**
3819 + * AJAX handler to fetch a paginated page of robots.txt backups
3820 + */
3821 + public function ajax_get_robots_backups()
3822 + {
3823 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3824 + }
3825 +
3414 3826 public function ajax_create_redirect_from_404()
3415 3827 {
3416 3828 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3417 3829 }
@@ -3524,9 +3936,9 @@
3524 3936 }
3525 3937
3526 3938 // Get pagination parameters
3527 3939 $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3528 - $per_page = 10;
3940 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3529 3941 $offset = ($page - 1) * $per_page;
3530 3942
3531 3943 // Get filters
3532 3944 $filters = [
@@ -3604,9 +4016,9 @@
3604 4016 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3605 4017 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3606 4018 🗑 Clear Log
3607 4019 </button>
3608 - <form method="get" class="sync-filters-form" onchange="this.submit()">
4020 + <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
3609 4021 <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
3610 4022
3611 4023 <select name="date_range" class="sync-filter-select">
3612 4024 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
@@ -3656,19 +4068,20 @@
3656 4068 </div>
3657 4069 </div>
3658 4070
3659 4071 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3660 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3661 - <span class="sync-status-badge sync-status-published">
3662 - <span class="sync-status-icon">✓</span>
3663 - Published
3664 - </span>
3665 - <?php else: ?>
3666 - <span class="sync-status-badge sync-status-draft">
3667 - <span class="sync-status-icon">i</span>
3668 - Draft
3669 - </span>
3670 - <?php endif; ?>
4072 + <?php
4073 + $st = (string) $record->status;
4074 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4075 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4076 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4077 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4078 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4079 + ?>
4080 + <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4081 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4082 + <?php echo esc_html($b_label); ?>
4083 + </span>
3671 4084 <?php if ($record->source === 'MCP Client'): ?>
3672 4085 <button type="button"
3673 4086 class="metasync-rollback-btn"
3674 4087 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3684,14 +4097,15 @@
3684 4097 <?php endif; ?>
3685 4098 </div>
3686 4099
3687 4100 <!-- Pagination -->
3688 - <?php if ($total_pages > 1): ?>
3689 - <div class="sync-log-pagination">
3690 - <div class="sync-log-pagination-info">
3691 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3692 - </div>
4101 + <div class="sync-log-pagination">
4102 + <div class="sync-log-pagination-info">
4103 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4104 + <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4105 + </div>
3693 4106
4107 + <?php if ($total_pages > 1): ?>
3694 4108 <div class="sync-log-pagination-controls">
3695 4109 <?php if ($page > 1): ?>
3696 4110 <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3697 4111 <?php endif; ?>
@@ -3704,10 +4118,10 @@
3704 4118 <?php if ($page < $total_pages): ?>
3705 4119 <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3706 4120 <?php endif; ?>
3707 4121 </div>
3708 - </div>
3709 - <?php endif; ?>
4122 + <?php endif; ?>
4123 + </div>
3710 4124 </div>
3711 4125 <?php $this->render_layout_close(); ?>
3712 4126
3713 4127 <script>
@@ -3788,8 +4202,19 @@
3788 4202 if (!empty($value)) {
3789 4203 $query_parts[] = $key . '=' . urlencode($value);
3790 4204 }
3791 4205 }
4206 +
4207 + // Preserve the user-selected results-per-page value across page
4208 + // navigation so a non-default page size survives clicking a page link.
4209 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4210 + if (isset($_GET[$per_page_key])) {
4211 + $per_page = (int) $_GET[$per_page_key];
4212 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4213 + $query_parts[] = $per_page_key . '=' . $per_page;
4214 + }
4215 + }
4216 +
3792 4217 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3793 4218 }
3794 4219
3795 4220 /**
@@ -4020,8 +4445,9 @@
4020 4445 $stats = Metasync_CPU_Monitor::get_stats();
4021 4446 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4022 4447 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4023 4448 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4449 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4024 4450 ?>
4025 4451 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4026 4452 <!-- CPU Cores Detected -->
4027 4453 <div style="margin-bottom: 24px;">
@@ -4028,12 +4454,20 @@
4028 4454 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4029 4455 CPU Cores Detected
4030 4456 </label>
4031 4457 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4032 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4458 + <?php if ($detection_reliable) : ?>
4459 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4460 + <?php else : ?>
4461 + <strong>Not detected</strong>
4462 + <?php endif; ?>
4033 4463 </div>
4034 4464 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4035 - Automatically detected on this system.
4465 + <?php if ($detection_reliable) : ?>
4466 + Automatically detected on this system.
4467 + <?php else : ?>
4468 + Core detection is not available on this hosting environment.
4469 + <?php endif; ?>
4036 4470 </p>
4037 4471 </div>
4038 4472
4039 4473 <!-- Per-Core Load Threshold -->
@@ -4039,8 +4473,9 @@
4039 4473 <!-- Per-Core Load Threshold -->
4040 4474 <div style="margin-bottom: 24px;">
4041 4475 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4042 4476 Per-Core Load Threshold
4477 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
4043 4478 </label>
4044 4479 <input type="number"
4045 4480 id="cpu_load_per_core_threshold"
4046 4481 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4592,88 +5027,8 @@
4592 5027
4593 5028 /**
4594 5029 * Get the settings option array and print one of its values
4595 5030 */
4596 - public function no_index_posts_callback()
4597 - {
4598 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4599 - }
4600 -
4601 - /**
4602 - * Get the settings option array and print one of its values
4603 - */
4604 - public function no_follow_links_callback()
4605 - {
4606 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4607 - }
4608 -
4609 - /**
4610 - * Get the settings option array and print one of its values
4611 - */
4612 - public function open_external_links_callback()
4613 - {
4614 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4615 - }
4616 -
4617 - /**
4618 - * Get the settings option array and print one of its values
4619 - */
4620 - public function add_alt_image_tags_callback()
4621 - {
4622 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4623 - }
4624 -
4625 - /**
4626 - * Get the settings option array and print one of its values
4627 - */
4628 - public function add_title_image_tags_callback()
4629 - {
4630 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4631 - }
4632 -
4633 - /**
4634 - * Get the settings option array and print one of its values
4635 - */
4636 - public function site_type_callback()
4637 - {
4638 - Metasync_Settings_Fields::instance()->site_type_callback();
4639 - }
4640 -
4641 - /**
4642 - * Get the settings option array and print one of its values
4643 - */
4644 - public function site_business_type_callback()
4645 - {
4646 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4647 - }
4648 -
4649 - /**
4650 - * Get the settings option array and print one of its values
4651 - */
4652 - public function site_company_name_callback()
4653 - {
4654 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4655 - }
4656 -
4657 - /**
4658 - * Get the settings option array and print one of its values
4659 - */
4660 - public function site_google_logo_callback()
4661 - {
4662 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4663 - }
4664 -
4665 - /**
4666 - * Get the settings option array and print one of its values
4667 - */
4668 - public function site_social_share_image_callback()
4669 - {
4670 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4671 - }
4672 -
4673 - /**
4674 - * Get the settings option array and print one of its values
4675 - */
4676 5031 public function common_robot_meta_tags_callback()
4677 5032 {
4678 5033 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4679 5034 }
@@ -4871,8 +5226,14 @@
4871 5226 * Show a one-time admin notice when a page builder is detected but the
4872 5227 * "Default Page Builder" setting has never been explicitly saved.
4873 5228 */
4874 5229 public function display_page_builder_notice() {
5230 + // Only relevant on the plugin's own settings page — avoid repeating
5231 + // this notice across every admin screen.
5232 + if (!isset($_GET['page']) || $_GET['page'] !== self::$page_slug) {
5233 + return;
5234 + }
5235 +
4875 5236 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4876 5237
4877 5238 // Setting already saved — nothing to warn about
4878 5239 if (!empty($configured)) {
@@ -5840,8 +6201,16 @@
5840 6201 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5841 6202 return;
5842 6203 }
5843 6204
6205 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6206 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6207 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6208 + // produced by the converter.
6209 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6210 + return;
6211 + }
6212 +
5844 6213 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5845 6214 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5846 6215
5847 6216 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5957,8 +6326,14 @@
5957 6326 * @return void Sends JSON response and exits
5958 6327 */
5959 6328 public function ajax_send_giapi()
5960 6329 {
6330 + check_ajax_referer('metasync_nonce', 'nonce');
6331 +
6332 + if (!Metasync::current_user_has_plugin_access()) {
6333 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6334 + }
6335 +
5961 6336 $post_data = metasync_sanitize_input_array($_POST);
5962 6337 if (!isset($post_data['metasync_giapi_url'])) {
5963 6338 return;
5964 6339 }
@@ -6024,8 +6399,14 @@
6024 6399 * @return void Sends JSON response and exits
6025 6400 */
6026 6401 public function ajax_send_bing_indexnow()
6027 6402 {
6403 + check_ajax_referer('metasync_nonce', 'nonce');
6404 +
6405 + if (!Metasync::current_user_has_plugin_access()) {
6406 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6407 + }
6408 +
6028 6409 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6029 6410 $bing_instant_index = new Metasync_Bing_Instant_Index();
6030 6411 $bing_instant_index->send();
6031 6412 }
@@ -6042,8 +6423,18 @@
6042 6423 if (!isset($_POST['submit'])) {
6043 6424 return;
6044 6425 }
6045 6426
6427 + // This handler runs on admin_init, which also fires inside
6428 + // admin-ajax.php before its login gate, so a bare isset() check let
6429 + // any request (including logged-out ones) rewrite the auto-submit
6430 + // post types. Require the nonce and plugin access before writing.
6431 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6432 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6433 + || !Metasync::current_user_has_plugin_access()) {
6434 + return;
6435 + }
6436 +
6046 6437 // Save post types for Google Instant Indexing auto-submit
6047 6438 if (isset($_POST['metasync_post_types'])) {
6048 6439 $post_data = metasync_sanitize_input_array($_POST);
6049 6440 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6127,12 +6518,24 @@
6127 6518 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6128 6519 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6129 6520 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6130 6521
6131 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6132 - $service_info = google_index_direct()->get_service_account_info();
6133 - if (!isset($service_info['error'])) {
6134 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6522 + if (in_array($post->post_type, $post_types)) {
6523 + // save_post is the single owner of auto-submit and also fires
6524 + // on REST-created posts, where nothing else may have loaded
6525 + // the Google Index helpers yet — load them on demand instead
6526 + // of silently skipping.
6527 + if (!function_exists('google_index_direct')) {
6528 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6529 + if (file_exists($google_index_path)) {
6530 + require_once $google_index_path;
6531 + }
6532 + }
6533 + if (function_exists('google_index_direct')) {
6534 + $service_info = google_index_direct()->get_service_account_info();
6535 + if (!isset($service_info['error'])) {
6536 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6537 + }
6135 6538 }
6136 6539 }
6137 6540 }
6138 6541