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 +340 -154 2.6.17trunk 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.
@@ -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'));
@@ -373,8 +376,15 @@
373 376 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 377 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 378 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 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 +
377 387 # Add AJAX handlers for Google Instant Indexing
378 388 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 389
380 390 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -708,11 +718,56 @@
708 718 exit;
709 719 }
710 720 }
711 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 +
712 766 public function metasync_display_error_log() {
713 767 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 768 }
769 +
715 770 public function metasync_update_wp_config() {
716 771 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 772 }
718 773
@@ -970,10 +1025,41 @@
970 1025 array('jquery'),
971 1026 $this->version,
972 1027 true
973 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 + );
974 1040 }
975 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 +
976 1062 // Dashboard iframe height (only on dashboard page)
977 1063 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
978 1064 wp_enqueue_script(
979 1065 $this->plugin_name . '-iframe',
@@ -1446,14 +1532,44 @@
1446 1532 }
1447 1533
1448 1534 /**
1449 1535 * Settings of HeartBeat API for admin area.
1450 - * 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.
1451 1554 */
1452 1555 function metasync_heartbeat_settings($settings)
1453 1556 {
1454 - global $heartbeat_frequency;
1455 - $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;
1456 1572 return $settings;
1457 1573 }
1458 1574
1459 1575 /**
@@ -1626,9 +1742,9 @@
1626 1742 <!-- OTTO Cache TTL Setting -->
1627 1743 <div style="margin-bottom: 30px; padding-top: 20px;">
1628 1744 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1629 1745 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1630 - 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.
1631 1747 </p>
1632 1748
1633 1749 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1634 1750 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1634,8 +1750,9 @@
1634 1750 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1635 1751 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1636 1752 Cache TTL (minutes):
1637 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.'); ?>
1638 1755 <input type="number"
1639 1756 id="metasync-otto-cache-ttl"
1640 1757 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1641 1758 min="30"
@@ -1779,8 +1896,9 @@
1779 1896 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1780 1897 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1781 1898 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1782 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.'); ?>
1783 1901 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1784 1902 </label>
1785 1903
1786 1904 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1789,8 +1907,9 @@
1789 1907 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1790 1908 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1791 1909 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1792 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.'); ?>
1793 1912 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1794 1913 </label>
1795 1914
1796 1915 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1938,9 +2057,9 @@
1938 2057 id="metasync-targeted-object-cache"
1939 2058 <?php checked('1', $targeted_cache_enabled); ?>
1940 2059 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1941 2060 <span>
1942 - <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>
1943 2062 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1944 2063 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1945 2064 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1946 2065 </span>
@@ -2705,8 +2824,15 @@
2705 2824 // Handle form submissions
2706 2825 if (isset($_POST['metasync_media_optimization_nonce'])) {
2707 2826 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2708 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 +
2709 2835 // Handle reset to defaults
2710 2836 if (!empty($_POST['metasync_media_reset'])) {
2711 2837 $defaults = Metasync_Media_Settings::get_defaults();
2712 2838 Metasync_Media_Settings::save_settings($defaults);
@@ -2796,8 +2922,15 @@
2796 2922 if (!$attachment_id) {
2797 2923 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2798 2924 }
2799 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 +
2800 2933 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2801 2934 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2802 2935 $settings = Metasync_Media_Settings::get_settings();
2803 2936
@@ -2849,8 +2982,20 @@
2849 2982 if (!$attachment_id) {
2850 2983 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2851 2984 }
2852 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 +
2853 2998 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2854 2999
2855 3000 if ($success) {
2856 3001 wp_send_json_success();
@@ -2902,8 +3047,15 @@
2902 3047 public function ajax_batch_progress()
2903 3048 {
2904 3049 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2905 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 +
2906 3058 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2907 3059 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2908 3060
2909 3061 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2934,10 +3086,19 @@
2934 3086 $settings = Metasync_Media_Settings::get_settings();
2935 3087
2936 3088 $success = 0;
2937 3089 $failed = 0;
3090 + $denied = 0;
2938 3091
2939 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 +
2940 3101 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2941 3102 $success++;
2942 3103 } else {
2943 3104 $failed++;
@@ -2946,8 +3107,9 @@
2946 3107
2947 3108 wp_send_json_success([
2948 3109 'success' => $success,
2949 3110 'failed' => $failed,
3111 + 'denied' => $denied,
2950 3112 ]);
2951 3113 }
2952 3114
2953 3115 /**
@@ -2970,11 +3132,19 @@
2970 3132
2971 3133 $success = 0;
2972 3134 $failed = 0;
2973 3135 $skipped = 0;
3136 + $denied = 0;
2974 3137 $errors = [];
2975 3138
2976 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 +
2977 3147 $format = get_post_meta($id, '_metasync_converted_format', true);
2978 3148
2979 3149 if (!$format) {
2980 3150 $skipped++;
@@ -3002,8 +3172,9 @@
3002 3172 wp_send_json_success([
3003 3173 'success' => $success,
3004 3174 'failed' => $failed,
3005 3175 'skipped' => $skipped,
3176 + 'denied' => $denied,
3006 3177 'errors' => $errors,
3007 3178 ]);
3008 3179 }
3009 3180
@@ -3079,8 +3250,86 @@
3079 3250 Metasync_Media_Batch_Optimizer::process_batch_tick();
3080 3251 }
3081 3252
3082 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 + /**
3083 3332 * Report Issue page callback
3084 3333 */
3085 3334 public function create_admin_report_issue_page()
3086 3335 {
@@ -3126,8 +3375,10 @@
3126 3375 if (is_wp_error($result)) {
3127 3376 echo '<div class="notice notice-error"><p>' . esc_html(
3128 3377 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3129 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>';
3130 3381 } else {
3131 3382 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3132 3383 }
3133 3384 }
@@ -3190,8 +3441,12 @@
3190 3441 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3191 3442 'auto_detect' => isset($_POST['auto_detect']),
3192 3443 'taxonomies' => $video_taxonomies,
3193 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'] ?? '')),
3194 3449 ];
3195 3450
3196 3451 // Always invalidate old cache before saving new settings
3197 3452 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3249,8 +3504,17 @@
3249 3504 if ($sitemap_generator->generate_news_sitemap()) {
3250 3505 $extras[] = 'news';
3251 3506 }
3252 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 +
3253 3517 if (!empty($video_opts['enabled'])) {
3254 3518 if ($sitemap_generator->generate_video_sitemap()) {
3255 3519 $extras[] = 'video';
3256 3520 }
@@ -3255,8 +3519,13 @@
3255 3519 $extras[] = 'video';
3256 3520 }
3257 3521 }
3258 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 +
3259 3528 // Generate main sitemap (its index will include news/video since they now exist)
3260 3529 $result = $sitemap_generator->generate_sitemap();
3261 3530
3262 3531 if (is_wp_error($result)) {
@@ -3264,8 +3533,10 @@
3264 3533 error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3265 3534 echo '<div class="notice notice-error"><p>' . esc_html(
3266 3535 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3267 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>';
3268 3539 } else {
3269 3540 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3270 3541 if (!empty($extras)) {
3271 3542 $message .= ' ' . sprintf(
@@ -3458,47 +3729,8 @@
3458 3729 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3459 3730 }
3460 3731
3461 3732 /**
3462 - * Google Instant Index Setting page callback
3463 - */
3464 - public function create_admin_google_instant_index_page()
3465 - {
3466 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3467 -
3468 - // Render shared Google Index credentials section
3469 - if (!function_exists('google_index_direct')) {
3470 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3471 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3472 - } else {
3473 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3474 - return;
3475 - }
3476 - }
3477 - $google_index = google_index_direct();
3478 - $service_info = $google_index->get_service_account_info();
3479 - $is_configured = !isset($service_info['error']);
3480 -
3481 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3482 -
3483 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3484 -
3485 - // Render post types selection with save form
3486 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3487 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3488 - ?>
3489 - <form method="POST" action="">
3490 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3491 - <div class="dashboard-card" style="padding: 20px;">
3492 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3493 - </div>
3494 - </form>
3495 - <?php
3496 -
3497 - $this->render_layout_close();
3498 - }
3499 -
3500 - /**
3501 3733 * Google Console page callback
3502 3734 */
3503 3735 public function create_admin_google_console_page()
3504 3736 {
@@ -3521,16 +3753,8 @@
3521 3753 $this->render_layout_close();
3522 3754 }
3523 3755
3524 3756 /**
3525 - * General Options page callback
3526 - */
3527 - public function create_admin_optimal_settings_page()
3528 - {
3529 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3530 - }
3531 -
3532 - /**
3533 3757 * Global Options page callback
3534 3758 */
3535 3759 public function create_admin_global_settings_page()
3536 3760 {
@@ -3562,16 +3786,8 @@
3562 3786 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3563 3787 }
3564 3788
3565 3789 /**
3566 - * Site Optimal Settings page callback
3567 - */
3568 - public function optimization_settings_options()
3569 - {
3570 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3571 - }
3572 -
3573 - /**
3574 3790 * redirection page callback with tabs
3575 3791 */
3576 3792 public function create_admin_redirections_page()
3577 3793 {
@@ -3691,8 +3907,17 @@
3691 3907 public function ajax_restore_robots_backup()
3692 3908 {
3693 3909 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3694 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 +
3695 3920 public function ajax_create_redirect_from_404()
3696 3921 {
3697 3922 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3698 3923 }
@@ -3805,9 +4030,9 @@
3805 4030 }
3806 4031
3807 4032 // Get pagination parameters
3808 4033 $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3809 - $per_page = 10;
4034 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3810 4035 $offset = ($page - 1) * $per_page;
3811 4036
3812 4037 // Get filters
3813 4038 $filters = [
@@ -3966,14 +4191,15 @@
3966 4191 <?php endif; ?>
3967 4192 </div>
3968 4193
3969 4194 <!-- Pagination -->
3970 - <?php if ($total_pages > 1): ?>
3971 - <div class="sync-log-pagination">
3972 - <div class="sync-log-pagination-info">
3973 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3974 - </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>
3975 4200
4201 + <?php if ($total_pages > 1): ?>
3976 4202 <div class="sync-log-pagination-controls">
3977 4203 <?php if ($page > 1): ?>
3978 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>
3979 4205 <?php endif; ?>
@@ -3986,10 +4212,10 @@
3986 4212 <?php if ($page < $total_pages): ?>
3987 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>
3988 4214 <?php endif; ?>
3989 4215 </div>
3990 - </div>
3991 - <?php endif; ?>
4216 + <?php endif; ?>
4217 + </div>
3992 4218 </div>
3993 4219 <?php $this->render_layout_close(); ?>
3994 4220
3995 4221 <script>
@@ -4070,8 +4296,19 @@
4070 4296 if (!empty($value)) {
4071 4297 $query_parts[] = $key . '=' . urlencode($value);
4072 4298 }
4073 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 +
4074 4311 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
4075 4312 }
4076 4313
4077 4314 /**
@@ -4330,8 +4567,9 @@
4330 4567 <!-- Per-Core Load Threshold -->
4331 4568 <div style="margin-bottom: 24px;">
4332 4569 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4333 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.'); ?>
4334 4572 </label>
4335 4573 <input type="number"
4336 4574 id="cpu_load_per_core_threshold"
4337 4575 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4883,88 +5121,8 @@
4883 5121
4884 5122 /**
4885 5123 * Get the settings option array and print one of its values
4886 5124 */
4887 - public function no_index_posts_callback()
4888 - {
4889 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4890 - }
4891 -
4892 - /**
4893 - * Get the settings option array and print one of its values
4894 - */
4895 - public function no_follow_links_callback()
4896 - {
4897 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4898 - }
4899 -
4900 - /**
4901 - * Get the settings option array and print one of its values
4902 - */
4903 - public function open_external_links_callback()
4904 - {
4905 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4906 - }
4907 -
4908 - /**
4909 - * Get the settings option array and print one of its values
4910 - */
4911 - public function add_alt_image_tags_callback()
4912 - {
4913 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4914 - }
4915 -
4916 - /**
4917 - * Get the settings option array and print one of its values
4918 - */
4919 - public function add_title_image_tags_callback()
4920 - {
4921 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4922 - }
4923 -
4924 - /**
4925 - * Get the settings option array and print one of its values
4926 - */
4927 - public function site_type_callback()
4928 - {
4929 - Metasync_Settings_Fields::instance()->site_type_callback();
4930 - }
4931 -
4932 - /**
4933 - * Get the settings option array and print one of its values
4934 - */
4935 - public function site_business_type_callback()
4936 - {
4937 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4938 - }
4939 -
4940 - /**
4941 - * Get the settings option array and print one of its values
4942 - */
4943 - public function site_company_name_callback()
4944 - {
4945 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4946 - }
4947 -
4948 - /**
4949 - * Get the settings option array and print one of its values
4950 - */
4951 - public function site_google_logo_callback()
4952 - {
4953 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4954 - }
4955 -
4956 - /**
4957 - * Get the settings option array and print one of its values
4958 - */
4959 - public function site_social_share_image_callback()
4960 - {
4961 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4962 - }
4963 -
4964 - /**
4965 - * Get the settings option array and print one of its values
4966 - */
4967 5125 public function common_robot_meta_tags_callback()
4968 5126 {
4969 5127 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4970 5128 }
@@ -5162,8 +5320,14 @@
5162 5320 * Show a one-time admin notice when a page builder is detected but the
5163 5321 * "Default Page Builder" setting has never been explicitly saved.
5164 5322 */
5165 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 +
5166 5330 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5167 5331
5168 5332 // Setting already saved — nothing to warn about
5169 5333 if (!empty($configured)) {
@@ -6353,8 +6517,18 @@
6353 6517 if (!isset($_POST['submit'])) {
6354 6518 return;
6355 6519 }
6356 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 +
6357 6531 // Save post types for Google Instant Indexing auto-submit
6358 6532 if (isset($_POST['metasync_post_types'])) {
6359 6533 $post_data = metasync_sanitize_input_array($_POST);
6360 6534 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6438,12 +6612,24 @@
6438 6612 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6439 6613 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6440 6614 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6441 6615
6442 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6443 - $service_info = google_index_direct()->get_service_account_info();
6444 - if (!isset($service_info['error'])) {
6445 - 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 + }
6446 6632 }
6447 6633 }
6448 6634 }
6449 6635