PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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
← All changes | admin/class-metasync-admin.php +521 -170 2.6.13trunk 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.
@@ -200,9 +198,9 @@
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 200 $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
203 201 $clean_slug = sanitize_title($raw_slug);
204 - # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
205 203 # reader that builds admin links from the stored option gets a valid WP menu slug.
206 204 if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
207 205 $options = Metasync::get_option();
208 206 $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
@@ -265,8 +263,9 @@
265 263 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
266 264
267 265 add_action('admin_init', array($this, 'initialize_cookie'));
268 266 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
267 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
269 268
270 269 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
271 270 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
272 271 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -284,8 +283,11 @@
284 283
285 284 // Add AJAX for saving execution settings
286 285 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
287 286
287 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
288 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
289 +
288 290 // Add AJAX for saving hosting cache settings
289 291 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
290 292
291 293 // Add AJAX for saving OTTO Cache TTL
@@ -315,8 +317,9 @@
315 317 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
316 318 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
317 319 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
318 320 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
321 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
319 322
320 323 // Add AJAX handlers for host blocking test
321 324 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
322 325 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -370,10 +373,18 @@
370 373 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
371 374 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
372 375 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
373 376 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
377 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
374 378 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
375 379
380 + # SEO Restore (Rollback) AJAX & Cron handlers
381 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
382 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
383 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
384 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
385 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
386 +
376 387 # Add AJAX handlers for Google Instant Indexing
377 388 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
378 389
379 390 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -466,9 +477,9 @@
466 477
467 478 $data= Metasync::get_option('general');
468 479
469 480 # Get white label menu slug
470 - # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
481 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
471 482 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
472 483 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
473 484
474 485 ?>
@@ -491,19 +502,125 @@
491 502 if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
492 503 return;
493 504 }
494 505 ?>
495 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
506 + <style>
507 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
508 + /*
509 + * WP prints admin notices at the top of #wpbody-content, then core's
510 + * common.js relocates them next to .wp-header-end inside our wrap on
511 + * DOM-ready. Keep them out of the flow until that move so they don't
512 + * flash above the dashboard header and shove the page down.
513 + * Once relocated they're no longer a direct child here, so this rule
514 + * stops matching and they fade in with the wrap. We mirror core's
515 + * relocation set exactly (.notice/.updated/.error, minus .inline and
516 + * .below-h2); .update-nag is left alone since core never moves it.
517 + */
518 + #wpbody-content > .notice:not(.inline):not(.below-h2),
519 + #wpbody-content > .updated:not(.inline):not(.below-h2),
520 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
521 + </style>
496 522 <?php
497 523 }
498 524
499 525 public function suppress_notices_on_wizard_page() {
500 - if ( isset( $_GET['page'] ) && strpos( $_GET['page'], '-setup-wizard' ) !== false ) {
526 + if ( ! isset( $_GET['page'] ) ) {
527 + return;
528 + }
529 +
530 + $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
531 +
532 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
533 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
501 534 remove_all_actions( 'admin_notices' );
502 535 remove_all_actions( 'all_admin_notices' );
536 + return;
503 537 }
538 +
539 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
540 + if ( strpos( $page, self::$page_slug ) === 0 ) {
541 + $this->remove_third_party_admin_notices();
542 + }
504 543 }
505 544
545 + /**
546 + * Remove admin-notice callbacks that don't belong to this plugin.
547 + *
548 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
549 + * global admin_notices / all_admin_notices actions, which WordPress fires on
550 + * every admin screen — so their notices leak onto our settings pages. We walk
551 + * the registered callbacks and drop any whose code lives outside this plugin's
552 + * directory, leaving every one of our own notices intact.
553 + */
554 + private function remove_third_party_admin_notices() {
555 + global $wp_filter;
556 +
557 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
558 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
559 + continue;
560 + }
561 +
562 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
563 + $to_remove = array();
564 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
565 + foreach ( $callbacks as $callback ) {
566 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
567 + $to_remove[] = array( $callback['function'], $priority );
568 + }
569 + }
570 + }
571 +
572 + foreach ( $to_remove as $entry ) {
573 + remove_action( $hook, $entry[0], $entry[1] );
574 + }
575 + }
576 + }
577 +
578 + /**
579 + * Determine whether an admin-notice callback is defined by this plugin.
580 + *
581 + * Ownership is decided by where the callback's code physically lives, not by
582 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
583 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
584 + * rename the plugin folder. Resolving the declaring file and checking it sits
585 + * inside this plugin's directory is robust against all of that. If a callback
586 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
587 + *
588 + * @param string|array|Closure $function The registered callback.
589 + * @return bool True when the callback's code lives inside this plugin.
590 + */
591 + private function is_own_admin_notice_callback( $function ) {
592 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
593 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
594 +
595 + try {
596 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
597 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
598 + $ref = new ReflectionMethod( $class, $function[1] );
599 + } elseif ( $function instanceof Closure ) {
600 + $ref = new ReflectionFunction( $function );
601 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
602 + list( $class, $method ) = explode( '::', $function, 2 );
603 + $ref = new ReflectionMethod( $class, $method );
604 + } elseif ( is_string( $function ) ) {
605 + $ref = new ReflectionFunction( $function );
606 + } else {
607 + return false;
608 + }
609 + } catch ( \Throwable $e ) {
610 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
611 + return true;
612 + }
613 +
614 + $file = $ref->getFileName();
615 + if ( ! $file ) {
616 + // Internal/built-in callback (no source file) — not ours.
617 + return false;
618 + }
619 +
620 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
621 + }
622 +
506 623 #---------fixes issue : #95 ----------
507 624 #This function is to redirect in case client changes slug on fresh install
508 625 #It is called by the add_option hook
509 626
@@ -601,11 +718,56 @@
601 718 exit;
602 719 }
603 720 }
604 721
722 + /**
723 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
724 + *
725 + * The menu entry is gone, but bookmarks and external links can still target
726 + * the old URL. Resolve the current white-label slug rather than assuming
727 + * "searchatlas".
728 + */
729 + public function redirect_legacy_instant_indexing_page() {
730 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) {
731 + return;
732 + }
733 +
734 + $page = sanitize_key(wp_unslash($_GET['page']));
735 + $legacy_page = self::$page_slug . '-instant-index';
736 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
737 + return;
738 + }
739 +
740 + // Indexation Control is registered only when access control keeps it
741 + // visible. Redirecting to it blindly would dead-end on an
742 + // unregistered slug (wp_die "Invalid plugin page") for users whose
743 + // white-label config hides that page — the exact configs where the
744 + // removed standalone page was the feature's only UI. The bare plugin
745 + // slug is the floor: its top-level callback resolves to the first
746 + // reachable page, so it is always a safe target.
747 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
748 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
749 + $query = [];
750 + foreach (['tab', 'subtab'] as $key) {
751 + if (isset($_GET[$key])) {
752 + $query[$key] = sanitize_key(wp_unslash($_GET[$key]));
753 + }
754 + }
755 + if (!empty($query)) {
756 + $target = add_query_arg($query, $target);
757 + }
758 + } else {
759 + $target = admin_url('admin.php?page=' . self::$page_slug);
760 + }
761 +
762 + wp_safe_redirect($target);
763 + exit;
764 + }
765 +
605 766 public function metasync_display_error_log() {
606 767 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
607 768 }
769 +
608 770 public function metasync_update_wp_config() {
609 771 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
610 772 }
611 773
@@ -863,10 +1025,41 @@
863 1025 array('jquery'),
864 1026 $this->version,
865 1027 true
866 1028 );
1029 +
1030 + // Keeps core's admin-menu pinning in step with pages whose height
1031 + // changes after load. Depends on 'common' so core has bound its
1032 + // own handlers before this runs.
1033 + wp_enqueue_script(
1034 + $this->plugin_name . '-admin-menu-height',
1035 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
1036 + array('jquery', 'common'),
1037 + $this->version,
1038 + true
1039 + );
867 1040 }
868 1041
1042 + $per_page_screens = [
1043 + self::$page_slug . '-redirections',
1044 + self::$page_slug . '-404-monitor',
1045 + self::$page_slug . '-sync-log',
1046 + self::$page_slug . '-media-optimization',
1047 + ];
1048 +
1049 + if (in_array($current_page, $per_page_screens, true)) {
1050 + // Results-per-page selectors use this external handler instead of
1051 + // an inline event attribute so changing a page size always reloads
1052 + // the current admin page.
1053 + wp_enqueue_script(
1054 + $this->plugin_name . '-per-page',
1055 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1056 + array(),
1057 + $this->version,
1058 + true
1059 + );
1060 + }
1061 +
869 1062 // Dashboard iframe height (only on dashboard page)
870 1063 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
871 1064 wp_enqueue_script(
872 1065 $this->plugin_name . '-iframe',
@@ -1051,9 +1244,8 @@
1051 1244 true
1052 1245 );
1053 1246 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1054 1247 'optionKey' => self::option_key,
1055 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1056 1248 'adminPostUrl' => admin_url('admin-post.php'),
1057 1249 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1058 1250 'ajaxUrl' => admin_url('admin-ajax.php'),
1059 1251 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1340,14 +1532,44 @@
1340 1532 }
1341 1533
1342 1534 /**
1343 1535 * Settings of HeartBeat API for admin area.
1344 - * Set time interval of send request.
1536 + *
1537 + * The only thing the plugin rides the beat for is a periodic admin
1538 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1539 + * so it slows the beat down rather than speeding it up. Two contexts are
1540 + * left on whatever core decided, because core leans on the beat there:
1541 + *
1542 + * - The front end, where the beat drives wp-auth-check — the "your session
1543 + * has expired, log in again" prompt.
1544 + * - The editor screens, where each tick renews the post lock. A lock only
1545 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1546 + * interval longer than that drops the lock between beats and lets a
1547 + * second editor silently take over a post someone still has open.
1548 + *
1549 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1550 + * over-long interval is not corrected on our behalf.
1551 + *
1552 + * @param array $settings Heartbeat settings array.
1553 + * @return array Heartbeat settings array.
1345 1554 */
1346 1555 function metasync_heartbeat_settings($settings)
1347 1556 {
1348 - global $heartbeat_frequency;
1349 - $settings['interval'] = 300;
1557 + if (!is_admin()) {
1558 + return $settings;
1559 + }
1560 +
1561 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1562 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1563 + global $pagenow;
1564 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1565 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1566 + return $settings;
1567 + }
1568 +
1569 + # 60s matches heartbeat.js's own default and stays well inside the
1570 + # 150s post-lock window, so nothing core does can fall through a gap.
1571 + $settings['interval'] = 60;
1350 1572 return $settings;
1351 1573 }
1352 1574
1353 1575 /**
@@ -1517,12 +1739,12 @@
1517 1739 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1518 1740
1519 1741 ?>
1520 1742 <!-- OTTO Cache TTL Setting -->
1521 - <div style="margin-bottom: 30px;">
1522 - <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">OTTO Cache TTL</h4>
1743 + <div style="margin-bottom: 30px; padding-top: 20px;">
1744 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1523 1745 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1524 - Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. Stale cache expires at 2× this value.
1746 + 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.
1525 1747 </p>
1526 1748
1527 1749 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1528 1750 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1528,8 +1750,9 @@
1528 1750 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1529 1751 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1530 1752 Cache TTL (minutes):
1531 1753 </label>
1754 + <?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.'); ?>
1532 1755 <input type="number"
1533 1756 id="metasync-otto-cache-ttl"
1534 1757 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1535 1758 min="30"
@@ -1673,8 +1896,9 @@
1673 1896 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1674 1897 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1675 1898 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1676 1899 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1900 + <?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.'); ?>
1677 1901 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1678 1902 </label>
1679 1903
1680 1904 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1683,8 +1907,9 @@
1683 1907 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1684 1908 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1685 1909 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1686 1910 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1911 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1687 1912 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1688 1913 </label>
1689 1914
1690 1915 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1832,9 +2057,9 @@
1832 2057 id="metasync-targeted-object-cache"
1833 2058 <?php checked('1', $targeted_cache_enabled); ?>
1834 2059 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1835 2060 <span>
1836 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2061 + <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>
1837 2062 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1838 2063 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1839 2064 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1840 2065 </span>
@@ -2098,9 +2323,9 @@
2098 2323
2099 2324 $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2100 2325
2101 2326 if ($ttl < 30 || $ttl > 1440) {
2102 - wp_send_json_error(array('message' => 'OTTO Cache TTL must be between 30 and 1440 minutes.'));
2327 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2103 2328 return;
2104 2329 }
2105 2330
2106 2331 $settings = get_option('metasync_execution_settings', array());
@@ -2106,9 +2331,9 @@
2106 2331 $settings = get_option('metasync_execution_settings', array());
2107 2332 $settings['otto_cache_ttl'] = $ttl;
2108 2333 update_option('metasync_execution_settings', $settings);
2109 2334
2110 - wp_send_json_success(array('message' => 'OTTO Cache TTL saved.'));
2335 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2111 2336 }
2112 2337
2113 2338 /**
2114 2339 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
@@ -2599,8 +2824,15 @@
2599 2824 // Handle form submissions
2600 2825 if (isset($_POST['metasync_media_optimization_nonce'])) {
2601 2826 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2602 2827
2828 + // Settings shape the whole site's conversion pipeline — restrict
2829 + // the save (and the reset) to site admins, not just anyone who can
2830 + // open the page via plugin_access_roles.
2831 + if (!current_user_can('manage_options')) {
2832 + wp_die(__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2833 + }
2834 +
2603 2835 // Handle reset to defaults
2604 2836 if (!empty($_POST['metasync_media_reset'])) {
2605 2837 $defaults = Metasync_Media_Settings::get_defaults();
2606 2838 Metasync_Media_Settings::save_settings($defaults);
@@ -2690,8 +2922,15 @@
2690 2922 if (!$attachment_id) {
2691 2923 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2692 2924 }
2693 2925
2926 + // Ownership: under the replace strategy this permanently deletes the
2927 + // attachment's original file, so upload_files alone is not enough —
2928 + // the caller must be allowed to edit this specific attachment.
2929 + if (!current_user_can('edit_post', $attachment_id)) {
2930 + wp_send_json_error(__('Permission denied.', 'metasync'));
2931 + }
2932 +
2694 2933 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2695 2934 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2696 2935 $settings = Metasync_Media_Settings::get_settings();
2697 2936
@@ -2743,8 +2982,20 @@
2743 2982 if (!$attachment_id) {
2744 2983 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2745 2984 }
2746 2985
2986 + // Ownership: reverting deletes the attachment's converted files, so
2987 + // restrict it to users who may edit this specific attachment.
2988 + if (!current_user_can('edit_post', $attachment_id)) {
2989 + wp_send_json_error(__('Permission denied.', 'metasync'));
2990 + }
2991 +
2992 + // Replace-strategy conversions have no original to restore — reverting
2993 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2994 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2995 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2996 + }
2997 +
2747 2998 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2748 2999
2749 3000 if ($success) {
2750 3001 wp_send_json_success();
@@ -2796,8 +3047,15 @@
2796 3047 public function ajax_batch_progress()
2797 3048 {
2798 3049 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2799 3050
3051 + // Read-only progress/stats polling — the same capability that can
3052 + // open the media optimization page is enough, but a nonce alone
3053 + // never was.
3054 + if (!current_user_can('upload_files')) {
3055 + wp_send_json_error(__('Permission denied.', 'metasync'));
3056 + }
3057 +
2800 3058 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2801 3059 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2802 3060
2803 3061 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2828,10 +3086,19 @@
2828 3086 $settings = Metasync_Media_Settings::get_settings();
2829 3087
2830 3088 $success = 0;
2831 3089 $failed = 0;
3090 + $denied = 0;
2832 3091
2833 3092 foreach ($ids as $id) {
3093 + // Per-attachment ownership: replace mode deletes originals, so
3094 + // upload_files alone must not allow converting images the caller
3095 + // does not own/cannot edit.
3096 + if (!current_user_can('edit_post', $id)) {
3097 + $denied++;
3098 + continue;
3099 + }
3100 +
2834 3101 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2835 3102 $success++;
2836 3103 } else {
2837 3104 $failed++;
@@ -2840,8 +3107,9 @@
2840 3107
2841 3108 wp_send_json_success([
2842 3109 'success' => $success,
2843 3110 'failed' => $failed,
3111 + 'denied' => $denied,
2844 3112 ]);
2845 3113 }
2846 3114
2847 3115 /**
@@ -2864,11 +3132,19 @@
2864 3132
2865 3133 $success = 0;
2866 3134 $failed = 0;
2867 3135 $skipped = 0;
3136 + $denied = 0;
2868 3137 $errors = [];
2869 3138
2870 3139 foreach ($ids as $id) {
3140 + // Per-attachment ownership: reverting deletes the attachment's
3141 + // converted files, so restrict it per image, not per role.
3142 + if (!current_user_can('edit_post', $id)) {
3143 + $denied++;
3144 + continue;
3145 + }
3146 +
2871 3147 $format = get_post_meta($id, '_metasync_converted_format', true);
2872 3148
2873 3149 if (!$format) {
2874 3150 $skipped++;
@@ -2896,8 +3172,9 @@
2896 3172 wp_send_json_success([
2897 3173 'success' => $success,
2898 3174 'failed' => $failed,
2899 3175 'skipped' => $skipped,
3176 + 'denied' => $denied,
2900 3177 'errors' => $errors,
2901 3178 ]);
2902 3179 }
2903 3180
@@ -2921,8 +3198,49 @@
2921 3198 wp_send_json_success($progress);
2922 3199 }
2923 3200
2924 3201 /**
3202 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3203 + *
3204 + * Only deletes when the file is confirmed missing on disk, so valid
3205 + * attachments can never be removed through this endpoint.
3206 + */
3207 + public function ajax_delete_orphaned_image()
3208 + {
3209 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3210 +
3211 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3212 + if (!$attachment_id) {
3213 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3214 + }
3215 +
3216 + if (!current_user_can('delete_post', $attachment_id)) {
3217 + wp_send_json_error(__('Permission denied.', 'metasync'));
3218 + }
3219 +
3220 + if (get_post_type($attachment_id) !== 'attachment') {
3221 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3222 + }
3223 +
3224 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3225 +
3226 + // Guard: only orphaned records (missing file) may be deleted here.
3227 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3228 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3229 + }
3230 +
3231 + $deleted = wp_delete_attachment($attachment_id, true);
3232 +
3233 + if ($deleted) {
3234 + wp_send_json_success([
3235 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3236 + ]);
3237 + }
3238 +
3239 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3240 + }
3241 +
3242 + /**
2925 3243 * Cron handler: Process batch optimization tick.
2926 3244 */
2927 3245 public function handle_media_batch_cron()
2928 3246 {
@@ -2932,8 +3250,86 @@
2932 3250 Metasync_Media_Batch_Optimizer::process_batch_tick();
2933 3251 }
2934 3252
2935 3253 /**
3254 + * AJAX: Start SEO Restore batch.
3255 + */
3256 + public function ajax_seo_restore_start()
3257 + {
3258 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3259 +
3260 + if (!current_user_can('manage_options')) {
3261 + wp_send_json_error(__('Permission denied.', 'metasync'));
3262 + }
3263 +
3264 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3265 +
3266 + $progress = Metasync_Seo_Restore::start_batch();
3267 + wp_send_json_success($progress);
3268 + }
3269 +
3270 + /**
3271 + * AJAX: Cancel SEO Restore batch.
3272 + */
3273 + public function ajax_seo_restore_cancel()
3274 + {
3275 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3276 +
3277 + if (!current_user_can('manage_options')) {
3278 + wp_send_json_error(__('Permission denied.', 'metasync'));
3279 + }
3280 +
3281 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3282 +
3283 + Metasync_Seo_Restore::cancel_batch();
3284 + wp_send_json_success();
3285 + }
3286 +
3287 + /**
3288 + * AJAX: Get SEO Restore batch progress.
3289 + */
3290 + public function ajax_seo_restore_progress()
3291 + {
3292 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3293 +
3294 + if (!current_user_can('manage_options')) {
3295 + wp_send_json_error(__('Permission denied.', 'metasync'));
3296 + }
3297 +
3298 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3299 +
3300 + $progress = Metasync_Seo_Restore::get_progress();
3301 + wp_send_json_success($progress);
3302 + }
3303 +
3304 + /**
3305 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3306 + */
3307 + public function ajax_seo_restore_process_tick()
3308 + {
3309 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3310 +
3311 + if (!current_user_can('manage_options')) {
3312 + wp_send_json_error(__('Permission denied.', 'metasync'));
3313 + }
3314 +
3315 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3316 +
3317 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3318 + wp_send_json_success($progress);
3319 + }
3320 +
3321 + /**
3322 + * Cron handler: Process SEO Restore batch tick.
3323 + */
3324 + public function handle_seo_restore_cron()
3325 + {
3326 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3327 +
3328 + Metasync_Seo_Restore::process_batch_tick();
3329 + }
3330 +
3331 + /**
2936 3332 * Report Issue page callback
2937 3333 */
2938 3334 public function create_admin_report_issue_page()
2939 3335 {
@@ -2979,8 +3375,10 @@
2979 3375 if (is_wp_error($result)) {
2980 3376 echo '<div class="notice notice-error"><p>' . esc_html(
2981 3377 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2982 3378 ) . '</p></div>';
3379 + } elseif (false === $result) {
3380 + 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>';
2983 3381 } else {
2984 3382 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2985 3383 }
2986 3384 }
@@ -3043,8 +3441,12 @@
3043 3441 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3044 3442 'auto_detect' => isset($_POST['auto_detect']),
3045 3443 'taxonomies' => $video_taxonomies,
3046 3444 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3445 + // This array replaces the stored option wholesale, so every
3446 + // setting must be represented here or it is wiped on save.
3447 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3448 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3047 3449 ];
3048 3450
3049 3451 // Always invalidate old cache before saving new settings
3050 3452 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3102,8 +3504,17 @@
3102 3504 if ($sitemap_generator->generate_news_sitemap()) {
3103 3505 $extras[] = 'news';
3104 3506 }
3105 3507 }
3508 +
3509 + // Reclaim unreferenced memory between the news and video passes
3510 + // so the video generator starts with more headroom (they share
3511 + // one request). Each generator already releases its own object
3512 + // cache via clean_post_cache(), so no cache flush is needed here.
3513 + if (function_exists('gc_collect_cycles')) {
3514 + gc_collect_cycles();
3515 + }
3516 +
3106 3517 if (!empty($video_opts['enabled'])) {
3107 3518 if ($sitemap_generator->generate_video_sitemap()) {
3108 3519 $extras[] = 'video';
3109 3520 }
@@ -3108,8 +3519,13 @@
3108 3519 $extras[] = 'video';
3109 3520 }
3110 3521 }
3111 3522
3523 + // Same inter-pass GC before the main sitemap pass begins.
3524 + if (function_exists('gc_collect_cycles')) {
3525 + gc_collect_cycles();
3526 + }
3527 +
3112 3528 // Generate main sitemap (its index will include news/video since they now exist)
3113 3529 $result = $sitemap_generator->generate_sitemap();
3114 3530
3115 3531 if (is_wp_error($result)) {
@@ -3117,8 +3533,10 @@
3117 3533 error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3118 3534 echo '<div class="notice notice-error"><p>' . esc_html(
3119 3535 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3120 3536 ) . '</p></div>';
3537 + } elseif (false === $result) {
3538 + 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>';
3121 3539 } else {
3122 3540 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3123 3541 if (!empty($extras)) {
3124 3542 $message .= ' ' . sprintf(
@@ -3157,9 +3575,9 @@
3157 3575
3158 3576 if ($deleted) {
3159 3577 // Disable auto-update only when the general sitemap is removed
3160 3578 update_option('metasync_sitemap_auto_update', false);
3161 - // Re-enable WP core sitemap only if no other MetaSync sitemaps remain (WP-396)
3579 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3162 3580 if (!$sitemap_generator->sitemap_exists()) {
3163 3581 delete_option('metasync_disable_wp_sitemap');
3164 3582 }
3165 3583 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3188,9 +3606,9 @@
3188 3606
3189 3607 if ($deleted) {
3190 3608 // Also disable auto-update when deleting
3191 3609 update_option('metasync_sitemap_auto_update', false);
3192 - // Re-enable WP core sitemap so the site isn't left with zero sitemaps (WP-396)
3610 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3193 3611 delete_option('metasync_disable_wp_sitemap');
3194 3612 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3195 3613 } else {
3196 3614 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>';
@@ -3311,47 +3729,8 @@
3311 3729 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3312 3730 }
3313 3731
3314 3732 /**
3315 - * Google Instant Index Setting page callback
3316 - */
3317 - public function create_admin_google_instant_index_page()
3318 - {
3319 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3320 -
3321 - // Render shared Google Index credentials section
3322 - if (!function_exists('google_index_direct')) {
3323 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3324 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3325 - } else {
3326 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3327 - return;
3328 - }
3329 - }
3330 - $google_index = google_index_direct();
3331 - $service_info = $google_index->get_service_account_info();
3332 - $is_configured = !isset($service_info['error']);
3333 -
3334 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3335 -
3336 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3337 -
3338 - // Render post types selection with save form
3339 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3340 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3341 - ?>
3342 - <form method="POST" action="">
3343 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3344 - <div class="dashboard-card" style="padding: 20px;">
3345 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3346 - </div>
3347 - </form>
3348 - <?php
3349 -
3350 - $this->render_layout_close();
3351 - }
3352 -
3353 - /**
3354 3733 * Google Console page callback
3355 3734 */
3356 3735 public function create_admin_google_console_page()
3357 3736 {
@@ -3374,16 +3753,8 @@
3374 3753 $this->render_layout_close();
3375 3754 }
3376 3755
3377 3756 /**
3378 - * General Options page callback
3379 - */
3380 - public function create_admin_optimal_settings_page()
3381 - {
3382 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3383 - }
3384 -
3385 - /**
3386 3757 * Global Options page callback
3387 3758 */
3388 3759 public function create_admin_global_settings_page()
3389 3760 {
@@ -3415,16 +3786,8 @@
3415 3786 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3416 3787 }
3417 3788
3418 3789 /**
3419 - * Site Optimal Settings page callback
3420 - */
3421 - public function optimization_settings_options()
3422 - {
3423 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3424 - }
3425 -
3426 - /**
3427 3790 * redirection page callback with tabs
3428 3791 */
3429 3792 public function create_admin_redirections_page()
3430 3793 {
@@ -3451,10 +3814,11 @@
3451 3814 }
3452 3815 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3453 3816 echo '<div class="notice notice-warning is-dismissible"><p>';
3454 3817 printf(
3455 - /* translators: 1: current load, 2: threshold, 3: core count */
3456 - 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' ),
3818 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3819 + 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' ),
3820 + esc_html( Metasync::get_effective_plugin_name() ),
3457 3821 esc_html( $data['load'] ),
3458 3822 esc_html( $data['threshold'] ),
3459 3823 esc_html( $data['cores'] )
3460 3824 );
@@ -3472,9 +3836,9 @@
3472 3836 if (!get_transient('metasync_llms_conflict')) {
3473 3837 return;
3474 3838 }
3475 3839 echo '<div class="notice notice-info is-dismissible"><p>';
3476 - 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');
3840 + 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()));
3477 3841 echo '</p></div>';
3478 3842 }
3479 3843
3480 3844 /**
@@ -3543,8 +3907,17 @@
3543 3907 public function ajax_restore_robots_backup()
3544 3908 {
3545 3909 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3546 3910 }
3911 +
3912 + /**
3913 + * AJAX handler to fetch a paginated page of robots.txt backups
3914 + */
3915 + public function ajax_get_robots_backups()
3916 + {
3917 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3918 + }
3919 +
3547 3920 public function ajax_create_redirect_from_404()
3548 3921 {
3549 3922 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3550 3923 }
@@ -3657,9 +4030,9 @@
3657 4030 }
3658 4031
3659 4032 // Get pagination parameters
3660 4033 $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3661 - $per_page = 10;
4034 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3662 4035 $offset = ($page - 1) * $per_page;
3663 4036
3664 4037 // Get filters
3665 4038 $filters = [
@@ -3818,14 +4191,15 @@
3818 4191 <?php endif; ?>
3819 4192 </div>
3820 4193
3821 4194 <!-- Pagination -->
3822 - <?php if ($total_pages > 1): ?>
3823 - <div class="sync-log-pagination">
3824 - <div class="sync-log-pagination-info">
3825 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3826 - </div>
4195 + <div class="sync-log-pagination">
4196 + <div class="sync-log-pagination-info">
4197 + 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; ?>
4198 + <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4199 + </div>
3827 4200
4201 + <?php if ($total_pages > 1): ?>
3828 4202 <div class="sync-log-pagination-controls">
3829 4203 <?php if ($page > 1): ?>
3830 4204 <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>
3831 4205 <?php endif; ?>
@@ -3838,10 +4212,10 @@
3838 4212 <?php if ($page < $total_pages): ?>
3839 4213 <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>
3840 4214 <?php endif; ?>
3841 4215 </div>
3842 - </div>
3843 - <?php endif; ?>
4216 + <?php endif; ?>
4217 + </div>
3844 4218 </div>
3845 4219 <?php $this->render_layout_close(); ?>
3846 4220
3847 4221 <script>
@@ -3922,8 +4296,19 @@
3922 4296 if (!empty($value)) {
3923 4297 $query_parts[] = $key . '=' . urlencode($value);
3924 4298 }
3925 4299 }
4300 +
4301 + // Preserve the user-selected results-per-page value across page
4302 + // navigation so a non-default page size survives clicking a page link.
4303 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4304 + if (isset($_GET[$per_page_key])) {
4305 + $per_page = (int) $_GET[$per_page_key];
4306 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4307 + $query_parts[] = $per_page_key . '=' . $per_page;
4308 + }
4309 + }
4310 +
3926 4311 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3927 4312 }
3928 4313
3929 4314 /**
@@ -4154,8 +4539,9 @@
4154 4539 $stats = Metasync_CPU_Monitor::get_stats();
4155 4540 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4156 4541 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4157 4542 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4543 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4158 4544 ?>
4159 4545 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4160 4546 <!-- CPU Cores Detected -->
4161 4547 <div style="margin-bottom: 24px;">
@@ -4162,12 +4548,20 @@
4162 4548 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4163 4549 CPU Cores Detected
4164 4550 </label>
4165 4551 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4166 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4552 + <?php if ($detection_reliable) : ?>
4553 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4554 + <?php else : ?>
4555 + <strong>Not detected</strong>
4556 + <?php endif; ?>
4167 4557 </div>
4168 4558 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4169 - Automatically detected on this system.
4559 + <?php if ($detection_reliable) : ?>
4560 + Automatically detected on this system.
4561 + <?php else : ?>
4562 + Core detection is not available on this hosting environment.
4563 + <?php endif; ?>
4170 4564 </p>
4171 4565 </div>
4172 4566
4173 4567 <!-- Per-Core Load Threshold -->
@@ -4173,8 +4567,9 @@
4173 4567 <!-- Per-Core Load Threshold -->
4174 4568 <div style="margin-bottom: 24px;">
4175 4569 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4176 4570 Per-Core Load Threshold
4571 + <?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.'); ?>
4177 4572 </label>
4178 4573 <input type="number"
4179 4574 id="cpu_load_per_core_threshold"
4180 4575 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4726,88 +5121,8 @@
4726 5121
4727 5122 /**
4728 5123 * Get the settings option array and print one of its values
4729 5124 */
4730 - public function no_index_posts_callback()
4731 - {
4732 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4733 - }
4734 -
4735 - /**
4736 - * Get the settings option array and print one of its values
4737 - */
4738 - public function no_follow_links_callback()
4739 - {
4740 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4741 - }
4742 -
4743 - /**
4744 - * Get the settings option array and print one of its values
4745 - */
4746 - public function open_external_links_callback()
4747 - {
4748 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4749 - }
4750 -
4751 - /**
4752 - * Get the settings option array and print one of its values
4753 - */
4754 - public function add_alt_image_tags_callback()
4755 - {
4756 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4757 - }
4758 -
4759 - /**
4760 - * Get the settings option array and print one of its values
4761 - */
4762 - public function add_title_image_tags_callback()
4763 - {
4764 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4765 - }
4766 -
4767 - /**
4768 - * Get the settings option array and print one of its values
4769 - */
4770 - public function site_type_callback()
4771 - {
4772 - Metasync_Settings_Fields::instance()->site_type_callback();
4773 - }
4774 -
4775 - /**
4776 - * Get the settings option array and print one of its values
4777 - */
4778 - public function site_business_type_callback()
4779 - {
4780 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4781 - }
4782 -
4783 - /**
4784 - * Get the settings option array and print one of its values
4785 - */
4786 - public function site_company_name_callback()
4787 - {
4788 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4789 - }
4790 -
4791 - /**
4792 - * Get the settings option array and print one of its values
4793 - */
4794 - public function site_google_logo_callback()
4795 - {
4796 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4797 - }
4798 -
4799 - /**
4800 - * Get the settings option array and print one of its values
4801 - */
4802 - public function site_social_share_image_callback()
4803 - {
4804 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4805 - }
4806 -
4807 - /**
4808 - * Get the settings option array and print one of its values
4809 - */
4810 5125 public function common_robot_meta_tags_callback()
4811 5126 {
4812 5127 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4813 5128 }
@@ -5005,8 +5320,14 @@
5005 5320 * Show a one-time admin notice when a page builder is detected but the
5006 5321 * "Default Page Builder" setting has never been explicitly saved.
5007 5322 */
5008 5323 public function display_page_builder_notice() {
5324 + // Only relevant on the plugin's own settings page — avoid repeating
5325 + // this notice across every admin screen.
5326 + if (!isset($_GET['page']) || $_GET['page'] !== self::$page_slug) {
5327 + return;
5328 + }
5329 +
5009 5330 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5010 5331
5011 5332 // Setting already saved — nothing to warn about
5012 5333 if (!empty($configured)) {
@@ -5974,8 +6295,16 @@
5974 6295 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5975 6296 return;
5976 6297 }
5977 6298
6299 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6300 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6301 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6302 + // produced by the converter.
6303 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6304 + return;
6305 + }
6306 +
5978 6307 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5979 6308 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5980 6309
5981 6310 if ($has_raw_html || !empty($has_custom_css)) {
@@ -6188,8 +6517,18 @@
6188 6517 if (!isset($_POST['submit'])) {
6189 6518 return;
6190 6519 }
6191 6520
6521 + // This handler runs on admin_init, which also fires inside
6522 + // admin-ajax.php before its login gate, so a bare isset() check let
6523 + // any request (including logged-out ones) rewrite the auto-submit
6524 + // post types. Require the nonce and plugin access before writing.
6525 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6526 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6527 + || !Metasync::current_user_has_plugin_access()) {
6528 + return;
6529 + }
6530 +
6192 6531 // Save post types for Google Instant Indexing auto-submit
6193 6532 if (isset($_POST['metasync_post_types'])) {
6194 6533 $post_data = metasync_sanitize_input_array($_POST);
6195 6534 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6273,12 +6612,24 @@
6273 6612 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6274 6613 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6275 6614 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6276 6615
6277 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6278 - $service_info = google_index_direct()->get_service_account_info();
6279 - if (!isset($service_info['error'])) {
6280 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6616 + if (in_array($post->post_type, $post_types)) {
6617 + // save_post is the single owner of auto-submit and also fires
6618 + // on REST-created posts, where nothing else may have loaded
6619 + // the Google Index helpers yet — load them on demand instead
6620 + // of silently skipping.
6621 + if (!function_exists('google_index_direct')) {
6622 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6623 + if (file_exists($google_index_path)) {
6624 + require_once $google_index_path;
6625 + }
6626 + }
6627 + if (function_exists('google_index_direct')) {
6628 + $service_info = google_index_direct()->get_service_account_info();
6629 + if (!isset($service_info['error'])) {
6630 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6631 + }
6281 6632 }
6282 6633 }
6283 6634 }
6284 6635