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

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

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