PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / admin / class-metasync-admin.php

class-metasync-admin.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at admin/class-metasync-admin.php

6,620 lines 287.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7
8 /**
9 * The admin-specific functionality of the plugin.
10 *
11 * @link https://searchatlas.com
12 * @since 1.0.0
13 *
14 * @package Metasync
15 * @subpackage Metasync/admin
16 */
17
18 /**
19 * The admin-specific functionality of the plugin.
20 *
21 * Defines the plugin name, version, and two examples hooks for how to
22 * enqueue the admin-specific stylesheet and JavaScript.
23 *
24 * @package Metasync
25 * @subpackage Metasync/admin
26 * @author Engineering Team <[email protected]>
27 */
28 class Metasync_Admin
29 {
30
31 // Section constants for settings
32 const SECTION_FEATURES = "features_settings";
33 const SECTION_METASYNC = "metasync_settings";
34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 const SECTION_LOCALSEO = "local_seo";
36 const SECTION_CODESNIPPETS = "code_snippets";
37 const SECTION_SITE_SETTINGS = "site_settings";
38 const SECTION_COMMON_SETTINGS = "common_settings";
39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
40 const SECTION_SOCIAL_META = "social_meta";
41 const SECTION_SEO_CONTROLS = "seo_controls";
42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
45 const SECTION_LLMS_TXT = "llms_txt_settings";
46
47 /**
48 * The ID of this plugin.
49 *
50 * @since 1.0.0
51 * @access private
52 * @var string $plugin_name The ID of this plugin.
53 */
54 private $plugin_name;
55
56 /**
57 * The version of this plugin.
58 *
59 * @since 1.0.0
60 * @access private
61 * @var string $version The current version of this plugin.
62 */
63 private $version;
64
65 /**
66 * Holds the values to be used in the fields callbacks
67 */
68 private $options;
69 public $menu_title = "Search Atlas"; // Default, overridden by get_effective_menu_title()
70 public const page_title = "MetaSync Settings";
71
72 /**
73 * Get effective menu title with whitelabel company name
74 */
75 public function get_effective_menu_title()
76 {
77 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
78
79 if ($whitelabel_company_name) {
80 return $whitelabel_company_name . ' SEO';
81 }
82
83 // Use centralized method for getting effective plugin name
84 return Metasync::get_effective_plugin_name();
85 }
86 public const option_group = "metasync_group";
87 public const option_key = "metasync_options";
88 public static $page_slug = "searchatlas";
89 /**
90 * Get the effective dashboard domain
91 * Returns whitelabel domain if set, otherwise returns default production domain
92 * Uses centralized constant from main Metasync class
93 */
94 public static function get_effective_dashboard_domain()
95 {
96 // Delegate to main Metasync class for consistent domain resolution
97 return Metasync::get_dashboard_domain();
98 }
99
100 /**
101 * Static method to render header and navigation for external pages (like AI Agent)
102 * This creates a minimal instance just for rendering
103 */
104 public static function render_standard_header_nav($page_title = null, $current_page = null) {
105 Metasync_Admin_Navigation::instance()->render_standard_header_nav($page_title, $current_page);
106 }
107
108 /**
109 * Static header render
110 */
111 public static function render_static_header($page_title = null) {
112 Metasync_Admin_Navigation::instance()->render_static_header($page_title);
113 }
114
115 /**
116 * Static navigation render
117 */
118 public static function render_static_navigation($current_page = null) {
119 Metasync_Admin_Navigation::instance()->render_static_navigation($current_page);
120 }
121
122 /**
123 * Get dashboard URL with authentication tokens and tracking parameters
124 * Returns the complete URL for accessing the Search Atlas dashboard
125 */
126 public function get_dashboard_url()
127 {
128 // Get the effective dashboard domain (whitelabel or production)
129 $dashboard_url = self::get_effective_dashboard_domain();
130
131 // Get current options for token inclusion
132 $general_options = Metasync::get_option('general');
133
134 // Add JWT token if available for seamless login
135 if (isset($general_options['linkgraph_token']) && !empty($general_options['linkgraph_token'])) {
136 $dashboard_url .= '/?jwtToken=' . urlencode($general_options['linkgraph_token']);
137 }
138
139 // Add source tracking parameter
140 $dashboard_url .= (strpos($dashboard_url, '?') !== false ? '&' : '?') . 'source=wordpress-plugin';
141
142 // Add whitelabel identification if in whitelabel mode
143 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
144 if ($whitelabel_company_name) {
145 $dashboard_url .= '&whitelabel=' . urlencode($whitelabel_company_name);
146 }
147
148 return $dashboard_url;
149 }
150
151 public const feature_sections = array(
152 'enable_404monitor' => 'Enable 404 Monitor',
153 'enable_siteverification' => 'Enable Site Verification',
154 'enable_localbusiness' => 'Enable Local Business',
155 'enable_codesnippets' => 'Enable Code Snippets',
156 'enable_googleconsole' => 'Enable Google Console',
157 'enable_optimalsettings' => 'Enable Optimal Settings',
158 'enable_globalsettings' => 'Enable Global Settings',
159 'enable_commonmetastatus' => 'Enable Common Meta Status',
160 'enable_socialmeta' => 'Enable Social Meta',
161 'enable_redirections' => 'Enable Redirections',
162 'enable_errorlogs' => 'Enable Error Logs'
163 );
164
165 private $database;
166 private $db_redirection;
167 public $db_heartbeat_errors;
168 public $setup_wizard;
169
170
171 /**
172 * Initialize the class and set its properties.
173 *
174 * @since 1.0.0
175 * @param string $plugin_name The name of this plugin.
176 * @param string $version The version of this plugin.
177 */
178
179 public function __construct($plugin_name, $version, &$database, $db_redirection, $db_heartbeat_errors) // , $data_error_log_list
180 {
181
182 $this->plugin_name = $plugin_name;
183 $this->version = $version;
184 $this->database = $database;
185 $this->db_redirection = $db_redirection;
186 $this->db_heartbeat_errors = $db_heartbeat_errors;
187 // $this->data_error_log_list = $data_error_log_list;
188
189 // Initialize setup wizard
190 $this->setup_wizard = new Metasync_Setup_Wizard($plugin_name, $version);
191
192 // Wire up the extracted settings-fields singleton
193 Metasync_Settings_Fields::instance()->set_admin_instance($this);
194
195 // Get data first for menu configuration
196 $data = Metasync::get_option('general');
197
198 // Set menu title using the effective title (includes whitelabel company name if available)
199 $this->menu_title = $this->get_effective_menu_title();
200 $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 $clean_slug = sanitize_title($raw_slug);
202 # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 # reader that builds admin links from the stored option gets a valid WP menu slug.
204 if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 $options = Metasync::get_option();
206 $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 Metasync::set_option($options);
208 }
209 self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
210
211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
213 add_action('admin_init', array($this, 'settings_page_init'));
214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
216
217 // Display transient error/success messages for redirections
218 add_action('admin_notices', array($this, 'display_redirection_messages'));
219
220 // Display CPU deferral notices when batch processing is deferred
221 add_action('admin_notices', array($this, 'display_cpu_deferral_notice'));
222
223 // Display LLMs.txt cross-plugin conflict notice
224 add_action('admin_notices', array($this, 'display_llms_txt_conflict_notice'));
225
226 // Add custom column for HTML-converted pages
227 add_filter('manage_posts_columns', array($this, 'add_html_converted_column'));
228 add_filter('manage_pages_columns', array($this, 'add_html_converted_column'));
229 add_action('manage_posts_custom_column', array($this, 'render_html_converted_column'), 10, 2);
230 add_action('manage_pages_custom_column', array($this, 'render_html_converted_column'), 10, 2);
231
232 // Add badge to page editor screen
233 add_action('edit_form_after_title', array($this, 'add_editor_source_notice'));
234
235 // Add badge to quick edit panel
236 add_action('quick_edit_custom_box', array($this, 'add_quick_edit_source_display'), 10, 2);
237
238 // Add dashboard widget
239 add_action('wp_dashboard_setup', array($this, 'add_html_pages_dashboard_widget'));
240
241 // Add Search Atlas status to WordPress admin bar (priority 999 to ensure plugin is fully loaded)
242 // Always add the action - the method will check the setting internally
243 add_action('admin_bar_menu', array($this, 'add_searchatlas_admin_bar_status'), 999);
244
245 #add css into admin header for icon image
246
247 add_action('admin_head', array($this,'metasync_admin_icon_style'));
248 add_action('admin_head', array($this, 'metasync_fouc_prevention_style'));
249 add_action('admin_head', array($this, 'suppress_notices_on_wizard_page'), 1);
250
251 // Always add admin bar styles - the method will check the setting internally
252 add_action('wp_head', array($this,'metasync_admin_bar_style')); // For frontend admin bar
253 add_action('admin_head', array($this,'metasync_admin_bar_style')); // For backend admin bar
254 // removing this as we don't need it anymore because we are using wp-ajax to implement the white label
255 // add_action('update_option_metasync_options', array($this, 'check_and_redirect_slug'), 10, 3);
256
257 // Sync plugin file headers whenever metasync_options is updated (covers AJAX save, Settings API, import)
258 add_action('update_option_metasync_options', array($this, 'on_options_updated_sync_file_headers'), 10, 2);
259
260 // Invalidate the admin bar status cache when settings change or the API key is rotated.
261 add_action('update_option_metasync_options', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
263
264 add_action('admin_init', array($this, 'initialize_cookie'));
265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
267
268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
271 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
272 add_action('admin_post_metasync_purge_hosting_cache', array($this, 'handle_purge_hosting_cache'));
273
274 // Add AJAX for saving general settings
275 add_action( 'wp_ajax_meta_sync_save_settings', array($this,'meta_sync_save_settings') );
276
277 // Add AJAX for saving Indexation Control settings
278 add_action( 'wp_ajax_meta_sync_save_seo_controls', array($this,'meta_sync_save_seo_controls') );
279
280 // Add AJAX handler for saving Performance (CPU Load) settings
281 add_action('wp_ajax_metasync_save_performance_settings', array($this, 'ajax_save_performance_settings'));
282
283 // Add AJAX for saving execution settings
284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
285
286 // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288
289 // Add AJAX for saving hosting cache settings
290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
291
292 // Add AJAX for saving OTTO Cache TTL
293 add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
294 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
295
296 // Add AJAX for saving edge cache / CDN settings
297 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
298 // Add AJAX handler for Plugin Auth Token refresh
299 add_action('wp_ajax_metasync_refresh_plugin_auth_token', array($this, 'refresh_plugin_auth_token'));
300
301 // Add AJAX handler to get current Plugin Auth Token (for UI updates)
302 add_action('wp_ajax_metasync_get_plugin_auth_token', array($this, 'get_plugin_auth_token'));
303
304 // Add AJAX handler for creating redirects from 404 suggestions
305 add_action('wp_ajax_metasync_create_redirect_from_404', array($this, 'ajax_create_redirect_from_404'));
306
307 // Add AJAX handler for updating database structure
308 add_action('wp_ajax_metasync_update_db_structure', array($this, 'ajax_update_db_structure'));
309
310 // Add AJAX handlers for setup wizard
311 add_action('wp_ajax_metasync_save_wizard_progress', array($this, 'ajax_save_wizard_progress'));
312 add_action('wp_ajax_metasync_complete_wizard', array($this, 'ajax_complete_wizard'));
313
314 // Add AJAX handlers for robots.txt
315 add_action('wp_ajax_metasync_validate_robots', array($this, 'ajax_validate_robots'));
316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
319 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
320 add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
321
322 // Add AJAX handlers for host blocking test
323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
325
326 // Add AJAX handlers for OTTO excluded URLs
327 add_action('wp_ajax_metasync_otto_add_excluded_url', array($this, 'ajax_otto_add_excluded_url'));
328 add_action('wp_ajax_metasync_otto_delete_excluded_url', array($this, 'ajax_otto_delete_excluded_url'));
329 add_action('wp_ajax_metasync_burst_ping', array($this, 'ajax_burst_ping'));
330 add_action('wp_ajax_metasync_otto_get_excluded_urls', array($this, 'ajax_otto_get_excluded_urls'));
331 add_action('wp_ajax_metasync_otto_recheck_excluded_url', array($this, 'ajax_otto_recheck_excluded_url'));
332
333 # Add AJAX handler for GA4 analytics tracking
334 add_action('wp_ajax_metasync_track_one_click_activation', array($this, 'ajax_track_one_click_activation'));
335
336 # Add AJAX handler for submitting issue reports
337 add_action('wp_ajax_metasync_submit_issue_report', array($this, 'ajax_submit_issue_report'));
338
339 # Add AJAX handlers for support token management
340
341 # Add AJAX handler for theme switcher
342 add_action('wp_ajax_metasync_save_theme', array($this, 'ajax_save_theme'));
343
344 # Add AJAX handlers for Sync Log management
345 add_action('wp_ajax_metasync_clear_sync_log', array($this, 'ajax_clear_sync_log'));
346 add_action('wp_ajax_metasync_rollback_mcp_change', array($this, 'ajax_rollback_mcp_change'));
347
348 # Add AJAX handler for external data import
349 add_action('wp_ajax_metasync_import_external_data', array($this, 'ajax_import_external_data'));
350
351 # Add AJAX handler for SEO metadata batch import
352 add_action('wp_ajax_metasync_import_seo_metadata', array($this, 'ajax_import_seo_metadata'));
353
354 # Add AJAX handler for password recovery
355 add_action('wp_ajax_metasync_recover_password', array($this, 'ajax_recover_password'));
356
357 # Add AJAX handler for resetting bot statistics
358 add_action('wp_ajax_metasync_reset_bot_stats', array($this, 'ajax_reset_bot_stats'));
359
360 # Add AJAX handlers for DB cleanup
361 add_action('wp_ajax_metasync_run_db_cleanup', array($this, 'ajax_run_db_cleanup'));
362 add_action('wp_ajax_metasync_save_db_cleanup_settings', array($this, 'ajax_save_db_cleanup_settings'));
363
364 # Add admin-post handler for exporting whitelabel settings (file download)
365 add_action('admin_post_metasync_export_whitelabel_settings', array($this, 'handle_export_whitelabel_settings'));
366
367 # Media Optimization AJAX handlers
368 add_action('wp_ajax_metasync_optimize_single_image', array($this, 'ajax_optimize_single_image'));
369 add_action('wp_ajax_metasync_revert_single_image', array($this, 'ajax_revert_single_image'));
370 add_action('wp_ajax_metasync_start_batch_optimize', array($this, 'ajax_start_batch_optimize'));
371 add_action('wp_ajax_metasync_cancel_batch_optimize', array($this, 'ajax_cancel_batch_optimize'));
372 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
373 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
374 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
376 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
378
379 # SEO Restore (Rollback) AJAX & Cron handlers
380 add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
381 add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
382 add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
383 add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
384 add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
385
386 # Add AJAX handlers for Google Instant Indexing
387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
388
389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
390 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
391
392 # Add hooks for instant indexing settings saves
393 add_action('admin_init', array($this, 'save_instant_indexing_settings'));
394
395 # Add hooks for instant indexing post actions
396 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
397 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
398
399 # Add hooks for auto-submission on post publish
400 add_action('save_post', array($this, 'auto_submit_to_instant_indexing'), 10, 3);
401
402 // Add REST API endpoint for ping
403 add_action('rest_api_init', array($this, 'register_ping_rest_endpoint'));
404
405 // Add heartbeat cron functionality
406 add_filter('cron_schedules', array($this, 'add_heartbeat_cron_schedule'));
407 add_action('metasync_heartbeat_cron_check', array($this, 'execute_heartbeat_cron_check'));
408 add_action('metasync_burst_heartbeat', array($this, 'execute_burst_heartbeat'));
409 add_action('metasync_announce_cron', array($this, 'execute_announce_cron'));
410
411 // PR3: Transition to KEY_PENDING when API key is added or rotated
412 add_action('metasync_heartbeat_state_key_pending', array($this, 'set_heartbeat_state_key_pending'));
413
414 // Add transient cleanup cron functionality
415 add_action('metasync_cleanup_transients', array($this, 'execute_transient_cleanup'));
416
417 // Add DB cleanup cron functionality
418 add_action('metasync_db_cleanup', array($this, 'execute_db_cleanup'));
419
420 // Schedule heartbeat cron on plugin load (if not already scheduled)
421 add_action('init', array($this, 'maybe_schedule_heartbeat_cron'));
422
423 // Schedule transient cleanup cron on plugin load
424 add_action('init', array($this, 'maybe_schedule_transient_cleanup_cron'));
425
426 // Schedule DB cleanup cron on plugin load (only when enabled)
427 add_action('init', array($this, 'maybe_schedule_db_cleanup_cron'));
428
429 # Schedule hidden post manager cron on plugin load (runs every 7 days)
430 add_action('init', array($this, 'maybe_schedule_hidden_post_check'));
431
432 # Schedule OTTO 404 exclusion recheck (runs daily to recheck URLs excluded 7+ days ago)
433 add_action('init', array($this, 'maybe_schedule_otto_recheck_404_cron'));
434
435 # Schedule support token cleanup cron on plugin load (runs daily)
436
437 // Listen for immediate heartbeat trigger requests from other parts of the plugin
438 add_action('metasync_trigger_immediate_heartbeat', array($this, 'handle_immediate_heartbeat_trigger'));
439
440 // Listen for cron scheduling requests (after Search Atlas connect authentication)
441 add_action('metasync_ensure_heartbeat_cron_scheduled', array($this, 'maybe_schedule_heartbeat_cron'));
442
443 // Action Scheduler configuration filters
444 add_filter('action_scheduler_queue_runner_concurrent_batches', array($this, 'get_action_scheduler_batches'));
445 add_filter('action_scheduler_retention_period', array($this, 'get_action_scheduler_retention_period'));
446
447 // Note: Option change monitoring is now handled by the centralized API Key Monitor class
448 // This provides more comprehensive and intelligent monitoring of API key changes
449
450
451 #--------------------------
452 # we are disabling this code
453 # To prevent enabling debuging on every pluging Update
454 # This causes Issue #102 on gilab
455 #--------------------------
456 #
457 # NOTE:
458 # Do not delete this as we may need it in implementing universal logging
459
460 # add_action('upgrader_process_complete', array($this,'metasync_plugin_updated_action'), 10, 2);
461
462 # Hook into post category creation and update (AFTER they are saved)
463 add_action('saved_term', array($this,'admin_crud_term'), 10, 3);
464
465 # Hook into post category deletion (AFTER it is deleted)
466 // add_action('pre_delete_term', array($this,'admin_delete_term'), 10, 2);
467 # Using delete_category hook which fires AFTER category is fully deleted and provides correct data
468 add_action('delete_category', array($this,'admin_delete_category'), 10, 1);
469
470 }
471 /*
472 This function add css to wp admin header
473 */
474 public function metasync_admin_icon_style(){
475 # Get Metasync Option
476
477 $data= Metasync::get_option('general');
478
479 # Get white label menu slug
480 # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
481 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
482 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
483
484 ?>
485 <style>
486 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
487 width: 36px;
488 height: 34px;
489 padding: 0!important;
490 object-fit: contain;
491 object-position: center;
492 }
493 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image img {
494 width: 20px !important;
495 }
496 </style>
497 <?php
498 }
499
500 public function metasync_fouc_prevention_style() {
501 if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
502 return;
503 }
504 ?>
505 <style>
506 .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
507 /*
508 * WP prints admin notices at the top of #wpbody-content, then core's
509 * common.js relocates them next to .wp-header-end inside our wrap on
510 * DOM-ready. Keep them out of the flow until that move so they don't
511 * flash above the dashboard header and shove the page down.
512 * Once relocated they're no longer a direct child here, so this rule
513 * stops matching and they fade in with the wrap. We mirror core's
514 * relocation set exactly (.notice/.updated/.error, minus .inline and
515 * .below-h2); .update-nag is left alone since core never moves it.
516 */
517 #wpbody-content > .notice:not(.inline):not(.below-h2),
518 #wpbody-content > .updated:not(.inline):not(.below-h2),
519 #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
520 </style>
521 <?php
522 }
523
524 public function suppress_notices_on_wizard_page() {
525 if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
526 return;
527 }
528
529 $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
530
531 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
532 if ( strpos( $page, '-setup-wizard' ) !== false ) {
533 remove_all_actions( 'admin_notices' );
534 remove_all_actions( 'all_admin_notices' );
535 return;
536 }
537
538 // Other Search Atlas plugin pages: strip third-party notices but keep our own.
539 if ( strpos( $page, self::$page_slug ) === 0 ) {
540 $this->remove_third_party_admin_notices();
541 }
542 }
543
544 /**
545 * Remove admin-notice callbacks that don't belong to this plugin.
546 *
547 * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
548 * global admin_notices / all_admin_notices actions, which WordPress fires on
549 * every admin screen — so their notices leak onto our settings pages. We walk
550 * the registered callbacks and drop any whose code lives outside this plugin's
551 * directory, leaving every one of our own notices intact.
552 */
553 private function remove_third_party_admin_notices() {
554 global $wp_filter;
555
556 foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
557 if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
558 continue;
559 }
560
561 // Collect first, then remove — avoids mutating the callbacks array mid-walk.
562 $to_remove = array();
563 foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
564 foreach ( $callbacks as $callback ) {
565 if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
566 $to_remove[] = array( $callback['function'], $priority );
567 }
568 }
569 }
570
571 foreach ( $to_remove as $entry ) {
572 remove_action( $hook, $entry[0], $entry[1] );
573 }
574 }
575 }
576
577 /**
578 * Determine whether an admin-notice callback is defined by this plugin.
579 *
580 * Ownership is decided by where the callback's code physically lives, not by
581 * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
582 * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
583 * rename the plugin folder. Resolving the declaring file and checking it sits
584 * inside this plugin's directory is robust against all of that. If a callback
585 * can't be introspected, we keep it rather than risk hiding a legitimate notice.
586 *
587 * @param string|array|Closure $function The registered callback.
588 * @return bool True when the callback's code lives inside this plugin.
589 */
590 private function is_own_admin_notice_callback( $function ) {
591 // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
592 $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
593
594 try {
595 if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
596 $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
597 $ref = new ReflectionMethod( $class, $function[1] );
598 } elseif ( $function instanceof Closure ) {
599 $ref = new ReflectionFunction( $function );
600 } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
601 list( $class, $method ) = explode( '::', $function, 2 );
602 $ref = new ReflectionMethod( $class, $method );
603 } elseif ( is_string( $function ) ) {
604 $ref = new ReflectionFunction( $function );
605 } else {
606 return false;
607 }
608 } catch ( \Throwable $e ) {
609 // Unintrospectable callback — keep it; never hide a notice we can't classify.
610 return true;
611 }
612
613 $file = $ref->getFileName();
614 if ( ! $file ) {
615 // Internal/built-in callback (no source file) — not ours.
616 return false;
617 }
618
619 return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
620 }
621
622 #---------fixes issue : #95 ----------
623 #This function is to redirect in case client changes slug on fresh install
624 #It is called by the add_option hook
625
626 public function redirect_slug_for_freshinstalls(){
627 #get the db menu slug
628 $plugin_menu_slug = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
629
630 #check that the slug is set or set it to the defaul class slug usually ('searchatlas')
631 $current_slug = empty($plugin_menu_slug) ? self::$page_slug : $plugin_menu_slug;
632
633 #check if we have the cookie set and check if the slug has changed
634 if (isset($_COOKIE['metasync_previous_slug']) && $_COOKIE['metasync_previous_slug'] !== $current_slug) {
635 # the slug changed so we need to update the cookie
636 setcookie('metasync_previous_slug', $current_slug, [
637 'expires' => time() + 3600,
638 'path' => COOKIEPATH,
639 'domain' => COOKIE_DOMAIN,
640 'secure' => is_ssl(),
641 'httponly' => true,
642 'samesite' => 'Lax',
643 ]);
644 $_COOKIE['metasync_previous_slug'] = $current_slug;
645
646 #Redirect url to the new slug
647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
648
649 wp_safe_redirect($redirect_url);
650 exit;
651 }
652 }
653
654 public function metasync_plugin_updated_action($upgrader_object, $options){
655 if ($options['action'] == 'update' && $options['type'] == 'plugin') {
656 // List of plugins being updated
657 $updated_plugins = $options['plugins'];
658
659 // Loop through plugins and check if your plugin is updated
660 if (in_array(plugin_basename(dirname(__DIR__) . '/metasync.php'), $updated_plugins, true)) {
661 // Only set debug options if they don't already exist (first install/update)
662 if (get_option('wp_debug_enabled') === false) {
663 update_option('wp_debug_enabled', 'true');
664 }
665 if (get_option('wp_debug_log_enabled') === false) {
666 update_option('wp_debug_log_enabled', 'true');
667 }
668 if (get_option('wp_debug_display_enabled') === false) {
669 update_option('wp_debug_display_enabled', 'false');
670 }
671 }
672 }
673 }
674
675 public function initialize_cookie() {
676 // Check if cookie is already set
677 if (!isset($_COOKIE['metasync_previous_slug'])) {
678 // Only set cookie if headers haven't been sent yet
679 if (!headers_sent()) {
680 $data = Metasync::get_option('general');
681 // Retrieve the current slug
682 $initial_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : self::$page_slug;
683 // Set the cookie
684 setcookie('metasync_previous_slug', $initial_slug, [
685 'expires' => time() + 3600,
686 'path' => COOKIEPATH,
687 'domain' => COOKIE_DOMAIN,
688 'secure' => is_ssl(),
689 'httponly' => true,
690 'samesite' => 'Lax',
691 ]);
692 }
693 }
694 }
695
696 /**
697 * Redirect to setup wizard on first activation
698 *
699 * @since 1.0.0
700 */
701 public function maybe_redirect_to_wizard() {
702 // Only redirect if wizard should be shown
703 if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
704 delete_option('metasync_show_wizard');
705
706 // Don't redirect during AJAX, cron, or bulk activation
707 if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
708 return;
709 }
710
711 // Only redirect if user has access to the plugin
712 if (!Metasync::current_user_has_plugin_access()) {
713 return;
714 }
715
716 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
717 exit;
718 }
719 }
720
721 /**
722 * Redirect the removed standalone Instant Indexing page to Indexation Control.
723 *
724 * The menu entry is gone, but bookmarks and external links can still target
725 * the old URL. Resolve the current white-label slug rather than assuming
726 * "searchatlas".
727 */
728 public function redirect_legacy_instant_indexing_page() {
729 if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
730 return;
731 }
732
733 $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
734 $legacy_page = self::$page_slug . '-instant-index';
735 if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
736 return;
737 }
738
739 // Indexation Control is registered only when access control keeps it
740 // visible. Redirecting to it blindly would dead-end on an
741 // unregistered slug (wp_die "Invalid plugin page") for users whose
742 // white-label config hides that page — the exact configs where the
743 // removed standalone page was the feature's only UI. The bare plugin
744 // slug is the floor: its top-level callback resolves to the first
745 // reachable page, so it is always a safe target.
746 if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
747 $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
748 $query = [];
749 foreach (['tab', 'subtab'] as $key) {
750 if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
751 $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
752 }
753 }
754 if (!empty($query)) {
755 $target = add_query_arg($query, $target);
756 }
757 } else {
758 $target = admin_url('admin.php?page=' . self::$page_slug);
759 }
760
761 wp_safe_redirect($target);
762 exit;
763 }
764
765 public function metasync_display_error_log() {
766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
767 }
768
769 public function metasync_update_wp_config() {
770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
771 }
772
773
774 /**
775 * Sync plugin file headers when metasync_options is updated.
776 * This ensures whitelabel values are written to the plugin file header
777 * so they persist even when the plugin is deactivated.
778 *
779 * @param mixed $old_value The old option value.
780 * @param mixed $new_value The new option value.
781 * @since 2.5.0
782 */
783 public function on_options_updated_sync_file_headers($old_value, $new_value)
784 {
785 Metasync_Debug_Manager::instance()->on_options_updated_sync_file_headers($old_value, $new_value);
786 }
787
788 public function check_and_redirect_slug($option, $old_value, $new_value) {
789 // Ensure this hook is only triggered for your specific option group
790
791 if (!isset($option['general'] ) && !isset($option['general']['white_label_plugin_menu_slug'])) {
792
793 return;
794 }
795
796 $new_slug = $new_value['general']['white_label_plugin_menu_slug'] ?? self::$page_slug;
797
798 $old_slug = $old_value['general']['white_label_plugin_menu_slug'] ?? self::$page_slug;
799
800 if ($new_slug !== $old_slug && $old_slug !=='' ){
801 // Set a new cookie
802 setcookie('metasync_previous_slug', $new_slug, [
803 'expires' => time() + 3600,
804 'path' => COOKIEPATH,
805 'domain' => COOKIE_DOMAIN,
806 'secure' => is_ssl(),
807 'httponly' => true,
808 'samesite' => 'Lax',
809 ]);
810 $_COOKIE['metasync_previous_slug'] = $new_slug;
811 // Redirect to the new slug
812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
813 wp_safe_redirect($redirect_url);
814 exit;
815 }else{
816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
818
819 #add redirection for when the old slug is not defined
820 #this fixes the redirect issue #
821 wp_safe_redirect($redirect_url);
822 exit;
823 }
824 }
825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
826 $plugin_uri = Metasync::get_option('general')['white_label_plugin_uri'] ?? '';
827 $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
828 if ($this_plugin === $plugin_file && $plugin_uri !== '') {
829 foreach ($plugin_meta as &$meta) {
830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
831 $meta = sprintf(
832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
833 add_query_arg('TB_iframe', 'true', $plugin_uri),
834 /* translators: %s: plugin name. */
835 esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
836 esc_attr($plugin_data['Name']),
837 esc_html__('View details', 'metasync')
838 );
839 break; // Exit loop after replacing the link
840 }
841 }
842 }
843 return $plugin_meta;
844 }
845
846 public function metasync_plugin_white_label($all_plugins) {
847 $general = Metasync::get_option('general');
848 if (!is_array($general)) {
849 return $all_plugins;
850 }
851
852 $plugin_name = $general['white_label_plugin_name'] ?? '';
853 $plugin_description = $general['white_label_plugin_description'] ?? '';
854 $plugin_author = $general['white_label_plugin_author'] ?? '';
855 $plugin_author_uri = $general['white_label_plugin_author_uri'] ?? '';
856 $plugin_uri = $general['white_label_plugin_uri'] ?? '';
857
858 // Dynamically resolve the plugin basename to handle renamed plugin folders
859 $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
860
861 if (isset($all_plugins[$this_plugin])) {
862 if ($plugin_name !== '') {
863 $all_plugins[$this_plugin]['Name'] = $plugin_name;
864 $all_plugins[$this_plugin]['Title'] = $plugin_name;
865 }
866 if ($plugin_description !== '') {
867 $all_plugins[$this_plugin]['Description'] = $plugin_description;
868 }
869 if ($plugin_author !== '') {
870 $all_plugins[$this_plugin]['Author'] = $plugin_author;
871 $all_plugins[$this_plugin]['AuthorName'] = $plugin_author;
872 }
873 if ($plugin_author_uri !== '') {
874 $all_plugins[$this_plugin]['AuthorURI'] = $plugin_author_uri;
875 }
876 if ($plugin_uri !== '') {
877 $all_plugins[$this_plugin]['PluginURI'] = $plugin_uri;
878 }
879 }
880
881 return $all_plugins;
882 }
883
884 /**
885 * Register the stylesheets for the admin area.
886 *
887 * @since 1.0.0
888 */
889 public function enqueue_styles()
890 {
891 $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
892
893 if (strpos($current_page, self::$page_slug) === 0) {
894 wp_enqueue_style(
895 $this->plugin_name,
896 plugin_dir_url(__FILE__) . 'css/metasync-admin.css',
897 array(),
898 $this->version,
899 'all'
900 );
901
902 // Enqueue dashboard-style CSS for admin pages
903 wp_enqueue_style(
904 $this->plugin_name . '-dashboard',
905 plugin_dir_url(__FILE__) . 'css/metasync-dashboard.css',
906 array($this->plugin_name),
907 $this->version,
908 'all'
909 );
910
911 // Enqueue 3-column layout CSS
912 wp_enqueue_style(
913 $this->plugin_name . '-layout',
914 plugin_dir_url(__FILE__) . 'css/metasync-layout.css',
915 array($this->plugin_name . '-dashboard'),
916 $this->version,
917 'all'
918 );
919 }
920
921 // Enqueue wizard CSS if on wizard page
922 if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
923 wp_enqueue_style(
924 $this->plugin_name . '-setup-wizard',
925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
926 array($this->plugin_name . '-dashboard'),
927 $this->version,
928 'all'
929 );
930 }
931
932 // Enqueue SEO Health CSS if on the SEO Health page
933 if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
934 wp_enqueue_style(
935 $this->plugin_name . '-seo-health',
936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
937 array($this->plugin_name . '-dashboard'),
938 $this->version,
939 'all'
940 );
941 }
942 }
943
944 /**
945 * Register the JavaScript for the admin area.
946 *
947 * @since 1.0.0
948 */
949 public function enqueue_scripts()
950 {
951 // --- Phase 5 (#887): Extracted inline JS files ---
952 $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
955
956 if ($is_metasync_page) {
957 wp_enqueue_media();
958
959 wp_enqueue_script(
960 $this->plugin_name,
961 plugin_dir_url(__FILE__) . 'js/metasync-admin.js',
962 array('jquery'),
963 $this->version,
964 false
965 );
966
967 // Enqueue dashboard-style JavaScript for enhanced interactions
968 wp_enqueue_script(
969 $this->plugin_name . '-dashboard',
970 plugin_dir_url(__FILE__) . 'js/metasync-dashboard.js',
971 array('jquery', $this->plugin_name),
972 $this->version,
973 true
974 );
975
976 # Enqueue theme switcher
977 wp_enqueue_script(
978 $this->plugin_name . '-theme-switcher',
979 plugin_dir_url(__FILE__) . 'js/metasync-theme-switcher.js',
980 array('jquery'),
981 $this->version,
982 true
983 );
984
985 // Keeps core's admin-menu pinning in step with pages whose height
986 // changes after load. Depends on 'common' so core has bound its
987 // own handlers before this runs.
988 wp_enqueue_script(
989 $this->plugin_name . '-admin-menu-height',
990 plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
991 array('jquery', 'common'),
992 $this->version,
993 true
994 );
995 }
996
997 $per_page_screens = [
998 self::$page_slug . '-redirections',
999 self::$page_slug . '-404-monitor',
1000 self::$page_slug . '-sync-log',
1001 self::$page_slug . '-media-optimization',
1002 ];
1003
1004 if (in_array($current_page, $per_page_screens, true)) {
1005 // Results-per-page selectors use this external handler instead of
1006 // an inline event attribute so changing a page size always reloads
1007 // the current admin page.
1008 wp_enqueue_script(
1009 $this->plugin_name . '-per-page',
1010 plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1011 array(),
1012 $this->version,
1013 true
1014 );
1015 // Selector navigation is a GET, so the per-page nonce rides along
1016 // as a query parameter and gates the user_meta persist in
1017 // Metasync_Per_Page_Helper::resolve().
1018 wp_localize_script(
1019 $this->plugin_name . '-per-page',
1020 'metasyncPerPage',
1021 array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 );
1023 }
1024
1025 // Dashboard iframe height (only on dashboard page)
1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
1027 wp_enqueue_script(
1028 $this->plugin_name . '-iframe',
1029 plugin_dir_url(__FILE__) . 'js/metasync-iframe.js',
1030 array(),
1031 $this->version,
1032 false // Load in head — needed before iframe renders
1033 );
1034 }
1035
1036 // Settings page scripts (save btn, clear settings, bing key, access roles)
1037 if ($current_page === self::$page_slug) {
1038 wp_enqueue_script(
1039 $this->plugin_name . '-settings',
1040 plugin_dir_url(__FILE__) . 'js/metasync-settings.js',
1041 array('jquery'),
1042 $this->version,
1043 true
1044 );
1045 }
1046
1047 // Tab switcher (redirections / 404-monitor page)
1048 if ($current_page === self::$page_slug . '-redirections') {
1049 wp_enqueue_script(
1050 $this->plugin_name . '-tab-switcher',
1051 plugin_dir_url(__FILE__) . 'js/metasync-tab-switcher.js',
1052 array('jquery'),
1053 $this->version,
1054 true
1055 );
1056 }
1057
1058 // Sitemap tabs (xml-sitemap page)
1059 if ($current_page === self::$page_slug . '-xml-sitemap') {
1060 wp_enqueue_style(
1061 $this->plugin_name . '-sitemap-tabs',
1062 plugin_dir_url(__FILE__) . 'css/metasync-sitemap-tabs.css',
1063 array($this->plugin_name . '-dashboard'),
1064 $this->version
1065 );
1066 wp_enqueue_script(
1067 $this->plugin_name . '-sitemap-tabs',
1068 plugin_dir_url(__FILE__) . 'js/metasync-sitemap-tabs.js',
1069 array('jquery'),
1070 $this->version,
1071 true
1072 );
1073 // Pass the active tab from server (handles POST redirect)
1074 $sitemap_active_tab = 'general';
1075 if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1076 $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1077 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1078 $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1079 }
1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1081 'activeTab' => $sitemap_active_tab,
1082 ]);
1083 }
1084
1085 // Error logs — copy to clipboard
1086 if ($current_page === self::$page_slug) {
1087 wp_enqueue_script(
1088 $this->plugin_name . '-error-logs',
1089 $plugin_root_url . 'site-error-logs/js/metasync-error-logs.js',
1090 array(),
1091 $this->version,
1092 true
1093 );
1094 }
1095
1096 // Access control UI
1097 if ($current_page === self::$page_slug) {
1098 wp_enqueue_script(
1099 $this->plugin_name . '-access-control',
1100 $plugin_root_url . 'includes/js/metasync-access-control.js',
1101 array(),
1102 $this->version,
1103 true
1104 );
1105 }
1106
1107 // 404 monitor filter
1108 if ($current_page === self::$page_slug . '-redirections' || $current_page === self::$page_slug . '-404-monitor') {
1109 wp_enqueue_script(
1110 $this->plugin_name . '-404-monitor',
1111 $plugin_root_url . 'views/js/metasync-404-monitor.js',
1112 array(),
1113 $this->version,
1114 true
1115 );
1116 }
1117
1118 // Redirections filter
1119 if ($current_page === self::$page_slug . '-redirections') {
1120 wp_enqueue_script(
1121 $this->plugin_name . '-redirections',
1122 $plugin_root_url . 'views/js/metasync-redirections.js',
1123 array(),
1124 $this->version,
1125 true
1126 );
1127 wp_localize_script($this->plugin_name . '-redirections', 'metasyncHealthCheck', array(
1128 'ajaxUrl' => admin_url('admin-ajax.php'),
1129 'healthNonce' => wp_create_nonce('metasync_redirect_health_check'),
1130 ));
1131 }
1132 // --- Phase 5 Part B: Extracted inline JS with wp_localize_script ---
1133
1134 // Navigation portal menus (top bar + settings inner page)
1135 $whitelabel_data = Metasync::get_option('whitelabel');
1136 if ($is_metasync_page) {
1137 wp_enqueue_script(
1138 $this->plugin_name . '-navigation',
1139 plugin_dir_url(__FILE__) . 'js/metasync-navigation.js',
1140 array(),
1141 $this->version,
1142 false // Load in head — global functions called from onclick attributes
1143 );
1144 wp_localize_script($this->plugin_name . '-navigation', 'metasyncNavData', array(
1145 'hideAdvanced' => !empty($whitelabel_data['hide_advanced']),
1146 'showGeneral' => Metasync_Access_Control::user_can_access('hide_settings'),
1147 'pageSlug' => self::$page_slug,
1148 ));
1149 }
1150
1151 // Debug mode timer
1152 if ($current_page === self::$page_slug) {
1153 $debug_manager = Metasync_Debug_Mode_Manager::get_instance();
1154 $debug_status = $debug_manager->get_status();
1155 wp_enqueue_script(
1156 $this->plugin_name . '-debug-mode',
1157 plugin_dir_url(__FILE__) . 'js/metasync-debug-mode.js',
1158 array('jquery'),
1159 $this->version,
1160 true
1161 );
1162 wp_localize_script($this->plugin_name . '-debug-mode', 'metasyncDebugData', array(
1163 'enabled' => !empty($debug_status['enabled']),
1164 'indefinite' => !empty($debug_status['indefinite']),
1165 'timeRemaining' => isset($debug_status['time_remaining']) ? (int) $debug_status['time_remaining'] : 0,
1166 'statusUrl' => rest_url('metasync/v1/debug-mode/status'),
1167 'restNonce' => wp_create_nonce('wp_rest'),
1168 ));
1169 }
1170
1171 // MetasyncConfig + admin bar sync
1172 if ($is_metasync_page) {
1173 wp_enqueue_script(
1174 $this->plugin_name . '-config',
1175 plugin_dir_url(__FILE__) . 'js/metasync-config.js',
1176 array('jquery'),
1177 $this->version,
1178 true
1179 );
1180 wp_localize_script($this->plugin_name . '-config', 'metasyncConfigData', array(
1181 'pluginName' => Metasync::get_effective_plugin_name(),
1182 'ottoName' => Metasync::get_whitelabel_otto_name(),
1183 ));
1184 }
1185
1186 // Whitelabel password (forgot password handler)
1187 if ($current_page === self::$page_slug) {
1188 wp_enqueue_script(
1189 $this->plugin_name . '-whitelabel',
1190 plugin_dir_url(__FILE__) . 'js/metasync-whitelabel.js',
1191 array('jquery'),
1192 $this->version,
1193 true
1194 );
1195 wp_localize_script($this->plugin_name . '-whitelabel', 'metasyncWhitelabelData', array(
1196 'recoverNonce' => wp_create_nonce('metasync_recover_password_nonce'),
1197 ));
1198 }
1199
1200 // Whitelabel connect (validation modal + lock section + export)
1201 if ($current_page === self::$page_slug) {
1202 wp_enqueue_script(
1203 $this->plugin_name . '-connect',
1204 plugin_dir_url(__FILE__) . 'js/metasync-connect.js',
1205 array('jquery'),
1206 $this->version,
1207 true
1208 );
1209 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1210 'optionKey' => self::option_key,
1211 'adminPostUrl' => admin_url('admin-post.php'),
1212 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1213 'ajaxUrl' => admin_url('admin-ajax.php'),
1214 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
1215 ));
1216 }
1217
1218 // Host blocking test (settings + dashboard)
1219 if ($current_page === self::$page_slug || $current_page === self::$page_slug . '-dashboard') {
1220 wp_enqueue_script(
1221 $this->plugin_name . '-host-blocking',
1222 plugin_dir_url(__FILE__) . 'js/metasync-host-blocking.js',
1223 array('jquery'),
1224 $this->version,
1225 true
1226 );
1227 wp_localize_script($this->plugin_name . '-host-blocking', 'metasyncHostBlockingData', array(
1228 'ajaxUrl' => admin_url('admin-ajax.php'),
1229 'nonce' => wp_create_nonce('metasync_nonce'),
1230 ));
1231 }
1232
1233 // OTTO excluded URLs (lives on the Compatibility page)
1234 if ($current_page === self::$page_slug . '-compatibility') {
1235 wp_enqueue_script(
1236 $this->plugin_name . '-excluded-urls',
1237 plugin_dir_url(__FILE__) . 'js/metasync-excluded-urls.js',
1238 array('jquery'),
1239 $this->version,
1240 true
1241 );
1242 wp_localize_script($this->plugin_name . '-excluded-urls', 'metasyncExcludedUrlsData', array(
1243 'ajaxUrl' => admin_url('admin-ajax.php'),
1244 'nonce' => wp_create_nonce('metasync_otto_excluded_urls'),
1245 ));
1246 }
1247
1248 // Execution settings form
1249 if ($current_page === self::$page_slug) {
1250 $server_limits = $this->get_server_limits();
1251 wp_enqueue_script(
1252 $this->plugin_name . '-execution-settings',
1253 plugin_dir_url(__FILE__) . 'js/metasync-execution-settings.js',
1254 array('jquery'),
1255 $this->version,
1256 true
1257 );
1258 wp_localize_script($this->plugin_name . '-execution-settings', 'metasyncExecSettingsData', array(
1259 'serverMaxExecTime' => ($server_limits['max_execution_time_raw'] == -1) ? 'Infinity' : (int) $server_limits['max_execution_time_raw'],
1260 'serverMaxMemory' => ($server_limits['memory_limit_raw'] == -1) ? 'Infinity' : (int) $server_limits['memory_limit_raw'],
1261 'canChangeMemory' => !empty($server_limits['can_change_memory']),
1262 ));
1263 }
1264
1265 // Quick edit badge (custom pages)
1266 global $pagenow;
1267 if ($pagenow === 'edit.php') {
1268 wp_enqueue_script(
1269 $this->plugin_name . '-quick-edit',
1270 plugin_dir_url(__FILE__) . 'js/metasync-quick-edit.js',
1271 array('jquery'),
1272 $this->version,
1273 true
1274 );
1275 wp_localize_script($this->plugin_name . '-quick-edit', 'metasyncQuickEditData', array(
1276 'standardPageLabel' => __('Standard page', 'metasync'),
1277 ));
1278 }
1279
1280 // Report issue form
1281 if ($current_page === self::$page_slug . '-report-issue') {
1282 wp_enqueue_script(
1283 $this->plugin_name . '-report-issue',
1284 $plugin_root_url . 'views/js/metasync-report-issue.js',
1285 array('jquery'),
1286 $this->version,
1287 true
1288 );
1289 }
1290
1291 // Bing console
1292 if ($current_page === self::$page_slug . '-bing-console') {
1293 wp_enqueue_script(
1294 $this->plugin_name . '-bing-console',
1295 $plugin_root_url . 'views/js/metasync-bing-console.js',
1296 array('jquery'),
1297 $this->version,
1298 true
1299 );
1300 wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1301 'nonce' => wp_create_nonce('metasync_nonce'),
1302 ));
1303 }
1304
1305 // Add redirection form
1306 if ($current_page === self::$page_slug . '-redirections') {
1307 wp_enqueue_script(
1308 $this->plugin_name . '-add-redirection',
1309 $plugin_root_url . 'views/js/metasync-add-redirection.js',
1310 array(),
1311 $this->version,
1312 true
1313 );
1314 }
1315
1316 // Import redirections
1317 if ($current_page === self::$page_slug . '-redirections') {
1318 wp_enqueue_script(
1319 $this->plugin_name . '-import-redirections',
1320 $plugin_root_url . 'views/js/metasync-import-redirections.js',
1321 array('jquery'),
1322 $this->version,
1323 true
1324 );
1325 wp_localize_script($this->plugin_name . '-import-redirections', 'metasyncImportRedirData', array(
1326 'nonce' => wp_create_nonce('metasync_import_redirections'),
1327 'redirectUrl' => admin_url('admin.php?page=' . self::$page_slug . '-redirections'),
1328 ));
1329 }
1330
1331 // Import external data (SEO metadata)
1332 if ($current_page === self::$page_slug . '-import-external') {
1333 wp_enqueue_script(
1334 $this->plugin_name . '-import-external-data',
1335 $plugin_root_url . 'views/js/metasync-import-external-data.js',
1336 array('jquery'),
1337 $this->version,
1338 true
1339 );
1340 wp_localize_script($this->plugin_name . '-import-external-data', 'metasyncImportData', array(
1341 'importNonce' => wp_create_nonce('metasync_import_external_data'),
1342 'seoImportNonce' => wp_create_nonce('metasync_import_seo_metadata'),
1343 ));
1344 }
1345
1346 // OTTO bot statistics
1347 if ($current_page === self::$page_slug . '-bot-statistics') {
1348 wp_enqueue_script(
1349 $this->plugin_name . '-bot-statistics',
1350 $plugin_root_url . 'views/js/metasync-bot-statistics.js',
1351 array('jquery'),
1352 $this->version,
1353 true
1354 );
1355 wp_localize_script($this->plugin_name . '-bot-statistics', 'metasyncBotStatsData', array(
1356 'resetNonce' => wp_create_nonce('metasync_reset_bot_stats'),
1357 ));
1358 }
1359
1360 // OTTO debug page
1361 if ($current_page === self::$page_slug . '-otto-debug') {
1362 wp_enqueue_script(
1363 $this->plugin_name . '-otto-debug',
1364 plugin_dir_url(__FILE__) . 'js/metasync-otto-debug.js',
1365 array('jquery'),
1366 $this->version,
1367 true
1368 );
1369 wp_localize_script($this->plugin_name . '-otto-debug', 'metasyncOttoDebugData', array(
1370 'nonce' => wp_create_nonce('metasync_otto_debug'),
1371 ));
1372 }
1373
1374 // --- End Phase 5 enqueues ---
1375
1376 if ($is_metasync_page) {
1377 # Localize theme switcher script
1378 wp_localize_script(
1379 $this->plugin_name . '-theme-switcher',
1380 'metasyncThemeData',
1381 array(
1382 'ajaxUrl' => admin_url('admin-ajax.php'),
1383 'nonce' => wp_create_nonce('metasync_theme_nonce'),
1384 'currentTheme' => get_option('metasync_theme', 'dark')
1385 )
1386 );
1387
1388 // Get connection status for JavaScript
1389 $general_settings = Metasync::get_option('general');
1390 $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1391 $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1392
1393 // SECURITY FIX (CVE-2025-14386): Only generate Search Atlas connect nonce for administrators
1394 // Using strict capability check instead of plugin access roles
1395 $sa_connect_nonce = '';
1396 if (current_user_can('manage_options')) {
1397 $sa_connect_nonce = wp_create_nonce('metasync_sa_connect_nonce');
1398 }
1399
1400 $heartbeat_state = $this->get_heartbeat_state();
1401 wp_localize_script( $this->plugin_name, 'metaSync', array(
1402 'ajax_url' => admin_url( 'admin-ajax.php' ),
1403 'admin_url'=>admin_url('admin.php'),
1404 'nonce' => wp_create_nonce('metasync_nonce'),
1405 'sa_connect_nonce' => $sa_connect_nonce,
1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1407 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1408 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1409 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1410 'heartbeat_state' => $heartbeat_state,
1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1412 'support_email' => Metasync::SUPPORT_EMAIL,
1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
1414 'debug_enabled' => WP_DEBUG || (defined('METASYNC_DEBUG') && constant('METASYNC_DEBUG')),
1415 'searchatlas_api_key' => !empty($searchatlas_api_key),
1416 'otto_pixel_uuid' => $otto_pixel_uuid,
1417 'is_connected' => (bool)$this->is_heartbeat_connected()
1418 ));
1419
1420 // Ensure ajaxurl is available for admin pages (WordPress standard)
1421 // This creates a global ajaxurl variable for JavaScript
1422 wp_enqueue_script('wp-util');
1423
1424 // Add inline script to ensure ajaxurl is defined
1425 $inline_script = "
1426 if (typeof ajaxurl === 'undefined') {
1427 var ajaxurl = '" . esc_js(admin_url('admin-ajax.php')) . "';
1428 }
1429
1430 // Add Plugin Auth Token refresh functionality
1431 jQuery(document).ready(function($) {
1432 $('#refresh-plugin-auth-token').click(function() {
1433 var button = $(this);
1434 var originalText = button.text();
1435
1436 if (confirm('Are you sure you want to refresh the Plugin Auth Token? This will generate a new token and update the heartbeat API.')) {
1437 // Disable button and show loading
1438 button.prop('disabled', true).text('🔄 Refreshing...');
1439
1440 $.post(ajaxurl, {
1441 action: 'metasync_refresh_plugin_auth_token',
1442 nonce: '" . wp_create_nonce('metasync_refresh_plugin_auth_token') . "'
1443 })
1444 .done(function(response) {
1445 if (response.success && response.data && response.data.new_token) {
1446 // Update the field value immediately
1447 $('#apikey').val(response.data.new_token);
1448
1449 // Visual feedback with green border
1450 $('#apikey').css('border', '2px solid #28a745').animate({borderColor: '#ddd'}, 2000);
1451
1452 alert('�
1453 Plugin Auth Token refreshed successfully!\\n\\nNew token: ' + response.data.new_token.substring(0, 8) + '...');
1454 } else {
1455 alert('❌ Error refreshing token: ' + (response.data ? response.data.message : 'Unknown error'));
1456 }
1457 })
1458 .fail(function() {
1459 alert('❌ Network error while refreshing token');
1460 })
1461 .always(function() {
1462 // Re-enable button
1463 button.prop('disabled', false).text(originalText);
1464 });
1465 }
1466 });
1467 });
1468 ";
1469 wp_add_inline_script($this->plugin_name, $inline_script);
1470 }
1471 add_action('admin_notices', array($this, 'permalink_structure_dashboard_warning'));
1472 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1473 // Display update warning banner if plugin update is available
1474 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1475 // Enqueue wizard assets if on wizard page
1476 if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1477 wp_enqueue_script(
1478 $this->plugin_name . '-setup-wizard',
1479 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1480 array('jquery'),
1481 $this->version,
1482 true
1483 );
1484
1485 wp_localize_script($this->plugin_name . '-setup-wizard', 'metasyncWizardData', array(
1486 'nonce' => wp_create_nonce('metasync_wizard'),
1487 'ssoNonce' => wp_create_nonce('metasync_sso_nonce'),
1488 'saConnectNonce' => wp_create_nonce('metasync_sa_connect_nonce'),
1489 'importNonce' => wp_create_nonce('metasync_import_external_data'),
1490 'dashboardUrl' => admin_url('admin.php?page=' . self::$page_slug . '-dashboard'),
1491 'currentStep' => 1,
1492 'totalSteps' => 6,
1493 'pluginName' => Metasync::get_effective_plugin_name()
1494 ));
1495 }
1496
1497 wp_enqueue_script('heartbeat');
1498 }
1499
1500 /**
1501 * Settings of HeartBeat API for admin area.
1502 *
1503 * The only thing the plugin rides the beat for is a periodic admin
1504 * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1505 * so it slows the beat down rather than speeding it up. Two contexts are
1506 * left on whatever core decided, because core leans on the beat there:
1507 *
1508 * - The front end, where the beat drives wp-auth-check — the "your session
1509 * has expired, log in again" prompt.
1510 * - The editor screens, where each tick renews the post lock. A lock only
1511 * lives for wp_check_post_lock_window() seconds (150 by default), so any
1512 * interval longer than that drops the lock between beats and lets a
1513 * second editor silently take over a post someone still has open.
1514 *
1515 * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1516 * over-long interval is not corrected on our behalf.
1517 *
1518 * @param array $settings Heartbeat settings array.
1519 * @return array Heartbeat settings array.
1520 */
1521 function metasync_heartbeat_settings($settings)
1522 {
1523 if (!is_admin()) {
1524 return $settings;
1525 }
1526
1527 # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1528 # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1529 global $pagenow;
1530 $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1531 if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1532 return $settings;
1533 }
1534
1535 # 60s matches heartbeat.js's own default and stays well inside the
1536 # 150s post-lock window, so nothing core does can fall through a gap.
1537 $settings['interval'] = 60;
1538 return $settings;
1539 }
1540
1541 /**
1542 * Data or Response received from HeartBeat API for admin area.
1543 */
1544 function metasync_received_data($response, $data)
1545 {
1546 // if ($data['client'] == 'marco')
1547
1548 $response['server'] = wp_json_encode($data);
1549
1550 return $response;
1551 }
1552
1553 /**
1554 * Add Import External Data page
1555 */
1556 public function add_import_external_data_page()
1557 {
1558 # Check if current user has plugin access based on role settings
1559 if (!$this->current_user_has_plugin_access()) {
1560 return; // Don't add this page for users without access
1561 }
1562
1563 // Use 'read' capability since actual access is controlled by current_user_has_plugin_access() check above
1564 add_submenu_page(
1565 '', // Hidden from menu, linked from other pages
1566 'Import External Data',
1567 'Import External Data',
1568 'read',
1569 self::$page_slug . '-import-external',
1570 array($this, 'render_import_external_data_page')
1571 );
1572 }
1573
1574 /**
1575 * Render Import External Data page
1576 */
1577 public function render_import_external_data_page()
1578 {
1579 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-external-importer.php';
1580 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-import-external-data.php';
1581 }
1582
1583 /**
1584 * AJAX handler for external data import
1585 */
1586 public function ajax_import_external_data()
1587 {
1588 Metasync_Admin_Ajax::instance()->ajax_import_external_data();
1589 }
1590
1591 /**
1592 * AJAX handler for SEO metadata batch import
1593 * Supports batch processing with progress tracking
1594 */
1595 public function ajax_import_seo_metadata()
1596 {
1597 Metasync_Admin_Ajax::instance()->ajax_import_seo_metadata();
1598 }
1599
1600 /**
1601 * Additional context validation for Search Atlas connect tokens.
1602 *
1603 * Validates site URL and optional IP/user-agent context for connect tokens
1604 * used during the Search Atlas API key + Otto UUID retrieval flow.
1605 * This does NOT create WordPress login sessions.
1606 */
1607 private function validate_searchatlas_context($token_data)
1608 {
1609 return Metasync_Connect_Manager::instance()->validate_searchatlas_context($token_data);
1610 }
1611
1612 private function should_validate_ip()
1613 {
1614 return Metasync_Connect_Manager::instance()->should_validate_ip();
1615 }
1616
1617 private function are_user_agents_incompatible($old_ua, $new_ua)
1618 {
1619 return Metasync_Connect_Manager::instance()->are_user_agents_incompatible($old_ua, $new_ua);
1620 }
1621
1622 private function extract_browser_name($ua)
1623 {
1624 return Metasync_Connect_Manager::instance()->extract_browser_name($ua);
1625 }
1626
1627 public function generate_searchatlas_wp_connect_token($regenerate = false)
1628 {
1629 return Metasync_Connect_Manager::instance()->generate_searchatlas_wp_connect_token($regenerate);
1630 }
1631
1632 private function ensure_plugin_auth_token_exists()
1633 {
1634 Metasync_Connect_Manager::instance()->ensure_plugin_auth_token_exists();
1635 }
1636
1637 public function generate_searchatlas_connect_url()
1638 {
1639 Metasync_Connect_Manager::instance()->generate_searchatlas_connect_url();
1640 }
1641
1642 public function check_searchatlas_connect_status()
1643 {
1644 Metasync_Connect_Manager::instance()->check_searchatlas_connect_status();
1645 }
1646
1647 private function create_searchatlas_nonce_token()
1648 {
1649 return Metasync_Connect_Manager::instance()->create_searchatlas_nonce_token();
1650 }
1651
1652 private function get_client_ip()
1653 {
1654 return Metasync_Connect_Manager::instance()->get_client_ip();
1655 }
1656
1657 private function create_encrypted_searchatlas_token($metadata = array())
1658 {
1659 return Metasync_Connect_Manager::instance()->create_encrypted_searchatlas_token($metadata);
1660 }
1661
1662 private function wp_encrypt_token($payload)
1663 {
1664 return Metasync_Connect_Manager::instance()->wp_encrypt_token($payload);
1665 }
1666
1667 public function test_enhanced_searchatlas_tokens()
1668 {
1669 return Metasync_Connect_Manager::instance()->test_enhanced_searchatlas_tokens();
1670 }
1671
1672 public function test_searchatlas_ajax_endpoint()
1673 {
1674 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1675 }
1676
1677 /**
1678 * Create Admin Dashboard Iframe Page
1679 * Embeds the Search Atlas dashboard directly in WordPress admin
1680 */
1681 public function create_admin_dashboard_iframe()
1682 {
1683 Metasync_Admin_Pages::get_instance($this)->create_admin_dashboard_iframe();
1684 }
1685
1686 /**
1687 * Render OTTO cache management interface
1688 */
1689 public function render_otto_cache_management()
1690 {
1691 // Check if OTTO transient cache class exists
1692 if (!class_exists('Metasync_Otto_Transient_Cache')) {
1693 echo '<div class="notice notice-error inline"><p>';
1694 echo '❌ <strong>Error:</strong> Transient Cache class not found.';
1695 echo '</p></div>';
1696 return;
1697 }
1698
1699 // Get cache count
1700 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1701
1702 ?>
1703 <!-- OTTO Cache TTL Setting -->
1704 <div style="margin-bottom: 30px; padding-top: 20px;">
1705 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1706 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1707 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.
1708 </p>
1709
1710 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1711 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1712 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1713 Cache TTL (minutes):
1714 </label>
1715 <?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.'); ?>
1716 <input type="number"
1717 id="metasync-otto-cache-ttl"
1718 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1719 min="30"
1720 max="1440"
1721 step="1"
1722 style="width: 100px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg, rgba(255,255,255,0.05)); color: var(--dashboard-text-primary);" />
1723 <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1724 </div>
1725
1726 <div style="display: flex; align-items: center; gap: 12px;">
1727 <button type="button"
1728 id="metasync-otto-ttl-save-btn"
1729 class="metasync-btn-primary"
1730 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1731 onmouseover="this.style.transform='translateY(-1px)';"
1732 onmouseout="this.style.transform='translateY(0)';">
1733 Save TTL
1734 </button>
1735 <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1736 </div>
1737
1738 <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1739 </div>
1740 </div>
1741
1742 <!-- Cache Plugin Management -->
1743 <div style="margin-bottom: 30px;">
1744 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1745 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
1746
1747 <?php
1748 // Display active cache plugins
1749 if (class_exists('Metasync_Cache_Purge')) {
1750 try {
1751 $cache_purge = Metasync_Cache_Purge::get_instance();
1752 $active_cache_plugins = $cache_purge->get_active_cache_plugins();
1753
1754 if (!empty($active_cache_plugins)) {
1755 echo '<p style="color: var(--dashboard-text-primary);"><strong>Active Cache Plugins Detected:</strong></p>';
1756 echo '<ul style="margin-bottom: 15px; color: var(--dashboard-text-primary);">';
1757 foreach ($active_cache_plugins as $plugin_name) {
1758 echo '<li>�
1759 ' . esc_html($plugin_name) . '</li>';
1760 }
1761 echo '</ul>';
1762 } else {
1763 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1764 }
1765 } catch (Exception $e) {
1766 error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1767 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1768 }
1769 } else {
1770 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
1771 }
1772 ?>
1773
1774 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
1775 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
1776 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
1777 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
1778 <span class="dashicons dashicons-controls-repeat"></span> Clear All Cache Plugins
1779 </button>
1780 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
1781 </form>
1782
1783 <?php
1784 // Display success/error messages
1785 if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1786 $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1787 $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1788 $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1789
1790 if ($cleared > 0) {
1791 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1792 echo '�
1793 <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
1794 if ($plugins) {
1795 echo ': ' . esc_html($plugins);
1796 }
1797 echo '</p></div>';
1798 } else {
1799 echo '<div class="notice notice-info inline" style="margin-top: 15px;"><p>';
1800 echo 'ℹ️ No cache plugins found to clear. WordPress object cache was cleared.';
1801 echo '</p></div>';
1802 }
1803
1804 if ($failed > 0) {
1805 echo '<div class="notice notice-warning inline" style="margin-top: 15px;"><p>';
1806 echo '⚠️ Failed to clear ' . intval( $failed ) . ' plugin(s).';
1807 echo '</p></div>';
1808 }
1809 }
1810
1811 if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1812 $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1813 if (empty($message)) {
1814 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1815 }
1816 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1817 echo '❌ <strong>Error clearing cache:</strong> ' . esc_html($message);
1818 echo '</p></div>';
1819 }
1820 ?>
1821 </div>
1822
1823 <!-- Hosting Cache Integration -->
1824 <?php
1825 $hosting_settings = $this->get_hosting_cache_settings();
1826 $wpe_detected = class_exists('WpeCommon');
1827 $kinsta_detected = class_exists('KinstaCache');
1828 ?>
1829 <div style="margin-bottom: 30px;">
1830 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Hosting Cache Integration</h4>
1831 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1832 Use your hosting provider's native API to purge the <strong>entire site cache</strong> in one click.
1833 These options are independent of cache plugins and target the server-level cache layer.
1834 </p>
1835
1836 <!-- Detection status badges -->
1837 <div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
1838 <span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
1839 background: <?php echo $wpe_detected ? 'rgba(34,197,94,0.12)' : 'rgba(156,163,175,0.12)'; ?>;
1840 color: <?php echo $wpe_detected ? '#22c55e' : 'var(--dashboard-text-secondary)'; ?>;
1841 border: 1px solid <?php echo $wpe_detected ? 'rgba(34,197,94,0.3)' : 'rgba(156,163,175,0.3)'; ?>;">
1842 <?php echo $wpe_detected ? '�
1843 ' : '⬜'; ?> WP Engine <?php echo $wpe_detected ? '(detected)' : '(not detected)'; ?>
1844 </span>
1845 <span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
1846 background: <?php echo $kinsta_detected ? 'rgba(34,197,94,0.12)' : 'rgba(156,163,175,0.12)'; ?>;
1847 color: <?php echo $kinsta_detected ? '#22c55e' : 'var(--dashboard-text-secondary)'; ?>;
1848 border: 1px solid <?php echo $kinsta_detected ? 'rgba(34,197,94,0.3)' : 'rgba(156,163,175,0.3)'; ?>;">
1849 <?php echo $kinsta_detected ? '�
1850 ' : '⬜'; ?> Kinsta <?php echo $kinsta_detected ? '(detected)' : '(not detected)'; ?>
1851 </span>
1852 </div>
1853
1854 <!-- Settings toggles -->
1855 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1856 <h5 style="margin: 0 0 14px 0; color: var(--dashboard-text-primary);">Enable Native Cache Purge</h5>
1857
1858 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px; cursor: pointer;">
1859 <input type="checkbox"
1860 id="metasync-hc-wpengine"
1861 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1862 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1863 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1864 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1865 <?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.'); ?>
1866 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1867 </label>
1868
1869 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
1870 <input type="checkbox"
1871 id="metasync-hc-kinsta"
1872 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1873 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1874 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1875 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1876 <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1877 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1878 </label>
1879
1880 <div style="display: flex; align-items: center; gap: 12px;">
1881 <button type="button"
1882 id="metasync-hc-save-btn"
1883 class="metasync-btn-primary"
1884 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1885 onmouseover="this.style.transform='translateY(-1px)';"
1886 onmouseout="this.style.transform='translateY(0)';">
1887 Save Settings
1888 </button>
1889 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1890 </div>
1891
1892 <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1893 </div>
1894
1895 <!-- Purge button -->
1896 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
1897 <input type="hidden" name="action" value="metasync_purge_hosting_cache" />
1898 <?php wp_nonce_field('metasync_hosting_cache_purge_nonce', 'hosting_cache_purge_nonce'); ?>
1899 <button type="submit"
1900 class="metasync-btn-primary"
1901 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: inline-block; min-width: 240px; max-width: fit-content;"
1902 onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0,0,0,0.15)';"
1903 onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.1)';">
1904 <span class="dashicons dashicons-update" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Purge Entire Hosting Cache
1905 </button>
1906 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">
1907 Triggers a full-site cache purge using the native WP Engine and/or Kinsta APIs (based on toggles above).
1908 </p>
1909 </form>
1910
1911 <?php
1912 // Hosting cache result messages
1913 if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1914 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1915 $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1916 $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1917 $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1918
1919 if ($hc_cleared) {
1920 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1921 echo '�
1922 <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
1923 echo '</p></div>';
1924 }
1925 if ($hc_failed) {
1926 echo '<div class="notice notice-error inline" style="margin-top: 10px;"><p>';
1927 echo '❌ <strong>Failed</strong> to purge: ' . esc_html($hc_failed);
1928 echo '</p></div>';
1929 }
1930 if ($hc_not_detected && !$hc_cleared && !$hc_failed) {
1931 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1932 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1933 echo '</p></div>';
1934 }
1935 // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1936 }
1937 ?>
1938
1939 <script>
1940 jQuery(document).ready(function($) {
1941 $('#metasync-hc-save-btn').on('click', function() {
1942 var $btn = $(this);
1943 var $msg = $('#metasync-hc-save-msg');
1944
1945 $btn.prop('disabled', true).text('Saving…');
1946
1947 $.ajax({
1948 url: ajaxurl,
1949 type: 'POST',
1950 data: {
1951 action: 'metasync_save_hosting_cache_settings',
1952 hosting_cache_nonce: $('#metasync-hc-nonce').val(),
1953 wpengine_enabled: $('#metasync-hc-wpengine').is(':checked') ? '1' : '0',
1954 kinsta_enabled: $('#metasync-hc-kinsta').is(':checked') ? '1' : '0',
1955 },
1956 success: function(response) {
1957 if (response.success) {
1958 $msg.text('�
1959 Saved').css('color', '#22c55e').show();
1960 } else {
1961 $msg.text('❌ ' + (response.data.message || 'Save failed')).css('color', '#ef4444').show();
1962 }
1963 },
1964 error: function() {
1965 $msg.text('❌ Request failed').css('color', '#ef4444').show();
1966 },
1967 complete: function() {
1968 $btn.prop('disabled', false).text('💾 Save Settings');
1969 setTimeout(function() { $msg.fadeOut(); }, 4000);
1970 }
1971 });
1972 });
1973
1974 // OTTO Cache TTL save
1975 $('#metasync-otto-ttl-save-btn').on('click', function() {
1976 var $btn = $(this);
1977 var $msg = $('#metasync-otto-ttl-save-msg');
1978 var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1979
1980 if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1981 $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1982 return;
1983 }
1984
1985 $btn.prop('disabled', true).text('Saving…');
1986
1987 $.ajax({
1988 url: ajaxurl,
1989 type: 'POST',
1990 data: {
1991 action: 'metasync_save_otto_cache_ttl',
1992 otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1993 otto_cache_ttl: ttl,
1994 },
1995 success: function(response) {
1996 if (response.success) {
1997 $msg.text('�
1998 Saved').css('color', '#22c55e').show();
1999 } else {
2000 $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
2001 }
2002 },
2003 error: function() {
2004 $msg.text('❌ Request failed').css('color', '#ef4444').show();
2005 },
2006 complete: function() {
2007 $btn.prop('disabled', false).text('Save TTL');
2008 setTimeout(function() { $msg.fadeOut(); }, 4000);
2009 }
2010 });
2011 });
2012 });
2013 </script>
2014 </div>
2015
2016 <!-- Object Cache Behaviour -->
2017 <?php $targeted_cache_enabled = get_option('metasync_targeted_object_cache', '1'); ?>
2018 <div style="margin-bottom: 30px;">
2019 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Object Cache Behaviour</h4>
2020 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
2021 Controls how the WordPress object cache (Redis/Memcached) is cleared when OTTO updates pages.
2022 </p>
2023
2024 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
2025 <label style="display: flex; align-items: flex-start; gap: 10px; cursor: pointer;">
2026 <input type="checkbox"
2027 id="metasync-targeted-object-cache"
2028 <?php checked('1', $targeted_cache_enabled); ?>
2029 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
2030 <span>
2031 <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>
2032 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
2033 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
2034 When disabled, a full <code>wp_cache_flush()</code> is used instead.
2035 </span>
2036 </span>
2037 </label>
2038
2039 <div style="display: flex; align-items: center; gap: 12px; margin-top: 16px;">
2040 <button type="button"
2041 id="metasync-toc-save-btn"
2042 class="metasync-btn-primary"
2043 style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
2044 onmouseover="this.style.transform='translateY(-1px)';"
2045 onmouseout="this.style.transform='translateY(0)';">
2046 Save Settings
2047 </button>
2048 <span id="metasync-toc-save-msg" style="display: none; font-size: 13px;"></span>
2049 </div>
2050
2051 <input type="hidden" id="metasync-toc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_object_cache_nonce')); ?>" />
2052 </div>
2053
2054 <script>
2055 jQuery(document).ready(function($) {
2056 $('#metasync-toc-save-btn').on('click', function() {
2057 var $btn = $(this);
2058 var $msg = $('#metasync-toc-save-msg');
2059
2060 $btn.prop('disabled', true).text('Saving…');
2061
2062 $.ajax({
2063 url: ajaxurl,
2064 type: 'POST',
2065 data: {
2066 action: 'metasync_save_object_cache_settings',
2067 object_cache_nonce: $('#metasync-toc-nonce').val(),
2068 targeted_object_cache: $('#metasync-targeted-object-cache').is(':checked') ? '1' : '0',
2069 },
2070 success: function(response) {
2071 if (response.success) {
2072 $msg.text('�
2073 Saved').css('color', '#22c55e').show();
2074 } else {
2075 $msg.text('❌ ' + (response.data.message || 'Save failed')).css('color', '#ef4444').show();
2076 }
2077 },
2078 error: function() {
2079 $msg.text('❌ Request failed').css('color', '#ef4444').show();
2080 },
2081 complete: function() {
2082 $btn.prop('disabled', false).text('💾 Save Settings');
2083 setTimeout(function() { $msg.fadeOut(); }, 4000);
2084 }
2085 });
2086 });
2087 });
2088 </script>
2089 </div>
2090
2091 <!-- OTTO Transient Cache -->
2092 <div style="margin-bottom: 30px;">
2093 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Transient Cache</h4>
2094 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
2095 Manage <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> suggestions cache. Clearing cache will force fresh API calls on next page load.
2096 </p>
2097
2098 <div style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 4px; margin-bottom: 20px; border: 1px solid var(--dashboard-border);">
2099 <strong style="color: var(--dashboard-text-primary);">Current Cache Status:</strong>
2100 <span style="color: var(--dashboard-accent);"><?php echo esc_html($cache_count); ?> cached entries</span>
2101 </div>
2102
2103 <?php
2104 // Display success/error messages
2105 if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2106 $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2107 $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2108
2109 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2110 if (!empty($url)) {
2111 echo '�
2112 <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
2113 } else {
2114 echo '�
2115 <strong>Success!</strong> Cleared entire transient cache (' . intval( $cleared_count ) . ' entries)';
2116 }
2117 echo '</p></div>';
2118 }
2119
2120 if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2121 $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2122 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2123 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2124 echo '</p></div>';
2125 }
2126 ?>
2127
2128 <!-- Clear Entire Cache -->
2129 <div style="margin-bottom: 30px; padding: 20px; border: 1px solid var(--dashboard-border); border-radius: 4px; background: rgba(255, 255, 255, 0.02);">
2130 <h5 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear Entire Transient Cache</h5>
2131 <p style="color: var(--dashboard-text-secondary); margin-bottom: 15px;">
2132 This will clear all <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> transient cache entries (suggestions, locks, stale cache, rate limits).
2133 </p>
2134 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
2135 onsubmit="return confirm('Are you sure you want to clear the entire transient cache? This will force fresh API calls for all URLs.');">
2136 <input type="hidden" name="action" value="metasync_clear_otto_cache_all" />
2137 <?php wp_nonce_field('metasync_clear_otto_cache_nonce', 'clear_otto_cache_nonce'); ?>
2138 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
2139 <span class="dashicons dashicons-trash" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Clear Entire Cache
2140 </button>
2141 </form>
2142 </div>
2143
2144 <!-- Clear Cache by URL -->
2145 <div style="padding: 20px; border: 1px solid var(--dashboard-border); border-radius: 4px; background: rgba(255, 255, 255, 0.02);">
2146 <h5 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear Cache by URL</h5>
2147 <p style="color: var(--dashboard-text-secondary); margin-bottom: 15px;">
2148 Enter a specific URL to clear its cached <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> suggestions. Use the full URL including protocol (e.g., https://example.com/page/).
2149 </p>
2150 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
2151 <input type="hidden" name="action" value="metasync_clear_otto_cache_url" />
2152 <?php wp_nonce_field('metasync_clear_otto_cache_nonce', 'clear_otto_cache_nonce'); ?>
2153 <table class="form-table">
2154 <tr>
2155 <th scope="row">
2156 <label for="otto_cache_url" style="color: var(--dashboard-text-primary);">URL to Clear</label>
2157 </th>
2158 <td>
2159 <input type="url"
2160 id="otto_cache_url"
2161 name="otto_cache_url"
2162 value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
2163 class="regular-text"
2164 placeholder="https://example.com/page/"
2165 required />
2166 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
2167 </td>
2168 </tr>
2169 </table>
2170 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
2171 <span class="dashicons dashicons-trash" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Clear Cache for URL
2172 </button>
2173 </form>
2174 </div>
2175 </div>
2176 <?php
2177 }
2178
2179 /**
2180 * Render debug mode section for inclusion in Advanced settings
2181 */
2182 public function render_debug_mode_section()
2183 {
2184 Metasync_Debug_Manager::instance()->render_debug_mode_section();
2185 }
2186
2187 /**
2188 * Render error log content for inclusion in Advanced settings
2189 */
2190 public function render_error_log_content()
2191 {
2192 Metasync_Debug_Manager::instance()->render_error_log_content();
2193 }
2194
2195 /**
2196 * WordPress standard handler for clearing all cache plugins (admin_post hook)
2197 * This method runs early and prevents any output before redirect
2198 */
2199 public function handle_clear_all_cache_plugins() {
2200 Metasync_Otto_Cache_Manager::instance()->handle_clear_all_cache_plugins();
2201 }
2202
2203 /**
2204 * WordPress standard handler for clearing OTTO cache (admin_post hook)
2205 */
2206 public function handle_clear_otto_cache_all() {
2207 Metasync_Otto_Cache_Manager::instance()->handle_clear_otto_cache_all();
2208 }
2209
2210 /**
2211 * WordPress standard handler for clearing OTTO cache by URL (admin_post hook)
2212 */
2213 public function handle_clear_otto_cache_url() {
2214 Metasync_Otto_Cache_Manager::instance()->handle_clear_otto_cache_url();
2215 }
2216
2217 /**
2218 * Get hosting cache integration settings with defaults
2219 *
2220 * @return array Settings array with 'wpengine_enabled' and 'kinsta_enabled' keys
2221 */
2222 private function get_hosting_cache_settings() {
2223 $defaults = array(
2224 'wpengine_enabled' => true,
2225 'kinsta_enabled' => true,
2226 );
2227 $saved = get_option('metasync_hosting_cache_options', array());
2228 return wp_parse_args($saved, $defaults);
2229 }
2230
2231 /**
2232 * AJAX handler for saving hosting cache settings
2233 */
2234 public function ajax_save_hosting_cache_settings() {
2235 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2236 if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2237 wp_send_json_error(array('message' => 'Security check failed'));
2238 return;
2239 }
2240
2241 if (!Metasync::current_user_has_plugin_access()) {
2242 wp_send_json_error(array('message' => 'Insufficient permissions'));
2243 return;
2244 }
2245
2246 $settings = array(
2247 'wpengine_enabled' => !empty($_POST['wpengine_enabled']) && $_POST['wpengine_enabled'] === '1',
2248 'kinsta_enabled' => !empty($_POST['kinsta_enabled']) && $_POST['kinsta_enabled'] === '1',
2249 );
2250
2251 update_option('metasync_hosting_cache_options', $settings);
2252 wp_send_json_success(array('message' => 'Hosting cache settings saved'));
2253 }
2254
2255 /**
2256 * AJAX handler: save object cache behaviour settings
2257 */
2258 public function ajax_save_object_cache_settings() {
2259 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2260 if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2261 wp_send_json_error(array('message' => 'Security check failed'));
2262 return;
2263 }
2264
2265 if (!Metasync::current_user_has_plugin_access()) {
2266 wp_send_json_error(array('message' => 'Insufficient permissions'));
2267 return;
2268 }
2269
2270 $targeted = (!empty($_POST['targeted_object_cache']) && $_POST['targeted_object_cache'] === '1') ? '1' : '0';
2271 update_option('metasync_targeted_object_cache', $targeted);
2272 wp_send_json_success(array('message' => 'Object cache settings saved'));
2273 }
2274
2275 /**
2276 * Get OTTO Cache TTL value in minutes from execution settings.
2277 *
2278 * @return int
2279 */
2280 private function get_otto_cache_ttl_minutes() {
2281 $execution_settings = get_option('metasync_execution_settings', array());
2282 return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2283 }
2284
2285 /**
2286 * AJAX handler for saving OTTO Cache TTL
2287 */
2288 public function ajax_save_otto_cache_ttl() {
2289 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2290 if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2291 wp_send_json_error(array('message' => 'Security check failed.'));
2292 return;
2293 }
2294
2295 if (!current_user_can('manage_options')) {
2296 wp_send_json_error(array('message' => 'Insufficient permissions.'));
2297 return;
2298 }
2299
2300 $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2301
2302 if ($ttl < 30 || $ttl > 1440) {
2303 wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2304 return;
2305 }
2306
2307 $settings = get_option('metasync_execution_settings', array());
2308 $settings['otto_cache_ttl'] = $ttl;
2309 update_option('metasync_execution_settings', $settings);
2310
2311 wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2312 }
2313
2314 /**
2315 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2316 */
2317 public function handle_purge_hosting_cache() {
2318 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2319 if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2320 wp_die('Security check failed');
2321 }
2322
2323 if (!Metasync::current_user_has_plugin_access()) {
2324 wp_die('You do not have permission to perform this action');
2325 }
2326
2327 $redirect_url = admin_url('admin.php?page=' . self::$page_slug . '&tab=advanced');
2328 $settings = $this->get_hosting_cache_settings();
2329 $cleared = array();
2330 $failed = array();
2331 $not_detected = array();
2332
2333 // WP Engine native purge (Varnish + Memcached)
2334 if (!empty($settings['wpengine_enabled'])) {
2335 if (class_exists('WpeCommon')) {
2336 try {
2337 WpeCommon::purge_varnish_cache();
2338 WpeCommon::purge_memcached();
2339 $cleared[] = 'WP Engine';
2340 } catch (Exception $e) {
2341 error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2342 $failed[] = 'WP Engine';
2343 }
2344 } else {
2345 $not_detected[] = 'WP Engine';
2346 }
2347 }
2348
2349 // Kinsta native purge (full site)
2350 if (!empty($settings['kinsta_enabled'])) {
2351 if (class_exists('KinstaCache')) {
2352 try {
2353 KinstaCache::get_instance()->kinsta_cache_purge_full();
2354 $cleared[] = 'Kinsta';
2355 } catch (Exception $e) {
2356 error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2357 $failed[] = 'Kinsta';
2358 }
2359 } else {
2360 $not_detected[] = 'Kinsta';
2361 }
2362 }
2363
2364 $redirect_url .= '&hosting_cache_cleared=1';
2365 if (!empty($cleared)) {
2366 $redirect_url .= '&hc_cleared=' . urlencode(implode(',', $cleared));
2367 }
2368 if (!empty($failed)) {
2369 $redirect_url .= '&hc_failed=' . urlencode(implode(',', $failed));
2370 }
2371 if (!empty($not_detected)) {
2372 $redirect_url .= '&hc_not_detected=' . urlencode(implode(',', $not_detected));
2373 }
2374
2375 wp_safe_redirect($redirect_url);
2376 exit;
2377 }
2378
2379 /**
2380 * Handle debug mode operations (enable/disable/extend)
2381 */
2382 private function handle_debug_mode_operations()
2383 {
2384 Metasync_Debug_Manager::instance()->handle_debug_mode_operations();
2385 }
2386
2387 /**
2388 * Handle error log operations (clear)
2389 */
2390 private function handle_error_log_operations()
2391 {
2392 Metasync_Debug_Manager::instance()->handle_error_log_operations();
2393 }
2394
2395 /**
2396 * Handle clear all settings operations
2397 */
2398 private function handle_clear_all_settings()
2399 {
2400 Metasync_Debug_Manager::instance()->handle_clear_all_settings();
2401 }
2402
2403 /**
2404 * Handle saving plugin access roles from Advanced Settings
2405 * @deprecated Now handled by Metasync_Settings_Registration::settings_page_init()
2406 */
2407 private function handle_plugin_access_roles_save() {
2408 }
2409
2410 /**
2411 * Get error log content for display
2412 */
2413 private function get_error_log_content()
2414 {
2415 return Metasync_Debug_Manager::instance()->get_error_log_content();
2416 }
2417
2418 /**
2419 * Memory-efficient function to get last N lines from a large file
2420 */
2421 private function get_log_tail($file_path, $lines = null)
2422 {
2423 return Metasync_Debug_Manager::instance()->get_log_tail($file_path, $lines);
2424 }
2425
2426 /**
2427 * Test whitelabel domain functionality (development/debugging)
2428 */
2429 public function test_whitelabel_domain()
2430 {
2431 Metasync_Connect_Manager::instance()->test_whitelabel_domain();
2432 }
2433
2434 /**
2435 * Decrypt token using WordPress SALTs
2436 */
2437 private function wp_decrypt_token($encrypted_token)
2438 {
2439 return Metasync_Connect_Manager::instance()->wp_decrypt_token($encrypted_token);
2440 }
2441
2442 /**
2443 * Get active JWT token for the plugin
2444 * Public static method accessible from anywhere in the plugin
2445 *
2446 * @param bool $force_refresh Force generation of new token even if cached one exists
2447 * @return string|false JWT token on success, false on failure
2448 */
2449 public static function get_active_jwt_token($force_refresh = false)
2450 {
2451 return Metasync_Connect_Manager::instance()->get_active_jwt_token($force_refresh);
2452 }
2453
2454
2455 /**
2456 * Get fresh JWT token from Search Atlas API with caching
2457 * Generates and caches JWT tokens to avoid repeated API calls
2458 *
2459 * @return string|false JWT token on success, false on failure
2460 */
2461 public function get_fresh_jwt_token()
2462 {
2463 return Metasync_Connect_Manager::instance()->get_fresh_jwt_token();
2464 }
2465
2466 /**
2467 * Clear cached JWT tokens
2468 * Useful when authentication is reset or API key changes
2469 */
2470 private function clear_jwt_token_cache()
2471 {
2472 Metasync_Connect_Manager::instance()->clear_jwt_token_cache();
2473 }
2474
2475
2476
2477 /**
2478 * Data or Response received from HeartBeat API for admin area.
2479 */
2480 public function lgSendCustomerParams()
2481 {
2482 Metasync_Admin_Ajax::instance()->lgSendCustomerParams();
2483 }
2484
2485
2486
2487 /**
2488 * Add CSS styles for Search Atlas admin bar status indicator
2489 */
2490 public function metasync_admin_bar_style()
2491 {
2492 Metasync_Admin_Navigation::instance()->metasync_admin_bar_style();
2493 }
2494
2495 /**
2496 * Add Search Atlas status indicator to WordPress admin bar
2497 * Shows sync status with green/red emoji
2498 */
2499 public function add_searchatlas_admin_bar_status($wp_admin_bar)
2500 {
2501 Metasync_Admin_Navigation::instance()->add_searchatlas_admin_bar_status($wp_admin_bar);
2502 }
2503
2504 // ------------------------------------------------------------------
2505 // Heartbeat / connection-monitoring – delegated to Metasync_Heartbeat_Manager
2506 // ------------------------------------------------------------------
2507
2508 public function is_heartbeat_connected($general_settings = null)
2509 {
2510 return Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general_settings);
2511 }
2512
2513 public function fetch_public_hash($otto_pixel_uuid, $jwt_token)
2514 {
2515 return Metasync_Heartbeat_Manager::instance()->fetch_public_hash($otto_pixel_uuid, $jwt_token);
2516 }
2517
2518 public function schedule_heartbeat_cron()
2519 {
2520 Metasync_Heartbeat_Manager::instance()->schedule_heartbeat_cron();
2521 }
2522
2523 public function unschedule_heartbeat_cron()
2524 {
2525 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
2526 }
2527
2528 public function execute_heartbeat_cron_check()
2529 {
2530 return Metasync_Heartbeat_Manager::instance()->execute_heartbeat_cron_check();
2531 }
2532
2533 public function add_heartbeat_cron_schedule($schedules)
2534 {
2535 return Metasync_Heartbeat_Manager::instance()->add_heartbeat_cron_schedule($schedules);
2536 }
2537
2538 public function get_heartbeat_state()
2539 {
2540 return Metasync_Heartbeat_Manager::instance()->get_heartbeat_state();
2541 }
2542
2543 public function set_heartbeat_state_key_pending()
2544 {
2545 Metasync_Heartbeat_Manager::instance()->set_heartbeat_state_key_pending();
2546 }
2547
2548 public function execute_burst_heartbeat()
2549 {
2550 Metasync_Heartbeat_Manager::instance()->execute_burst_heartbeat();
2551 }
2552
2553 public function execute_announce_cron()
2554 {
2555 Metasync_Heartbeat_Manager::instance()->execute_announce_cron();
2556 }
2557
2558 public function unschedule_burst_heartbeat_cron()
2559 {
2560 Metasync_Heartbeat_Manager::instance()->unschedule_burst_heartbeat_cron();
2561 }
2562
2563 public function unschedule_announce_cron()
2564 {
2565 Metasync_Heartbeat_Manager::instance()->unschedule_announce_cron();
2566 }
2567
2568 public function maybe_schedule_heartbeat_cron()
2569 {
2570 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
2571 }
2572
2573
2574 public function trigger_immediate_heartbeat_check($context = 'Manual trigger')
2575 {
2576 return Metasync_Heartbeat_Manager::instance()->trigger_immediate_heartbeat_check($context);
2577 }
2578
2579 public function handle_immediate_heartbeat_trigger($context = 'WordPress action trigger')
2580 {
2581 Metasync_Heartbeat_Manager::instance()->handle_immediate_heartbeat_trigger($context);
2582 }
2583
2584 public function ajax_burst_ping()
2585 {
2586 Metasync_Heartbeat_Manager::instance()->ajax_burst_ping();
2587 }
2588
2589 public function update_heartbeat_cache_after_sync($is_connected, $context = 'Sync operation')
2590 {
2591 return Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync($is_connected, $context);
2592 }
2593
2594
2595 /**
2596 * Refresh Plugin Auth Token
2597 * Generates a new Plugin Auth Token and updates heartbeat API
2598 */
2599 public function refresh_plugin_auth_token()
2600 {
2601 Metasync_Connect_Manager::instance()->refresh_plugin_auth_token();
2602 }
2603
2604 public function get_plugin_auth_token()
2605 {
2606 Metasync_Connect_Manager::instance()->get_plugin_auth_token();
2607 }
2608
2609 public function reset_searchatlas_authentication()
2610 {
2611 Metasync_Connect_Manager::instance()->reset_searchatlas_authentication();
2612 }
2613
2614 private function cleanup_searchatlas_nonce_tokens()
2615 {
2616 return Metasync_Connect_Manager::instance()->cleanup_searchatlas_nonce_tokens();
2617 }
2618
2619 private function cleanup_searchatlas_rate_limits()
2620 {
2621 return Metasync_Connect_Manager::instance()->cleanup_searchatlas_rate_limits();
2622 }
2623
2624 private function get_available_menu_items()
2625 {
2626 return Metasync_Admin_Navigation::instance()->get_available_menu_items();
2627 }
2628
2629 /**
2630 * Add options page
2631 */
2632 public function add_plugin_settings_page()
2633 {
2634 Metasync_Admin_Navigation::instance()->add_plugin_settings_page($this);
2635 }
2636
2637 /**
2638 * General Options page callback
2639 */
2640 public function create_admin_settings_page()
2641 {
2642 Metasync_Admin_Pages::get_instance($this)->create_admin_settings_page();
2643 }
2644
2645 public function render_navigation_menu($current_page = null)
2646 {
2647 Metasync_Admin_Navigation::instance()->render_navigation_menu($current_page);
2648 }
2649
2650 public function render_plugin_header($page_title = null)
2651 {
2652 Metasync_Admin_Navigation::instance()->render_plugin_header($page_title);
2653 }
2654
2655 /**
2656 * Open the Yoast-style 3-column page layout.
2657 * Must be paired with render_layout_close().
2658 */
2659 public function render_layout_open($page_title = '', $current_page = '', $description = '')
2660 {
2661 Metasync_Admin_Navigation::instance()->render_layout_open($page_title, $current_page, $description);
2662 }
2663
2664 /**
2665 * Close the 3-column layout opened by render_layout_open().
2666 *
2667 * @param bool $show_promo Whether to render the right promo sidebar. Default true.
2668 */
2669 public function render_layout_close($show_promo = true)
2670 {
2671 Metasync_Admin_Navigation::instance()->render_layout_close($show_promo);
2672 }
2673
2674 /*
2675 Method to handle Ajax request from "General Settings" page
2676 */
2677 public function meta_sync_save_settings() {
2678 Metasync_Settings_Registration::instance()->meta_sync_save_settings();
2679 }
2680
2681 /**
2682 * AJAX handler for saving execution settings
2683 */
2684 public function ajax_save_execution_settings() {
2685 Metasync_Settings_Registration::instance()->ajax_save_execution_settings();
2686 }
2687
2688 /**
2689 * Get Action Scheduler concurrent batches from execution settings
2690 *
2691 * @param int $default_batches Default concurrent batches
2692 * @return int Configured concurrent batches
2693 */
2694 public function get_action_scheduler_batches($default_batches) {
2695 // Only apply if Action Scheduler is active
2696 if (!class_exists('ActionScheduler')) {
2697 return $default_batches;
2698 }
2699
2700 return $this->get_execution_setting('action_scheduler_batches');
2701 }
2702
2703 /**
2704 * Get Action Scheduler retention period from execution settings
2705 * Converts days to seconds for Action Scheduler
2706 *
2707 * @param int $default_seconds Default retention period in seconds
2708 * @return int Configured retention period in seconds
2709 */
2710 public function get_action_scheduler_retention_period($default_seconds) {
2711 // Only apply if Action Scheduler is active
2712 if (!class_exists('ActionScheduler')) {
2713 return $default_seconds; // Default is 30 days = 2592000 seconds
2714 }
2715
2716 $cleanup_days = $this->get_execution_setting('queue_cleanup_days');
2717 return $cleanup_days * DAY_IN_SECONDS;
2718 }
2719
2720 /*
2721 * Sync setting on CRUD term category
2722 */
2723
2724 public function admin_crud_term($term_id,$term_tax_id,$taxonomy)
2725 {
2726 # Handle term creation, update, or deletion
2727 $this->sync_term($term_id, $taxonomy);
2728
2729 }
2730
2731
2732 /*
2733 * Sync setting on Delete term category
2734 */
2735
2736 public function admin_delete_term($term_id,$taxonomy)
2737 {
2738 # Handle term deletion
2739 $this->sync_term($term_id, $taxonomy);
2740 }
2741
2742 /*
2743 * Handle category deletion - Sync after category is deleted
2744 * This hook fires AFTER the category is fully deleted from the database
2745 */
2746 public function admin_delete_category($term_id)
2747 {
2748 try {
2749 # Initialize MetaSync API request class and trigger synchronization
2750 (new Metasync_Sync_Requests())->SyncCustomerParams();
2751 } catch (Exception $e) {
2752 # Log any API request errors for debugging
2753 error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2754 }
2755 }
2756
2757 /*
2758 * Call the SYNC API
2759 */
2760 private function sync_term($term_id, $taxonomy)
2761 {
2762 # Ensure the term belongs to the 'category' taxonomy and is not an error
2763 if ($taxonomy !== 'category' ) return;
2764
2765 try {
2766 # Initialize MetaSync API request class and trigger synchronization
2767 (new Metasync_Sync_Requests())->SyncCustomerParams();
2768 } catch (Exception $e) {
2769 # Log any API request errors for debugging
2770 error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2771 }
2772 }
2773
2774 /**
2775 * Dashboard page callback
2776 */
2777 public function create_admin_dashboard_page()
2778 {
2779 Metasync_Admin_Pages::get_instance($this)->create_admin_dashboard_page();
2780 }
2781
2782 /**
2783 * Robots.txt page callback
2784 */
2785 public function create_admin_robots_txt_page()
2786 {
2787 Metasync_Admin_Pages::get_instance($this)->create_admin_robots_txt_page();
2788 }
2789
2790 /**
2791 * Media Optimization page callback
2792 */
2793 public function create_admin_media_optimization_page()
2794 {
2795 $this->render_layout_open('Media Optimization', 'media_optimization', 'Compress and optimize images to improve page load speed.');
2796 // Load media optimization settings class
2797 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2798
2799 $save_success = false;
2800
2801 // Handle form submissions
2802 if (isset($_POST['metasync_media_optimization_nonce'])) {
2803 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2804
2805 // Settings shape the whole site's conversion pipeline — restrict
2806 // the save (and the reset) to site admins, not just anyone who can
2807 // open the page via plugin_access_roles.
2808 if (!current_user_can('manage_options')) {
2809 wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2810 }
2811
2812 // Handle reset to defaults
2813 if (!empty($_POST['metasync_media_reset'])) {
2814 $defaults = Metasync_Media_Settings::get_defaults();
2815 Metasync_Media_Settings::save_settings($defaults);
2816 $save_success = true;
2817 } elseif (isset($_POST['metasync_media'])) {
2818 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2819 $input = wp_unslash($_POST['metasync_media']);
2820 Metasync_Media_Settings::save_settings($input);
2821 $save_success = true;
2822 }
2823 }
2824
2825 // Tab handling
2826 $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'settings';
2827
2828 // Prepare image library data
2829 $list_table = null;
2830 $stats = null;
2831 $batch_progress = null;
2832
2833 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2834 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2835
2836 $list_table = new Metasync_Media_Library_List_Table();
2837 $list_table->prepare_items();
2838
2839 $stats = Metasync_Media_Library_List_Table::get_stats();
2840 $batch_progress = Metasync_Media_Batch_Optimizer::get_progress();
2841
2842 // Render the admin page view
2843 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/views/admin-page.php';
2844 $this->render_layout_close();
2845 }
2846
2847 /**
2848 * Code Minification page callback
2849 */
2850 public function create_admin_code_minification_page()
2851 {
2852 // Settings shape the whole site's asset pipeline — restrict
2853 // the save (and the reset) to site admins, not just anyone who can
2854 // open the page via plugin_access_roles.
2855 if (!empty($_POST) && !current_user_can('manage_options')) {
2856 wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2857 }
2858
2859 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2860 // Load settings and compatibility classes
2861 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2862 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
2863 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-compatibility-guard.php';
2864
2865 $save_success = false;
2866
2867 // Handle form submissions
2868 if (isset($_POST['metasync_code_minification_nonce'])) {
2869 check_admin_referer('metasync_save_code_minification', 'metasync_code_minification_nonce');
2870
2871 // Handle reset to defaults
2872 if (!empty($_POST['metasync_code_min_reset'])) {
2873 $defaults = Metasync_Minification_Settings::get_defaults();
2874 Metasync_Minification_Settings::save_settings($defaults);
2875 $save_success = true;
2876 } elseif (isset($_POST['metasync_code_min'])) {
2877 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2878 $input = (array) wp_unslash($_POST['metasync_code_min']);
2879 Metasync_Minification_Settings::save_settings($input);
2880 $save_success = true;
2881 }
2882 }
2883
2884 // Tab handling
2885 $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'settings';
2886 $settings = Metasync_Minification_Settings::get_settings();
2887 $conflicts = Metasync_Compatibility_Guard::get_active_conflicts();
2888
2889 // Render the admin page view
2890 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/views/admin-page.php';
2891 $this->render_layout_close();
2892 }
2893
2894 // ── Media Optimization AJAX Handlers ──
2895
2896 /**
2897 * AJAX: Optimize a single image.
2898 */
2899 public function ajax_optimize_single_image()
2900 {
2901 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2902
2903 if (!current_user_can('upload_files')) {
2904 wp_send_json_error(__('Permission denied.', 'metasync'));
2905 }
2906
2907 $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
2908 if (!$attachment_id) {
2909 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2910 }
2911
2912 // Ownership: under the replace strategy this permanently deletes the
2913 // attachment's original file, so upload_files alone is not enough —
2914 // the caller must be allowed to edit this specific attachment.
2915 if (!current_user_can('edit_post', $attachment_id)) {
2916 wp_send_json_error(__('Permission denied.', 'metasync'));
2917 }
2918
2919 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2920 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2921 $settings = Metasync_Media_Settings::get_settings();
2922
2923 $file = get_attached_file($attachment_id);
2924 $mime = get_post_mime_type($attachment_id);
2925
2926 if (!$file || !file_exists($file)) {
2927 wp_send_json_error(__('Optimization failed: file not found.', 'metasync'));
2928 }
2929
2930 $max_bytes = 10 * 1024 * 1024;
2931 if (filesize($file) > $max_bytes) {
2932 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2933 wp_send_json_error(sprintf(
2934 /* translators: %s: file size in megabytes. */
2935 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2936 $size_mb
2937 ));
2938 }
2939
2940 if (!in_array($mime, ['image/jpeg', 'image/png'], true)) {
2941 wp_send_json_error(__('Optimization failed: unsupported image format.', 'metasync'));
2942 }
2943
2944 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
2945
2946 if ($success) {
2947 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2948 wp_send_json_success([
2949 'status_html' => Metasync_Media_Library_List_Table::render_status_html($attachment_id),
2950 'can_revert' => Metasync_Image_Converter::can_revert($attachment_id),
2951 ]);
2952 }
2953
2954 wp_send_json_error(__('Optimization failed: conversion could not be completed.', 'metasync'));
2955 }
2956
2957 /**
2958 * AJAX: Revert a single image.
2959 */
2960 public function ajax_revert_single_image()
2961 {
2962 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2963
2964 if (!current_user_can('upload_files')) {
2965 wp_send_json_error(__('Permission denied.', 'metasync'));
2966 }
2967
2968 $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
2969 if (!$attachment_id) {
2970 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2971 }
2972
2973 // Ownership: reverting deletes the attachment's converted files, so
2974 // restrict it to users who may edit this specific attachment.
2975 if (!current_user_can('edit_post', $attachment_id)) {
2976 wp_send_json_error(__('Permission denied.', 'metasync'));
2977 }
2978
2979 // Replace-strategy conversions have no original to restore — reverting
2980 // would delete the attachment's only file. Refuse up front, like bulk revert does.
2981 if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2982 wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2983 }
2984
2985 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2986
2987 if ($success) {
2988 wp_send_json_success();
2989 }
2990
2991 wp_send_json_error(__('Revert failed. Original file may not exist (replace strategy).', 'metasync'));
2992 }
2993
2994 /**
2995 * AJAX: Start batch optimization.
2996 */
2997 public function ajax_start_batch_optimize()
2998 {
2999 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3000
3001 if (!current_user_can('manage_options')) {
3002 wp_send_json_error(__('Permission denied.', 'metasync'));
3003 }
3004
3005 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
3006 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
3007
3008 $settings = Metasync_Media_Settings::get_settings();
3009 $progress = Metasync_Media_Batch_Optimizer::start_batch($settings);
3010
3011 wp_send_json_success($progress);
3012 }
3013
3014 /**
3015 * AJAX: Cancel batch optimization.
3016 */
3017 public function ajax_cancel_batch_optimize()
3018 {
3019 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3020
3021 if (!current_user_can('manage_options')) {
3022 wp_send_json_error(__('Permission denied.', 'metasync'));
3023 }
3024
3025 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
3026 Metasync_Media_Batch_Optimizer::cancel_batch();
3027
3028 wp_send_json_success();
3029 }
3030
3031 /**
3032 * AJAX: Get batch progress + stats.
3033 */
3034 public function ajax_batch_progress()
3035 {
3036 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3037
3038 // Read-only progress/stats polling — the same capability that can
3039 // open the media optimization page is enough, but a nonce alone
3040 // never was.
3041 if (!current_user_can('upload_files')) {
3042 wp_send_json_error(__('Permission denied.', 'metasync'));
3043 }
3044
3045 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
3046 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3047
3048 $progress = Metasync_Media_Batch_Optimizer::get_progress();
3049 $progress['stats'] = Metasync_Media_Library_List_Table::get_stats();
3050
3051 wp_send_json_success($progress);
3052 }
3053
3054 /**
3055 * AJAX: Bulk optimize selected images.
3056 */
3057 public function ajax_bulk_optimize_selected()
3058 {
3059 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3060
3061 if (!current_user_can('upload_files')) {
3062 wp_send_json_error(__('Permission denied.', 'metasync'));
3063 }
3064
3065 $ids = isset($_POST['ids']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : [];
3066 $ids = array_filter($ids);
3067
3068 if (empty($ids)) {
3069 wp_send_json_error(__('No images selected.', 'metasync'));
3070 }
3071
3072 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
3073 $settings = Metasync_Media_Settings::get_settings();
3074
3075 $success = 0;
3076 $failed = 0;
3077 $denied = 0;
3078
3079 foreach ($ids as $id) {
3080 // Per-attachment ownership: replace mode deletes originals, so
3081 // upload_files alone must not allow converting images the caller
3082 // does not own/cannot edit.
3083 if (!current_user_can('edit_post', $id)) {
3084 $denied++;
3085 continue;
3086 }
3087
3088 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
3089 $success++;
3090 } else {
3091 $failed++;
3092 }
3093 }
3094
3095 wp_send_json_success([
3096 'success' => $success,
3097 'failed' => $failed,
3098 'denied' => $denied,
3099 ]);
3100 }
3101
3102 /**
3103 * AJAX: Bulk unoptimize (revert) selected images.
3104 */
3105 public function ajax_bulk_unoptimize_selected()
3106 {
3107 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3108
3109 if (!current_user_can('upload_files')) {
3110 wp_send_json_error(__('Permission denied.', 'metasync'));
3111 }
3112
3113 $ids = isset($_POST['ids']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : [];
3114 $ids = array_filter($ids);
3115
3116 if (empty($ids)) {
3117 wp_send_json_error(__('No images selected.', 'metasync'));
3118 }
3119
3120 $success = 0;
3121 $failed = 0;
3122 $skipped = 0;
3123 $denied = 0;
3124 $errors = [];
3125
3126 foreach ($ids as $id) {
3127 // Per-attachment ownership: reverting deletes the attachment's
3128 // converted files, so restrict it per image, not per role.
3129 if (!current_user_can('edit_post', $id)) {
3130 $denied++;
3131 continue;
3132 }
3133
3134 $format = get_post_meta($id, '_metasync_converted_format', true);
3135
3136 if (!$format) {
3137 $skipped++;
3138 continue;
3139 }
3140
3141 if (!Metasync_Image_Converter::can_revert($id)) {
3142 $skipped++;
3143 $file = get_attached_file($id);
3144 $name = $file ? basename($file) : "ID {$id}";
3145 /* translators: %s: attachment file name. */
3146 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3147 continue;
3148 }
3149
3150 if (Metasync_Image_Converter::revert_attachment($id)) {
3151 $success++;
3152 } else {
3153 $failed++;
3154 $file = get_attached_file($id);
3155 $name = $file ? basename($file) : "ID {$id}";
3156 /* translators: %s: attachment file name. */
3157 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3158 }
3159 }
3160
3161 wp_send_json_success([
3162 'success' => $success,
3163 'failed' => $failed,
3164 'skipped' => $skipped,
3165 'denied' => $denied,
3166 'errors' => $errors,
3167 ]);
3168 }
3169
3170 /**
3171 * AJAX: Process one batch tick (browser-driven chaining).
3172 */
3173 public function ajax_process_batch_tick()
3174 {
3175 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3176
3177 if (!current_user_can('manage_options')) {
3178 wp_send_json_error(__('Permission denied.', 'metasync'));
3179 }
3180
3181 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
3182 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3183
3184 $progress = Metasync_Media_Batch_Optimizer::process_ajax_tick();
3185 $progress['stats'] = Metasync_Media_Library_List_Table::get_stats();
3186
3187 wp_send_json_success($progress);
3188 }
3189
3190 /**
3191 * AJAX: Delete an orphaned media record (attachment whose file is missing).
3192 *
3193 * Only deletes when the file is confirmed missing on disk, so valid
3194 * attachments can never be removed through this endpoint.
3195 */
3196 public function ajax_delete_orphaned_image()
3197 {
3198 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3199
3200 $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3201 if (!$attachment_id) {
3202 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3203 }
3204
3205 if (!current_user_can('delete_post', $attachment_id)) {
3206 wp_send_json_error(__('Permission denied.', 'metasync'));
3207 }
3208
3209 if (get_post_type($attachment_id) !== 'attachment') {
3210 wp_send_json_error(__('Not an attachment.', 'metasync'));
3211 }
3212
3213 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3214
3215 // Guard: only orphaned records (missing file) may be deleted here.
3216 if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3217 wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3218 }
3219
3220 $deleted = wp_delete_attachment($attachment_id, true);
3221
3222 if ($deleted) {
3223 wp_send_json_success([
3224 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3225 ]);
3226 }
3227
3228 wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3229 }
3230
3231 /**
3232 * Cron handler: Process batch optimization tick.
3233 */
3234 public function handle_media_batch_cron()
3235 {
3236 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
3237 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
3238
3239 Metasync_Media_Batch_Optimizer::process_batch_tick();
3240 }
3241
3242 /**
3243 * AJAX: Start SEO Restore batch.
3244 */
3245 public function ajax_seo_restore_start()
3246 {
3247 check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3248
3249 if (!current_user_can('manage_options')) {
3250 wp_send_json_error(__('Permission denied.', 'metasync'));
3251 }
3252
3253 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3254
3255 $progress = Metasync_Seo_Restore::start_batch();
3256 wp_send_json_success($progress);
3257 }
3258
3259 /**
3260 * AJAX: Cancel SEO Restore batch.
3261 */
3262 public function ajax_seo_restore_cancel()
3263 {
3264 check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3265
3266 if (!current_user_can('manage_options')) {
3267 wp_send_json_error(__('Permission denied.', 'metasync'));
3268 }
3269
3270 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3271
3272 Metasync_Seo_Restore::cancel_batch();
3273 wp_send_json_success();
3274 }
3275
3276 /**
3277 * AJAX: Get SEO Restore batch progress.
3278 */
3279 public function ajax_seo_restore_progress()
3280 {
3281 check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3282
3283 if (!current_user_can('manage_options')) {
3284 wp_send_json_error(__('Permission denied.', 'metasync'));
3285 }
3286
3287 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3288
3289 $progress = Metasync_Seo_Restore::get_progress();
3290 wp_send_json_success($progress);
3291 }
3292
3293 /**
3294 * AJAX: Process one SEO Restore tick (browser-driven chaining).
3295 */
3296 public function ajax_seo_restore_process_tick()
3297 {
3298 check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3299
3300 if (!current_user_can('manage_options')) {
3301 wp_send_json_error(__('Permission denied.', 'metasync'));
3302 }
3303
3304 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3305
3306 $progress = Metasync_Seo_Restore::process_ajax_tick();
3307 wp_send_json_success($progress);
3308 }
3309
3310 /**
3311 * Cron handler: Process SEO Restore batch tick.
3312 */
3313 public function handle_seo_restore_cron()
3314 {
3315 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3316
3317 Metasync_Seo_Restore::process_batch_tick();
3318 }
3319
3320 /**
3321 * Report Issue page callback
3322 */
3323 public function create_admin_report_issue_page()
3324 {
3325 // Load the Report Issue view
3326 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-report-issue.php';
3327 }
3328
3329 /**
3330 * XML Sitemap page callback
3331 */
3332 public function create_admin_xml_sitemap_page()
3333 {
3334 // Every form action below mutates site-wide state — sitemap options,
3335 // sitemap files on disk, robots.txt, other plugins' sitemap generators
3336 // — so they are restricted to site admins, not just anyone who can
3337 // open the page via plugin_access_roles. The nonce is CSRF
3338 // protection, not authorization.
3339 if (!empty($_POST) && !current_user_can('manage_options')) {
3340 wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3341 }
3342
3343 // Load sitemap generator class
3344 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3345
3346 $sitemap_generator = new Metasync_Sitemap_Generator();
3347
3348 // Determine active tab (from GET param or POST redirect)
3349 $active_tab = 'general';
3350 if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
3351 $active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
3352 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
3353 $active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
3354 }
3355
3356 // Handle Main Sitemap settings form submission
3357 if (isset($_POST['metasync_sitemap_settings_nonce']) && isset($_POST['save_sitemap_settings'])) {
3358 check_admin_referer('metasync_sitemap_settings_action', 'metasync_sitemap_settings_nonce');
3359
3360 $sitemap_settings = [
3361 '_configured' => true,
3362 'post_types' => array_map('sanitize_key', (array) ($_POST['sitemap_post_types'] ?? [])),
3363 'categories' => array_map('absint', (array) ($_POST['sitemap_categories'] ?? [])),
3364 'tags' => array_map('absint', (array) ($_POST['sitemap_tags'] ?? [])),
3365 'taxonomies' => array_map('sanitize_key', (array) ($_POST['sitemap_taxonomies'] ?? [])),
3366 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['sitemap_excluded_urls'] ?? '')),
3367 ];
3368
3369 update_option('metasync_sitemap_settings', $sitemap_settings);
3370
3371 // Regenerate sitemap with new content settings
3372 $result = $sitemap_generator->generate_sitemap();
3373 if (is_wp_error($result)) {
3374 echo '<div class="notice notice-error"><p>' . esc_html(
3375 /* translators: %s: error message. */
3376 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3377 ) . '</p></div>';
3378 } elseif (false === $result) {
3379 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>';
3380 } else {
3381 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3382 }
3383 }
3384
3385 // Handle News Sitemap settings form submission
3386 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['save_news_sitemap'])) {
3387 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
3388
3389 // Build generic taxonomy filters
3390 $news_taxonomies = [];
3391 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3392 foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3393 $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3394 }
3395 }
3396
3397 $news_settings = [
3398 'enabled' => isset($_POST['news_enabled']),
3399 'post_types' => array_map('sanitize_key', (array) ($_POST['news_post_types'] ?? ['post'])),
3400 'categories' => array_map('absint', (array) ($_POST['news_categories'] ?? [])),
3401 'tags' => array_map('absint', (array) ($_POST['news_tags'] ?? [])),
3402 'taxonomies' => $news_taxonomies,
3403 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['news_excluded_urls'] ?? '')),
3404 'publication_name' => sanitize_text_field(wp_unslash($_POST['publication_name'] ?? '')),
3405 'publication_language' => sanitize_text_field(wp_unslash($_POST['publication_language'] ?? '')),
3406 ];
3407
3408 // Always invalidate old cache before saving new settings
3409 delete_transient('metasync_vsm_' . md5('news-sitemap.xml'));
3410
3411 update_option('metasync_news_sitemap_settings', $news_settings);
3412
3413 if ($news_settings['enabled']) {
3414 $sitemap_generator->generate_news_sitemap();
3415 update_option('metasync_sitemap_last_generated', current_time('mysql'));
3416 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3417 } else {
3418 // Also remove physical file if it exists
3419 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3420 @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3421 }
3422 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3423 }
3424 }
3425
3426 // Handle Video Sitemap settings form submission
3427 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['save_video_sitemap'])) {
3428 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
3429
3430 // Build generic taxonomy filters
3431 $video_taxonomies = [];
3432 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3433 foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3434 $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3435 }
3436 }
3437
3438 $video_settings = [
3439 'enabled' => isset($_POST['video_enabled']),
3440 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3441 'auto_detect' => isset($_POST['auto_detect']),
3442 'taxonomies' => $video_taxonomies,
3443 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3444 // This array replaces the stored option wholesale, so every
3445 // setting must be represented here or it is wiped on save.
3446 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3447 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3448 ];
3449
3450 // Always invalidate old cache before saving new settings
3451 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
3452
3453 update_option('metasync_video_sitemap_settings', $video_settings);
3454
3455 if ($video_settings['enabled']) {
3456 $sitemap_generator->generate_video_sitemap();
3457 update_option('metasync_sitemap_last_generated', current_time('mysql'));
3458 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3459 } else {
3460 // Also remove physical file if it exists
3461 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3462 @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3463 }
3464 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3465 }
3466 }
3467
3468 // Handle News Sitemap generate action
3469 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['generate_news_sitemap'])) {
3470 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
3471 $result = $sitemap_generator->generate_news_sitemap();
3472 if ($result) {
3473 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap generated successfully!', 'metasync') . '</p></div>';
3474 } else {
3475 echo '<div class="notice notice-error"><p>' . esc_html__('News sitemap generation failed. Check if it is enabled and no conflicting plugins are active.', 'metasync') . '</p></div>';
3476 }
3477 }
3478
3479 // Handle Video Sitemap generate action
3480 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['generate_video_sitemap'])) {
3481 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
3482 $result = $sitemap_generator->generate_video_sitemap();
3483 if ($result) {
3484 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap generated successfully!', 'metasync') . '</p></div>';
3485 } else {
3486 echo '<div class="notice notice-error"><p>' . esc_html__('Video sitemap generation failed. Check if it is enabled and no conflicting plugins are active.', 'metasync') . '</p></div>';
3487 }
3488 }
3489
3490 // Handle form submissions
3491 if (isset($_POST['metasync_sitemap_nonce'])) {
3492 check_admin_referer('metasync_sitemap_action', 'metasync_sitemap_nonce');
3493
3494 if (isset($_POST['generate_sitemap'])) {
3495 // Auto-disable other sitemap generators before generating
3496 $disabled_plugins = $sitemap_generator->disable_other_sitemap_generators();
3497
3498 // Generate news/video sitemaps FIRST so they exist when the main sitemap builds its index
3499 $news_opts = get_option('metasync_news_sitemap_settings', []);
3500 $video_opts = get_option('metasync_video_sitemap_settings', []);
3501 $extras = [];
3502 if (!empty($news_opts['enabled'])) {
3503 if ($sitemap_generator->generate_news_sitemap()) {
3504 $extras[] = 'news';
3505 }
3506 }
3507
3508 // Reclaim unreferenced memory between the news and video passes
3509 // so the video generator starts with more headroom (they share
3510 // one request). Each generator already releases its own object
3511 // cache via clean_post_cache(), so no cache flush is needed here.
3512 if (function_exists('gc_collect_cycles')) {
3513 gc_collect_cycles();
3514 }
3515
3516 if (!empty($video_opts['enabled'])) {
3517 if ($sitemap_generator->generate_video_sitemap()) {
3518 $extras[] = 'video';
3519 }
3520 }
3521
3522 // Same inter-pass GC before the main sitemap pass begins.
3523 if (function_exists('gc_collect_cycles')) {
3524 gc_collect_cycles();
3525 }
3526
3527 // Generate main sitemap (its index will include news/video since they now exist)
3528 $result = $sitemap_generator->generate_sitemap();
3529
3530 if (is_wp_error($result)) {
3531 $error_msg = $result->get_error_message();
3532 error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3533 echo '<div class="notice notice-error"><p>' . esc_html(
3534 /* translators: %s: error message. */
3535 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
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>';
3539 } else {
3540 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3541 if (!empty($extras)) {
3542 $message .= ' ' . sprintf(
3543 /* translators: %s: list of sitemap types. */
3544 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3545 implode(' & ', $extras)
3546 );
3547 }
3548 if ($disabled_plugins) {
3549 $message .= ' ' . esc_html__('Conflicting sitemap generators have been automatically disabled.', 'metasync');
3550 }
3551
3552 // Check if robots.txt was updated
3553 $robots_result = get_transient('metasync_sitemap_robots_updated');
3554 if ($robots_result && $robots_result['success']) {
3555 if ($robots_result['action'] === 'added') {
3556 $message .= ' ' . esc_html__('Sitemap URL has been added to robots.txt.', 'metasync');
3557 } elseif ($robots_result['action'] === 'updated') {
3558 $message .= ' ' . esc_html__('Sitemap URL has been updated in robots.txt.', 'metasync');
3559 } elseif ($robots_result['action'] === 'created') {
3560 $message .= ' ' . esc_html__('robots.txt file has been created with sitemap URL.', 'metasync');
3561 }
3562 delete_transient('metasync_sitemap_robots_updated');
3563 }
3564
3565 echo '<div class="notice notice-success"><p>' . esc_html($message) . '</p></div>';
3566 }
3567 } elseif (isset($_POST['enable_auto_update'])) {
3568 update_option('metasync_sitemap_auto_update', true);
3569 $sitemap_generator->setup_auto_update_hooks();
3570 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update enabled!', 'metasync') . '</p></div>';
3571 } elseif (isset($_POST['disable_auto_update'])) {
3572 update_option('metasync_sitemap_auto_update', false);
3573 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update disabled!', 'metasync') . '</p></div>';
3574 } elseif (isset($_POST['delete_general_sitemap'])) {
3575 $deleted = $sitemap_generator->delete_sitemap('general');
3576
3577 if ($deleted) {
3578 // Disable auto-update only when the general sitemap is removed
3579 update_option('metasync_sitemap_auto_update', false);
3580 // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3581 if (!$sitemap_generator->sitemap_exists()) {
3582 delete_option('metasync_disable_wp_sitemap');
3583 }
3584 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3585 } else {
3586 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete general sitemap. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3587 }
3588 } elseif (isset($_POST['delete_news_sitemap'])) {
3589 $deleted = $sitemap_generator->delete_sitemap('news');
3590
3591 if ($deleted) {
3592 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap deleted successfully!', 'metasync') . '</p></div>';
3593 } else {
3594 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete news sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3595 }
3596 } elseif (isset($_POST['delete_video_sitemap'])) {
3597 $deleted = $sitemap_generator->delete_sitemap('video');
3598
3599 if ($deleted) {
3600 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap deleted successfully!', 'metasync') . '</p></div>';
3601 } else {
3602 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete video sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3603 }
3604 } elseif (isset($_POST['delete_sitemap'])) {
3605 // Delete all sitemaps: main + news + video (handled by delete_sitemap)
3606 $deleted = $sitemap_generator->delete_sitemap();
3607
3608 if ($deleted) {
3609 // Also disable auto-update when deleting
3610 update_option('metasync_sitemap_auto_update', false);
3611 // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3612 delete_option('metasync_disable_wp_sitemap');
3613 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3614 } else {
3615 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>';
3616 }
3617 } elseif (isset($_POST['enable_other_sitemaps'])) {
3618 // Re-enable other sitemap plugins
3619 $enabled_plugins = $sitemap_generator->enable_other_sitemap_generators();
3620 if ($enabled_plugins) {
3621 echo '<div class="notice notice-success"><p>' . esc_html__('Other sitemap plugins have been re-enabled successfully!', 'metasync') . '</p></div>';
3622 } else {
3623 echo '<div class="notice notice-info"><p>' . esc_html__('No sitemap plugins were found to re-enable.', 'metasync') . '</p></div>';
3624 }
3625 }
3626 }
3627
3628 // Get sitemap info
3629 $sitemap_exists = $sitemap_generator->sitemap_exists();
3630 $sitemap_url = $sitemap_generator->get_sitemap_url();
3631 $url_count = $sitemap_generator->count_urls();
3632 $last_generated = $sitemap_generator->get_last_generated_time();
3633 $auto_update_enabled = get_option('metasync_sitemap_auto_update', false);
3634 $active_sitemap_plugins = $sitemap_generator->check_active_sitemap_plugins();
3635
3636 // Main sitemap content settings
3637 $sitemap_settings = get_option('metasync_sitemap_settings', [
3638 'post_types' => [],
3639 'categories' => [],
3640 'tags' => [],
3641 'taxonomies' => [],
3642 'excluded_urls' => '',
3643 ]);
3644
3645 // News and video sitemap settings for tabs
3646 $news_settings = get_option('metasync_news_sitemap_settings', [
3647 'enabled' => false,
3648 'post_types' => ['post'],
3649 'categories' => [],
3650 'tags' => [],
3651 'taxonomies' => [],
3652 'excluded_urls' => '',
3653 'publication_name' => '',
3654 'publication_language' => '',
3655 ]);
3656 $video_settings = get_option('metasync_video_sitemap_settings', [
3657 'enabled' => false,
3658 'post_types' => ['post', 'page'],
3659 'auto_detect' => true,
3660 'taxonomies' => [],
3661 'excluded_urls' => '',
3662 ]);
3663
3664 // Load view
3665 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-xml-sitemap.php';
3666 }
3667
3668 /**
3669 * Custom Pages page callback
3670 */
3671 public function create_admin_custom_pages_page()
3672 {
3673 Metasync_Admin_Pages::get_instance($this)->create_admin_custom_pages_page();
3674 }
3675
3676 /**
3677 * 404 Monitor page callback
3678 */
3679 public function create_admin_404_monitor_page()
3680 {
3681 Metasync_Admin_Pages::get_instance($this)->create_admin_404_monitor_page();
3682 }
3683
3684 /**
3685 * SEO Health dashboard page callback
3686 */
3687 public function create_admin_seo_health_page()
3688 {
3689 require_once plugin_dir_path(__FILE__) . 'class-metasync-seo-health.php';
3690 Metasync_SEO_Health::get_instance()->render_page();
3691 }
3692
3693 /**
3694 * Site Verification page callback
3695 */
3696 public function create_admin_search_engine_verification_page()
3697 {
3698 Metasync_Admin_Pages::get_instance($this)->create_admin_search_engine_verification_page();
3699 }
3700
3701 /**
3702 * Local Business page callback
3703 */
3704 public function create_admin_local_business_page()
3705 {
3706 Metasync_Admin_Pages::get_instance($this)->create_admin_local_business_page();
3707 }
3708
3709 /**
3710 * Code Snippets page callback
3711 */
3712 public function create_admin_code_snippets_page()
3713 {
3714 Metasync_Admin_Pages::get_instance($this)->create_admin_code_snippets_page();
3715 }
3716
3717 /**
3718 * Schema Markup settings page callback
3719 */
3720 public function create_admin_schema_markup_page()
3721 {
3722 Metasync_Admin_Pages::get_instance($this)->create_admin_schema_markup_page();
3723 }
3724
3725 /**
3726 * Breadcrumbs settings page callback
3727 */
3728 public function create_admin_breadcrumbs_page()
3729 {
3730 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3731 }
3732
3733 /**
3734 * Google Console page callback
3735 */
3736 public function create_admin_google_console_page()
3737 {
3738 $this->render_layout_open('Google Console', 'google_console', 'View Google Search Console data and manage indexing requests.');
3739 $service_info = function_exists('google_index_direct') ? google_index_direct()->get_service_account_info() : ['error' => 'Module not loaded'];
3740 $is_configured = !isset($service_info['error']);
3741 include_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-console.php';
3742 $this->render_layout_close();
3743 }
3744
3745 /**
3746 * Bing Console page callback
3747 */
3748 public function create_admin_bing_console_page()
3749 {
3750 $this->render_layout_open('Bing Console', 'bing_console', 'Submit URLs to Bing for instant indexing via IndexNow.');
3751 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
3752 $bing_instant_index = new Metasync_Bing_Instant_Index();
3753 $bing_instant_index->show_bing_instant_indexing_console();
3754 $this->render_layout_close();
3755 }
3756
3757 /**
3758 * Global Options page callback
3759 */
3760 public function create_admin_global_settings_page()
3761 {
3762 Metasync_Admin_Pages::get_instance($this)->create_admin_global_settings_page();
3763 }
3764
3765 /**
3766 * Common Meta Options page callback
3767 */
3768 public function create_admin_common_meta_settings_page()
3769 {
3770 Metasync_Admin_Pages::get_instance($this)->create_admin_common_meta_settings_page();
3771 }
3772
3773 /**
3774 * Social meta page callback
3775 */
3776 public function create_admin_social_meta_page()
3777 {
3778 Metasync_Admin_Pages::get_instance($this)->create_admin_social_meta_page();
3779 }
3780
3781
3782 /**
3783 * Indexation Control page callback
3784 */
3785 public function create_admin_seo_controls_page()
3786 {
3787 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3788 }
3789
3790 /**
3791 * redirection page callback with tabs
3792 */
3793 public function create_admin_redirections_page()
3794 {
3795 Metasync_Redirections_Admin::get_instance($this->db_redirection, $this)->create_admin_redirections_page();
3796 }
3797
3798 /**
3799 * Display transient error/success messages for redirections
3800 */
3801 public function display_redirection_messages()
3802 {
3803 Metasync_Redirections_Admin::get_instance($this->db_redirection, $this)->display_redirection_messages();
3804 }
3805
3806 /**
3807 * Display admin notice when batch processing was deferred due to high CPU load.
3808 * Reads transient set by Metasync_CPU_Monitor::record_deferral() and clears it.
3809 */
3810 public function display_cpu_deferral_notice()
3811 {
3812 $data = get_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3813 if ( ! $data || ! is_array( $data ) ) {
3814 return;
3815 }
3816 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3817 echo '<div class="notice notice-warning is-dismissible"><p>';
3818 printf(
3819 /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3820 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' ),
3821 esc_html( Metasync::get_effective_plugin_name() ),
3822 esc_html( $data['load'] ),
3823 esc_html( $data['threshold'] ),
3824 esc_html( $data['cores'] )
3825 );
3826 echo '</p></div>';
3827 }
3828
3829 /**
3830 * Display info notice when another SEO plugin also generates /llms.txt.
3831 *
3832 * MetaSync always serves its own version when enabled (priority 1). This
3833 * notice simply informs the admin that another plugin was detected.
3834 */
3835 public function display_llms_txt_conflict_notice()
3836 {
3837 if (!get_transient('metasync_llms_conflict')) {
3838 return;
3839 }
3840 echo '<div class="notice notice-info is-dismissible"><p>';
3841 /* translators: %s: effective plugin name (whitelabel-aware). */
3842 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()));
3843 echo '</p></div>';
3844 }
3845
3846 /**
3847 * AJAX handler for updating database structure
3848 */
3849 public function ajax_update_db_structure()
3850 {
3851 Metasync_Admin_Ajax::instance()->ajax_update_db_structure();
3852 }
3853
3854 /**
3855 * AJAX handler to save wizard progress
3856 *
3857 * @since 1.0.0
3858 */
3859 public function ajax_save_wizard_progress()
3860 {
3861 Metasync_Admin_Ajax::instance()->ajax_save_wizard_progress();
3862 }
3863
3864 /**
3865 * AJAX handler to complete wizard
3866 *
3867 * @since 1.0.0
3868 */
3869 public function ajax_complete_wizard()
3870 {
3871 Metasync_Admin_Ajax::instance()->ajax_complete_wizard();
3872 }
3873
3874 /**
3875 * AJAX handler to validate robots.txt content
3876 */
3877 public function ajax_validate_robots()
3878 {
3879 Metasync_Admin_Ajax::instance()->ajax_validate_robots();
3880 }
3881
3882 /**
3883 * AJAX handler to get default robots.txt content
3884 */
3885 public function ajax_get_default_robots()
3886 {
3887 Metasync_Admin_Ajax::instance()->ajax_get_default_robots();
3888 }
3889
3890 /**
3891 * AJAX handler to preview robots.txt backup content
3892 */
3893 public function ajax_preview_robots_backup()
3894 {
3895 Metasync_Admin_Ajax::instance()->ajax_preview_robots_backup();
3896 }
3897
3898 /**
3899 * AJAX handler to delete robots.txt backup
3900 */
3901 public function ajax_delete_robots_backup()
3902 {
3903 Metasync_Admin_Ajax::instance()->ajax_delete_robots_backup();
3904 }
3905
3906 /**
3907 * AJAX handler to restore robots.txt backup
3908 */
3909 public function ajax_restore_robots_backup()
3910 {
3911 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3912 }
3913
3914 /**
3915 * AJAX handler to fetch a paginated page of robots.txt backups
3916 */
3917 public function ajax_get_robots_backups()
3918 {
3919 Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3920 }
3921
3922 public function ajax_create_redirect_from_404()
3923 {
3924 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3925 }
3926
3927 /**
3928 * AJAX handler for testing host blocking with GET request
3929 */
3930 public function ajax_test_host_blocking_get()
3931 {
3932 Metasync_Admin_Ajax::instance()->ajax_test_host_blocking_get();
3933 }
3934
3935 /**
3936 * AJAX handler for testing host blocking with POST request
3937 */
3938 public function ajax_test_host_blocking_post()
3939 {
3940 Metasync_Admin_Ajax::instance()->ajax_test_host_blocking_post();
3941 }
3942
3943 /**
3944 * Register REST API endpoint for ping
3945 */
3946 public function register_ping_rest_endpoint()
3947 {
3948 register_rest_route('metasync/v1', '/ping', array(
3949 'methods' => array('GET', 'POST'),
3950 'callback' => array($this, 'handle_ping_rest_endpoint'),
3951 'permission_callback' => '__return_true', // Allow public access
3952 'args' => array(
3953 'test' => array(
3954 'description' => 'Optional test parameter',
3955 'type' => 'string',
3956 'sanitize_callback' => 'sanitize_text_field',
3957 ),
3958 ),
3959 ));
3960 }
3961
3962 /**
3963 * Handle REST API ping endpoint
3964 */
3965 public function handle_ping_rest_endpoint($request)
3966 {
3967 // Get request method
3968 $method = $request->get_method();
3969
3970 // Prepare response data
3971 $response_data = array(
3972 'response' => 'pong',
3973 'method' => $method,
3974 'timestamp' => current_time('mysql'),
3975 'site_url' => home_url(),
3976 'plugin_version' => METASYNC_VERSION
3977 );
3978
3979 // Add request data for POST requests
3980 if ($method === 'POST') {
3981 $body = $request->get_body();
3982 if (!empty($body)) {
3983 $response_data['received_data'] = json_decode($body, true);
3984 }
3985
3986 // Add any query parameters
3987 $params = $request->get_params();
3988 if (!empty($params)) {
3989 $response_data['query_params'] = $params;
3990 }
3991 }
3992
3993 // Add test parameter if provided
3994 $test_param = $request->get_param('test');
3995 if (!empty($test_param)) {
3996 $response_data['test_param'] = $test_param;
3997 }
3998
3999 return new WP_REST_Response($response_data, 200);
4000 }
4001
4002 /**
4003 * Site error logs page callback
4004 */
4005
4006 public function create_admin_error_logs_page()
4007 {
4008 Metasync_Admin_Pages::get_instance($this)->create_admin_error_logs_page();
4009 }
4010
4011 /**
4012 * Compatibility page callback
4013 */
4014 public function create_admin_compatibility_page()
4015 {
4016 Metasync_Compatibility_Checker::instance()->create_admin_compatibility_page($this);
4017 }
4018
4019 /**
4020 * Sync Log page callback
4021 */
4022 public function create_admin_sync_log_page()
4023 {
4024 // Classes are now autoloaded
4025
4026 $sync_db = new Metasync_Sync_History_Database();
4027
4028 // Handle AJAX requests for sync log data
4029 if (wp_doing_ajax()) {
4030 $this->handle_sync_log_ajax();
4031 return;
4032 }
4033
4034 // Get pagination parameters
4035 $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4036 $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
4037 $offset = ($page - 1) * $per_page;
4038
4039 // Get filters
4040 $filters = [
4041 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
4042 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4043 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4044 ];
4045
4046 // Map date_range to concrete date_from/date_to for DB queries
4047 $date_range = $filters['date_range'];
4048 $wp_now_ts = current_time('timestamp');
4049 $date_from = '';
4050 $date_to = '';
4051
4052 if (!empty($date_range)) {
4053 // End boundary is now by default
4054 $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
4055
4056 if ($date_range === 'today') {
4057 $start_ts = strtotime('today', $wp_now_ts);
4058 $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 } elseif ($date_range === 'yesterday') {
4060 $start_ts = strtotime('yesterday', $wp_now_ts);
4061 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
4062 $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4063 $date_to = gmdate('Y-m-d H:i:s', $end_ts);
4064 } elseif ($date_range === 'this_week') {
4065 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
4066 $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
4067 // Convert start_of_week to PHP's 0..6 where 0=Sunday
4068 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
4069 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
4070 $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4071 } elseif ($date_range === 'this_month') {
4072 $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4073 $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4074 } elseif ($date_range === 'all') {
4075 // no bounds
4076 }
4077 }
4078
4079 if (!empty($date_from)) {
4080 $filters['date_from'] = $date_from;
4081 }
4082 if (!empty($date_to)) {
4083 $filters['date_to'] = $date_to;
4084 }
4085
4086 // Remove empty filters
4087 $filters = array_filter($filters);
4088
4089 // Get sync history records
4090 $sync_records = $sync_db->getAllRecords($per_page, $offset, $filters);
4091 $total_records = $sync_db->get_count($filters);
4092 $total_pages = ceil($total_records / $per_page);
4093
4094 // Get statistics
4095 $stats = $sync_db->get_statistics();
4096
4097 $this->render_layout_open('Changes Log', 'sync_log', 'Track recent content synchronizations and changes from external tools.');
4098 ?>
4099 <div class="dashboard-card">
4100 <div class="sync-log-header">
4101 <div class="sync-log-title-section">
4102 <?php
4103 // The page title and description are already rendered by
4104 // render_layout_open() above. Repeating them here printed
4105 // "Changes Log" twice on screen, so the card keeps only the
4106 // retention hint — the one piece of copy the layout header
4107 // does not carry.
4108 ?>
4109 <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4110 <?php
4111 // No opacity here: this used to be a de-emphasised
4112 // aside trailing a full-contrast sentence, but it is
4113 // now the card's only copy. At 12px/.75 it composites
4114 // to 4.28:1 on the card background, under the 4.5:1
4115 // AA floor; at full strength it reads 6.52:1.
4116 ?>
4117 <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
4118 </p>
4119 </div>
4120
4121 <!-- Filters + Clear Log button - Right aligned -->
4122 <div class="sync-log-filters" style="display:flex;align-items:center;gap:10px;">
4123 <button type="button" id="metasync-clear-sync-log-btn"
4124 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
4125 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
4126 🗑 Clear Log
4127 </button>
4128 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4129 <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
4130
4131 <select name="date_range" class="sync-filter-select">
4132 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
4133 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
4134 <option value="yesterday" <?php selected($filters['date_range'] ?? '', 'yesterday'); ?>>Yesterday</option>
4135 <option value="this_week" <?php selected($filters['date_range'] ?? '', 'this_week'); ?>>This week</option>
4136 <option value="this_month" <?php selected($filters['date_range'] ?? '', 'this_month'); ?>>This month</option>
4137 </select>
4138
4139 <select name="status" class="sync-filter-select">
4140 <option value="" <?php selected($filters['status'] ?? '', ''); ?>>Status Filter</option>
4141 <option value="published" <?php selected($filters['status'] ?? '', 'published'); ?>>Published</option>
4142 <option value="draft" <?php selected($filters['status'] ?? '', 'draft'); ?>>Draft</option>
4143 </select>
4144 </form>
4145 </div>
4146 </div>
4147
4148 <!-- Sync History List -->
4149 <div class="sync-log-list">
4150 <?php if (empty($sync_records)): ?>
4151 <div class="sync-log-empty">
4152 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
4153 <h2>No sync records found</h2>
4154 <p>Sync records will appear here when content/pages receive new updates.</p>
4155 </div>
4156 <?php else: ?>
4157 <?php foreach ($sync_records as $record): ?>
4158 <div class="sync-log-item">
4159 <div class="sync-log-icon">
4160 <div class="sync-icon-circle">
4161 <span class="sync-icon"><?php echo ($record->source === 'MCP Client') ? '<span class="dashicons dashicons-admin-users" style="font-size:16px;width:16px;height:16px;"></span>' : '<span class="dashicons dashicons-media-default" style="font-size:16px;width:16px;height:16px;"></span>'; ?></span>
4162 </div>
4163 </div>
4164
4165 <div class="sync-log-content">
4166 <div class="sync-log-title"><?php echo esc_html($record->title); ?>
4167 <?php if (!empty($record->url)): ?>
4168 <a href="<?php echo esc_url($record->url); ?>" target="_blank" rel="noopener" title="Open URL" style="margin-left:8px; text-decoration:none;"><span class="dashicons dashicons-external" style="font-size:14px;width:14px;height:14px;vertical-align:middle;"></span></a>
4169 <?php endif; ?>
4170 </div>
4171 <div class="sync-log-meta">
4172 <?php echo esc_html( $this->time_elapsed_string($record->created_at) ); ?>
4173 <?php if (!empty($record->source)): ?>
4174 &nbsp;·&nbsp;<span style="opacity:.7;"><?php echo esc_html($record->source); ?></span>
4175 <?php endif; ?>
4176 </div>
4177 </div>
4178
4179 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
4180 <?php
4181 $st = (string) $record->status;
4182 $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4183 if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4184 elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4185 elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4186 elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4187 ?>
4188 <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4189 <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4190 <?php echo esc_html($b_label); ?>
4191 </span>
4192 <?php if ($record->source === 'MCP Client'): ?>
4193 <button type="button"
4194 class="metasync-rollback-btn"
4195 data-id="<?php echo esc_attr($record->id); ?>"
4196 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_rollback_mcp_change')); ?>"
4197 style="background:none;border:1px solid #aaa;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:12px;color:inherit;"
4198 title="Rollback this MCP change">
4199 ↩ Rollback
4200 </button>
4201 <?php endif; ?>
4202 </div>
4203 </div>
4204 <?php endforeach; ?>
4205 <?php endif; ?>
4206 </div>
4207
4208 <!-- Pagination -->
4209 <div class="sync-log-pagination">
4210 <div class="sync-log-pagination-info">
4211 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; ?>
4212 <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4213 echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4214 </div>
4215
4216 <?php if ($total_pages > 1): ?>
4217 <div class="sync-log-pagination-controls">
4218 <?php if ($page > 1): ?>
4219 <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4220 <?php endif; ?>
4221
4222 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
4223 <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4224 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
4225 <?php endfor; ?>
4226
4227 <?php if ($page < $total_pages): ?>
4228 <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4229 <?php endif; ?>
4230 </div>
4231 <?php endif; ?>
4232 </div>
4233 </div>
4234 <?php $this->render_layout_close(); ?>
4235
4236 <script>
4237 (function () {
4238 // ── Clear Log ──────────────────────────────────────────────
4239 var clearBtn = document.getElementById('metasync-clear-sync-log-btn');
4240 if (clearBtn) {
4241 clearBtn.addEventListener('click', function () {
4242 if (!confirm('Are you sure you want to permanently delete all sync log records? This cannot be undone.')) {
4243 return;
4244 }
4245 clearBtn.disabled = true;
4246 clearBtn.textContent = 'Clearing…';
4247 var data = new FormData();
4248 data.append('action', 'metasync_clear_sync_log');
4249 data.append('nonce', clearBtn.dataset.nonce);
4250 fetch(ajaxurl, { method: 'POST', body: data })
4251 .then(function (r) { return r.json(); })
4252 .then(function (resp) {
4253 if (resp.success) {
4254 window.location.reload();
4255 } else {
4256 alert(resp.data && resp.data.message ? resp.data.message : 'Failed to clear log.');
4257 clearBtn.disabled = false;
4258 clearBtn.textContent = '🗑 Clear Log';
4259 }
4260 })
4261 .catch(function () {
4262 alert('Request failed. Please try again.');
4263 clearBtn.disabled = false;
4264 clearBtn.textContent = '🗑 Clear Log';
4265 });
4266 });
4267 }
4268
4269 // ── Rollback ───────────────────────────────────────────────
4270 document.querySelectorAll('.metasync-rollback-btn').forEach(function (btn) {
4271 btn.addEventListener('click', function () {
4272 if (!confirm('Rollback this MCP change to its previous state?')) {
4273 return;
4274 }
4275 btn.disabled = true;
4276 btn.textContent = '…';
4277 var data = new FormData();
4278 data.append('action', 'metasync_rollback_mcp_change');
4279 data.append('nonce', btn.dataset.nonce);
4280 data.append('sync_history_id', btn.dataset.id);
4281 fetch(ajaxurl, { method: 'POST', body: data })
4282 .then(function (r) { return r.json(); })
4283 .then(function (resp) {
4284 if (resp.success) {
4285 btn.textContent = '✓ Done';
4286 btn.style.color = 'green';
4287 } else {
4288 alert(resp.data && resp.data.message ? resp.data.message : 'Rollback failed.');
4289 btn.disabled = false;
4290 btn.textContent = '↩ Rollback';
4291 }
4292 })
4293 .catch(function () {
4294 alert('Request failed. Please try again.');
4295 btn.disabled = false;
4296 btn.textContent = '↩ Rollback';
4297 });
4298 });
4299 });
4300 })();
4301 </script>
4302 <?php
4303 }
4304 /**
4305 * Build filter query string for pagination
4306 */
4307 private function build_filter_query_string($filters)
4308 {
4309 $query_parts = [];
4310 foreach ($filters as $key => $value) {
4311 if (!empty($value)) {
4312 $query_parts[] = $key . '=' . urlencode($value);
4313 }
4314 }
4315
4316 // Preserve the user-selected results-per-page value across page
4317 // navigation so a non-default page size survives clicking a page link.
4318 $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4319 if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4320 $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4321 if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4322 $query_parts[] = $per_page_key . '=' . $per_page;
4323 }
4324 }
4325
4326 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
4327 }
4328
4329 /**
4330 * Handle AJAX requests for sync log data
4331 */
4332 private function handle_sync_log_ajax()
4333 {
4334 // This can be used for future AJAX functionality like real-time updates
4335 wp_die();
4336 }
4337
4338 /**
4339 * AJAX: Clear all Sync Log records (admin-only, nonce protected).
4340 */
4341 public function ajax_clear_sync_log()
4342 {
4343 check_ajax_referer('metasync_clear_sync_log', 'nonce');
4344
4345 if (!current_user_can('manage_options')) {
4346 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
4347 }
4348
4349 $sync_db = new Metasync_Sync_History_Database();
4350 $sync_db->clear_logs();
4351
4352 wp_send_json_success(['message' => 'Sync log cleared successfully.']);
4353 }
4354
4355 /**
4356 * AJAX: Rollback a single MCP Client sync history entry.
4357 */
4358 public function ajax_rollback_mcp_change()
4359 {
4360 check_ajax_referer('metasync_rollback_mcp_change', 'nonce');
4361
4362 if (!current_user_can('manage_options')) {
4363 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
4364 }
4365
4366 $id = isset($_POST['sync_history_id']) ? intval($_POST['sync_history_id']) : 0;
4367 if (!$id) {
4368 wp_send_json_error(['message' => 'Invalid sync history ID.']);
4369 }
4370
4371 $result = Metasync_MCP_Sync_Logger::rollback($id);
4372
4373 if ($result['success']) {
4374 wp_send_json_success(['message' => $result['message']]);
4375 } else {
4376 wp_send_json_error(['message' => $result['message']]);
4377 }
4378 }
4379
4380 /**
4381 * Render compatibility sections
4382 */
4383 private function render_compatibility_sections()
4384 {
4385 Metasync_Compatibility_Checker::instance()->render_compatibility_sections();
4386 }
4387
4388 /**
4389 * Render Page Builders section
4390 */
4391 private function render_page_builders_section()
4392 {
4393 Metasync_Compatibility_Checker::instance()->render_page_builders_section();
4394 }
4395
4396 /**
4397 * Render SEO Plugins section
4398 */
4399 private function render_seo_plugins_section()
4400 {
4401 Metasync_Compatibility_Checker::instance()->render_seo_plugins_section();
4402 }
4403
4404 /**
4405 * Render Cache Plugins section
4406 */
4407 private function render_cache_plugins_section()
4408 {
4409 Metasync_Compatibility_Checker::instance()->render_cache_plugins_section();
4410 }
4411
4412 /**
4413 * Render Lock Section button for protected tabs
4414 *
4415 * @param string $tab The tab identifier (general, whitelabel, advanced)
4416 */
4417 private function render_lock_button($tab)
4418 {
4419 Metasync_Compatibility_Checker::instance()->render_lock_button($tab);
4420 }
4421
4422 /**
4423 * Get Page Builders compatibility information
4424 */
4425 private function get_page_builders_compatibility()
4426 {
4427 return Metasync_Compatibility_Checker::instance()->get_page_builders_compatibility();
4428 }
4429
4430 /**
4431 * Get SEO Plugins compatibility information
4432 */
4433 private function get_seo_plugins_compatibility()
4434 {
4435 return Metasync_Compatibility_Checker::instance()->get_seo_plugins_compatibility();
4436 }
4437
4438 /**
4439 * Get Cache Plugins compatibility information
4440 */
4441 private function get_cache_plugins_compatibility()
4442 {
4443 return Metasync_Compatibility_Checker::instance()->get_cache_plugins_compatibility();
4444 }
4445
4446 /**
4447 * Check if a plugin is installed and active
4448 * @deprecated Use get_plugin_status() instead
4449 */
4450 private function is_plugin_installed($plugin_file)
4451 {
4452 return Metasync_Compatibility_Checker::instance()->is_plugin_installed($plugin_file);
4453 }
4454
4455 /**
4456 * Get detailed plugin status (installed and/or active)
4457 * Checks multiple plugin file paths (e.g., free and premium versions)
4458 *
4459 * @param array $plugin_files Array of plugin file paths to check (e.g., ['free/plugin.php', 'pro/plugin.php'])
4460 * @param bool $is_core Whether this is a WordPress core feature (always installed/active)
4461 * @param string $theme_name Optional theme name to check if it's a theme instead of plugin
4462 * @return array ['is_installed' => bool, 'is_active' => bool, 'active_version' => string|null]
4463 */
4464 private function get_plugin_status($plugin_files, $is_core = false, $theme_name = null)
4465 {
4466 return Metasync_Compatibility_Checker::instance()->get_plugin_status($plugin_files, $is_core, $theme_name);
4467 }
4468
4469
4470 /**
4471 * Get plugin logo URL (optimized for performance)
4472 */
4473 private function get_plugin_logo($plugin_key, $type)
4474 {
4475 return Metasync_Compatibility_Checker::instance()->get_plugin_logo($plugin_key, $type);
4476 }
4477
4478
4479 public function creat_error_Logs_List()
4480 {
4481 Metasync_Admin_Pages::get_instance($this)->creat_error_Logs_List();
4482 }
4483
4484 /**
4485 * Site error logs page callback
4486 */
4487 public function create_admin_heartbeat_error_logs_page()
4488 {
4489 Metasync_Admin_Pages::get_instance($this)->create_admin_heartbeat_error_logs_page();
4490 }
4491
4492
4493 /**
4494 * Handle session management early for whitelabel functionality
4495 */
4496 private function handle_session_management_early()
4497 {
4498 Metasync_Connect_Manager::instance()->handle_session_management_early();
4499 }
4500
4501 private function handle_whitelabel_session_logic()
4502 {
4503 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4504 }
4505
4506 private function handle_whitelabel_password_early()
4507 {
4508 Metasync_Connect_Manager::instance()->handle_whitelabel_password_early();
4509 }
4510
4511 /**
4512 * Get accordion sections configuration for General Settings
4513 *
4514 * @return array Accordion sections with field IDs, icons, and descriptions
4515 */
4516 private function get_accordion_sections_config() {
4517 return Metasync_Settings_Fields::instance()->get_accordion_sections_config();
4518 }
4519
4520 /**
4521 * Get accordion sections configuration for Advanced Settings Tab
4522 *
4523 * @return array Accordion sections configuration
4524 */
4525 private function get_advanced_accordion_config() {
4526 return Metasync_Settings_Fields::instance()->get_advanced_accordion_config();
4527 }
4528
4529 /**
4530 * Render accordion sections for Advanced Settings Tab
4531 */
4532 public function render_advanced_accordion() {
4533 Metasync_Settings_Fields::instance()->render_advanced_accordion();
4534 }
4535
4536 /**
4537 * Render reset settings section for Advanced tab accordion
4538 */
4539 /**
4540 * Render CPU Monitor section for Performance accordion
4541 */
4542 public function render_cpu_monitor_section() {
4543 $cpu_monitor = new Metasync_CPU_Monitor();
4544 $stats = Metasync_CPU_Monitor::get_stats();
4545 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4546 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4547 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4548 $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4549 ?>
4550 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4551 <!-- CPU Cores Detected -->
4552 <div style="margin-bottom: 24px;">
4553 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4554 CPU Cores Detected
4555 </label>
4556 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4557 <?php if ($detection_reliable) : ?>
4558 <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4559 <?php else : ?>
4560 <strong>Not detected</strong>
4561 <?php endif; ?>
4562 </div>
4563 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4564 <?php if ($detection_reliable) : ?>
4565 Automatically detected on this system.
4566 <?php else : ?>
4567 Core detection is not available on this hosting environment.
4568 <?php endif; ?>
4569 </p>
4570 </div>
4571
4572 <!-- Per-Core Load Threshold -->
4573 <div style="margin-bottom: 24px;">
4574 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4575 Per-Core Load Threshold
4576 <?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.'); ?>
4577 </label>
4578 <input type="number"
4579 id="cpu_load_per_core_threshold"
4580 name="metasync_options[performance][cpu_load_per_core_threshold]"
4581 value="<?php echo esc_attr($per_core_threshold); ?>"
4582 step="0.1"
4583 min="0.5"
4584 max="10.0"
4585 style="width: 100%; padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text); font-size: 14px; box-sizing: border-box;"
4586 onchange="updateEffectiveThreshold()">
4587 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4588 Set the load average per CPU core (0.5–10.0). Default: 2.0
4589 </p>
4590 </div>
4591
4592 <!-- Effective Threshold (Read-Only) -->
4593 <div style="margin-bottom: 24px;">
4594 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4595 Effective Threshold
4596 </label>
4597 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4598 <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4599 </div>
4600 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4601 Calculated as: cores × per-core threshold
4602 </p>
4603 </div>
4604
4605 <!-- Statistics -->
4606 <div style="background: rgba(59, 130, 246, 0.05); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 8px; padding: 16px; margin-bottom: 24px;">
4607 <h4 style="margin: 0 0 12px 0; color: var(--dashboard-text); display: flex; align-items: center; gap: 8px;">
4608 <span class="dashicons dashicons-chart-bar" style="font-size:18px;width:18px;height:18px;"></span>
4609 <span>CPU Load Statistics</span>
4610 </h4>
4611 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
4612 <div>
4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Total Deferrals</div>
4614 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4615 </div>
4616 <div>
4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4618 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4619 </div>
4620 <div>
4621 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4622 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4623 </div>
4624 </div>
4625 </div>
4626
4627 <!-- Save Button -->
4628 <button type="button" class="metasync-btn-primary" onclick="submitPerformanceSettings(event)" style="background: var(--dashboard-primary, #3b82f6); color: #ffffff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);" onmouseover="this.style.background='var(--dashboard-primary-hover, #2563eb)'; this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(59, 130, 246, 0.3)';" onmouseout="this.style.background='var(--dashboard-primary, #3b82f6)'; this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(59, 130, 246, 0.2)';">
4629 Save Performance Settings
4630 </button>
4631 </div>
4632 <script>
4633 function updateEffectiveThreshold() {
4634 const coresCount = <?php echo intval($cores); ?>;
4635 const perCoreInput = document.getElementById('cpu_load_per_core_threshold');
4636 const effectiveValue = coresCount * parseFloat(perCoreInput.value);
4637 document.getElementById('effective_threshold_value').textContent = effectiveValue.toFixed(2);
4638 }
4639
4640 function submitPerformanceSettings(event) {
4641 // Prevent any form submission (defensive)
4642 if (event) {
4643 event.preventDefault();
4644 }
4645
4646 // Get the threshold value
4647 const thresholdInput = document.getElementById('cpu_load_per_core_threshold');
4648 if (!thresholdInput) {
4649 console.error('CPU threshold input not found');
4650 return;
4651 }
4652
4653 const threshold = parseFloat(thresholdInput.value);
4654 if (isNaN(threshold) || threshold < 0.5 || threshold > 10.0) {
4655 alert('Please enter a valid threshold between 0.5 and 10.0');
4656 return;
4657 }
4658
4659 // Get AJAX URL
4660 const ajaxUrl = (typeof window.ajaxurl !== 'undefined' && window.ajaxurl)
4661 ? window.ajaxurl
4662 : '<?php echo esc_js(admin_url('admin-ajax.php')); ?>';
4663
4664 // Get nonce from form
4665 const nonceInput = document.querySelector('input[name="meta_sync_nonce"]');
4666 const nonce = nonceInput ? nonceInput.value : '';
4667
4668 // Prepare AJAX request data
4669 const formData = new FormData();
4670 formData.append('action', 'metasync_save_performance_settings');
4671 formData.append('meta_sync_nonce', nonce);
4672 formData.append('metasync_options[performance][cpu_load_per_core_threshold]', threshold);
4673
4674 // Show saving state
4675 const button = event.target;
4676 const originalText = button.innerHTML;
4677 button.innerHTML = '⏳ Saving...';
4678 button.disabled = true;
4679
4680 // Make AJAX request
4681 fetch(ajaxUrl, {
4682 method: 'POST',
4683 body: formData,
4684 headers: {
4685 'X-Requested-With': 'XMLHttpRequest'
4686 }
4687 })
4688 .then(response => response.json())
4689 .then(data => {
4690 button.innerHTML = originalText;
4691 button.disabled = false;
4692
4693 if (data.success) {
4694 // Update the effective threshold display
4695 if (data.data && data.data.effective_threshold) {
4696 document.getElementById('effective_threshold_value').textContent =
4697 data.data.effective_threshold.toFixed(2);
4698 }
4699
4700 // Show success notice
4701 showPerformanceNotice(data.data.message || 'Settings saved successfully!', 'success');
4702 } else {
4703 // Show error notice
4704 showPerformanceNotice(data.data?.message || 'Failed to save settings', 'error');
4705 }
4706 })
4707 .catch(error => {
4708 console.error('AJAX Error:', error);
4709 button.innerHTML = originalText;
4710 button.disabled = false;
4711 showPerformanceNotice('An error occurred while saving settings', 'error');
4712 });
4713 }
4714
4715 function showPerformanceNotice(message, type) {
4716 // Create notice element
4717 const notice = document.createElement('div');
4718 notice.className = `notice notice-${type} is-dismissible`;
4719 notice.style.cssText = 'margin: 20px auto; max-width: 800px;';
4720 notice.innerHTML = `
4721 <p><strong>${type === 'success' ? '�
4722 ' : '❌'} ${message}</strong></p>
4723 <button type="button" class="notice-dismiss" onclick="this.parentElement.remove()" style="cursor: pointer;"></button>
4724 `;
4725
4726 // Find the page header to insert before
4727 const pageHeader = document.querySelector('h1, h2');
4728 if (pageHeader) {
4729 pageHeader.parentElement.insertBefore(notice, pageHeader.nextSibling);
4730 } else {
4731 document.body.insertBefore(notice, document.body.firstChild);
4732 }
4733
4734 // Auto-remove error notices after 5 seconds
4735 if (type === 'error') {
4736 setTimeout(() => {
4737 if (notice.parentElement) {
4738 notice.remove();
4739 }
4740 }, 5000);
4741 }
4742 }
4743 </script>
4744 <?php
4745 }
4746
4747 private function render_reset_settings_section() {
4748 Metasync_Settings_Fields::instance()->render_reset_settings_section();
4749 }
4750
4751 /**
4752 * Render Google Index API section for Indexation Control page
4753 */
4754 public function render_google_index_section() {
4755 Metasync_Settings_Fields::instance()->render_google_index_section();
4756 }
4757
4758 /**
4759 * Render Bing Index (IndexNow) section
4760 *
4761 * @since 2.6.0
4762 * @return void
4763 */
4764 public function render_bing_index_section() {
4765 Metasync_Settings_Fields::instance()->render_bing_index_section();
4766 }
4767
4768 /**
4769 * Render Plugin Access Roles section for Advanced tab accordion
4770 */
4771 private function render_plugin_access_roles_section() {
4772 Metasync_Settings_Fields::instance()->render_plugin_access_roles_section();
4773 }
4774
4775 /**
4776 * Check if the current user has access to the plugin based on role settings
4777 * Wrapper method that delegates to the common Metasync::current_user_has_plugin_access()
4778 * but also requires manage_options capability for admin area access
4779 *
4780 * @return bool True if user has access, false otherwise
4781 */
4782 public function current_user_has_plugin_access() {
4783 return Metasync::current_user_has_plugin_access();
4784 }
4785
4786 /**
4787 * Get default execution settings
4788 *
4789 * @return array Default execution settings
4790 */
4791 private function get_default_execution_settings() {
4792 return Metasync_Settings_Fields::instance()->get_default_execution_settings();
4793 }
4794
4795 /**
4796 * Get execution setting value
4797 *
4798 * @param string $key Setting key
4799 * @param mixed $default Default value if setting doesn't exist
4800 * @return mixed Setting value or default
4801 */
4802 public function get_execution_setting($key, $default = null) {
4803 return Metasync_Settings_Fields::instance()->get_execution_setting($key, $default);
4804 }
4805
4806 /**
4807 * Get all execution settings
4808 *
4809 * @return array All execution settings with defaults merged
4810 */
4811 public function get_all_execution_settings() {
4812 return Metasync_Settings_Fields::instance()->get_all_execution_settings();
4813 }
4814
4815 /**
4816 * Check if server allows changing memory limit
4817 * Tests if ini_set('memory_limit') is allowed
4818 *
4819 * @return bool True if memory limit can be changed, false otherwise
4820 */
4821 private function can_change_memory_limit() {
4822 return Metasync_Settings_Fields::instance()->can_change_memory_limit();
4823 }
4824
4825 /**
4826 * Get PHP server limits for display
4827 *
4828 * @return array Server limits (execution_time, memory_limit, can_change_memory)
4829 */
4830 private function get_server_limits() {
4831 return Metasync_Settings_Fields::instance()->get_server_limits();
4832 }
4833
4834 /**
4835 * Apply memory limit from execution settings
4836 * Only applies if server allows changing memory limit
4837 *
4838 * @return bool True if memory limit was applied, false otherwise
4839 */
4840 public function apply_memory_limit() {
4841 return Metasync_Settings_Fields::instance()->apply_memory_limit();
4842 }
4843
4844 /**
4845 * Parse memory limit string to MB
4846 *
4847 * @param string $memory_limit Memory limit string (e.g., "256M", "1G")
4848 * @return int Memory limit in MB
4849 */
4850 private function parse_memory_limit_to_mb($memory_limit) {
4851 return Metasync_Settings_Fields::instance()->parse_memory_limit_to_mb($memory_limit);
4852 }
4853
4854 /**
4855 * Render Execution Settings section for Advanced tab accordion
4856 */
4857 private function render_execution_settings_section() {
4858 Metasync_Settings_Fields::instance()->render_execution_settings_section();
4859 }
4860
4861 /**
4862 * Get tooltip content for settings fields
4863 *
4864 * @return array Field ID => Tooltip text mapping
4865 */
4866 private function get_field_tooltips() {
4867 return Metasync_Settings_Fields::instance()->get_field_tooltips();
4868 }
4869
4870 /**
4871 * Get the section key for a given field ID
4872 *
4873 * @param string $field_id The settings field ID
4874 * @return string|null Section key or null if not found
4875 */
4876 private function get_field_section($field_id) {
4877 return Metasync_Settings_Fields::instance()->get_field_section($field_id);
4878 }
4879
4880 /**
4881 * Render accordion sections for General Settings
4882 *
4883 * @param string $page The settings page slug
4884 */
4885 public function render_accordion_sections($page) {
4886 Metasync_Settings_Fields::instance()->render_accordion_sections($page);
4887 }
4888
4889 /**
4890 * Register and add settings
4891 */
4892 public function settings_page_init()
4893 {
4894 Metasync_Settings_Registration::instance()->settings_page_init();
4895 }
4896
4897 /**
4898 * Sanitize each setting field as needed
4899 *
4900 * @param array $input Contains all settings fields as array keys
4901 */
4902 public function sanitize($input)
4903 {
4904 return Metasync_Settings_Registration::instance()->sanitize($input);
4905 }
4906
4907 public function metasync_settings_genkey_callback()
4908 {
4909 Metasync_Settings_Fields::instance()->metasync_settings_genkey_callback();
4910 }
4911
4912 /**
4913 * Get the settings option array and print one of its values
4914 */
4915 public function linkgraph_token_callback()
4916 {
4917 Metasync_Settings_Fields::instance()->linkgraph_token_callback();
4918 }
4919
4920
4921 private function time_elapsed_string($datetime, $full = false)
4922 {
4923 return Metasync_Settings_Fields::instance()->time_elapsed_string($datetime, $full);
4924 }
4925
4926 /**
4927 * Get the settings option array and print one of its values
4928 */
4929 public function searchatlas_api_key_callback()
4930 {
4931 Metasync_Settings_Fields::instance()->searchatlas_api_key_callback();
4932 }
4933
4934
4935 /**
4936 * Site Verification Tools
4937 *
4938 * Bing Site Verification
4939 * Baidu Site Verification
4940 * Alexa Site Verification
4941 * Yandex Site Verification
4942 * Google Site Verification
4943 * Pinterest Site Verification
4944 * Norton Safe Web Site Verification
4945 */
4946
4947 /**
4948 * Get the settings option array and print one of its values
4949 */
4950 public function bing_site_verification_callback()
4951 {
4952 Metasync_Settings_Fields::instance()->bing_site_verification_callback();
4953 }
4954
4955
4956
4957
4958
4959 /**
4960 * Get the settings option array and print one of its values
4961 */
4962 public function yandex_site_verification_callback()
4963 {
4964 Metasync_Settings_Fields::instance()->yandex_site_verification_callback();
4965 }
4966
4967 /**
4968 * Get the settings option array and print one of its values
4969 */
4970 public function google_site_verification_callback()
4971 {
4972 Metasync_Settings_Fields::instance()->google_site_verification_callback();
4973 }
4974
4975 /**
4976 * Get the settings option array and print one of its values
4977 */
4978 public function pinterest_site_verification_callback()
4979 {
4980 Metasync_Settings_Fields::instance()->pinterest_site_verification_callback();
4981 }
4982
4983
4984
4985 /**
4986 * Local SEO for business and person
4987 *
4988 */
4989
4990 /**
4991 * Get the settings option array and print one of its values
4992 */
4993 public function local_seo_person_organization_callback()
4994 {
4995 Metasync_Settings_Fields::instance()->local_seo_person_organization_callback();
4996 }
4997
4998 /**
4999 * Get the settings option array and print one of its values
5000 */
5001 public function local_seo_name_callback()
5002 {
5003 Metasync_Settings_Fields::instance()->local_seo_name_callback();
5004 }
5005
5006 /**
5007 * Get the settings option array and print one of its values
5008 */
5009 public function local_seo_logo_callback()
5010 {
5011 Metasync_Settings_Fields::instance()->local_seo_logo_callback();
5012 }
5013
5014 /**
5015 * Get the settings option array and print one of its values
5016 */
5017 public function local_seo_url_callback()
5018 {
5019 Metasync_Settings_Fields::instance()->local_seo_url_callback();
5020 }
5021
5022 /**
5023 * Get the settings option array and print one of its values
5024 */
5025 public function local_seo_email_callback()
5026 {
5027 Metasync_Settings_Fields::instance()->local_seo_email_callback();
5028 }
5029
5030 /**
5031 * Get the settings option array and print one of its values
5032 */
5033 public function local_seo_phone_callback()
5034 {
5035 Metasync_Settings_Fields::instance()->local_seo_phone_callback();
5036 }
5037
5038 /**
5039 * Get the settings option array and print one of its values
5040 */
5041 public function local_seo_address_callback()
5042 {
5043 Metasync_Settings_Fields::instance()->local_seo_address_callback();
5044 }
5045
5046 /**
5047 * Get the settings option array and print one of its values
5048 */
5049 public function local_seo_business_type_callback()
5050 {
5051 Metasync_Settings_Fields::instance()->local_seo_business_type_callback();
5052 }
5053
5054
5055
5056 /**
5057 * Get the settings option array and print one of its values
5058 */
5059 public function local_seo_opening_hours_callback()
5060 {
5061 Metasync_Settings_Fields::instance()->local_seo_opening_hours_callback();
5062 }
5063
5064 /**
5065 * Get the settings option array and print one of its values
5066 */
5067 public function local_seo_phone_numbers_callback()
5068 {
5069 Metasync_Settings_Fields::instance()->local_seo_phone_numbers_callback();
5070 }
5071
5072 /**
5073 * Get the settings option array and print one of its values
5074 */
5075 public function local_seo_price_range_callback()
5076 {
5077 Metasync_Settings_Fields::instance()->local_seo_price_range_callback();
5078 }
5079
5080 /**
5081 * Get the settings option array and print one of its values
5082 */
5083 public function local_seo_about_page_callback()
5084 {
5085 Metasync_Settings_Fields::instance()->local_seo_about_page_callback();
5086 }
5087
5088 /**
5089 * Get the settings option array and print one of its values
5090 */
5091 public function local_seo_contact_page_callback()
5092 {
5093 Metasync_Settings_Fields::instance()->local_seo_contact_page_callback();
5094 }
5095
5096 /**
5097 * Get the settings option array and print one of its values
5098 */
5099 public function local_seo_map_key_callback()
5100 {
5101 Metasync_Settings_Fields::instance()->local_seo_map_key_callback();
5102 }
5103
5104 /**
5105 * Get the settings option array and print one of its values
5106 */
5107 public function local_seo_geo_coordinates_callback()
5108 {
5109 Metasync_Settings_Fields::instance()->local_seo_geo_coordinates_callback();
5110 }
5111
5112 /**
5113 * Get the settings option array and print one of its values
5114 */
5115 public function header_snippets_callback()
5116 {
5117 Metasync_Settings_Fields::instance()->header_snippets_callback();
5118 }
5119
5120 /**
5121 * Get the settings option array and print one of its values
5122 */
5123 public function footer_snippets_callback()
5124 {
5125 Metasync_Settings_Fields::instance()->footer_snippets_callback();
5126 }
5127
5128 /**
5129 * Get the settings option array and print one of its values
5130 */
5131 public function common_robot_meta_tags_callback()
5132 {
5133 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
5134 }
5135
5136 /**
5137 * Backward compatibility alias for common_robot_mata_tags_callback
5138 * @deprecated Use common_robot_meta_tags_callback() instead
5139 */
5140 public function common_robot_mata_tags_callback()
5141 {
5142 Metasync_Settings_Fields::instance()->common_robot_mata_tags_callback();
5143 }
5144
5145 /**
5146 * Get the settings option array and print one of its values
5147 */
5148 public function advance_robot_meta_tags_callback()
5149 {
5150 Metasync_Settings_Fields::instance()->advance_robot_meta_tags_callback();
5151 }
5152
5153 /**
5154 * Backward compatibility alias for advance_robot_mata_tags_callback
5155 * @deprecated Use advance_robot_meta_tags_callback() instead
5156 */
5157 public function advance_robot_mata_tags_callback()
5158 {
5159 Metasync_Settings_Fields::instance()->advance_robot_mata_tags_callback();
5160 }
5161
5162 /**
5163 * Get the settings option array and print one of its values
5164 */
5165 public function global_twitter_card_type_callback()
5166 {
5167 Metasync_Settings_Fields::instance()->global_twitter_card_type_callback();
5168 }
5169
5170 /**
5171 * Get the settings option array and print one of its values
5172 */
5173 public function global_open_graph_meta_callback()
5174 {
5175 Metasync_Settings_Fields::instance()->global_open_graph_meta_callback();
5176 }
5177
5178 /**
5179 * Get the settings option array and print one of its values
5180 */
5181 public function global_facebook_meta_callback()
5182 {
5183 Metasync_Settings_Fields::instance()->global_facebook_meta_callback();
5184 }
5185
5186 /**
5187 * Get the settings option array and print one of its values
5188 */
5189 public function global_twitter_meta_callback()
5190 {
5191 Metasync_Settings_Fields::instance()->global_twitter_meta_callback();
5192 }
5193
5194 /**
5195 * Get the settings option array and print one of its values
5196 */
5197 public function og_image_dimensions_callback()
5198 {
5199 Metasync_Settings_Fields::instance()->og_image_dimensions_callback();
5200 }
5201
5202 /**
5203 * Get the settings option array and print one of its values
5204 */
5205 public function article_timestamps_callback()
5206 {
5207 Metasync_Settings_Fields::instance()->article_timestamps_callback();
5208 }
5209
5210 /**
5211 * Get the settings option array and print one of its values
5212 */
5213 public function article_author_callback()
5214 {
5215 Metasync_Settings_Fields::instance()->article_author_callback();
5216 }
5217
5218 /**
5219 * Get the settings option array and print one of its values
5220 */
5221 public function article_section_callback()
5222 {
5223 Metasync_Settings_Fields::instance()->article_section_callback();
5224 }
5225
5226 /**
5227 * Get the settings option array and print one of its values
5228 */
5229 public function article_tags_callback()
5230 {
5231 Metasync_Settings_Fields::instance()->article_tags_callback();
5232 }
5233
5234 /**
5235 * Get the settings option array and print one of its values
5236 */
5237 public function twitter_image_alt_callback()
5238 {
5239 Metasync_Settings_Fields::instance()->twitter_image_alt_callback();
5240 }
5241
5242 /**
5243 * Get the settings option array and print one of its values
5244 */
5245 public function facebook_page_url_callback()
5246 {
5247 Metasync_Settings_Fields::instance()->facebook_page_url_callback();
5248 }
5249
5250 /**
5251 * Get the settings option array and print one of its values
5252 */
5253 public function facebook_authorship_callback()
5254 {
5255 Metasync_Settings_Fields::instance()->facebook_authorship_callback();
5256 }
5257
5258 /**
5259 * Get the settings option array and print one of its values
5260 */
5261 public function facebook_admin_callback()
5262 {
5263 Metasync_Settings_Fields::instance()->facebook_admin_callback();
5264 }
5265
5266 /**
5267 * Get the settings option array and print one of its values
5268 */
5269 public function facebook_app_callback()
5270 {
5271 Metasync_Settings_Fields::instance()->facebook_app_callback();
5272 }
5273
5274 /**
5275 * Get the settings option array and print one of its values
5276 */
5277 public function facebook_secret_callback()
5278 {
5279 Metasync_Settings_Fields::instance()->facebook_secret_callback();
5280 }
5281
5282 /**
5283 * Get the settings option array and print one of its values
5284 */
5285 public function twitter_username_callback()
5286 {
5287 Metasync_Settings_Fields::instance()->twitter_username_callback();
5288 }
5289
5290 /**
5291 * Get business types as choices in local business.
5292 *
5293 * @return array
5294 */
5295 public static function get_business_types()
5296 {
5297 return Metasync_Settings_Fields::get_business_types();
5298 }
5299
5300 /**
5301 * Display a dashboard warning when using the plain permalink structure.
5302 * @param $data An array of data passed.
5303 */
5304 public function permalink_structure_dashboard_warning() {
5305 $current_permalink_structure = get_option('permalink_structure');
5306
5307 # Get the plugin name using centralized method
5308 $plugin_name = Metasync::get_effective_plugin_name();
5309
5310 # Check if the current permalink structure is set to "Plain"
5311 if ($current_permalink_structure === '/%post_id%/' || $current_permalink_structure === '') {
5312 printf(
5313 '<div class="notice notice-error is-dismissible">
5314 <p>
5315 <b>Warning from %s</b><br>
5316 To ensure compatibility, please update your permalink structure to any option other than "Plain".
5317 For any inquiries, contact support.
5318 </p>
5319 </div>',
5320 esc_html($plugin_name)
5321 );
5322 }
5323 }
5324
5325 /**
5326 * Show a one-time admin notice when a page builder is detected but the
5327 * "Default Page Builder" setting has never been explicitly saved.
5328 */
5329 public function display_page_builder_notice() {
5330 // Only relevant on the plugin's own settings page — avoid repeating
5331 // this notice across every admin screen.
5332 if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5333 return;
5334 }
5335
5336 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5337
5338 // Setting already saved — nothing to warn about
5339 if (!empty($configured)) {
5340 return;
5341 }
5342
5343 // Check if user dismissed this notice
5344 $dismissed = get_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', true);
5345 if ($dismissed) {
5346 return;
5347 }
5348
5349 // Handle dismiss action
5350 if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5351 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5352 return;
5353 }
5354
5355 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
5356 $detected = Metasync_HTML_To_Builder_Converter::auto_detect_builder();
5357
5358 // No non-Gutenberg builder detected — no need to warn
5359 if ($detected === 'gutenberg') {
5360 return;
5361 }
5362
5363 $builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
5364 $builder_label = $builders[$detected]['label'] ?? $detected;
5365 $plugin_name = Metasync::get_effective_plugin_name();
5366 $settings_url = admin_url('admin.php?page=' . self::$page_slug . '&tab=general#metasync-section-content_rendering');
5367 $dismiss_url = wp_nonce_url(add_query_arg('metasync_dismiss_builder_notice', '1'), 'metasync_dismiss_builder');
5368
5369 printf(
5370 '<div class="notice notice-info is-dismissible" style="border-left-color: #0073aa;">
5371 <p>
5372 <strong>%s — Page Builder Detected</strong><br>
5373 <strong>%s</strong> is active on this site. Content synced by Content Genius currently uses <strong>Gutenberg (WordPress Block Editor)</strong> format by default.
5374 </p>
5375 <p>
5376 If you want synced content to use %s\'s native widget format instead, you can change this in
5377 <a href="%s"><strong>Settings → Content Rendering → Default Page Builder</strong></a>.
5378 </p>
5379 <p><a href="%s" style="text-decoration: none;">Dismiss this notice</a></p>
5380 </div>',
5381 esc_html($plugin_name),
5382 esc_html($builder_label),
5383 esc_html($builder_label),
5384 esc_url($settings_url),
5385 esc_url($dismiss_url)
5386 );
5387 }
5388
5389 /**
5390 * Display update warning banner if plugin update is available
5391 * Checks WordPress update API to see if a newer version is available
5392 *
5393 * @since 1.0.0
5394 */
5395 public function display_update_warning_banner() {
5396 // Get the installed version from database
5397 $installed_version = get_option('metasync_version', '0.0.0');
5398
5399 // Get plugin basename for WordPress update API check
5400 // This is the plugin file path relative to plugins directory (e.g., 'metasync/metasync.php')
5401 $plugin_file = plugin_basename(plugin_dir_path(dirname(__FILE__)) . 'metasync.php');
5402
5403 // Get WordPress update plugins transient (contains available updates)
5404 $update_plugins = get_site_transient('update_plugins');
5405
5406 // Check if update information exists and if our plugin has an update available
5407 if ($update_plugins && isset($update_plugins->response) && isset($update_plugins->response[$plugin_file])) {
5408 $update_info = $update_plugins->response[$plugin_file];
5409 $latest_version = isset($update_info->new_version) ? $update_info->new_version : '';
5410
5411 // Compare installed version with latest available version
5412 if ($latest_version && version_compare($installed_version, $latest_version, '<')) {
5413 // Get the plugin name using centralized method
5414 $plugin_name = Metasync::get_effective_plugin_name();
5415
5416 // Show admin notice with plugin name included in the message
5417 printf(
5418 '<div class="notice notice-error is-dismissible">
5419 <p>
5420 <b>Warning from %s</b><br>
5421 A new version of %s is available. Please update to the latest version to ensure compatibility and access new features.
5422 For any inquiries, contact support.
5423 </p>
5424 </div>',
5425 esc_html($plugin_name),
5426 esc_html($plugin_name)
5427 );
5428 }
5429 }
5430 }
5431
5432
5433 /*
5434 Method to handle Ajax request from "Indexation Control" page
5435 */
5436 public function meta_sync_save_seo_controls() {
5437 Metasync_Settings_Registration::instance()->meta_sync_save_seo_controls();
5438 }
5439
5440 /**
5441 * AJAX handler for saving Performance (CPU Load) settings
5442 *
5443 * Saves the CPU load threshold and returns statistics
5444 */
5445 public function ajax_save_performance_settings() {
5446 # Check nonce for security and return early if invalid
5447 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5448 if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5449 wp_send_json_error(array('message' => 'Invalid nonce'));
5450 return;
5451 }
5452
5453 # Check user capabilities
5454 if (!Metasync::current_user_has_plugin_access()) {
5455 wp_send_json_error(array('message' => 'Insufficient permissions'));
5456 return;
5457 }
5458
5459 # Get current options
5460 $current_options = Metasync::get_option();
5461 if (!is_array($current_options)) {
5462 $current_options = array();
5463 }
5464
5465 # Initialize performance section if it doesn't exist
5466 if (!isset($current_options['performance']) || !is_array($current_options['performance'])) {
5467 $current_options['performance'] = array();
5468 }
5469
5470 # Validate and sanitize CPU load threshold
5471 if (isset($_POST['metasync_options']['performance']['cpu_load_per_core_threshold'])) {
5472 $threshold = floatval($_POST['metasync_options']['performance']['cpu_load_per_core_threshold']);
5473 # Clamp value between 0.5 and 10.0
5474 $threshold = max(0.5, min(10.0, $threshold));
5475 $current_options['performance']['cpu_load_per_core_threshold'] = $threshold;
5476 } else {
5477 # Ensure default value exists
5478 if (!isset($current_options['performance']['cpu_load_per_core_threshold'])) {
5479 $current_options['performance']['cpu_load_per_core_threshold'] = Metasync_CPU_Monitor::DEFAULT_PER_CORE;
5480 }
5481 }
5482
5483 # Save the updated options
5484 $result = Metasync::set_option($current_options);
5485
5486 if ($result) {
5487 # Get current statistics to return
5488 $stats = Metasync_CPU_Monitor::get_stats();
5489 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
5490 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
5491
5492 wp_send_json_success(array(
5493 'message' => 'Performance settings saved successfully!',
5494 'cpu_load_per_core_threshold' => $current_options['performance']['cpu_load_per_core_threshold'],
5495 'effective_threshold' => $effective_threshold,
5496 'cores' => $cores,
5497 'stats' => $stats
5498 ));
5499 } else {
5500 wp_send_json_error(array('message' => 'Failed to save Performance settings'));
5501 }
5502 }
5503
5504 /**
5505 * Schedule transient cleanup cron job
5506 * Runs daily to clean up expired transients and reduce database load
5507 */
5508 public function schedule_transient_cleanup_cron()
5509 {
5510 // Clear any existing scheduled event first
5511 $this->unschedule_transient_cleanup_cron();
5512
5513 // Schedule new cron job daily
5514 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5515 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5516
5517 if (!$scheduled) {
5518 error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5519 }
5520 }
5521 }
5522
5523 /**
5524 * Unschedule transient cleanup cron job
5525 */
5526 public function unschedule_transient_cleanup_cron()
5527 {
5528 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5529 if ($timestamp) {
5530 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5531 error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5532 }
5533 }
5534
5535 /**
5536 * Maybe schedule transient cleanup cron job
5537 * Called on init hook - always schedules for database maintenance
5538 */
5539 public function maybe_schedule_transient_cleanup_cron()
5540 {
5541 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5542 $this->schedule_transient_cleanup_cron();
5543 }
5544 }
5545
5546 /**
5547 * Schedule hidden post manager cron job (runs every 7 days)
5548 * Called on init hook - always schedules for template checking
5549 */
5550 public function maybe_schedule_hidden_post_check()
5551 {
5552 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5553 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5554
5555 if (!$scheduled) {
5556 error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5557 }
5558 }
5559 }
5560
5561 /**
5562 * Schedule OTTO 404 exclusion recheck cron job (runs daily)
5563 * Rechecks URLs auto-excluded due to 404 after 7 days; removes from exclusion if now available
5564 */
5565 public function maybe_schedule_otto_recheck_404_cron()
5566 {
5567 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5568 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5569 if (!$scheduled) {
5570 error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5571 }
5572 }
5573 }
5574
5575 /**
5576 * Execute transient cleanup cron job
5577 * Cleans up expired transients and plugin-specific transients to reduce database load
5578 */
5579 public function execute_transient_cleanup()
5580 {
5581 Metasync_Admin_Ajax::instance()->execute_transient_cleanup();
5582 }
5583
5584 // -------------------------------------------------------------------------
5585 // DB CLEANUP — cron scheduling, execution, render, AJAX
5586 // -------------------------------------------------------------------------
5587
5588 /**
5589 * Returns saved DB cleanup settings with defaults merged in.
5590 */
5591 private function get_db_cleanup_settings() {
5592 $defaults = array(
5593 'enabled' => false,
5594 'clean_post_revisions' => true,
5595 'clean_trashed_posts' => true,
5596 'clean_trashed_comments' => true,
5597 'clean_spam_comments' => true,
5598 'clean_expired_transients' => true,
5599 'clean_orphaned_postmeta' => true,
5600 'last_run_at' => 0,
5601 'last_run_stats' => array(),
5602 );
5603 $saved = get_option('metasync_db_cleanup_settings', array());
5604 return array_merge($defaults, $saved);
5605 }
5606
5607 /**
5608 * Schedules the weekly DB cleanup cron if the feature is enabled and not yet scheduled.
5609 */
5610 public function maybe_schedule_db_cleanup_cron() {
5611 $settings = $this->get_db_cleanup_settings();
5612 if (!empty($settings['enabled'])) {
5613 if (!wp_next_scheduled('metasync_db_cleanup')) {
5614 wp_schedule_event(time(), 'metasync_weekly', 'metasync_db_cleanup');
5615 }
5616 } else {
5617 $this->unschedule_db_cleanup_cron();
5618 }
5619 }
5620
5621 /**
5622 * Removes the DB cleanup cron event.
5623 */
5624 public function unschedule_db_cleanup_cron() {
5625 $timestamp = wp_next_scheduled('metasync_db_cleanup');
5626 if ($timestamp) {
5627 wp_unschedule_event($timestamp, 'metasync_db_cleanup');
5628 }
5629 }
5630
5631 /**
5632 * Cron callback: runs each enabled cleanup task and records stats.
5633 * Never calls wp_cache_flush() — only targeted DB deletes.
5634 */
5635 public function execute_db_cleanup() {
5636 global $wpdb;
5637
5638 $settings = $this->get_db_cleanup_settings();
5639 $stats = array();
5640 $start = microtime(true);
5641
5642 try {
5643 // 1. Post revisions
5644 if (!empty($settings['clean_post_revisions'])) {
5645 $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5646 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5647 );
5648 // Remove postmeta left behind by deleted revisions
5649 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5650 "DELETE pm FROM {$wpdb->postmeta} pm
5651 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5652 WHERE p.ID IS NULL"
5653 );
5654 }
5655
5656 // 2. Trashed posts + their postmeta
5657 if (!empty($settings['clean_trashed_posts'])) {
5658 // Collect IDs first to cleanly remove postmeta
5659 $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5660 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5661 );
5662 if (!empty($trashed_ids)) {
5663 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5664 $wpdb->prepare(
5665 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5666 $trashed_ids
5667 )
5668 );
5669 $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5670 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5671 );
5672 } else {
5673 $stats['trashed_posts'] = 0;
5674 }
5675 }
5676
5677 // 3. Trashed comments
5678 if (!empty($settings['clean_trashed_comments'])) {
5679 $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5680 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5681 );
5682 }
5683
5684 // 4. Spam comments
5685 if (!empty($settings['clean_spam_comments'])) {
5686 $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5687 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5688 );
5689 }
5690
5691 // 5. Expired transients — direct SQL, no cache flush
5692 if (!empty($settings['clean_expired_transients'])) {
5693 // Delete timeout rows that have already expired
5694 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5695 "DELETE FROM {$wpdb->options}
5696 WHERE option_name LIKE '\_transient\_timeout\_%'
5697 AND option_value + 0 < UNIX_TIMESTAMP()"
5698 );
5699 // Delete value rows whose timeout row no longer exists
5700 $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5701 "DELETE o FROM {$wpdb->options} o
5702 LEFT JOIN {$wpdb->options} t
5703 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5704 WHERE o.option_name LIKE '\_transient\_%'
5705 AND o.option_name NOT LIKE '\_transient\_timeout\_%'
5706 AND t.option_id IS NULL"
5707 );
5708 }
5709
5710 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5711 if (!empty($settings['clean_orphaned_postmeta'])) {
5712 $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5713 "DELETE pm FROM {$wpdb->postmeta} pm
5714 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5715 WHERE p.ID IS NULL"
5716 );
5717 }
5718
5719 $stats['execution_ms'] = round((microtime(true) - $start) * 1000, 2);
5720
5721 // Persist last-run timestamp and stats
5722 $settings['last_run_at'] = time();
5723 $settings['last_run_stats'] = $stats;
5724 update_option('metasync_db_cleanup_settings', $settings);
5725
5726 } catch (Exception $e) {
5727 error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5728 }
5729 }
5730
5731 /**
5732 * Renders the Database Cleanup accordion section in Advanced Settings.
5733 */
5734 public function render_db_cleanup_section() {
5735 $settings = $this->get_db_cleanup_settings();
5736 $last_run = !empty($settings['last_run_at']) ? $settings['last_run_at'] : 0;
5737 $stats = !empty($settings['last_run_stats']) ? $settings['last_run_stats'] : array();
5738 $next_run = wp_next_scheduled('metasync_db_cleanup');
5739
5740 $task_labels = array(
5741 'clean_post_revisions' => 'Post revisions',
5742 'clean_trashed_posts' => 'Trashed posts',
5743 'clean_trashed_comments' => 'Trashed comments',
5744 'clean_spam_comments' => 'Spam comments',
5745 'clean_expired_transients' => 'Expired transients',
5746 'clean_orphaned_postmeta' => 'Orphaned post meta',
5747 );
5748 ?>
5749 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
5750 <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
5751 Remove orphaned database rows that accumulate over time and slow down queries. Runs weekly via WP-Cron when enabled.
5752 </p>
5753
5754 <form id="metasync-db-cleanup-settings-form" method="post">
5755 <?php wp_nonce_field('metasync_db_cleanup_settings_nonce', 'db_cleanup_settings_nonce'); ?>
5756
5757 <!-- Enable weekly cleanup -->
5758 <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
5759 <label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
5760 <input type="checkbox"
5761 id="db_cleanup_enabled"
5762 name="enabled"
5763 value="1"
5764 <?php checked(!empty($settings['enabled'])); ?>
5765 style="width: 16px; height: 16px; cursor: pointer;" />
5766 <span style="color: var(--dashboard-text-primary); font-weight: 600; font-size: 14px;">
5767 Enable Weekly AI Cleanup
5768 </span>
5769 </label>
5770 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 26px;">
5771 Schedules an automatic cleanup every 7 days via WP-Cron.
5772 <?php if ($next_run): ?>
5773 Next run: <strong style="color: var(--dashboard-text-primary);"><?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $next_run)); ?></strong>
5774 <?php endif; ?>
5775 </p>
5776 </div>
5777
5778 <!-- Cleanup tasks -->
5779 <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
5780 <h3 style="color: var(--dashboard-text-primary); margin: 0 0 16px 0; font-size: 16px; font-weight: 600;">Cleanup Tasks</h3>
5781 <?php foreach ($task_labels as $key => $label): ?>
5782 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px; cursor: pointer;">
5783 <input type="checkbox"
5784 name="<?php echo esc_attr($key); ?>"
5785 value="1"
5786 <?php checked(!empty($settings[$key])); ?>
5787 style="width: 16px; height: 16px; cursor: pointer;" />
5788 <span style="color: var(--dashboard-text-primary); font-size: 14px;"><?php echo esc_html($label); ?></span>
5789 </label>
5790 <?php endforeach; ?>
5791 </div>
5792
5793 <!-- Last run info -->
5794 <div id="metasync-db-cleanup-last-run"
5795 style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px; <?php echo $last_run ? '' : 'display:none;'; ?>">
5796 <h3 style="color: var(--dashboard-text-primary); margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Last Cleanup</h3>
5797 <p id="metasync-db-cleanup-last-run-time" style="color: var(--dashboard-text-secondary); font-size: 13px; margin: 0 0 10px 0;">
5798 <?php echo $last_run ? esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $last_run)) : ''; ?>
5799 </p>
5800 <div id="metasync-db-cleanup-stats" style="display: flex; flex-wrap: wrap; gap: 10px;">
5801 <?php foreach ($stats as $stat_key => $count): ?>
5802 <?php if ($stat_key === 'execution_ms') continue; ?>
5803 <span style="background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: #22c55e; padding: 4px 10px; border-radius: 4px; font-size: 12px;">
5804 <?php echo esc_html(str_replace('_', ' ', $stat_key)); ?>: <?php echo intval($count); ?>
5805 </span>
5806 <?php endforeach; ?>
5807 <?php if (!empty($stats['execution_ms'])): ?>
5808 <span style="color: var(--dashboard-text-secondary); font-size: 12px; align-self: center;">
5809 in <?php echo esc_html($stats['execution_ms']); ?>ms
5810 </span>
5811 <?php endif; ?>
5812 </div>
5813 </div>
5814
5815 <!-- Buttons -->
5816 <div style="display: flex; gap: 12px; align-items: center; margin-top: 4px;">
5817 <button type="submit"
5818 id="metasync-db-cleanup-save-btn"
5819 class="button button-primary"
5820 style="padding: 10px 20px; font-size: 14px; font-weight: 500;">
5821 <span class="save-text">Save Settings</span>
5822 <span class="save-spinner" style="display:none; margin-left: 8px;">⏳</span>
5823 </button>
5824 <button type="button"
5825 id="metasync-db-cleanup-run-btn"
5826 class="button"
5827 style="padding: 10px 20px; font-size: 14px; font-weight: 500;">
5828 <span class="run-text">Run Cleanup Now</span>
5829 <span class="run-spinner" style="display:none; margin-left: 8px;">⏳</span>
5830 </button>
5831 </div>
5832
5833 <!-- Messages -->
5834 <div id="metasync-db-cleanup-message" style="display:none; margin-top: 16px; padding: 12px; border-radius: 6px;"></div>
5835 </form>
5836 </div>
5837
5838 <script>
5839 jQuery(document).ready(function($) {
5840 var $form = $('#metasync-db-cleanup-settings-form');
5841 var $saveBtn = $('#metasync-db-cleanup-save-btn');
5842 var $runBtn = $('#metasync-db-cleanup-run-btn');
5843 var $message = $('#metasync-db-cleanup-message');
5844
5845 function showMessage(text, type) {
5846 $message.css({
5847 'background' : type === 'success' ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
5848 'border' : '1px solid ' + (type === 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'),
5849 'color' : type === 'success' ? '#22c55e' : '#ef4444',
5850 'padding' : '12px 16px',
5851 'border-radius' : '6px',
5852 'font-size' : '14px',
5853 'line-height': '1.5',
5854 'display' : 'block'
5855 }).html('<strong style="margin-right:8px;">' + (type === 'success' ? '✓' : '✗') + '</strong>' + text).show();
5856
5857 if (type === 'success') {
5858 setTimeout(function() { $message.fadeOut(300); }, 5000);
5859 }
5860 }
5861
5862 // Save settings
5863 $form.on('submit', function(e) {
5864 e.preventDefault();
5865 $saveBtn.prop('disabled', true);
5866 $saveBtn.find('.save-text').text('Saving...');
5867 $saveBtn.find('.save-spinner').show();
5868 $message.hide();
5869
5870 $.ajax({
5871 url : ajaxurl,
5872 type : 'POST',
5873 data : $form.serialize() + '&action=metasync_save_db_cleanup_settings',
5874 success: function(response) {
5875 $saveBtn.prop('disabled', false);
5876 $saveBtn.find('.save-text').text('Save Settings');
5877 $saveBtn.find('.save-spinner').hide();
5878 if (response.success) {
5879 showMessage(response.data.message, 'success');
5880 // Update next-run label if returned
5881 if (response.data.next_run_label) {
5882 $('#db_cleanup_enabled').closest('label')
5883 .next('p').find('strong').text(response.data.next_run_label);
5884 }
5885 } else {
5886 showMessage(response.data.message || 'Error saving settings.', 'error');
5887 }
5888 },
5889 error: function() {
5890 $saveBtn.prop('disabled', false);
5891 $saveBtn.find('.save-text').text('Save Settings');
5892 $saveBtn.find('.save-spinner').hide();
5893 showMessage('An error occurred. Please try again.', 'error');
5894 }
5895 });
5896 });
5897
5898 // Run cleanup now — sends current form state so unsaved changes are respected
5899 $runBtn.on('click', function() {
5900 $runBtn.prop('disabled', true);
5901 $runBtn.find('.run-text').text('Running...');
5902 $runBtn.find('.run-spinner').show();
5903 $message.hide();
5904
5905 $.ajax({
5906 url : ajaxurl,
5907 type : 'POST',
5908 data : $form.serialize() + '&action=metasync_run_db_cleanup',
5909 success: function(response) {
5910 $runBtn.prop('disabled', false);
5911 $runBtn.find('.run-text').text('Run Cleanup Now');
5912 $runBtn.find('.run-spinner').hide();
5913
5914 if (response.success) {
5915 showMessage(response.data.message, 'success');
5916
5917 // Update last-run panel
5918 if (response.data.timestamp_label) {
5919 $('#metasync-db-cleanup-last-run').show();
5920 $('#metasync-db-cleanup-last-run-time').text(response.data.timestamp_label);
5921 }
5922 if (response.data.stats_html) {
5923 $('#metasync-db-cleanup-stats').html(response.data.stats_html);
5924 }
5925 } else {
5926 showMessage(response.data.message || 'Cleanup failed.', 'error');
5927 }
5928 },
5929 error: function() {
5930 $runBtn.prop('disabled', false);
5931 $runBtn.find('.run-text').text('Run Cleanup Now');
5932 $runBtn.find('.run-spinner').hide();
5933 showMessage('An error occurred. Please try again.', 'error');
5934 }
5935 });
5936 });
5937 });
5938 </script>
5939 <?php
5940 }
5941
5942 /**
5943 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5944 */
5945 public function ajax_save_db_cleanup_settings() {
5946 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5947 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5948 !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5949 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5950 return;
5951 }
5952
5953 if (!Metasync::current_user_has_plugin_access()) {
5954 wp_send_json_error(array('message' => 'Insufficient permissions.'));
5955 return;
5956 }
5957
5958 $existing = $this->get_db_cleanup_settings();
5959
5960 $task_keys = array(
5961 'clean_post_revisions',
5962 'clean_trashed_posts',
5963 'clean_trashed_comments',
5964 'clean_spam_comments',
5965 'clean_expired_transients',
5966 'clean_orphaned_postmeta',
5967 );
5968
5969 $new_settings = array(
5970 'enabled' => !empty($_POST['enabled']),
5971 'last_run_at' => $existing['last_run_at'],
5972 'last_run_stats' => $existing['last_run_stats'],
5973 );
5974
5975 foreach ($task_keys as $key) {
5976 $new_settings[$key] = !empty($_POST[$key]);
5977 }
5978
5979 update_option('metasync_db_cleanup_settings', $new_settings);
5980
5981 // Reschedule based on new enabled state
5982 if (!empty($new_settings['enabled'])) {
5983 if (!wp_next_scheduled('metasync_db_cleanup')) {
5984 wp_schedule_event(time(), 'metasync_weekly', 'metasync_db_cleanup');
5985 }
5986 $next_run = wp_next_scheduled('metasync_db_cleanup');
5987 $next_run_label = $next_run
5988 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $next_run)
5989 : '';
5990 wp_send_json_success(array(
5991 'message' => 'Settings saved. Weekly cleanup is enabled.',
5992 'next_run_label' => $next_run_label,
5993 ));
5994 } else {
5995 $this->unschedule_db_cleanup_cron();
5996 wp_send_json_success(array(
5997 'message' => 'Settings saved. Weekly cleanup is disabled.',
5998 ));
5999 }
6000 }
6001
6002 /**
6003 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
6004 * Persists current form state first so unsaved checkbox changes are respected.
6005 */
6006 public function ajax_run_db_cleanup() {
6007 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
6008 if (!isset($_POST['db_cleanup_settings_nonce']) ||
6009 !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
6010 wp_send_json_error(array('message' => 'Invalid security token.'));
6011 return;
6012 }
6013
6014 if (!Metasync::current_user_has_plugin_access()) {
6015 wp_send_json_error(array('message' => 'Insufficient permissions.'));
6016 return;
6017 }
6018
6019 // Save current form state before running so the cleanup uses what the user sees
6020 $existing = $this->get_db_cleanup_settings();
6021 $task_keys = array(
6022 'clean_post_revisions',
6023 'clean_trashed_posts',
6024 'clean_trashed_comments',
6025 'clean_spam_comments',
6026 'clean_expired_transients',
6027 'clean_orphaned_postmeta',
6028 );
6029 $to_save = array(
6030 'enabled' => !empty($_POST['enabled']),
6031 'last_run_at' => $existing['last_run_at'],
6032 'last_run_stats' => $existing['last_run_stats'],
6033 );
6034 foreach ($task_keys as $key) {
6035 $to_save[$key] = !empty($_POST[$key]);
6036 }
6037 update_option('metasync_db_cleanup_settings', $to_save);
6038
6039 // Reschedule cron to match the (possibly updated) enabled state
6040 if (!empty($to_save['enabled'])) {
6041 if (!wp_next_scheduled('metasync_db_cleanup')) {
6042 wp_schedule_event(time(), 'metasync_weekly', 'metasync_db_cleanup');
6043 }
6044 } else {
6045 $this->unschedule_db_cleanup_cron();
6046 }
6047
6048 $this->execute_db_cleanup();
6049
6050 $settings = $this->get_db_cleanup_settings();
6051 $stats = $settings['last_run_stats'];
6052 $timestamp_label = date_i18n(
6053 get_option('date_format') . ' ' . get_option('time_format'),
6054 $settings['last_run_at']
6055 );
6056
6057 // Build stats badges HTML
6058 $stat_labels = array(
6059 'post_revisions' => 'Post revisions',
6060 'trashed_posts' => 'Trashed posts',
6061 'trashed_comments' => 'Trashed comments',
6062 'spam_comments' => 'Spam comments',
6063 'expired_transients' => 'Expired transients',
6064 'orphaned_postmeta' => 'Orphaned post meta',
6065 );
6066
6067 $stats_html = '';
6068 foreach ($stat_labels as $key => $label) {
6069 if (isset($stats[$key])) {
6070 $stats_html .= '<span style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);color:#22c55e;padding:4px 10px;border-radius:4px;font-size:12px;">'
6071 . esc_html($label) . ': ' . intval($stats[$key]) . '</span> ';
6072 }
6073 }
6074 if (!empty($stats['execution_ms'])) {
6075 $stats_html .= '<span style="color:var(--dashboard-text-secondary);font-size:12px;align-self:center;">in '
6076 . esc_html($stats['execution_ms']) . 'ms</span>';
6077 }
6078
6079 wp_send_json_success(array(
6080 'message' => 'Cleanup completed successfully.',
6081 'timestamp_label' => $timestamp_label,
6082 'stats_html' => $stats_html,
6083 ));
6084 }
6085
6086 /**
6087 * AJAX handler to add excluded URL for OTTO
6088 */
6089 public function ajax_otto_add_excluded_url()
6090 {
6091 Metasync_Otto_Cache_Manager::instance()->ajax_otto_add_excluded_url();
6092 }
6093
6094 /**
6095 * AJAX handler to delete excluded URL for OTTO
6096 */
6097 public function ajax_otto_delete_excluded_url()
6098 {
6099 Metasync_Otto_Cache_Manager::instance()->ajax_otto_delete_excluded_url();
6100 }
6101
6102 /**
6103 * AJAX handler to recheck if an excluded URL is now available
6104 * Used for "Recheck" action on Excluded URLs list
6105 */
6106 public function ajax_otto_recheck_excluded_url()
6107 {
6108 Metasync_Otto_Cache_Manager::instance()->ajax_otto_recheck_excluded_url();
6109 }
6110
6111 /**
6112 * AJAX handler to get excluded URLs with pagination
6113 */
6114 public function ajax_otto_get_excluded_urls()
6115 {
6116 Metasync_Otto_Cache_Manager::instance()->ajax_otto_get_excluded_urls();
6117 }
6118
6119 /**
6120 * AJAX handler for submitting issue reports to Sentry
6121 *
6122 * @since 2.5.10
6123 * @return void Sends JSON response and exits
6124 */
6125 public function ajax_submit_issue_report()
6126 {
6127 Metasync_Admin_Ajax::instance()->ajax_submit_issue_report();
6128 }
6129
6130 /**
6131 * Format duration seconds into human-readable label
6132 *
6133 * @since 2.5.11
6134 * @param int $seconds Duration in seconds
6135 * @return string Human-readable duration
6136 */
6137 private function format_duration_label($seconds)
6138 {
6139 $labels = array(
6140 3600 => '1 hour',
6141 14400 => '4 hours',
6142 28800 => '8 hours',
6143 86400 => '24 hours',
6144 172800 => '48 hours',
6145 604800 => '7 days',
6146 1209600 => '14 days',
6147 2592000 => '30 days'
6148 );
6149
6150 if (isset($labels[$seconds])) {
6151 return $labels[$seconds];
6152 }
6153
6154 # Calculate hours if not a standard duration
6155 $hours = round($seconds / 3600);
6156 return $hours . ' hours';
6157 }
6158
6159 /**
6160 * AJAX handler for password recovery
6161 * Sends the whitelabel settings password to the configured recovery email
6162 */
6163 public function ajax_recover_password()
6164 {
6165 Metasync_Admin_Ajax::instance()->ajax_recover_password();
6166 }
6167
6168 /**
6169 * AJAX handler for saving theme preference
6170 * Saves the user's theme choice (light/dark) to WordPress options
6171 */
6172 public function ajax_save_theme()
6173 {
6174 Metasync_Admin_Ajax::instance()->ajax_save_theme();
6175 }
6176
6177 /**
6178 * AJAX handler for tracking 1-click activation in GA4
6179 */
6180 public function ajax_track_one_click_activation()
6181 {
6182 Metasync_Admin_Ajax::instance()->ajax_track_one_click_activation();
6183 }
6184
6185 /**
6186 * Handler for exporting whitelabel settings to a zip file
6187 * Uses admin-post action for file downloads
6188 */
6189 public function handle_export_whitelabel_settings()
6190 {
6191 Metasync_Admin_Ajax::instance()->handle_export_whitelabel_settings();
6192 }
6193
6194
6195 /**
6196 * Add custom column to posts/pages list for HTML-converted pages
6197 *
6198 * @param array $columns Existing columns
6199 * @return array Modified columns
6200 */
6201 public function add_html_converted_column($columns)
6202 {
6203 // Add column after the title column
6204 $new_columns = array();
6205 foreach ($columns as $key => $value) {
6206 $new_columns[$key] = $value;
6207 if ($key === 'title') {
6208 $new_columns['metasync_html_source'] = __('Source', 'metasync');
6209 }
6210 }
6211 return $new_columns;
6212 }
6213
6214 /**
6215 * Render content for the HTML-converted column
6216 *
6217 * @param string $column_name Name of the column
6218 * @param int $post_id Post ID
6219 */
6220 public function render_html_converted_column($column_name, $post_id)
6221 {
6222 if ($column_name !== 'metasync_html_source') {
6223 return;
6224 }
6225
6226 // Check if this is an HTML-converted page
6227 $has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true);
6228 $has_custom_css = get_post_meta($post_id, '_metasync_custom_css', true);
6229
6230 // If page has raw HTML or custom CSS from conversion, show badge
6231 if ($has_raw_html || !empty($has_custom_css)) {
6232 $label = $this->get_html_source_label();
6233 $tooltip = sprintf(
6234 /* translators: %s: HTML source label. */
6235 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6236 $label
6237 );
6238
6239 echo sprintf(
6240 '<span class="metasync-html-badge" title="%s">
6241 <span class="metasync-badge-icon">⚡</span>
6242 <span class="metasync-badge-text">%s</span>
6243 </span>',
6244 esc_attr($tooltip),
6245 esc_html($label)
6246 );
6247 }
6248 }
6249
6250 /**
6251 * Get the label for HTML-converted pages (respects whitelabel settings)
6252 *
6253 * @return string Label to display
6254 */
6255 private function get_html_source_label()
6256 {
6257 $whitelabel_company = Metasync::get_whitelabel_company_name();
6258 if (!empty($whitelabel_company)) {
6259 return $whitelabel_company . ' AI';
6260 }
6261 return Metasync::get_effective_plugin_name() . ' AI';
6262 }
6263
6264 /**
6265 * Add source notice banner in the page editor
6266 *
6267 * @param WP_Post $post Current post object
6268 */
6269 public function add_editor_source_notice($post)
6270 {
6271 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
6272 return;
6273 }
6274
6275 // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6276 // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6277 // page builder, so the banner mislabels them. It still shows for pages genuinely
6278 // produced by the converter.
6279 if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6280 return;
6281 }
6282
6283 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
6284 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
6285
6286 if ($has_raw_html || !empty($has_custom_css)) {
6287 $label = $this->get_html_source_label();
6288 $message = sprintf(
6289 /* translators: %s: HTML source label. */
6290 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6291 '<strong>' . esc_html($label) . '</strong>'
6292 );
6293
6294 echo sprintf(
6295 '<div class="metasync-editor-notice notice notice-info is-dismissible">
6296 <div class="metasync-editor-notice-content">
6297 <span class="metasync-editor-badge">
6298 <span class="metasync-badge-icon">⚡</span>
6299 <span class="metasync-badge-text">%s</span>
6300 </span>
6301 <p class="metasync-editor-message">%s</p>
6302 </div>
6303 </div>',
6304 esc_html($label),
6305 wp_kses($message, array('strong' => array()))
6306 );
6307 }
6308 }
6309
6310 /**
6311 * Add source display in quick edit panel
6312 *
6313 * @param string $column_name Column name
6314 * @param string $post_type Post type
6315 */
6316 public function add_quick_edit_source_display($column_name, $post_type)
6317 {
6318 if ($column_name !== 'metasync_html_source') {
6319 return;
6320 }
6321
6322 if (!in_array($post_type, array('post', 'page'))) {
6323 return;
6324 }
6325
6326 ?>
6327 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6328 <div class="inline-edit-col">
6329 <label>
6330 <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6331 <span class="metasync-quick-edit-badge-container"></span>
6332 </label>
6333 </div>
6334 </fieldset>
6335 <?php
6336 }
6337
6338 /**
6339 * Add dashboard widget for HTML-converted pages
6340 */
6341 public function add_html_pages_dashboard_widget()
6342 {
6343 $label = $this->get_html_source_label();
6344 /* translators: %s: HTML source label. */
6345 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6346
6347 wp_add_dashboard_widget(
6348 'metasync_html_pages_widget',
6349 $widget_title,
6350 array($this, 'render_html_pages_dashboard_widget')
6351 );
6352 }
6353
6354 /**
6355 * Render the dashboard widget content
6356 */
6357 public function render_html_pages_dashboard_widget()
6358 {
6359 Metasync_Admin_Ajax::instance()->render_html_pages_dashboard_widget();
6360 }
6361
6362 /**
6363 * Bot Statistics page callback
6364 * Displays bot detection statistics and logs
6365 */
6366 public function create_admin_bot_statistics_page()
6367 {
6368 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-otto-bot-statistics.php';
6369 }
6370
6371 /**
6372 * AJAX handler for resetting bot statistics
6373 */
6374 public function ajax_reset_bot_stats()
6375 {
6376 check_ajax_referer('metasync_reset_bot_stats', 'nonce');
6377
6378 if (!Metasync::current_user_has_plugin_access()) {
6379 wp_send_json_error(['message' => 'Insufficient permissions.']);
6380 }
6381
6382 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-bot-statistics-database.php';
6383 $db = Metasync_Otto_Bot_Statistics_Database::get_instance();
6384
6385 $result = $db->reset_statistics();
6386
6387 if ($result) {
6388 wp_send_json_success(['message' => 'Statistics reset successfully.']);
6389 } else {
6390 wp_send_json_error(['message' => 'Failed to reset statistics.']);
6391 }
6392 }
6393
6394 /**
6395 * AJAX handler for sending URLs to Google Instant Indexing API
6396 *
6397 * @since 2.6.0
6398 * @return void Sends JSON response and exits
6399 */
6400 public function ajax_send_giapi()
6401 {
6402 check_ajax_referer('metasync_nonce', 'nonce');
6403
6404 if (!Metasync::current_user_has_plugin_access()) {
6405 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6406 }
6407
6408 $post_data = metasync_sanitize_input_array($_POST);
6409 if (!isset($post_data['metasync_giapi_url'])) {
6410 return;
6411 }
6412
6413 // Parse URLs from textarea input (one per line)
6414 $urls = array_values(array_filter(array_map('trim', explode("\n", sanitize_textarea_field(wp_unslash($post_data['metasync_giapi_url']))))));
6415
6416 if (empty($urls)) {
6417 return;
6418 }
6419
6420 if (!isset($post_data['metasync_giapi_action'])) {
6421 return;
6422 }
6423 $action = sanitize_title($post_data['metasync_giapi_action']);
6424
6425 // Map form action values to google_index_direct action values
6426 if ($action === 'remove') {
6427 $action = 'delete';
6428 }
6429
6430 header('Content-type: application/json');
6431
6432 $result_data = [];
6433 foreach ($urls as $i => $url) {
6434 $url = esc_url_raw($url);
6435 if (empty($url)) {
6436 continue;
6437 }
6438
6439 if ($action === 'status') {
6440 $result = google_index_direct()->get_url_status($url);
6441 } else {
6442 $result = google_index_direct()->index_url($url, $action);
6443 }
6444
6445 $key = 'url-' . $i;
6446 if (!empty($result['success'])) {
6447 $result_data[$key] = $result['data'];
6448 } else {
6449 $result_data[$key] = (object) [
6450 'error' => (object) [
6451 'code' => isset($result['error']['code']) ? $result['error']['code'] : 400,
6452 'message' => isset($result['error']['message']) ? $result['error']['message'] : 'Unknown error',
6453 ]
6454 ];
6455 }
6456 }
6457
6458 // For single URL, unwrap from the batch format (matches old behavior)
6459 if (count($result_data) === 1) {
6460 $result_data = reset($result_data);
6461 }
6462
6463 wp_send_json($result_data);
6464 wp_die();
6465 }
6466
6467 /**
6468 * AJAX handler for sending URLs to Bing via IndexNow API
6469 *
6470 * @since 2.6.0
6471 * @return void Sends JSON response and exits
6472 */
6473 public function ajax_send_bing_indexnow()
6474 {
6475 check_ajax_referer('metasync_nonce', 'nonce');
6476
6477 if (!Metasync::current_user_has_plugin_access()) {
6478 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6479 }
6480
6481 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6482 $bing_instant_index = new Metasync_Bing_Instant_Index();
6483 $bing_instant_index->send();
6484 }
6485
6486 /**
6487 * Save instant indexing settings (Google and Bing)
6488 *
6489 * @since 2.6.0
6490 * @return void
6491 */
6492 public function save_instant_indexing_settings()
6493 {
6494 // Check if this is a settings submission
6495 if (!isset($_POST['submit'])) {
6496 return;
6497 }
6498
6499 // This handler runs on admin_init, which also fires inside
6500 // admin-ajax.php before its login gate, so a bare isset() check let
6501 // any request (including logged-out ones) rewrite the auto-submit
6502 // post types. Require the nonce and plugin access before writing.
6503 if (!isset($_POST['metasync_instant_indexing_nonce'])
6504 || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6505 || !Metasync::current_user_has_plugin_access()) {
6506 return;
6507 }
6508
6509 // Save post types for Google Instant Indexing auto-submit
6510 if (isset($_POST['metasync_post_types'])) {
6511 $post_data = metasync_sanitize_input_array($_POST);
6512 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6513
6514 $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6515 $settings['post_types'] = array_values($post_types);
6516 update_option('metasync_options_instant_indexing', $settings);
6517 }
6518
6519 // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6520 }
6521
6522 /**
6523 * Save Bing instant indexing settings from inline form (Indexation Control page)
6524 *
6525 * @since 2.6.0
6526 * @return bool True on success, false on failure
6527 */
6528 private function save_bing_inline_settings_ajax() {
6529 return Metasync_Settings_Registration::instance()->save_bing_inline_settings_ajax();
6530 }
6531
6532 /**
6533 * Add instant indexing action links to post/page rows
6534 *
6535 * @since 2.6.0
6536 * @param array $actions Current actions
6537 * @param WP_Post $post Current post object
6538 * @return array Modified actions
6539 */
6540 public function add_instant_indexing_post_actions($actions, $post)
6541 {
6542 // Add Google Instant Indexing links
6543 $options = get_option('metasync_options_instant_indexing', ['json_key' => '', 'post_types' => []]);
6544 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6545
6546 if (in_array($post->post_type, $post_types) && $post->post_status == 'publish') {
6547 $link = get_permalink($post);
6548
6549 // Get menu slug (support white label)
6550 $general_options = Metasync::get_option('general') ?? [];
6551 $menu_slug = !empty($general_options['white_label_plugin_menu_slug']) ? $general_options['white_label_plugin_menu_slug'] : 'searchatlas';
6552 $page_slug = $menu_slug . '-google-console';
6553
6554 $actions['index-update'] = '<a href="' . admin_url("admin.php?page=" . $page_slug . "&postaction=update&posturl=" . rawurlencode($link)) . '" title="" rel="permalink">Update Google Index</a>';
6555 $actions['index-status'] = '<a href="' . admin_url("admin.php?page=" . $page_slug . "&postaction=status&posturl=" . rawurlencode($link)) . '" title="" rel="permalink">Status Google Index</a>';
6556 }
6557
6558 // Add Bing Instant Indexing links
6559 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6560 $bing_instant_index = new Metasync_Bing_Instant_Index();
6561 $actions = $bing_instant_index->bing_instant_index_post_link($actions, $post);
6562
6563 return $actions;
6564 }
6565
6566 /**
6567 * Auto-submit post to instant indexing services when published
6568 *
6569 * @since 2.6.0
6570 * @param int $post_id Post ID
6571 * @param WP_Post $post Post object
6572 * @param bool $update Whether this is an update
6573 * @return void
6574 */
6575 public function auto_submit_to_instant_indexing($post_id, $post, $update)
6576 {
6577 // Skip revisions, autosaves, and non-published posts
6578 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
6579 return;
6580 }
6581 if ($post->post_status !== 'publish') {
6582 return;
6583 }
6584 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
6585 return;
6586 }
6587
6588 // Auto-submit to Google Instant Indexing
6589 $seo_controls = Metasync::get_option('seo_controls');
6590 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6591 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6592 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6593
6594 if (in_array($post->post_type, $post_types)) {
6595 // save_post is the single owner of auto-submit and also fires
6596 // on REST-created posts, where nothing else may have loaded
6597 // the Google Index helpers yet — load them on demand instead
6598 // of silently skipping.
6599 if (!function_exists('google_index_direct')) {
6600 $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6601 if (file_exists($google_index_path)) {
6602 require_once $google_index_path;
6603 }
6604 }
6605 if (function_exists('google_index_direct')) {
6606 $service_info = google_index_direct()->get_service_account_info();
6607 if (!isset($service_info['error'])) {
6608 google_index_direct()->index_post($post_id, $post->post_type, 'update');
6609 }
6610 }
6611 }
6612 }
6613
6614 // Auto-submit to Bing Instant Indexing
6615 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6616 $bing_instant_index = new Metasync_Bing_Instant_Index();
6617 $bing_instant_index->auto_submit_on_publish($post_id, $post, $update);
6618 }
6619 }
6620