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 / includes / class-metasync-settings-registration.php

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

4,642 lines 251.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * Settings Registration manager.
8 *
9 * Extracted from Metasync_Admin to keep the admin class focused on UI concerns.
10 * Handles register_setting / add_settings_section / add_settings_field calls
11 * (settings_page_init) and the sanitize callback for the main option group.
12 *
13 * @package Metasync
14 * @subpackage Metasync/includes
15 */
16 class Metasync_Settings_Registration
17 {
18 private static $options_generation_at_sanitize = null;
19 private static $allow_recovery_password_write = false;
20
21 public static function authorize_recovery_password_write($allow = true)
22 {
23 self::$allow_recovery_password_write = (bool) $allow;
24 }
25 private static $settings_recovery_lock_owner = '';
26 private static $abort_locked_settings_update = false;
27 private static $defer_settings_recovery_lock_release = false;
28
29 public static function capture_options_generation()
30 {
31 self::$options_generation_at_sanitize = (int) get_option('metasync_pw_reset_generation', 0);
32 }
33 /** @var self|null */
34 private static $instance = null;
35
36 /**
37 * Get singleton instance.
38 *
39 * @return self
40 */
41 public static function instance()
42 {
43 if (self::$instance === null) {
44 self::$instance = new self();
45 }
46 return self::$instance;
47 }
48
49 private function __construct() {
50 add_filter('pre_update_option_metasync_options', array($this, 'protect_recovery_password_update'), 10, 3);
51 add_action('updated_option_metasync_options', array($this, 'release_settings_recovery_lock'), PHP_INT_MAX, 0);
52 add_action('wp_ajax_metasync_save_headless_settings', array($this, 'ajax_save_headless_settings'));
53 }
54
55 private function acquire_settings_recovery_lock()
56 {
57 if (self::$settings_recovery_lock_owner !== '') {
58 self::$abort_locked_settings_update = false;
59 Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
60 return true;
61 }
62
63 $owner = '';
64 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($owner)) {
65 self::$abort_locked_settings_update = true;
66 return false;
67 }
68
69 self::$settings_recovery_lock_owner = $owner;
70 self::$abort_locked_settings_update = false;
71 register_shutdown_function(array($this, 'force_release_settings_recovery_lock'));
72 Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
73 return true;
74 }
75
76 public function release_settings_recovery_lock()
77 {
78 if (self::$defer_settings_recovery_lock_release) {
79 return;
80 }
81 if (self::$settings_recovery_lock_owner === '') {
82 return;
83 }
84
85 Metasync_Admin_Ajax::instance()->release_recovery_lock(self::$settings_recovery_lock_owner);
86 self::$settings_recovery_lock_owner = '';
87 self::$options_generation_at_sanitize = null;
88 self::$allow_recovery_password_write = false;
89 }
90
91 public function force_release_settings_recovery_lock()
92 {
93 self::$defer_settings_recovery_lock_release = false;
94 $this->release_settings_recovery_lock();
95 }
96
97 public function protect_recovery_password_update($new_value, $old_value, $option)
98 {
99 if (self::$allow_recovery_password_write) {
100 return $new_value;
101 }
102 if (self::$options_generation_at_sanitize === null && is_array($new_value) && is_array($old_value)
103 && isset($new_value['whitelabel']) && is_array($new_value['whitelabel'])
104 && array_key_exists('settings_password', $new_value['whitelabel'])) {
105 if (!$this->acquire_settings_recovery_lock()) {
106 self::$abort_locked_settings_update = false;
107 return $old_value;
108 }
109 Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
110 $fresh = Metasync::get_option('whitelabel');
111 if (is_array($fresh) && array_key_exists('settings_password', $fresh)) {
112 $proposed = (string) $new_value['whitelabel']['settings_password'];
113 $fresh_value = (string) $fresh['settings_password'];
114 if (Metasync::is_encrypted_secret($proposed) && !Metasync::is_encrypted_secret($fresh_value)
115 && Metasync::decrypt_secret($proposed) === $fresh_value) {
116 return $new_value;
117 }
118 $new_value['whitelabel']['settings_password'] = $fresh['settings_password'];
119 }
120 }
121 if (self::$abort_locked_settings_update) {
122 self::$abort_locked_settings_update = false;
123 return $old_value;
124 }
125 if (self::$options_generation_at_sanitize === null || !is_array($new_value) || !is_array($old_value)) return $new_value;
126 $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
127 if ($current_generation !== (int) self::$options_generation_at_sanitize
128 && isset($new_value['whitelabel'])
129 && is_array($new_value['whitelabel'])
130 && array_key_exists('settings_password', $new_value['whitelabel'])) {
131 // Returning the same value update_option() already considers old
132 // prevents any database write, even if this request's option cache
133 // predates the completed recovery request.
134 return $old_value;
135 }
136 return $new_value;
137 }
138
139 /**
140 * Helper – replicate Metasync_Admin::get_effective_menu_title() without
141 * needing an admin instance.
142 */
143 private function get_effective_menu_title()
144 {
145 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
146 if ($whitelabel_company_name) {
147 return $whitelabel_company_name . ' SEO';
148 }
149 return Metasync::get_effective_plugin_name();
150 }
151
152 /**
153 * Handle saving the delete-data-on-uninstall toggle from the Reset
154 * Settings section. Stored in a dedicated option (not the main options
155 * blob) so the main settings form's sanitizer can never drop it and
156 * uninstall.php can read it directly.
157 */
158 private function handle_uninstall_data_toggle_save()
159 {
160 if (!isset($_POST['metasync_uninstall_data_toggle_form'])) {
161 return;
162 }
163
164 if (!isset($_POST['uninstall_data_toggle_nonce'])
165 || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['uninstall_data_toggle_nonce'])), 'metasync_uninstall_data_toggle_nonce')
166 || !current_user_can('manage_options')) {
167 return;
168 }
169
170 update_option(
171 'metasync_delete_data_on_uninstall',
172 isset($_POST['metasync_delete_data_on_uninstall']) ? 'yes' : 'no',
173 false
174 );
175 }
176
177 /**
178 * Handle saving plugin access roles from Advanced Settings.
179 * Moved from Metasync_Admin – only called from settings_page_init().
180 */
181 private function handle_plugin_access_roles_save()
182 {
183 if (isset($_POST['save_plugin_access_roles']) && $_POST['save_plugin_access_roles'] === 'yes') {
184 $access_roles_nonce = isset($_POST['plugin_access_roles_nonce']) ? sanitize_text_field(wp_unslash($_POST['plugin_access_roles_nonce'])) : '';
185 if (wp_verify_nonce($access_roles_nonce, 'metasync_plugin_access_roles_nonce')) {
186
187 if (!Metasync::current_user_has_plugin_access()) {
188 wp_safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Insufficient permissions')));
189 exit;
190 }
191
192 self::$defer_settings_recovery_lock_release = true;
193 if (!$this->acquire_settings_recovery_lock()) {
194 self::$defer_settings_recovery_lock_release = false;
195 wp_die('Password recovery is being processed. Please try again later.');
196 }
197 self::$defer_settings_recovery_lock_release = false;
198
199 $metasync_options = Metasync::get_option();
200 if (!is_array($metasync_options)) {
201 $metasync_options = array();
202 }
203 if (!isset($metasync_options['general']) || !is_array($metasync_options['general'])) {
204 $metasync_options['general'] = array();
205 }
206
207 if (isset($_POST['plugin_access_roles']) && is_array($_POST['plugin_access_roles'])) {
208 $metasync_options['general']['plugin_access_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['plugin_access_roles']));
209 } else {
210 $metasync_options['general']['plugin_access_roles'] = array();
211 }
212
213 Metasync::set_option($metasync_options);
214
215 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_saved=1');
216 wp_safe_redirect($redirect_url);
217 exit;
218 } else {
219 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Invalid security token'));
220 wp_safe_redirect($redirect_url);
221 exit;
222 }
223 }
224 }
225
226 /**
227 * Register and add settings – formerly Metasync_Admin::settings_page_init().
228 */
229 public function settings_page_init()
230 {
231 Metasync_Connect_Manager::instance()->handle_session_management_early();
232
233 Metasync_Debug_Manager::instance()->handle_debug_mode_operations();
234 Metasync_Debug_Manager::instance()->handle_error_log_operations();
235 Metasync_Debug_Manager::instance()->handle_clear_all_settings();
236
237 $this->handle_plugin_access_roles_save();
238 $this->handle_uninstall_data_toggle_save();
239
240 $SECTION_FEATURES = Metasync_Admin::SECTION_FEATURES;
241 $SECTION_METASYNC = Metasync_Admin::SECTION_METASYNC;
242 $SECTION_SEARCHENGINE = Metasync_Admin::SECTION_SEARCHENGINE;
243 $SECTION_LOCALSEO = Metasync_Admin::SECTION_LOCALSEO;
244 $SECTION_CODESNIPPETS = Metasync_Admin::SECTION_CODESNIPPETS;
245 $SECTION_SITE_SETTINGS = Metasync_Admin::SECTION_SITE_SETTINGS;
246 $SECTION_COMMON_SETTINGS = Metasync_Admin::SECTION_COMMON_SETTINGS;
247 $SECTION_COMMON_META_SETTINGS = Metasync_Admin::SECTION_COMMON_META_SETTINGS;
248 $SECTION_SOCIAL_META = Metasync_Admin::SECTION_SOCIAL_META;
249 $SECTION_SEO_CONTROLS = Metasync_Admin::SECTION_SEO_CONTROLS;
250 $SECTION_SEO_CONTROLS_ADVANCED = Metasync_Admin::SECTION_SEO_CONTROLS_ADVANCED;
251 $SECTION_SEO_CONTROLS_INSTANT_INDEX = Metasync_Admin::SECTION_SEO_CONTROLS_INSTANT_INDEX;
252 $SECTION_PLUGIN_VISIBILITY = Metasync_Admin::SECTION_PLUGIN_VISIBILITY;
253 $SECTION_LLMS_TXT = Metasync_Admin::SECTION_LLMS_TXT;
254
255 $option_key = Metasync_Admin::option_key;
256 $page_slug = Metasync_Admin::$page_slug;
257
258 # Use whitelabel OTTO name if configured, fallback to 'OTTO'
259 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
260
261 register_setting(
262 Metasync_Admin::option_group,
263 Metasync_Admin::option_key,
264 array(self::instance(), 'sanitize')
265 );
266
267 // Analytics consent is deliberately NOT registered in the shared option
268 // group: options.php writes null for every registered-but-absent field,
269 // so saving any other page in the group would silently flip consent to
270 // 'no'. It is persisted only by the General tab AJAX save path, which
271 // handles both opt-in and opt-out explicitly.
272
273 // LLMs.txt settings are stored in a dedicated option so MCP tools and
274 // the admin UI share one source of truth.
275 register_setting(
276 Metasync_Admin::option_group,
277 'metasync_llms_txt_settings',
278 array(
279 'sanitize_callback' => array(self::instance(), 'sanitize_llms_txt_settings'),
280 'default' => array(),
281 )
282 );
283
284
285 add_settings_section(
286 $SECTION_METASYNC, // ID
287 '', // Title - removed to prevent duplication with dashboard card
288 function(){}, // Callback
289 $page_slug . '_general' // Page
290 );
291
292 add_settings_section(
293 $SECTION_LLMS_TXT,
294 esc_html__('LLMs.txt Settings', 'metasync'),
295 function () {
296 echo '<p>' . esc_html__('Publish a standards-compliant /llms.txt so AI search engines and assistants can discover your most important pages.', 'metasync') . '</p>';
297 },
298 $page_slug . '_general'
299 );
300
301 add_settings_field(
302 'llms_txt_enabled',
303 esc_html__('Enable /llms.txt', 'metasync'),
304 function () {
305 $settings = get_option('metasync_llms_txt_settings', array());
306 $checked = !empty($settings['enabled']);
307 printf(
308 '<input type="checkbox" id="llms_txt_enabled" name="metasync_llms_txt_settings[enabled]" value="1" %s />',
309 $checked ? 'checked' : ''
310 );
311 echo '<span class="description">' . esc_html__('Serve /llms.txt via a virtual route.', 'metasync') . '</span>';
312 },
313 $page_slug . '_general',
314 $SECTION_LLMS_TXT
315 );
316
317 add_settings_field(
318 'llms_txt_post_types',
319 esc_html__('Post types to include', 'metasync'),
320 function () {
321 $settings = get_option('metasync_llms_txt_settings', array());
322 $selected = isset($settings['post_types']) && is_array($settings['post_types'])
323 ? $settings['post_types']
324 : array('page', 'post');
325 $public_types = get_post_types(array('public' => true), 'objects');
326 echo '<div class="llms-txt-dependent">';
327 foreach ($public_types as $type) {
328 if (in_array($type->name, array('attachment'), true)) {
329 continue;
330 }
331 printf(
332 '<label style="display:inline-block;margin-right:12px;"><input type="checkbox" name="metasync_llms_txt_settings[post_types][]" value="%1$s" %2$s /> %3$s</label>',
333 esc_attr($type->name),
334 in_array($type->name, $selected, true) ? 'checked' : '',
335 esc_html($type->labels->singular_name ?: $type->name)
336 );
337 }
338 echo '</div>';
339 },
340 $page_slug . '_general',
341 $SECTION_LLMS_TXT
342 );
343
344 add_settings_field(
345 'llms_txt_max_posts',
346 esc_html__('Max posts to include', 'metasync'),
347 function () {
348 $settings = get_option('metasync_llms_txt_settings', array());
349 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
350 echo '<div class="llms-txt-dependent">';
351 printf(
352 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
353 absint($value)
354 );
355 echo '</div>';
356 },
357 $page_slug . '_general',
358 $SECTION_LLMS_TXT
359 );
360
361 add_settings_field(
362 'llms_txt_excluded_ids',
363 esc_html__('Excluded post/page IDs', 'metasync'),
364 function () {
365 $settings = get_option('metasync_llms_txt_settings', array());
366 $ids = isset($settings['excluded_ids']) && is_array($settings['excluded_ids'])
367 ? implode(', ', array_map('absint', $settings['excluded_ids']))
368 : '';
369 echo '<div class="llms-txt-dependent">';
370 printf(
371 '<input type="text" id="llms_txt_excluded_ids" name="metasync_llms_txt_settings[excluded_ids]" value="%s" size="40" />',
372 esc_attr($ids)
373 );
374 echo '<p class="description">' . esc_html__('Comma-separated list of IDs to exclude from the listing.', 'metasync') . '</p>';
375 echo '</div>';
376 },
377 $page_slug . '_general',
378 $SECTION_LLMS_TXT
379 );
380
381 add_settings_field(
382 'llms_txt_custom_description',
383 esc_html__('Custom description', 'metasync'),
384 function () {
385 $settings = get_option('metasync_llms_txt_settings', array());
386 $value = isset($settings['custom_description']) ? (string) $settings['custom_description'] : '';
387 echo '<div class="llms-txt-dependent">';
388 printf(
389 '<input type="text" id="llms_txt_custom_description" name="metasync_llms_txt_settings[custom_description]" value="%s" size="60" />',
390 esc_attr($value)
391 );
392 echo '<p class="description">' . esc_html__('Overrides the site tagline in the /llms.txt header.', 'metasync') . '</p>';
393 echo '</div>';
394 },
395 $page_slug . '_general',
396 $SECTION_LLMS_TXT
397 );
398
399 add_settings_field(
400 'llms_full_enabled',
401 esc_html__('Enable /llms-full.txt', 'metasync'),
402 function () {
403 $settings = get_option('metasync_llms_txt_settings', array());
404 $checked = !empty($settings['llms_full_enabled']);
405 echo '<div class="llms-txt-dependent">';
406 printf(
407 '<input type="checkbox" id="llms_full_enabled" name="metasync_llms_txt_settings[llms_full_enabled]" value="1" %s />',
408 $checked ? 'checked' : ''
409 );
410 echo '<span class="description">' . esc_html__('Also serve /llms-full.txt with full post bodies converted to markdown.', 'metasync') . '</span>';
411 echo '</div>';
412 },
413 $page_slug . '_general',
414 $SECTION_LLMS_TXT
415 );
416
417 add_settings_field(
418 'llms_txt_max_posts_full',
419 esc_html__('Max posts for full-text', 'metasync'),
420 function () {
421 $settings = get_option('metasync_llms_txt_settings', array());
422 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
423 echo '<div class="llms-full-dependent">';
424 printf(
425 '<input type="number" id="llms_txt_max_posts_full" name="metasync_llms_txt_settings[max_posts_full]" min="1" max="500" value="%d" />',
426 absint($value)
427 );
428 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
429 echo '</div>';
430 },
431 $page_slug . '_general',
432 $SECTION_LLMS_TXT
433 );
434
435
436 add_settings_section(
437 $SECTION_METASYNC, // ID
438 '', // Title - removed to prevent duplication with dashboard card
439 function(){}, // Callback
440 $page_slug . '_branding' // Page
441 );
442
443 add_settings_section(
444 $SECTION_METASYNC, // ID
445 $this->get_effective_menu_title() . ' Caching Settings:', // Title
446 function(){}, // Callback
447 $page_slug . '_otto_cache' // Page
448 );
449
450 add_settings_section(
451 $SECTION_SEO_CONTROLS, // ID
452 '', // Title - removed to prevent duplication with dashboard card
453 function(){}, // Callback
454 $page_slug . '_seo-controls' // Page
455 );
456
457 // Archive Indexation Control Fields
458 add_settings_field(
459 'noindex_empty_archives',
460 'Disallow Empty Archives',
461 function() use ($option_key) {
462 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
463 printf(
464 '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
465 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
466 );
467 echo '<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>';
468 },
469 $page_slug . '_seo-controls',
470 $SECTION_SEO_CONTROLS
471 );
472
473 add_settings_field(
474 'index_date_archives',
475 'Disallow Date Archives',
476 function() use ($option_key) {
477 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
478 printf(
479 '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
480 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
481 );
482 echo '<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>';
483 },
484 $page_slug . '_seo-controls',
485 $SECTION_SEO_CONTROLS
486 );
487
488 add_settings_field(
489 'index_tag_archives',
490 'Disallow Tag Archives',
491 function() use ($option_key) {
492 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
493 printf(
494 '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
495 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
496 );
497 echo '<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>';
498 },
499 $page_slug . '_seo-controls',
500 $SECTION_SEO_CONTROLS
501 );
502
503 add_settings_field(
504 'index_author_archives',
505 'Disallow Author Archives',
506 function() use ($option_key) {
507 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
508 printf(
509 '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
510 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
511 );
512 echo '<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>';
513 },
514 $page_slug . '_seo-controls',
515 $SECTION_SEO_CONTROLS
516 );
517
518 add_settings_field(
519 'index_format_archives',
520 'Disallow Format Archives',
521 function() use ($option_key) {
522 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
523 printf(
524 '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
525 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
526 );
527 echo '<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>';
528 },
529 $page_slug . '_seo-controls',
530 $SECTION_SEO_CONTROLS
531 );
532
533 add_settings_field(
534 'index_category_archives',
535 'Disallow Category Archives',
536 function() use ($option_key) {
537 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
538 printf(
539 '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
540 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
541 );
542 echo '<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>';
543 },
544 $page_slug . '_seo-controls',
545 $SECTION_SEO_CONTROLS
546 );
547
548 add_settings_field(
549 'add_nofollow_to_external_links',
550 'Add No-follow to External Links',
551 function() use ($option_key) {
552 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
553 printf(
554 '<input type="checkbox" id="add_nofollow_to_external_links" name="' . esc_attr($option_key) . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
555 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
556 );
557 printf('<span class="description"><strong>No-follow External Links:</strong> When checked, automatically adds <code>rel="nofollow"</code> attribute to all external links appearing in posts, pages, and other post types when rendered by %s. This tells search engines not to follow these links, which can help preserve your site\'s SEO value and prevent passing link juice to external sites.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
558 },
559 $page_slug . '_seo-controls',
560 $SECTION_SEO_CONTROLS
561 );
562
563 // Advanced SEO Controls Section
564 add_settings_section(
565 $SECTION_SEO_CONTROLS_ADVANCED, // ID
566 '', // Title - will be handled by custom rendering
567 function(){
568 echo '</div>'; // Close main Indexation Control card
569 echo '<div class="dashboard-card" style="margin-top: 20px;">';
570 echo '<h2>Advanced Settings</h2>';
571 echo '<p style="color: var(--dashboard-text-secondary); margin-bottom: 20px;">Configure how ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' interacts with other SEO plugins.</p>';
572 },
573 $page_slug . '_seo-controls' // Page
574 );
575
576 add_settings_field(
577 'override_robots_tags',
578 'Override Other Plugins\' Robots Tags',
579 function() use ($option_key) {
580 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
581 printf(
582 '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
583 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
584 );
585 echo '<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>';
586 },
587 $page_slug . '_seo-controls',
588 $SECTION_SEO_CONTROLS_ADVANCED
589 );
590
591 // Google Instant Indexing Section
592 add_settings_section(
593 $SECTION_SEO_CONTROLS_INSTANT_INDEX, // ID
594 '', // Title - will be handled by custom rendering
595 function(){
596 echo '</div>'; // Close Advanced Settings card
597 echo '<div class="dashboard-card" style="margin-top: 20px;">';
598 echo '<h2>Google Instant Indexing</h2>';
599 echo '<p style="color: var(--dashboard-text-secondary); margin-bottom: 20px;">Configure Google Indexing API for faster URL indexing. Enable this feature to access the Instant Indexing page.</p>';
600 },
601 $page_slug . '_seo-controls' // Page
602 );
603
604 add_settings_field(
605 'enable_googleinstantindex',
606 'Enable Google Instant Indexing',
607 function() use ($option_key) {
608 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
609 printf(
610 '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
611 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
612 );
613 Metasync::render_tooltip_icon('enable_googleinstantindex', 'Turns on direct URL submission to Google. It only works after you add a service-account key below and verify your site in Search Console — set those up first.');
614 echo '<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>';
615 },
616 $page_slug . '_seo-controls',
617 $SECTION_SEO_CONTROLS_INSTANT_INDEX
618 );
619
620 add_settings_field(
621 'google_index_api_config',
622 'Google Index API Configuration',
623 array(Metasync_Settings_Fields::instance(), 'render_google_index_section'),
624 $page_slug . '_seo-controls',
625 $SECTION_SEO_CONTROLS_INSTANT_INDEX
626 );
627
628 // Bing Instant Indexing Section
629 add_settings_section(
630 'seo_controls_bing_instant_index', // ID
631 '', // Title - will be handled by custom rendering
632 function(){
633 echo '</div>'; // Close Google Instant Indexing card
634 echo '<div class="dashboard-card" style="margin-top: 20px;">';
635 echo '<h2>Bing Instant Indexing (IndexNow)</h2>';
636 echo '<p style="color: var(--dashboard-text-secondary); margin-bottom: 20px;">Configure IndexNow API for instant URL submission to Bing, Yandex, and other search engines that support the IndexNow protocol.</p>';
637 },
638 $page_slug . '_seo-controls' // Page
639 );
640
641 add_settings_field(
642 'enable_binginstantindex',
643 'Enable Bing Instant Indexing',
644 function() use ($option_key) {
645 // Auto-enable if API key is configured
646 $bing_settings = get_option('metasync_options_bing_instant_indexing', []);
647 $has_api_key = !empty($bing_settings['api_key']);
648
649 $enable_binginstantindex = Metasync::get_option('seo_controls')['enable_binginstantindex'] ?? 'false';
650
651 // Auto-check if API key exists
652 if ($has_api_key && $enable_binginstantindex !== 'true') {
653 $enable_binginstantindex = 'true';
654 }
655
656 printf(
657 '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
658 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
659 $has_api_key ? 'data-auto-enabled="true"' : ''
660 );
661 echo '<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>';
662 },
663 $page_slug . '_seo-controls',
664 'seo_controls_bing_instant_index'
665 );
666
667 // Bing Index API configuration
668 add_settings_field(
669 'bing_index_api_config',
670 'IndexNow API Configuration',
671 array(Metasync_Settings_Fields::instance(), 'render_bing_index_section'),
672 $page_slug . '_seo-controls',
673 'seo_controls_bing_instant_index'
674 );
675
676 add_settings_field(
677 'searchatlas_api_key',
678 $this->get_effective_menu_title() . ' API Key',
679 array(Metasync_Settings_Fields::instance(), 'searchatlas_api_key_callback'),
680 $page_slug . '_general',
681 $SECTION_METASYNC
682 );
683
684 add_settings_field(
685 'apikey', // ID
686 'Plugin Auth Token', // Title
687 array(Metasync_Settings_Fields::instance(), 'metasync_settings_genkey_callback'), // Callback
688 $page_slug . '_general', // Page
689 $SECTION_METASYNC // Section
690 );
691
692 /**
693 * SERVER SIDE RENDERING SETTING OPTIONS
694 * @see SSR functionality
695 * Note: OTTO SSR is always enabled by default when connected
696 */
697
698 # field to accept the otto pixel
699 # Read-only: the UUID is populated only by the trusted Search
700 # Atlas / heartbeat flows. The input stays in the DOM (readonly, like
701 # the apikey/linkgraph_token fields) so metasync-admin.js can keep
702 # targeting it by name to reflect connect/disconnect state, and the
703 # server rejects any submitted value regardless.
704 add_settings_field(
705 'otto_pixel_uuid',
706 $whitelabel_otto_name . ' Pixel UUID',
707 function() use ($option_key, $whitelabel_otto_name) {
708 $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
709 printf(
710 '<input type="text" id="otto_pixel_uuid" size="40" value="%s" name="%s" readonly="readonly" />',
711 esc_attr($value),
712 esc_attr($option_key . '[general][otto_pixel_uuid]')
713 );
714 printf(
715 '<p class="description">%s</p>',
716 esc_html(sprintf(
717 'Read-only. This UUID is assigned automatically by %s when you connect your account.',
718 $whitelabel_otto_name
719 ))
720 );
721 },
722 $page_slug . '_general',
723 $SECTION_METASYNC
724 );
725
726
727 # check box to toggle on and off disabling OTTO for logged in users
728 add_settings_field(
729 'otto_disable_on_loggedin',
730 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
731 function() use ($whitelabel_otto_name, $option_key) {
732 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
733 printf(
734 '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
735 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
736 );
737 echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
738 },
739 $page_slug . '_general',
740 $SECTION_METASYNC
741 );
742
743 # check box to disable OTTO frontend toolbar
744 add_settings_field(
745 'otto_disable_preview_button',
746 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
747 function() use ($whitelabel_otto_name, $option_key) {
748 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
749 printf(
750 '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
751 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
752 );
753 printf('<span class="description"> Hide the entire frontend toolbar (status indicator, preview button, and debug button) on the frontend. %s functionality will still work, but the toolbar controls will be hidden.</span>', esc_html($whitelabel_otto_name));
754 },
755 $page_slug . '_general',
756 $SECTION_METASYNC
757 );
758
759 # Global SEO title & description priority.
760 #
761 # Unchecked (the default) is the behaviour every site already runs: a
762 # value the customer typed in the sidebar outranks OTTO's suggestion.
763 # Checked hands precedence to OTTO, which is what makes a successful
764 # OTTO deployment visible on a page that also holds a custom value.
765 #
766 # This only re-orders what RENDERS. Nothing is written to or deleted
767 # from the stored post meta, so unchecking it restores the custom value
768 # untouched. Applies to the SEO title, meta description and focus
769 # keyword.
770 add_settings_field(
771 'seo_priority',
772 'SEO Meta Priority',
773 function() use ($whitelabel_otto_name, $option_key) {
774 $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
775 $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
776 $name = $option_key . '[general][seo_priority]';
777
778 echo '<div class="metasync-seo-priority-control">';
779 printf(
780 '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
781 esc_attr($name),
782 esc_attr($checked),
783 esc_html($whitelabel_otto_name)
784 );
785 printf(
786 '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom SEO title, description and focus keyword — the values you edit on the post/page edit screen — appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
787 esc_html($whitelabel_otto_name)
788 );
789 echo '</div>';
790 },
791 $page_slug . '_general',
792 $SECTION_METASYNC
793 );
794
795 # ───────────────────────────────────────────────────────────────
796 # Plugin-compatibility toggles live on the dedicated Compatibility
797 # page (admin.php?page=<slug>-compatibility), not the General tab.
798 # Each toggle is registered only while its related plugin is active,
799 # so the page never offers a control for a plugin that is not
800 # present. The stored value survives while the field is hidden —
801 # the save loop is isset-guarded and keeps absent keys.
802 # ───────────────────────────────────────────────────────────────
803 $compat_page_slug = $page_slug . '_compatibility';
804
805 add_settings_section(
806 'metasync_compat_toggles',
807 '',
808 function() {},
809 $compat_page_slug
810 );
811
812 # WP Rocket compatibility toggle.
813 # Only registered when WP Rocket is actually present.
814 # WP Rocket defines WP_ROCKET_VERSION; a WP_Rocket class only exists in
815 # some builds, so check both.
816 if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
817 add_settings_field(
818 'otto_wp_rocket_compat',
819 'WP Rocket Compatibility',
820 function() use ($whitelabel_otto_name, $option_key) {
821 $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
822
823 echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
824 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
825 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
826 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
827 echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
828 echo '</select>';
829
830 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
831
832 echo '<p class="description" style="margin-top: 8px;">';
833 echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>';
834 echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
835 echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
836 echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.';
837 echo '</p>';
838 },
839 $compat_page_slug,
840 'metasync_compat_toggles'
841 );
842 }
843
844 # SiteGround Optimizer compatibility toggle.
845 # Only registered when SiteGround Optimizer is actually present.
846 if (defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser')) {
847 add_settings_field(
848 'otto_sg_optimizer_compat',
849 'SiteGround Optimizer Compatibility',
850 function() use ($option_key) {
851 $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
852
853 echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
854 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
855 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
856 echo '</select>';
857
858 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
859
860 echo '<p class="description" style="margin-top: 8px;">';
861 echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
862 echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.';
863 echo '</p>';
864 },
865 $compat_page_slug,
866 'metasync_compat_toggles'
867 );
868 }
869
870 # BookingPress front-end session compatibility toggle.
871 # Only registered when BookingPress is actually present on the site:
872 # the shim no-ops without it, so the field would be noise for every
873 # other install.
874 if (class_exists('bookingpress_spam_protection')) {
875 add_settings_field(
876 'bookingpress_session_compat',
877 'BookingPress Session Compatibility',
878 function() use ($option_key) {
879 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
880
881 echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
882 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
883 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
884 echo '</select>';
885
886 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ BookingPress detected - its front-end session start is removed so pages can be host-cached</p>';
887
888 echo '<p class="description" style="margin-top: 8px;">';
889 echo 'BookingPress starts a PHP session on every public page, which makes hosts (e.g. SiteGround) skip their page cache for the whole site. Its booking forms run through admin-ajax and manage their own sessions there, so the page session is not needed. Visitors with an active BookingPress cart keep the session until their cart cookie clears.<br>';
890 echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
891 echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
892 echo '</p>';
893 },
894 $compat_page_slug,
895 'metasync_compat_toggles'
896 );
897 }
898
899 /**
900 * BOT DETECTION SETTINGS FOR OTTO
901 * @since 1.0.0
902 */
903
904 # Checkbox to disable OTTO for bot traffic
905 add_settings_field(
906 'otto_disable_for_bots',
907 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
908 function() use ($whitelabel_otto_name, $option_key) {
909 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
910 printf(
911 '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
912 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
913 );
914 printf('<span class="description"> Skip %s processing for detected bots (search engines, crawlers, SEO tools) to reduce unnecessary API calls. View bot statistics in the Bot Statistics page under the SEO dropdown.</span>', esc_html($whitelabel_otto_name));
915 },
916 $page_slug . '_general',
917 $SECTION_METASYNC
918 );
919
920 # Textarea for bot whitelist
921 add_settings_field(
922 'otto_bot_whitelist',
923 'Bot Whitelist',
924 function() use ($whitelabel_otto_name, $option_key) {
925 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
926 printf(
927 '<textarea id="otto_bot_whitelist" name="' . esc_attr($option_key) . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
928 esc_textarea($otto_bot_whitelist)
929 );
930 echo '<p class="description">';
931 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be processed by ' . esc_html($whitelabel_otto_name) . ', even when "Disable for Bots" is enabled.<br>';
932 echo '<strong>Example:</strong><br>';
933 echo 'Googlebot<br>';
934 echo 'Bingbot';
935 echo '</p>';
936 },
937 $page_slug . '_general',
938 $SECTION_METASYNC
939 );
940
941 # Textarea for bot blacklist
942 add_settings_field(
943 'otto_bot_blacklist',
944 'Bot Blacklist',
945 function() use ($whitelabel_otto_name, $option_key) {
946 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
947 printf(
948 '<textarea id="otto_bot_blacklist" name="' . esc_attr($option_key) . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
949 esc_textarea($otto_bot_blacklist)
950 );
951 echo '<p class="description">';
952 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be blocked from ' . esc_html($whitelabel_otto_name) . ' processing, regardless of other settings.<br>';
953 echo '<strong>Example:</strong><br>';
954 echo 'BadBot<br>';
955 echo 'MaliciousCrawler';
956 echo '</p>';
957 },
958 $page_slug . '_general',
959 $SECTION_METASYNC
960 );
961
962
963 # Link button to Bot Statistics page
964 add_settings_field(
965 'otto_bot_statistics_link',
966 'Bot Detection Statistics',
967 function() use ($page_slug) {
968 printf(
969 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-chart-bar" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> View Bot Statistics</a>',
970 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
971 );
972 echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
973 },
974 $page_slug . '_general',
975 $SECTION_METASYNC
976 );
977
978 # END BOT DETECTION SETTINGS
979
980 add_settings_field(
981 'periodic_clear_ottopage_cache',
982 'Clear Page Cache',
983 function() use ($option_key) {
984 $periodic_clear_ottopage_cache = Metasync::get_option('general')['periodic_clear_ottopage_cache'] ?? 'default';
985 echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
986 echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
987 echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
988 echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
989 echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
990 echo '</select>';
991
992 # get last cleared timestamp
993 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
994
995 if($timestamp > 0){
996 echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
997 }
998
999 },
1000 $page_slug . '_otto_cache',
1001 $SECTION_METASYNC
1002 );
1003
1004 add_settings_field(
1005 'periodic_clear_ottopost_cache',
1006 'Clear Post Cache',
1007 function() use ($option_key) {
1008 $periodic_clear_ottopost_cache = Metasync::get_option('general')['periodic_clear_ottopost_cache'] ?? 'default';
1009 echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
1010 echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
1011 echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
1012 echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
1013 echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
1014 echo '</select>';
1015
1016 # get last cleared timestamp
1017 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
1018
1019 if($timestamp > 0){
1020 echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
1021 }
1022 },
1023 $page_slug . '_otto_cache',
1024 $SECTION_METASYNC
1025 );
1026
1027 add_settings_field(
1028 'periodic_clear_otto_cache',
1029 'Clear all cache',
1030 function() use ($option_key) {
1031 $periodic_clear_otto_cache = Metasync::get_option('general')['periodic_clear_otto_cache'] ?? 'default';
1032 echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
1033 echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
1034 echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
1035 echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
1036 echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
1037 echo '</select>';
1038
1039 # get last cleared timestamp
1040 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
1041
1042 if($timestamp > 0){
1043 echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
1044 }
1045
1046 },
1047 $page_slug . '_otto_cache',
1048 $SECTION_METASYNC
1049 );
1050
1051 # END SERVER SIDE RENDERING SETTINGS
1052
1053 // Meta Box Visibility Controls
1054 add_settings_field(
1055 'disable_common_robots_metabox',
1056 'Disable Common Robots Meta Box',
1057 function() use ($option_key) {
1058 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
1059 printf(
1060 '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
1061 $disabled ? 'checked' : ''
1062 );
1063 printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1064 },
1065 $page_slug . '_general',
1066 $SECTION_METASYNC
1067 );
1068
1069 add_settings_field(
1070 'disable_advance_robots_metabox',
1071 'Disable Advance Robots Meta Box',
1072 function() use ($option_key) {
1073 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
1074 printf(
1075 '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
1076 $disabled ? 'checked' : ''
1077 );
1078 printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1079 },
1080 $page_slug . '_general',
1081 $SECTION_METASYNC
1082 );
1083
1084 add_settings_field(
1085 'disable_redirection_metabox',
1086 'Disable Redirection Meta Box',
1087 function() use ($option_key) {
1088 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
1089 printf(
1090 '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
1091 $disabled ? 'checked' : ''
1092 );
1093 echo '<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>';
1094 },
1095 $page_slug . '_general',
1096 $SECTION_METASYNC
1097 );
1098
1099 add_settings_field(
1100 'disable_canonical_metabox',
1101 'Disable Canonical Meta Box',
1102 function() use ($option_key) {
1103 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
1104 printf(
1105 '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
1106 $disabled ? 'checked' : ''
1107 );
1108 printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1109 },
1110 $page_slug . '_general',
1111 $SECTION_METASYNC
1112 );
1113
1114 add_settings_field(
1115 'disable_social_opengraph_metabox',
1116 'Disable Social Media & Open Graph Meta Box',
1117 function() use ($option_key) {
1118 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
1119 printf(
1120 '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1121 $disabled ? 'checked' : ''
1122 );
1123 printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1124 },
1125 $page_slug . '_general',
1126 $SECTION_METASYNC
1127 );
1128
1129 add_settings_field(
1130 'disable_schema_markup_metabox',
1131 'Disable Schema Markup Meta Box',
1132 function() use ($option_key) {
1133 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
1134 printf(
1135 '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
1136 $disabled ? 'checked' : ''
1137 );
1138 printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1139 },
1140 $page_slug . '_general',
1141 $SECTION_METASYNC
1142 );
1143
1144 add_settings_field(
1145 'disable_language_alternates_metabox',
1146 'Disable Language Alternates Meta Box',
1147 function() use ($option_key) {
1148 $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1149 printf(
1150 '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1151 $disabled ? 'checked' : ''
1152 );
1153 printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')), '&lt;', '&gt;');
1154 },
1155 $page_slug . '_general',
1156 $SECTION_METASYNC
1157 );
1158
1159 add_settings_field(
1160 'disable_seo_metabox',
1161 'Disable SEO Meta Box',
1162 function() use ($option_key) {
1163 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
1164 printf(
1165 '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
1166 $disabled ? 'checked' : ''
1167 );
1168 echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
1169 },
1170 $page_slug . '_general',
1171 $SECTION_METASYNC
1172 );
1173
1174 add_settings_field(
1175 'open_external_links',
1176 'Open External Links in New Tab/Window',
1177 function() use ($option_key) {
1178 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
1179 printf(
1180 '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
1181 $enabled ? 'checked' : ''
1182 );
1183 printf('<span class="description"> Automatically add <code>target="_blank"</code> attribute to external links appearing in your posts, pages, and other post types when rendered by %s. The attribute is applied when the url is displayed.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
1184 },
1185 $page_slug . '_general',
1186 $SECTION_METASYNC
1187 );
1188
1189 add_settings_field(
1190 'permalink_structure',
1191 'The Permalink setting of website',
1192 function() {
1193
1194 $current_permalink_structure = get_option('permalink_structure');
1195 $current_rewrite_rules = get_option('rewrite_rules');
1196 // Check if the current permalink structure is set to "Plain"
1197 if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
1198 echo '<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . esc_url(get_admin_url() . 'options-permalink.php') . '">Check Setting</a> </span>';
1199 } else {
1200 echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
1201 }
1202 },
1203 $page_slug . '_general',
1204 $SECTION_METASYNC
1205 );
1206
1207 # Add Hide Dashboard Framework setting
1208 add_settings_field(
1209 'hide_dashboard_framework',
1210 'Hide Dashboard',
1211 function() use ($option_key) {
1212 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1213 printf(
1214 '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
1215 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1216 );
1217 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1218 },
1219 $page_slug . '_general',
1220 $SECTION_METASYNC
1221 );
1222
1223 // REMOVED (CVE-2025-14386): disable_single_signup_login setting was removed
1224
1225 # Adding the "Show Admin Bar Status" setting
1226 add_settings_field(
1227 'show_admin_bar_status',
1228 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1229 function() use ($option_key) {
1230 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1231 printf(
1232 '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
1233 $show_admin_bar ? 'checked' : ''
1234 );
1235 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1236 },
1237 $page_slug . '_general',
1238 $SECTION_METASYNC
1239 );
1240
1241 # Add User Role Sync Setting
1242 add_settings_field(
1243 'content_genius_sync_roles',
1244 'Content Genius User Roles to Sync',
1245 function() use ($option_key) {
1246 $selected_roles = Metasync::get_option('general')['content_genius_sync_roles'] ?? array();
1247
1248 # If it's a string (single role from old version), convert to array
1249 if (!is_array($selected_roles)) {
1250 $selected_roles = array($selected_roles);
1251 }
1252
1253 # Get all WordPress user roles safely
1254 if (!function_exists('wp_roles')) {
1255 require_once(ABSPATH . 'wp-admin/includes/user.php');
1256 }
1257 global $wp_roles;
1258
1259 # Safety check for $wp_roles
1260 if (!isset($wp_roles) || !is_object($wp_roles)) {
1261 $wp_roles = new WP_Roles();
1262 }
1263
1264 $all_roles = $wp_roles->roles;
1265
1266 # Start container
1267 ?>
1268 <div class="metasync-role-selector-container">
1269
1270 <!-- All Roles Option -->
1271 <label class="metasync-role-option-all">
1272 <input type="checkbox"
1273 name="<?php echo esc_attr($option_key); ?>[general][content_genius_sync_roles][]"
1274 value="all"
1275 <?php checked(empty($selected_roles) || in_array('all', $selected_roles), true); ?> />
1276 <strong>All Roles</strong>
1277 </label>
1278
1279 <!-- Divider -->
1280 <hr class="metasync-role-divider">
1281
1282 <?php
1283 # Display each role as a checkbox
1284 if (!empty($all_roles) && is_array($all_roles)) {
1285 foreach ($all_roles as $role_key => $role_details) {
1286 # Safety check for role data
1287 if (!isset($role_details['name'])) {
1288 continue;
1289 }
1290
1291 # Only list roles that can author content (have the edit_posts capability).
1292 # Keep any role already in the saved selection even if it lacks the cap,
1293 # so existing configurations are preserved and not silently dropped on re-save.
1294 $caps = isset($role_details['capabilities']) ? $role_details['capabilities'] : array();
1295 if (empty($caps['edit_posts']) && !in_array($role_key, $selected_roles)) {
1296 continue;
1297 }
1298
1299 $is_checked = in_array($role_key, $selected_roles);
1300 $role_name = translate_user_role($role_details['name']);
1301 ?>
1302 <label class="metasync-role-option">
1303 <input type="checkbox"
1304 name="<?php echo esc_attr($option_key); ?>[general][content_genius_sync_roles][]"
1305 value="<?php echo esc_attr($role_key); ?>"
1306 <?php checked($is_checked, true); ?> />
1307 <span class="metasync-role-label"><?php echo esc_html($role_name); ?></span>
1308 </label>
1309 <?php
1310 }
1311 }
1312 ?>
1313
1314 </div>
1315
1316 <!-- Description -->
1317 <p class="description" style="margin-top: 10px;">
1318 <strong>How it works:</strong>
1319 Select which user roles should be synced with Content Genius.
1320 If <strong>"All Roles"</strong> is selected or none are selected, all users will be synced.
1321 Otherwise, only users with the selected roles will be included in the sync.
1322 </p>
1323 <?php
1324 },
1325 $page_slug . '_general',
1326 $SECTION_METASYNC
1327 );
1328
1329 # Default Page Builder setting
1330 add_settings_field(
1331 'default_page_builder',
1332 'Default Page Builder',
1333 function() use ($option_key) {
1334 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
1335 $current = Metasync::get_option('general')['default_page_builder'] ?? '';
1336 $builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
1337 $auto_detected = Metasync_HTML_To_Builder_Converter::auto_detect_builder();
1338
1339 echo '<div class="metasync-page-builder-setting">';
1340 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1341
1342 foreach ($builders as $key => $builder) {
1343 $is_detected = $builder['detected'];
1344 $label = $builder['label'];
1345 if ($is_detected && $key !== 'gutenberg') {
1346 $label .= ' — Detected';
1347 } elseif (!$is_detected) {
1348 $label .= ' — Not Detected';
1349 }
1350 if ($key === 'gutenberg' && empty($current)) {
1351 $label .= ' — Default';
1352 }
1353 printf(
1354 '<option value="%s" %s%s>%s</option>',
1355 esc_attr($key),
1356 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1357 $is_detected ? '' : ' disabled',
1358 esc_html($label)
1359 );
1360 }
1361
1362 echo '</select>';
1363 echo '<p class="description" style="margin-top: 6px;">Only theme builders detected on your site can be selected. Undetected builders are shown as disabled.</p>';
1364
1365 # Show current auto-detection result
1366 if (!empty($builders[$auto_detected]) && $auto_detected !== 'gutenberg') {
1367 printf(
1368 '<p class="description" style="margin-top: 8px; color: #0073aa;">🔍 Auto-detected: <strong>%s</strong> is active on this site.</p>',
1369 esc_html($builders[$auto_detected]['label'])
1370 );
1371 }
1372
1373 # Show description for the selected builder
1374 echo '<div id="metasync-builder-descriptions" style="margin-top: 10px;">';
1375 foreach ($builders as $key => $builder) {
1376 $is_visible = (empty($current) ? 'gutenberg' : $current) === $key;
1377 printf(
1378 '<p class="description metasync-builder-desc" data-builder="%s" style="%s">%s</p>',
1379 esc_attr($key),
1380 $is_visible ? '' : 'display:none;',
1381 esc_html($builder['description'])
1382 );
1383 }
1384 echo '</div>';
1385
1386 # Inline JS to toggle description on select change
1387 ?>
1388 <script>
1389 document.getElementById('default_page_builder')?.addEventListener('change', function() {
1390 document.querySelectorAll('.metasync-builder-desc').forEach(function(el) {
1391 el.style.display = el.dataset.builder === this.value ? '' : 'none';
1392 }.bind(this));
1393 });
1394 </script>
1395 <?php
1396 },
1397 $page_slug . '_general',
1398 $SECTION_METASYNC
1399 );
1400
1401 add_settings_field(
1402 'import_external_data',
1403 'Import settings and data from SEO Plugins',
1404 function() {
1405 printf(
1406 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-download" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Import from SEO Plugins</a>',
1407 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1408 );
1409 echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1410 },
1411 $page_slug . '_general',
1412 $SECTION_METASYNC
1413 );
1414
1415
1416
1417 /*
1418 add field to save color for elementor and assign custom color to the heading
1419 */
1420 if(is_admin()){
1421 add_settings_field(
1422 'white_label_plugin_name',
1423 'Plugin Name' . Metasync::get_tooltip_icon_html('wl_plugin_name_tooltip', 'The name shown for this plugin throughout the WordPress admin — menus, page titles, and system messages. Replace it with your own brand name (max 18 characters).'),
1424 function() use ($option_key) {
1425 $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1426 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1427 echo '<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>';
1428 },
1429 $page_slug . '_branding',
1430 $SECTION_METASYNC
1431 );
1432
1433 add_settings_field(
1434 'whitelabel_otto_name',
1435 'OTTO Name' . Metasync::get_tooltip_icon_html('wl_otto_name_tooltip', 'The name used wherever OTTO is mentioned in the plugin (e.g. "Enable [Name] Server Side Rendering"). Rename it to match your own product branding.'),
1436 function() use ($option_key) {
1437 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1438 echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1439 $example_name = !empty($value) ? $value : 'OTTO';
1440 printf('<p class="description">This name will be used for OTTO feature references (e.g., "Enable %s Server Side Rendering").</p>', esc_html($example_name));
1441 },
1442 $page_slug . '_branding',
1443 $SECTION_METASYNC
1444 );
1445
1446 add_settings_field(
1447 'whitelabel_logo_light_url',
1448 'Logo (Light Theme)' . Metasync::get_tooltip_icon_html('wl_logo_light_tooltip', 'Your logo shown in the admin dashboard when it\'s set to light mode. Leave blank to use the default logo.'),
1449 function() use ($option_key) {
1450 $whitelabel_settings = Metasync::get_whitelabel_settings();
1451 $value = $whitelabel_settings['logo_light'] ?? '';
1452 echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1453 printf('<p class="description">Displayed when the admin UI is in light mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1454 },
1455 $page_slug . '_branding',
1456 $SECTION_METASYNC
1457 );
1458
1459 add_settings_field(
1460 'whitelabel_logo_dark_url',
1461 'Logo (Dark Theme)' . Metasync::get_tooltip_icon_html('wl_logo_dark_tooltip', 'Your logo shown in the admin dashboard when it\'s set to dark mode. Leave blank to use the default logo.'),
1462 function() use ($option_key) {
1463 $whitelabel_settings = Metasync::get_whitelabel_settings();
1464 $value = $whitelabel_settings['logo_dark'] ?? '';
1465 echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1466 printf('<p class="description">Displayed when the admin UI is in dark mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1467 },
1468 $page_slug . '_branding',
1469 $SECTION_METASYNC
1470 );
1471
1472 add_settings_field(
1473 'whitelabel_domain_url',
1474 'Dashboard URL' . Metasync::get_tooltip_icon_html('wl_dashboard_url_tooltip', 'The web address of your own branded dashboard, shown to clients instead of the Search Atlas dashboard link. This is cosmetic only — it doesn\'t change where your data is actually stored.'),
1475 function() use ($option_key) {
1476 $whitelabel_settings = Metasync::get_whitelabel_settings();
1477 $value = $whitelabel_settings['domain'] ?? '';
1478 echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1479 echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1480 },
1481 $page_slug . '_branding',
1482 $SECTION_METASYNC
1483 );
1484 add_settings_field(
1485 'white_label_plugin_description',
1486 'Plugin Description',
1487 function() use ($option_key) {
1488 $value = Metasync::get_option('general')['white_label_plugin_description'] ?? '';
1489 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1490 },
1491 $page_slug . '_branding',
1492 $SECTION_METASYNC
1493 );
1494
1495 add_settings_field(
1496 'white_label_plugin_author',
1497 'Author',
1498 function() use ($option_key) {
1499 $value = Metasync::get_option('general')['white_label_plugin_author'] ?? '';
1500 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1501 },
1502 $page_slug . '_branding',
1503 $SECTION_METASYNC
1504 );
1505
1506 add_settings_field(
1507 'white_label_plugin_author_uri',
1508 'Author URI',
1509 function() use ($option_key) {
1510 $value = Metasync::get_option('general')['white_label_plugin_author_uri'] ?? '';
1511 # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1512 # Fixed printf usage
1513 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1514
1515 },
1516 $page_slug . '_branding',
1517 $SECTION_METASYNC
1518 );
1519 add_settings_field(
1520 'white_label_plugin_uri',
1521 'Plugin URI',
1522 function() use ($option_key) {
1523 $value = Metasync::get_option('general')['white_label_plugin_uri'] ?? ''; // New option for Plugin URI
1524 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1525 },
1526 $page_slug . '_branding',
1527 $SECTION_METASYNC
1528 );
1529 register_setting(Metasync_Admin::option_group,
1530 Metasync_Admin::option_key,
1531 array(self::instance(), 'sanitize')
1532 );
1533
1534 // DEPRECATED: Menu Name and Menu Title fields removed
1535 add_settings_field(
1536 'white_label_plugin_menu_slug',
1537 'Menu Slug',
1538 function() use ($option_key) {
1539 $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1540 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1541 echo '<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>';
1542 },
1543 $page_slug . '_branding',
1544 $SECTION_METASYNC
1545 );
1546 add_settings_field(
1547 'white_label_plugin_menu_icon',
1548 'Menu Icon',
1549 function() use ($option_key) {
1550 $value = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
1551 echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1552 },
1553 $page_slug . '_branding',
1554 $SECTION_METASYNC
1555 );
1556 }
1557 // HIDDEN: Choose Style Option setting
1558 /*
1559 add_settings_field(
1560 'enabled_plugin_css',
1561 'Choose Style Option',
1562 function() use ($option_key) {
1563 $enabled_plugin_css = Metasync::get_option('general')['enabled_plugin_css'] ?? '';
1564
1565 // Output radio button for Default Style.css active
1566
1567 printf(
1568 '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1569 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1570 );
1571 echo '<label for="enable_default">Default</label><br>';
1572
1573
1574 // Output radio button for Metasync Style
1575 printf(
1576 '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1577 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1578 );
1579 echo '<label for="enable_metasync">Metasync Style</label>';
1580
1581 echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1582 },
1583 $page_slug . '_branding',
1584 $SECTION_METASYNC
1585 );
1586 */
1587
1588 add_settings_field(
1589 'enabled_elementor_plugin_css',
1590 'Choose Elementor Font Style',
1591 function() use ($option_key) {
1592 $enabled_elementor_plugin_css = Metasync::get_option('general')['enabled_elementor_plugin_css'] ?? 'default';
1593 echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1594 echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1595 echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1596 echo '</select>';
1597 },
1598 $page_slug . '_branding',
1599 $SECTION_METASYNC
1600 );
1601 add_settings_field(
1602 'enabled_elementor_plugin_css_color',
1603 'Choose Elementor Font Color',
1604 function() use ($option_key) {
1605 $enabled_elementor_plugin_css_color = Metasync::get_option('general')['enabled_elementor_plugin_css_color'] ?? '#000000';
1606 echo '<input type="color" id="elementor_default_color_metasync" name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css_color]" value="'.esc_attr($enabled_elementor_plugin_css_color).'">';
1607 },
1608 $page_slug . '_branding',
1609 $SECTION_METASYNC
1610 );
1611
1612 add_settings_field(
1613 'whitelabel_settings_password',
1614 'Settings Password' . Metasync::get_tooltip_icon_html('whitelabel_settings_password_tooltip', 'Set a password to lock the plugin\'s white-label and settings tabs so your clients can\'t change your branding. You\'ll also need a recovery email in case you forget it.'),
1615 function() use ($option_key) {
1616 // Decrypted for display — this field only renders after the
1617 // whitelabel password gate has been passed.
1618 $value = Metasync::get_whitelabel_password();
1619 echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1620 echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1621 },
1622 $page_slug . '_branding',
1623 $SECTION_METASYNC
1624 );
1625
1626 add_settings_field(
1627 'whitelabel_recovery_email',
1628 'Recovery Email' . Metasync::get_tooltip_icon_html('whitelabel_recovery_email_tooltip', 'The email where we\'ll send a reset link if you forget your white-label settings password. Required whenever a password is set.'),
1629 function() use ($option_key) {
1630 $whitelabel_settings = Metasync::get_whitelabel_settings();
1631 $value = $whitelabel_settings['recovery_email'] ?? '';
1632 $has_password = !empty($whitelabel_settings['settings_password']);
1633 printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1634 echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1635 },
1636 $page_slug . '_branding',
1637 $SECTION_METASYNC
1638 );
1639
1640 add_settings_field(
1641 'whitelabel_quick_links',
1642 'Quick Links',
1643 function() use ($option_key) {
1644 $whitelabel_settings = Metasync::get_whitelabel_settings();
1645 $links = isset($whitelabel_settings['quick_links']) && is_array($whitelabel_settings['quick_links'])
1646 ? $whitelabel_settings['quick_links']
1647 : [];
1648 // Ensure at least 5 rows
1649 while (count($links) < 5) {
1650 $links[] = ['label' => '', 'url' => '', 'external' => false];
1651 }
1652 echo '<p class="description" style="margin-bottom:8px;">Add up to 5 links to show in the sidebar Quick Links widget. Leave blank to hide the widget when whitelabeling is active.</p>';
1653 echo '<style>.metasync-quick-links-table.widefat, .metasync-quick-links-table.widefat th, .metasync-quick-links-table.widefat td { background:transparent; border-color:var(--dashboard-border, #374151); }</style>';
1654 echo '<table class="widefat metasync-quick-links-table" style="max-width:560px;">';
1655 echo '<thead><tr><th>Label</th><th>URL</th><th>Open in new tab</th></tr></thead><tbody>';
1656 foreach (array_slice($links, 0, 5) as $i => $link) {
1657 $label = esc_attr($link['label'] ?? '');
1658 $url = esc_attr($link['url'] ?? '');
1659 $external = !empty($link['external']);
1660 printf(
1661 '<tr>
1662 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1663 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1664 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1665 </tr>',
1666 esc_attr($option_key), esc_attr($i), esc_attr($label),
1667 esc_attr($option_key), esc_attr($i), esc_attr($url),
1668 esc_attr($option_key), esc_attr($i), checked($external, true, false)
1669 );
1670 }
1671 echo '</tbody></table>';
1672 },
1673 $page_slug . '_branding',
1674 $SECTION_METASYNC
1675 );
1676
1677 // ======================================================================
1678 // Schema Markup Settings — dedicated page (SEO → Schema Markup)
1679 // ======================================================================
1680 $schema_page = $page_slug . '_schema-markup';
1681
1682 add_settings_section(
1683 'metasync_schema_org',
1684 'Organization Schema (Site-Wide)',
1685 function() {
1686 echo '<p>This Organization schema is injected on every page of your site.</p>';
1687 },
1688 $schema_page
1689 );
1690
1691 add_settings_field(
1692 'schema_org_name',
1693 'Organization Name',
1694 function() use ($option_key) {
1695 $schema = Metasync::get_option('schema') ?? [];
1696 $value = $schema['org_name'] ?? '';
1697 printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1698 },
1699 $schema_page,
1700 'metasync_schema_org'
1701 );
1702
1703 add_settings_field(
1704 'schema_org_url',
1705 'Organization URL',
1706 function() use ($option_key) {
1707 $schema = Metasync::get_option('schema') ?? [];
1708 $value = $schema['org_url'] ?? '';
1709 printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1710 },
1711 $schema_page,
1712 'metasync_schema_org'
1713 );
1714
1715 add_settings_field(
1716 'schema_org_logo',
1717 'Organization Logo URL' . Metasync::get_tooltip_icon_html('schema_org_logo_tooltip', 'A direct link to your logo image. Google uses this in your Organization knowledge panel — use a clear, square-ish image on a plain background, ideally at least 112x112 pixels.'),
1718 function() use ($option_key) {
1719 $schema = Metasync::get_option('schema') ?? [];
1720 $value = $schema['org_logo'] ?? '';
1721 printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1722 },
1723 $schema_page,
1724 'metasync_schema_org'
1725 );
1726
1727 add_settings_field(
1728 'schema_org_contact_telephone',
1729 'Contact Telephone',
1730 function() use ($option_key) {
1731 $schema = Metasync::get_option('schema') ?? [];
1732 $value = $schema['org_contact_telephone'] ?? '';
1733 printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1734 },
1735 $schema_page,
1736 'metasync_schema_org'
1737 );
1738
1739 add_settings_field(
1740 'schema_org_contact_type',
1741 'Contact Type' . Metasync::get_tooltip_icon_html('schema_org_contact_type_tooltip', 'What this phone number is for, using schema.org wording such as "customer support", "sales", or "technical support". It tells search engines how the number should be labeled.'),
1742 function() use ($option_key) {
1743 $schema = Metasync::get_option('schema') ?? [];
1744 $value = $schema['org_contact_type'] ?? '';
1745 printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1746 },
1747 $schema_page,
1748 'metasync_schema_org'
1749 );
1750
1751 add_settings_field(
1752 'schema_org_same_as',
1753 'Social Profile URLs' . Metasync::get_tooltip_icon_html('schema_org_same_as_tooltip', 'Links to your official profiles on other sites (Facebook, X/Twitter, LinkedIn, Wikipedia, etc.), one per line. This is the schema.org "sameAs" property — it helps search engines confirm these accounts are really you.'),
1754 function() use ($option_key) {
1755 $schema = Metasync::get_option('schema') ?? [];
1756 $value = $schema['org_same_as'] ?? '';
1757 printf('<textarea name="' . esc_attr($option_key) . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1758 echo '<p class="description">Enter one social profile URL per line.</p>';
1759 },
1760 $schema_page,
1761 'metasync_schema_org'
1762 );
1763
1764 add_settings_section(
1765 'metasync_schema_website',
1766 'WebSite Schema (Homepage Only)',
1767 function() {
1768 echo '<p>This WebSite schema is injected on the homepage only. Enables the Google Sitelinks Searchbox.</p>';
1769 },
1770 $schema_page
1771 );
1772
1773 add_settings_field(
1774 'schema_website_name',
1775 'WebSite Name',
1776 function() use ($option_key) {
1777 $schema = Metasync::get_option('schema') ?? [];
1778 $value = $schema['website_name'] ?? '';
1779 printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1780 },
1781 $schema_page,
1782 'metasync_schema_website'
1783 );
1784
1785 add_settings_field(
1786 'schema_website_url',
1787 'WebSite URL',
1788 function() use ($option_key) {
1789 $schema = Metasync::get_option('schema') ?? [];
1790 $value = $schema['website_url'] ?? '';
1791 printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1792 },
1793 $schema_page,
1794 'metasync_schema_website'
1795 );
1796
1797 add_settings_field(
1798 'schema_website_searchbox',
1799 'Enable Sitelinks Searchbox' . Metasync::get_tooltip_icon_html('schema_website_searchbox_tooltip', 'Adds a search action to your homepage schema so Google can show a search box directly under your site in results. Only affects how your site can appear in Google, not your actual site search.'),
1800 function() use ($option_key) {
1801 $schema = Metasync::get_option('schema') ?? [];
1802 $value = $schema['website_searchbox'] ?? false;
1803 printf(
1804 '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1805 checked($value, true, false)
1806 );
1807 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1808 },
1809 $schema_page,
1810 'metasync_schema_website'
1811 );
1812
1813 add_settings_section(
1814 'metasync_schema_compat',
1815 'Compatibility',
1816 function() {
1817 echo '<p>Control how MetaSync interacts with other plugins that output structured data.</p>';
1818 },
1819 $schema_page
1820 );
1821
1822 add_settings_field(
1823 'schema_override_woocommerce',
1824 'Override WooCommerce Product Schema' . Metasync::get_tooltip_icon_html('schema_override_woocommerce_tooltip', 'By default MetaSync stays out of the way and lets WooCommerce output Product schema, to avoid two copies. Turn this on only if you want MetaSync to replace WooCommerce\'s product structured data with its own.'),
1825 function() use ($option_key) {
1826 $schema = Metasync::get_option('schema') ?? [];
1827 $value = $schema['override_woocommerce_schema'] ?? false;
1828 printf(
1829 '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1830 checked($value, true, false)
1831 );
1832 echo '<span class="description">When unchecked (default), MetaSync suppresses its Product schema when WooCommerce is active to avoid duplication. Check to override WooCommerce\'s native Product schema.</span>';
1833 },
1834 $schema_page,
1835 'metasync_schema_compat'
1836 );
1837
1838 // ----------------------------------------------------------------
1839 // Site Verification page
1840 // ----------------------------------------------------------------
1841 add_settings_section(
1842 $SECTION_SEARCHENGINE,
1843 '',
1844 function() {},
1845 $page_slug . '_searchengines-verification'
1846 );
1847
1848 add_settings_field(
1849 'google_site_verification',
1850 'Google Search Console' . Metasync::get_tooltip_icon_html('google_site_verification_tooltip', 'Paste the verification code Google Search Console gave you. This lets Google confirm you own this site — it does not change anything visitors see.'),
1851 array( Metasync_Settings_Fields::instance(), 'google_site_verification_callback' ),
1852 $page_slug . '_searchengines-verification',
1853 $SECTION_SEARCHENGINE
1854 );
1855
1856 add_settings_field(
1857 'bing_site_verification',
1858 'Bing Webmaster Tools' . Metasync::get_tooltip_icon_html('bing_site_verification_tooltip', 'Paste the verification code Bing Webmaster Tools gave you. This lets Bing confirm you own this site — it does not change anything visitors see.'),
1859 array( Metasync_Settings_Fields::instance(), 'bing_site_verification_callback' ),
1860 $page_slug . '_searchengines-verification',
1861 $SECTION_SEARCHENGINE
1862 );
1863
1864 add_settings_field(
1865 'yandex_site_verification',
1866 'Yandex Webmaster' . Metasync::get_tooltip_icon_html('yandex_site_verification_tooltip', 'Paste the verification code Yandex Webmaster gave you. This lets Yandex confirm you own this site — it does not change anything visitors see.'),
1867 array( Metasync_Settings_Fields::instance(), 'yandex_site_verification_callback' ),
1868 $page_slug . '_searchengines-verification',
1869 $SECTION_SEARCHENGINE
1870 );
1871
1872 add_settings_field(
1873 'pinterest_site_verification',
1874 'Pinterest' . Metasync::get_tooltip_icon_html('pinterest_site_verification_tooltip', 'Paste the verification code Pinterest gave you when you claimed your website. This lets Pinterest confirm you own this site — it does not change anything visitors see.'),
1875 array( Metasync_Settings_Fields::instance(), 'pinterest_site_verification_callback' ),
1876 $page_slug . '_searchengines-verification',
1877 $SECTION_SEARCHENGINE
1878 );
1879
1880 add_settings_field(
1881 'baidu_site_verification',
1882 'Baidu' . Metasync::get_tooltip_icon_html('baidu_site_verification_tooltip', 'Paste the verification code Baidu Webmaster gave you. This lets Baidu confirm you own this site — it does not change anything visitors see.'),
1883 function() use ($option_key) {
1884 $value = Metasync::get_option('searchengines')['baidu_site_verification'] ?? '';
1885 printf( '<input type="text" id="baidu_site_verification" name="' . esc_attr($option_key) . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1886 },
1887 $page_slug . '_searchengines-verification',
1888 $SECTION_SEARCHENGINE
1889 );
1890
1891 add_settings_field(
1892 'alexa_site_verification',
1893 'Alexa' . Metasync::get_tooltip_icon_html('alexa_site_verification_tooltip', 'Paste the verification code your Alexa/site directory gave you. This confirms you own this site — it does not change anything visitors see.'),
1894 function() use ($option_key) {
1895 $value = Metasync::get_option('searchengines')['alexa_site_verification'] ?? '';
1896 printf( '<input type="text" id="alexa_site_verification" name="' . esc_attr($option_key) . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1897 },
1898 $page_slug . '_searchengines-verification',
1899 $SECTION_SEARCHENGINE
1900 );
1901
1902 add_settings_field(
1903 'norton_save_site_verification',
1904 'Norton Safe Web' . Metasync::get_tooltip_icon_html('norton_site_verification_tooltip', 'Paste the verification code Norton Safe Web gave you. This confirms you own this site — it does not change anything visitors see.'),
1905 function() use ($option_key) {
1906 $value = Metasync::get_option('searchengines')['norton_save_site_verification'] ?? '';
1907 printf( '<input type="text" id="norton_save_site_verification" name="' . esc_attr($option_key) . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1908 },
1909 $page_slug . '_searchengines-verification',
1910 $SECTION_SEARCHENGINE
1911 );
1912
1913 // ----------------------------------------------------------------
1914 // Code Snippets page
1915 // ----------------------------------------------------------------
1916 add_settings_section(
1917 $SECTION_CODESNIPPETS,
1918 '',
1919 function() {},
1920 $page_slug . '_code-snippets'
1921 );
1922
1923 add_settings_field(
1924 'header_snippet',
1925 'Header Code',
1926 array( Metasync_Settings_Fields::instance(), 'header_snippets_callback' ),
1927 $page_slug . '_code-snippets',
1928 $SECTION_CODESNIPPETS
1929 );
1930
1931 add_settings_field(
1932 'footer_snippet',
1933 'Footer Code',
1934 array( Metasync_Settings_Fields::instance(), 'footer_snippets_callback' ),
1935 $page_slug . '_code-snippets',
1936 $SECTION_CODESNIPPETS
1937 );
1938
1939 // ----------------------------------------------------------------
1940 // Local Business page
1941 // ----------------------------------------------------------------
1942 add_settings_section(
1943 $SECTION_LOCALSEO,
1944 '',
1945 function() {},
1946 $page_slug . '_local-seo'
1947 );
1948
1949 $fields_instance = Metasync_Settings_Fields::instance();
1950
1951 add_settings_field( 'local_seo_person_organization', 'Type' . Metasync::get_tooltip_icon_html('local_seo_type_tooltip', 'Pick whether this site represents a Person or an Organization/business. This sets the schema.org @type search engines use to describe you in results.'), array( $fields_instance, 'local_seo_person_organization_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1952 add_settings_field( 'local_seo_name', 'Name', array( $fields_instance, 'local_seo_name_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1953 add_settings_field( 'local_seo_logo', 'Logo', array( $fields_instance, 'local_seo_logo_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1954 add_settings_field( 'local_seo_url', 'Website URL', array( $fields_instance, 'local_seo_url_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1955 add_settings_field( 'local_seo_email', 'Email', array( $fields_instance, 'local_seo_email_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1956 add_settings_field( 'local_seo_phone', 'Phone', array( $fields_instance, 'local_seo_phone_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1957 add_settings_field( 'local_seo_address', 'Address', array( $fields_instance, 'local_seo_address_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1958 add_settings_field( 'local_seo_business_type', 'Business Type' . Metasync::get_tooltip_icon_html('local_seo_business_type_tooltip', 'The category that best describes your business (for example Restaurant, LawFirm, Store). This maps to a schema.org LocalBusiness type so search engines can classify you correctly.'), array( $fields_instance, 'local_seo_business_type_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1959 add_settings_field( 'local_seo_opening_hours', 'Opening Hours' . Metasync::get_tooltip_icon_html('local_seo_opening_hours_tooltip', 'Your business hours, shown in search results and maps. Use the day/time format shown in the field so search engines can read it.'), array( $fields_instance, 'local_seo_opening_hours_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1960 add_settings_field( 'local_seo_phone_numbers', 'Phone Numbers', array( $fields_instance, 'local_seo_phone_numbers_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1961 add_settings_field( 'local_seo_price_range', 'Price Range' . Metasync::get_tooltip_icon_html('local_seo_price_range_tooltip', 'A rough indication of how expensive your business is, usually shown as $ to $$$$. This appears in your business listing in search results.'), array( $fields_instance, 'local_seo_price_range_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1962 add_settings_field( 'local_seo_about_page', 'About Page', array( $fields_instance, 'local_seo_about_page_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1963 add_settings_field( 'local_seo_contact_page', 'Contact Page', array( $fields_instance, 'local_seo_contact_page_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1964 add_settings_field( 'local_seo_map_key', 'Google Maps Key' . Metasync::get_tooltip_icon_html('local_seo_map_key_tooltip', 'A Google Maps API key from your Google Cloud account. Only needed if you want an embedded map; it lets Google bill and authorize the map requests.'), array( $fields_instance, 'local_seo_map_key_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1965 add_settings_field( 'local_seo_geo_coordinates', 'Geo Coordinates' . Metasync::get_tooltip_icon_html('local_seo_geo_coordinates_tooltip', 'The exact latitude and longitude of your business. This helps search engines and maps pin your location precisely, especially when your street address is ambiguous.'), array( $fields_instance, 'local_seo_geo_coordinates_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1966
1967 // ----------------------------------------------------------------
1968 // Breadcrumbs page — intentionally registers no Settings API fields.
1969 // The dedicated Breadcrumbs admin page renders the richer controls
1970 // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1971 // and writes the same metasync_options[breadcrumbs] keys.
1972 // ----------------------------------------------------------------
1973
1974 # Open Graph / Article meta toggles — surface each new tag family as its
1975 # own opt-out checkbox on the main Settings page (Open Graph accordion).
1976 add_settings_field(
1977 'og_image_dimensions',
1978 'OG Image Dimensions',
1979 array(Metasync_Settings_Fields::instance(), 'og_image_dimensions_callback'),
1980 $page_slug . '_general',
1981 $SECTION_METASYNC
1982 );
1983
1984 add_settings_field(
1985 'article_timestamps',
1986 'Article Timestamps',
1987 array(Metasync_Settings_Fields::instance(), 'article_timestamps_callback'),
1988 $page_slug . '_general',
1989 $SECTION_METASYNC
1990 );
1991
1992 add_settings_field(
1993 'article_author',
1994 'Article Author',
1995 array(Metasync_Settings_Fields::instance(), 'article_author_callback'),
1996 $page_slug . '_general',
1997 $SECTION_METASYNC
1998 );
1999
2000 add_settings_field(
2001 'article_section',
2002 'Article Section',
2003 array(Metasync_Settings_Fields::instance(), 'article_section_callback'),
2004 $page_slug . '_general',
2005 $SECTION_METASYNC
2006 );
2007
2008 add_settings_field(
2009 'article_tags',
2010 'Article Tags',
2011 array(Metasync_Settings_Fields::instance(), 'article_tags_callback'),
2012 $page_slug . '_general',
2013 $SECTION_METASYNC
2014 );
2015
2016 add_settings_field(
2017 'twitter_image_alt',
2018 'Twitter Image Alt',
2019 array(Metasync_Settings_Fields::instance(), 'twitter_image_alt_callback'),
2020 $page_slug . '_general',
2021 $SECTION_METASYNC
2022 );
2023
2024 }
2025
2026 /**
2027 * Coerce a loosely-typed option value into an array.
2028 *
2029 * The value arrives untyped (a crafted or partial submission may send a
2030 * scalar for an option stored as an array), so the check lives behind an
2031 * untyped parameter instead of an inline is_array() guard on a value the
2032 * docblock already narrows.
2033 *
2034 * @param mixed $value Raw option value.
2035 * @return array The value as an array, or an empty array.
2036 */
2037 private static function coerce_to_array($value)
2038 {
2039 return is_array($value) ? $value : array();
2040 }
2041
2042 /**
2043 * Recursively merge sanitized settings over raw and stored layers.
2044 *
2045 * Later layers win per-key; associative arrays merge key-by-key so
2046 * submitted values only replace their own field, while sequential
2047 * lists (rows of opening hours, phone numbers) replace wholesale
2048 * so deleted rows stay deleted.
2049 *
2050 * @param array $base Stored option layer (lowest precedence).
2051 * @param array ...$layers Layers to fold in, lowest precedence first
2052 * (raw input, then sanitized values).
2053 * @return array The merged option array.
2054 */
2055 private static function merge_option_layers(array $base, array ...$layers)
2056 {
2057 foreach ($layers as $layer) {
2058 foreach ($layer as $key => $value) {
2059 if (is_array($value)
2060 && isset($base[$key])
2061 && is_array($base[$key])
2062 && !self::is_sequential_list($value)
2063 && !self::clears_a_stored_list($value, $base[$key])
2064 ) {
2065 $base[$key] = self::merge_option_layers($base[$key], $value);
2066 } else {
2067 $base[$key] = $value;
2068 }
2069 }
2070 }
2071 return $base;
2072 }
2073
2074 /**
2075 * Enforce the read-only contract on general.otto_pixel_uuid.
2076 *
2077 * The UUID is owned by trusted Search Atlas flows and explicit disconnect.
2078 * Any changed value submitted through the settings form is rejected and the
2079 * stored UUID is preserved verbatim. Programmatic trusted writers bypass
2080 * this form-submission guard.
2081 *
2082 * @param array $merged The layered-merge result about to be saved.
2083 * @param array $stored The stored option, used as the preserved value.
2084 * @return array The merged array with the stored otto_pixel_uuid kept.
2085 */
2086 private static function protect_otto_pixel_uuid(array $merged, array $stored)
2087 {
2088 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2089 if (!isset($_POST['metasync_options']['general'])
2090 || !is_array($_POST['metasync_options']['general'])
2091 || !array_key_exists('otto_pixel_uuid', $_POST['metasync_options']['general'])) {
2092 return $merged;
2093 }
2094
2095 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2096 $submitted = $_POST['metasync_options']['general']['otto_pixel_uuid'];
2097 if (is_string($submitted)) {
2098 $submitted = sanitize_text_field(wp_unslash($submitted));
2099 }
2100 $candidate = $merged['general']['otto_pixel_uuid'] ?? null;
2101
2102 // A trusted flow may have replaced the submitted value after the form
2103 // was posted; such a value is not user-authored and must survive.
2104 // The trust inference must NOT rest on the inequality alone: the
2105 // merged candidate comes from the raw submission layer and can differ
2106 // from the sanitized $submitted merely because sanitization alters
2107 // the payload (tags, line breaks, repeated spaces). A mismatch on
2108 // its own must never authorize storing the raw value, so only a
2109 // sanctioned shape may pass through: the empty string (the
2110 // disconnected state) or a strictly valid UUID — the only values
2111 // trusted writers ever set. Everything else falls back to stored.
2112 if ($candidate !== $submitted
2113 && ($candidate === '' || Metasync::is_valid_uuid($candidate))) {
2114 return $merged;
2115 }
2116
2117 $stored_value = $stored['general']['otto_pixel_uuid'] ?? null;
2118 if ($candidate === $stored_value) {
2119 return $merged;
2120 }
2121
2122 if (is_string($stored_value)) {
2123 $merged['general']['otto_pixel_uuid'] = $stored_value;
2124 } else {
2125 unset($merged['general']['otto_pixel_uuid']);
2126 }
2127
2128 add_settings_error(
2129 'metasync_messages',
2130 'metasync_otto_pixel_uuid_readonly',
2131 __('OTTO Pixel UUID is read-only and can only be assigned by the Search Atlas / heartbeat synchronization.', 'metasync'),
2132 'error'
2133 );
2134 return $merged;
2135 }
2136
2137 /**
2138 * Whether the array is a numerically-indexed list (keys 0..n-1).
2139 *
2140 * Answers false for the empty array — range(0, -1) counts downwards and
2141 * yields [0, -1], not []. That is load-bearing rather than incidental: an
2142 * empty array is ambiguous, and clears_a_stored_list() is where the
2143 * ambiguity is resolved.
2144 *
2145 * @param array $value Array to inspect.
2146 * @return bool True for lists, false for associative maps.
2147 */
2148 private static function is_sequential_list(array $value)
2149 {
2150 return array_keys($value) === range(0, count($value) - 1);
2151 }
2152
2153 /**
2154 * Whether an empty submitted value is a stored list being cleared.
2155 *
2156 * An empty array reaches the merge meaning one of two opposite things, and
2157 * only the stored value tells them apart.
2158 *
2159 * When what is stored is a list — the roles of a multi-select, the rows of
2160 * a repeater — an empty submission means the user removed every entry, and
2161 * it has to replace the stored value or the clear is silently ignored.
2162 *
2163 * When what is stored is an associative map, an empty submission means the
2164 * form carried nothing for that section: a tab whose every checkbox is
2165 * unchecked posts no fields at all. Replacing there would wipe the section,
2166 * API key included, which is the whole failure this merge exists to
2167 * prevent. So that case still merges, leaving the stored keys standing.
2168 *
2169 * @param array $value The submitted value.
2170 * @param array $stored The value already in the option.
2171 * @return bool True when the submission is clearing a list.
2172 */
2173 private static function clears_a_stored_list(array $value, array $stored)
2174 {
2175 return $value === array() && self::is_sequential_list($stored);
2176 }
2177
2178 /**
2179 * Sanitize each setting field as needed.
2180 *
2181 * Formerly Metasync_Admin::sanitize().
2182 *
2183 * @param array $input Contains all settings fields as array keys
2184 * @return array Sanitized settings
2185 */
2186 public function sanitize($input)
2187 {
2188 $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2189 ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2190 : null;
2191 // Legacy/stale forms that include a password but no reset generation
2192 // must never be allowed to overwrite a newer recovery password.
2193 if (!self::$allow_recovery_password_write
2194 && $submitted_generation === null
2195 && isset($input['whitelabel']['settings_password'])) {
2196 unset($input['whitelabel']['settings_password']);
2197 add_settings_error(
2198 'metasync_messages',
2199 'metasync_password_generation_missing',
2200 __('This settings form is stale. Reload the page before changing the password.', 'metasync'),
2201 'error'
2202 );
2203 }
2204 // Every settings save rewrites the aggregate option, including its
2205 // current password value. Serialize the full save with recovery even
2206 // when the submitted tab does not expose the password field.
2207 if (!$this->acquire_settings_recovery_lock()) {
2208 add_settings_error(
2209 'metasync_messages',
2210 'metasync_password_update_busy',
2211 __('Password recovery is being processed. No settings were saved; please try again.', 'metasync'),
2212 'error'
2213 );
2214 } else {
2215 self::$options_generation_at_sanitize = $submitted_generation !== null
2216 ? $submitted_generation
2217 : (int) get_option('metasync_pw_reset_generation', 0);
2218 }
2219 $input = self::coerce_to_array($input);
2220
2221 // The saved option is assembled from three layers (see the merge at
2222 // the end of this method): the stored option, the raw submission,
2223 // and the sanitized values collected in $new_input. Sanitization
2224 // therefore wins over raw input, a submitted field without a
2225 // sanitizer still beats its stored value, and stored keys the
2226 // submission omits survive instead of being dropped.
2227 $stored_option = self::coerce_to_array(Metasync::get_option());
2228 $new_input = array();
2229 $whitelabel_validation_failed = false;
2230 $whitelabel_reset = false;
2231
2232 # Determine which tab is being submitted (same logic as AJAX handler)
2233 # Check POST first (from AJAX), then GET (from form action URL)
2234 $active_tab = 'general';
2235 if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2236 $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2237 } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2238 $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2239 } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2240 # Parse referer URL to get tab parameter
2241 $referer = wp_parse_url(esc_url_raw(wp_unslash($_POST['_wp_http_referer']))); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2242 if (isset($referer['query'])) {
2243 parse_str($referer['query'], $referer_params);
2244 if (isset($referer_params['tab'])) {
2245 $active_tab = sanitize_text_field($referer_params['tab']);
2246 }
2247 }
2248 }
2249 $general_tab_submitted = ($active_tab === 'general');
2250 $compatibility_tab_submitted = ($active_tab === 'compatibility');
2251
2252 // General Settings
2253 if (isset($input['general']['apikey'])) {
2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
2255 }
2256
2257 # Plugin-compatibility toggles live on the Compatibility page, which
2258 # saves through options.php with a hidden active_tab=compatibility so
2259 # the General-tab-gated blocks below never fire. Sanitize the two
2260 # toggles explicitly so the raw submission never reaches storage
2261 # unsanitized, and so a value that is absent (toggle hidden because
2262 # its plugin is not active) keeps its stored value via the merge.
2263 if ($compatibility_tab_submitted) {
2264 if (isset($input['general']['otto_wp_rocket_compat'])) {
2265 $new_input['general']['otto_wp_rocket_compat'] = sanitize_text_field($input['general']['otto_wp_rocket_compat']);
2266 }
2267 if (isset($input['general']['otto_sg_optimizer_compat'])) {
2268 $new_input['general']['otto_sg_optimizer_compat'] = sanitize_text_field($input['general']['otto_sg_optimizer_compat']);
2269 }
2270 if (isset($input['general']['bookingpress_session_compat'])) {
2271 $new_input['general']['bookingpress_session_compat'] = sanitize_text_field($input['general']['bookingpress_session_compat']);
2272 }
2273 }
2274
2275 // Meta Box Visibility Settings
2276 if (isset($input['general']) && $general_tab_submitted) {
2277 $checkbox_fields = [
2278 'disable_common_robots_metabox',
2279 'disable_advance_robots_metabox',
2280 'disable_redirection_metabox',
2281 'disable_canonical_metabox',
2282 'disable_social_opengraph_metabox',
2283 'disable_schema_markup_metabox',
2284 'disable_language_alternates_metabox',
2285 'disable_seo_metabox',
2286 'open_external_links',
2287 // Consent switch for writing into other SEO plugins. Absent from
2288 // the POST means unchecked, which must resolve to off — a
2289 // permanent write into another plugin's data is never inferred.
2290 Metasync_Seo_Backup::CONSENT_OPTION_KEY
2291 ];
2292
2293 foreach ($checkbox_fields as $field) {
2294 if (isset($input['general'][$field])) {
2295 $new_input['general'][$field] = filter_var($input['general'][$field], FILTER_VALIDATE_BOOLEAN);
2296 } else {
2297 // Unchecked HTML checkboxes are absent from the submission;
2298 // persist an explicit false so the stored value cannot leak
2299 // back through the layered merge.
2300 $new_input['general'][$field] = false;
2301 }
2302 }
2303
2304 // SEO priority is a checkbox whose stored value is a string, so it
2305 // cannot ride the boolean loop above. Only the exact opt-in value
2306 // is accepted; unchecked (absent) or anything else resolves to
2307 // custom-first. Writing an explicit value rather than leaving the
2308 // key absent matters for the same reason the loop above writes
2309 // false: the layered merge would otherwise let the stored 'otto'
2310 // leak back and the box could never be unticked.
2311 $new_input['general']['seo_priority'] =
2312 (isset($input['general']['seo_priority'])
2313 && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2314 ? Metasync_Seo_Precedence::PRIORITY_OTTO
2315 : 'custom';
2316 }
2317
2318 // Site Verification Settings
2319 if (isset($input['searchengines']['bing_site_verification'])) {
2320 $new_input['searchengines']['bing_site_verification'] = sanitize_text_field($input['searchengines']['bing_site_verification']);
2321 }
2322 if (isset($input['searchengines']['baidu_site_verification'])) {
2323 $new_input['searchengines']['baidu_site_verification'] = sanitize_text_field($input['searchengines']['baidu_site_verification']);
2324 }
2325 if (isset($input['searchengines']['alexa_site_verification'])) {
2326 $new_input['searchengines']['alexa_site_verification'] = sanitize_text_field($input['searchengines']['alexa_site_verification']);
2327 }
2328 if (isset($input['searchengines']['yandex_site_verification'])) {
2329 $new_input['searchengines']['yandex_site_verification'] = sanitize_text_field($input['searchengines']['yandex_site_verification']);
2330 }
2331 if (isset($input['searchengines']['google_site_verification'])) {
2332 $new_input['searchengines']['google_site_verification'] = sanitize_text_field($input['searchengines']['google_site_verification']);
2333 }
2334 if (isset($input['searchengines']['pinterest_site_verification'])) {
2335 $new_input['searchengines']['pinterest_site_verification'] = sanitize_text_field($input['searchengines']['pinterest_site_verification']);
2336 }
2337 if (isset($input['searchengines']['norton_save_site_verification'])) {
2338 $new_input['searchengines']['norton_save_site_verification'] = sanitize_text_field($input['searchengines']['norton_save_site_verification']);
2339 }
2340
2341 // Local Business SEO Settings
2342 if (isset($input['localseo']['local_seo_person_organization'])) {
2343 $new_input['localseo']['local_seo_person_organization'] = sanitize_text_field($input['localseo']['local_seo_person_organization']);
2344 }
2345 if (isset($input['localseo']['local_seo_name'])) {
2346 $new_input['localseo']['local_seo_name'] = sanitize_text_field($input['localseo']['local_seo_name']);
2347 }
2348 if (isset($input['localseo']['local_seo_logo'])) {
2349 // The logo may be either a media-library attachment ID (what the
2350 // media picker stores) or a raw URL. sanitize_url() is esc_url_raw(),
2351 // which prepends a scheme to any value that has none — so an ID like
2352 // "45589" would be rewritten to "http://45589" and the image lost.
2353 // Keep the two formats apart so the ID survives a save.
2354 $logo = trim((string) $input['localseo']['local_seo_logo']);
2355 $new_input['localseo']['local_seo_logo'] = is_numeric($logo)
2356 ? absint($logo)
2357 : sanitize_url($logo);
2358 }
2359 if (isset($input['localseo']['local_seo_url'])) {
2360 $new_input['localseo']['local_seo_url'] = sanitize_url($input['localseo']['local_seo_url']);
2361 }
2362 if (isset($input['localseo']['local_seo_email'])) {
2363 $new_input['localseo']['local_seo_email'] = sanitize_email($input['localseo']['local_seo_email']);
2364 }
2365 if (isset($input['localseo']['local_seo_phone'])) {
2366 $new_input['localseo']['local_seo_phone'] = sanitize_text_field($input['localseo']['local_seo_phone']);
2367 }
2368 if (isset($input['localseo']['address']['street'])) {
2369 $new_input['localseo']['address']['street'] = sanitize_text_field($input['localseo']['address']['street']);
2370 }
2371 if (isset($input['localseo']['address']['locality'])) {
2372 $new_input['localseo']['address']['locality'] = sanitize_text_field($input['localseo']['address']['locality']);
2373 }
2374 if (isset($input['localseo']['address']['region'])) {
2375 $new_input['localseo']['address']['region'] = sanitize_text_field($input['localseo']['address']['region']);
2376 }
2377 if (isset($input['localseo']['address']['postalcode'])) {
2378 $new_input['localseo']['address']['postalcode'] = sanitize_text_field($input['localseo']['address']['postalcode']);
2379 }
2380 if (isset($input['localseo']['address']['country'])) {
2381 $new_input['localseo']['address']['country'] = sanitize_text_field($input['localseo']['address']['country']);
2382 }
2383 if (isset($input['localseo']['local_seo_business_type'])) {
2384 $new_input['localseo']['local_seo_business_type'] = sanitize_text_field($input['localseo']['local_seo_business_type']);
2385 }
2386 if (isset($input['localseo']['local_seo_hours_format'])) {
2387 $new_input['localseo']['local_seo_hours_format'] = sanitize_text_field($input['localseo']['local_seo_hours_format']);
2388 }
2389 if (isset($input['localseo']['days'])) {
2390 $new_input['localseo']['days'] = array_map('sanitize_text_field', (array) $input['localseo']['days']);
2391 }
2392 if (isset($input['localseo']['times'])) {
2393 $new_input['localseo']['times'] = array_map('sanitize_text_field', (array) $input['localseo']['times']);
2394 }
2395 if (isset($input['localseo']['phonetype'])) {
2396 $new_input['localseo']['phonetype'] = array_map('sanitize_text_field', (array) $input['localseo']['phonetype']);
2397 }
2398 if (isset($input['localseo']['phonenumber'])) {
2399 $new_input['localseo']['phonenumber'] = array_map('sanitize_text_field', (array) $input['localseo']['phonenumber']);
2400 }
2401 if (isset($input['localseo']['local_seo_price_range'])) {
2402 $new_input['localseo']['local_seo_price_range'] = sanitize_text_field($input['localseo']['local_seo_price_range']);
2403 }
2404 if (isset($input['localseo']['local_seo_about_page'])) {
2405 $new_input['localseo']['local_seo_about_page'] = sanitize_text_field($input['localseo']['local_seo_about_page']);
2406 }
2407 if (isset($input['localseo']['local_seo_contact_page'])) {
2408 $new_input['localseo']['local_seo_contact_page'] = sanitize_text_field($input['localseo']['local_seo_contact_page']);
2409 }
2410 if (isset($input['localseo']['local_seo_map_key'])) {
2411 $new_input['localseo']['local_seo_map_key'] = sanitize_text_field($input['localseo']['local_seo_map_key']);
2412 }
2413 if (isset($input['localseo']['local_seo_geo_coordinates'])) {
2414 $new_input['localseo']['local_seo_geo_coordinates'] = sanitize_text_field($input['localseo']['local_seo_geo_coordinates']);
2415 }
2416
2417 // Breadcrumbs Settings
2418 if (isset($input['breadcrumbs'])) {
2419 // Checkbox fields are only written when the submitting form
2420 // actually rendered them. render_breadcrumbs_section() pairs every
2421 // checkbox with a hidden companion input, so the key is always
2422 // present there and unchecking works. A missing key means the form
2423 // does not manage that field, so the stored value is preserved
2424 // instead of being silently reset to false.
2425 foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2426 if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2427 $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2428 }
2429 }
2430 // The Custom radio always renders and can submit an empty value
2431 // when selected without typing. An empty separator would render
2432 // a blank gap between breadcrumb links, so it is dropped and the
2433 // stored value survives, exactly like an absent key.
2434 if (isset($input['breadcrumbs']['separator'])) {
2435 $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2436 if ('' !== $breadcrumb_separator) {
2437 $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2438 }
2439 }
2440 if (isset($input['breadcrumbs']['home_label'])) {
2441 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2442 }
2443 if (isset($input['breadcrumbs']['home_url'])) {
2444 $new_input['breadcrumbs']['home_url'] = esc_url_raw($input['breadcrumbs']['home_url']);
2445 }
2446 if (isset($input['breadcrumbs']['prefix_text'])) {
2447 $new_input['breadcrumbs']['prefix_text'] = sanitize_text_field($input['breadcrumbs']['prefix_text']);
2448 }
2449 if (isset($input['breadcrumbs']['archive_label_format'])) {
2450 $new_input['breadcrumbs']['archive_label_format'] = sanitize_text_field($input['breadcrumbs']['archive_label_format']);
2451 }
2452 // Drop the raw section: the sanitized copies above (including the
2453 // explicit checkbox booleans) are authoritative for the merge.
2454 unset($input['breadcrumbs']);
2455 }
2456
2457 // Code Snippets Settings. These fields intentionally hold raw markup
2458 // (analytics scripts, verification tags, inline styles) that is echoed
2459 // verbatim on the front end, so only the request slashes are stripped —
2460 // tag-stripping sanitizers would destroy the snippet. The form is
2461 // restricted to users with plugin settings access.
2462 if (isset($input['codesnippets']['header_snippet'])) {
2463 $snippet = wp_unslash($input['codesnippets']['header_snippet']);
2464 $new_input['codesnippets']['header_snippet'] = is_string($snippet) ? trim($snippet) : '';
2465 }
2466 if (isset($input['codesnippets']['footer_snippet'])) {
2467 $snippet = wp_unslash($input['codesnippets']['footer_snippet']);
2468 $new_input['codesnippets']['footer_snippet'] = is_string($snippet) ? trim($snippet) : '';
2469 }
2470
2471 // Common Setting - Global Settings
2472 $common_robots_field = isset($input['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
2473
2474 if (isset($input[$common_robots_field]['noindex'])) {
2475 $new_input['common_robots_meta']['noindex'] = boolval($input[$common_robots_field]['noindex']);
2476 }
2477 if (isset($input[$common_robots_field]['nofollow'])) {
2478 $new_input['common_robots_meta']['nofollow'] = boolval($input[$common_robots_field]['nofollow']);
2479 }
2480 if (isset($input[$common_robots_field]['noarchive'])) {
2481 $new_input['common_robots_meta']['noarchive'] = boolval($input[$common_robots_field]['noarchive']);
2482 }
2483 if (isset($input[$common_robots_field]['noimageindex'])) {
2484 $new_input['common_robots_meta']['noimageindex'] = boolval($input[$common_robots_field]['noimageindex']);
2485 }
2486 if (isset($input[$common_robots_field]['nosnippet'])) {
2487 $new_input['common_robots_meta']['nosnippet'] = boolval($input[$common_robots_field]['nosnippet']);
2488 }
2489
2490 // Advance Setting - Global Settings
2491 $advance_robots_field = isset($input['advance_robots_meta']) ? 'advance_robots_meta' : 'advance_robots_mata';
2492
2493 if (isset($input[$advance_robots_field]['max-snippet']['enable'])) {
2494 $new_input['advance_robots_meta']['max-snippet']['enable'] = boolval($input[$advance_robots_field]['max-snippet']['enable']);
2495 }
2496 if (isset($input[$advance_robots_field]['max-snippet']['length'])) {
2497 $new_input['advance_robots_meta']['max-snippet']['length'] = sanitize_text_field($input[$advance_robots_field]['max-snippet']['length']);
2498 }
2499 if (isset($input[$advance_robots_field]['max-video-preview']['enable'])) {
2500 $new_input['advance_robots_meta']['max-video-preview']['enable'] = boolval($input[$advance_robots_field]['max-video-preview']['enable']);
2501 }
2502 if (isset($input[$advance_robots_field]['max-video-preview']['length'])) {
2503 $new_input['advance_robots_meta']['max-video-preview']['length'] = sanitize_text_field($input[$advance_robots_field]['max-video-preview']['length']);
2504 }
2505 if (isset($input[$advance_robots_field]['max-image-preview']['enable'])) {
2506 $new_input['advance_robots_meta']['max-image-preview']['enable'] = boolval($input[$advance_robots_field]['max-image-preview']['enable']);
2507 }
2508 if (isset($input[$advance_robots_field]['max-image-preview']['length'])) {
2509 $new_input['advance_robots_meta']['max-image-preview']['length'] = sanitize_text_field($input[$advance_robots_field]['max-image-preview']['length']);
2510 }
2511
2512 // Social meta settings
2513 if (isset($input['social_meta']['facebook_page_url'])) {
2514 $new_input['social_meta']['facebook_page_url'] = sanitize_text_field($input['social_meta']['facebook_page_url']);
2515 }
2516 if (isset($input['social_meta']['facebook_authorship'])) {
2517 $new_input['social_meta']['facebook_authorship'] = sanitize_text_field($input['social_meta']['facebook_authorship']);
2518 }
2519 if (isset($input['social_meta']['facebook_admin'])) {
2520 $new_input['social_meta']['facebook_admin'] = sanitize_text_field($input['social_meta']['facebook_admin']);
2521 }
2522 if (isset($input['social_meta']['facebook_app'])) {
2523 $new_input['social_meta']['facebook_app'] = sanitize_text_field($input['social_meta']['facebook_app']);
2524 }
2525 if (isset($input['social_meta']['facebook_secret'])) {
2526 $new_input['social_meta']['facebook_secret'] = sanitize_text_field($input['social_meta']['facebook_secret']);
2527 }
2528 if (isset($input['social_meta']['twitter_username'])) {
2529 $new_input['social_meta']['twitter_username'] = sanitize_text_field($input['social_meta']['twitter_username']);
2530 }
2531
2532 // Common Meta Settings — Open Graph / article toggle checkboxes.
2533 // Unchecked HTML checkboxes are absent from $input, so an explicit
2534 // 'true'/'false' round-trip is required to persist opt-outs.
2535 if (isset($input['common_meta_settings'])) {
2536 $og_toggle_fields = [
2537 'og_image_dimensions',
2538 'article_timestamps',
2539 'article_author',
2540 'article_section',
2541 'article_tags',
2542 'twitter_image_alt',
2543 ];
2544 foreach ($og_toggle_fields as $field) {
2545 $new_input['common_meta_settings'][$field] = (isset($input['common_meta_settings'][$field]) && $input['common_meta_settings'][$field] === 'true') ? 'true' : 'false';
2546 }
2547 // Drop the raw section so only the sanitized toggle values reach
2548 // the stored option.
2549 unset($input['common_meta_settings']);
2550 }
2551
2552 # Handle whitelabel URL fields with improved empty value handling
2553 if (isset($input['whitelabel'])) {
2554 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2555
2556 $general_input = $input['general'] ?? [];
2557 $plugin_name = isset($general_input['white_label_plugin_name']) ? trim((string) $general_input['white_label_plugin_name']) : '';
2558 $logo_light = isset($input['whitelabel']['logo_light']) ? trim((string) $input['whitelabel']['logo_light']) : '';
2559 $logo_dark = isset($input['whitelabel']['logo_dark']) ? trim((string) $input['whitelabel']['logo_dark']) : '';
2560 $author = isset($general_input['white_label_plugin_author']) ? trim((string) $general_input['white_label_plugin_author']) : '';
2561 $author_uri = isset($general_input['white_label_plugin_author_uri']) ? trim((string) $general_input['white_label_plugin_author_uri']) : '';
2562 $plugin_uri = isset($general_input['white_label_plugin_uri']) ? trim((string) $general_input['white_label_plugin_uri']) : '';
2563 $domain = isset($input['whitelabel']['domain']) ? trim((string) $input['whitelabel']['domain']) : '';
2564 $description = isset($general_input['white_label_plugin_description']) ? trim((string) $general_input['white_label_plugin_description']) : '';
2565
2566 $has_core_field = (!empty($plugin_name) || (!empty($logo_light) && filter_var($logo_light, FILTER_VALIDATE_URL)) || (!empty($logo_dark) && filter_var($logo_dark, FILTER_VALIDATE_URL)) || !empty($author));
2567 $has_optional_only = (!empty($author_uri) || !empty($plugin_uri) || (!empty($domain) && filter_var($domain, FILTER_VALIDATE_URL)) || !empty($description));
2568
2569 if ($has_optional_only && !$has_core_field) {
2570 add_settings_error(
2571 'metasync_options',
2572 'whitelabel_partial',
2573 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.',
2574 'error'
2575 );
2576 $new_input['whitelabel'] = $existing_whitelabel;
2577 $whitelabel_validation_failed = true;
2578 } else {
2579 $new_input['whitelabel'] = $existing_whitelabel;
2580
2581 if (isset($input['whitelabel']['logo'])) {
2582 $logo_value = trim($input['whitelabel']['logo']);
2583
2584 if (!empty($logo_value) && filter_var($logo_value, FILTER_VALIDATE_URL)) {
2585 $new_input['whitelabel']['logo'] = esc_url_raw($logo_value);
2586
2587 } else {
2588 $new_input['whitelabel']['logo'] = '';
2589
2590 }
2591 }
2592
2593 if (isset($input['whitelabel']['logo_light'])) {
2594 $logo_light_value = trim($input['whitelabel']['logo_light']);
2595
2596 if (!empty($logo_light_value) && filter_var($logo_light_value, FILTER_VALIDATE_URL)) {
2597 $new_input['whitelabel']['logo_light'] = esc_url_raw($logo_light_value);
2598 } else {
2599 $new_input['whitelabel']['logo_light'] = '';
2600 }
2601 }
2602
2603 if (isset($input['whitelabel']['logo_dark'])) {
2604 $logo_dark_value = trim($input['whitelabel']['logo_dark']);
2605
2606 if (!empty($logo_dark_value) && filter_var($logo_dark_value, FILTER_VALIDATE_URL)) {
2607 $new_input['whitelabel']['logo_dark'] = esc_url_raw($logo_dark_value);
2608 } else {
2609 $new_input['whitelabel']['logo_dark'] = '';
2610 }
2611 }
2612
2613 if (isset($input['whitelabel']['domain'])) {
2614 $domain_value = trim($input['whitelabel']['domain']);
2615
2616 if (!empty($domain_value) && filter_var($domain_value, FILTER_VALIDATE_URL)) {
2617 $new_input['whitelabel']['domain'] = esc_url_raw($domain_value);
2618
2619 } else {
2620 $old_domain = $existing_whitelabel['domain'] ?? '';
2621 $new_input['whitelabel']['domain'] = '';
2622
2623 if (!empty($old_domain)) {
2624 $new_input['_trigger_heartbeat_after_save'] = 'Domain cleared from: ' . $old_domain;
2625 }
2626 }
2627 }
2628
2629 if (isset($input['whitelabel']['settings_password'])) {
2630 // WordPress unslashes registered settings before invoking the
2631 // sanitize callback. Preserve the resulting password exactly.
2632 $password_value = (string) $input['whitelabel']['settings_password'];
2633 $encrypted_password = Metasync::encrypt_secret($password_value);
2634 if ($password_value === '') {
2635 $new_input['whitelabel']['settings_password'] = '';
2636 } elseif (Metasync::is_encrypted_secret($password_value)) {
2637 // update_option() may invoke this sanitizer after the
2638 // AJAX handler has already encrypted the value. Treat
2639 // valid ciphertext as an idempotent input, not a failed
2640 // encryption attempt.
2641 $new_input['whitelabel']['settings_password'] = $password_value;
2642 } elseif (Metasync::is_encrypted_secret($encrypted_password) && $encrypted_password !== $password_value) {
2643 $new_input['whitelabel']['settings_password'] = $encrypted_password;
2644 } else {
2645 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
2646 add_settings_error(
2647 'metasync_messages',
2648 'metasync_password_encryption_failed',
2649 __('Unable to securely save the password. Please choose a different password.', 'metasync'),
2650 'error'
2651 );
2652 }
2653 } else {
2654 if (isset($existing_whitelabel['settings_password'])) {
2655 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2656 }
2657 }
2658
2659 if (isset($input['whitelabel']['recovery_email'])) {
2660 $recovery_email = trim($input['whitelabel']['recovery_email']);
2661 if (!empty($recovery_email)) {
2662 $sanitized_email = sanitize_email($recovery_email);
2663 if (is_email($sanitized_email)) {
2664 $new_input['whitelabel']['recovery_email'] = $sanitized_email;
2665 } else {
2666 add_settings_error(
2667 'metasync_messages',
2668 'metasync_message',
2669 __('Invalid recovery email address.', 'metasync'),
2670 'error'
2671 );
2672 }
2673 } else {
2674 $new_input['whitelabel']['recovery_email'] = '';
2675 }
2676 } else {
2677 if (isset($existing_whitelabel['recovery_email'])) {
2678 $new_input['whitelabel']['recovery_email'] = $existing_whitelabel['recovery_email'];
2679 }
2680 }
2681
2682 $has_password = !empty($new_input['whitelabel']['settings_password']);
2683 $has_recovery_email = !empty($new_input['whitelabel']['recovery_email']);
2684 if ($has_password && !$has_recovery_email) {
2685 add_settings_error(
2686 'metasync_messages',
2687 'metasync_message',
2688 __('Password Recovery Email is required when a password is set.', 'metasync'),
2689 'error'
2690 );
2691 $new_input['whitelabel']['settings_password'] = '';
2692 }
2693
2694 if (isset($input['whitelabel']['access_control'])) {
2695 $new_input['whitelabel']['access_control'] = Metasync_Access_Control::sanitize_access_control($input['whitelabel']['access_control']);
2696 }
2697
2698 // Sanitize quick_links (up to 5 entries)
2699 if (isset($input['whitelabel']['quick_links']) && is_array($input['whitelabel']['quick_links'])) {
2700 $sanitized_links = [];
2701 foreach (array_slice($input['whitelabel']['quick_links'], 0, 5) as $link) {
2702 $url = isset($link['url']) ? esc_url_raw(trim((string) $link['url'])) : '';
2703 if (empty($url)) {
2704 continue; // skip blank rows
2705 }
2706 $sanitized_links[] = [
2707 'label' => sanitize_text_field($link['label'] ?? ''),
2708 'url' => $url,
2709 'external' => !empty($link['external']),
2710 ];
2711 }
2712 $new_input['whitelabel']['quick_links'] = $sanitized_links;
2713 } else {
2714 // Preserve existing links when not submitted
2715 if (isset($existing_whitelabel['quick_links'])) {
2716 $new_input['whitelabel']['quick_links'] = $existing_whitelabel['quick_links'];
2717 }
2718 }
2719
2720 // Sanitize color palette
2721 if (isset($input['whitelabel']['color_palette']) && is_array($input['whitelabel']['color_palette'])) {
2722 $allowed_vars = array(
2723 'dashboard-bg', 'dashboard-card-bg', 'dashboard-card-hover',
2724 'dashboard-text-primary', 'dashboard-text-secondary',
2725 'dashboard-accent', 'dashboard-accent-hover',
2726 'dashboard-success', 'dashboard-warning', 'dashboard-error',
2727 'dashboard-border',
2728 'dashboard-gradient-primary-from', 'dashboard-gradient-primary-to',
2729 'dashboard-gradient-accent-from', 'dashboard-gradient-accent-to'
2730 );
2731 $sanitized_palette = array();
2732 foreach (array('dark', 'light') as $theme_key) {
2733 if (isset($input['whitelabel']['color_palette'][$theme_key]) && is_array($input['whitelabel']['color_palette'][$theme_key])) {
2734 $sanitized_palette[$theme_key] = array();
2735 foreach ($input['whitelabel']['color_palette'][$theme_key] as $var_name => $color_value) {
2736 if (in_array($var_name, $allowed_vars, true)) {
2737 $color_value = trim(sanitize_text_field($color_value));
2738 if ($color_value !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $color_value)) {
2739 $sanitized_palette[$theme_key][$var_name] = $color_value;
2740 }
2741 }
2742 }
2743 }
2744 }
2745 $new_input['whitelabel']['color_palette'] = $sanitized_palette;
2746 } else {
2747 if (isset($existing_whitelabel['color_palette'])) {
2748 $new_input['whitelabel']['color_palette'] = $existing_whitelabel['color_palette'];
2749 }
2750 }
2751
2752 $new_input['whitelabel']['updated_at'] = time();
2753 }
2754 } else {
2755 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2756 $has_existing_whitelabel = !empty($existing_whitelabel['domain']) || !empty($existing_whitelabel['logo']);
2757
2758 if ($has_existing_whitelabel) {
2759 // A reset replaces the whole section (password ciphertext,
2760 // palette, quick links and all) rather than merging leaves.
2761 $whitelabel_reset = true;
2762 $new_input['whitelabel'] = [
2763 'is_whitelabel' => false,
2764 'domain' => '',
2765 'logo' => '',
2766 'company_name' => '',
2767 'updated_at' => time()
2768 ];
2769
2770 if (!empty($existing_whitelabel['domain'])) {
2771 delete_transient('metasync_heartbeat_status_cache');
2772 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel settings cleared - domain changed to default');
2773 }
2774 }
2775 }
2776
2777 // Indexation Control Settings. Stored as the strings 'true'/'false' (the form,
2778 // the setup wizard and count_index_rule_changes() all compare against those);
2779 // boolval('false') is true, which silently turned every unchecked box on.
2780 foreach (array('index_date_archives', 'index_tag_archives', 'index_author_archives', 'index_format_archives') as $index_field) {
2781 if (isset($input['seo_controls'][$index_field])) {
2782 $new_input['seo_controls'][$index_field] = filter_var($input['seo_controls'][$index_field], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
2783 }
2784 }
2785
2786 // Handle post-save heartbeat trigger for domain changes
2787 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2788 $context = $new_input['_trigger_heartbeat_after_save'];
2789 unset($new_input['_trigger_heartbeat_after_save']);
2790
2791 add_action('updated_option_metasync_options', function() use ($context) {
2792 delete_transient('metasync_heartbeat_status_cache');
2793 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2794 });
2795 }
2796
2797 // Headless settings are never written through this form. They are owned by
2798 // Metasync_Headless_Config and saved by their own handler, so anything
2799 // arriving here under that key is a stale form or a hand-built request.
2800 // The merge below would let it write over the stored block key by key —
2801 // switching the mode off that way removes the GraphQL field and breaks
2802 // every persisted query a headless frontend holds, so the key is dropped
2803 // rather than merged.
2804 //
2805 // Except when the owner is the one writing. register_setting() makes this
2806 // method a `sanitize_option_metasync_options` filter, and WordPress runs
2807 // it on every update_option() call for the option, not only on Settings
2808 // API form posts — so dropping the key unconditionally also dropped
2809 // Metasync_Headless_Config's own writes, and the Headless Mode block
2810 // could never save anything on a real request.
2811 if (!Metasync_Headless_Config::is_writing()) {
2812 unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2813 }
2814
2815 // Keep the fully sanitized/encrypted white-label values. The layered
2816 // merge below already lets them win; mirroring them into the raw
2817 // layer also excludes stray raw whitelabel keys from the saved option.
2818 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2819 $input['whitelabel'] = $new_input['whitelabel'];
2820 }
2821
2822 // The branding contract must also hold on the native options.php
2823 // submit (administrators with JavaScript disabled or broken — the
2824 // AJAX handler never runs for them): a value that could not be
2825 // written to metasync.php must not be persisted either. A rejected
2826 // key is stripped from the raw layer so the layered merge keeps the
2827 // stored value, leaving options and the physical header untouched.
2828 // The AJAX save and the JSON import already enforce this contract
2829 // before their writes, so for them this is a consistent no-op.
2830 if (!class_exists('Metasync_Activator')) {
2831 // Defensive only: real requests always have the activator via the
2832 // plugin boot / Composer classmap. Plain dirname() (not
2833 // plugin_dir_path()) keeps this loadable in the isolated unit
2834 // harnesses that define neither.
2835 $activator_file = dirname(__FILE__) . '/class-metasync-activator.php';
2836 if (is_readable($activator_file)) {
2837 require_once $activator_file;
2838 }
2839 }
2840 if (class_exists('Metasync_Activator') && isset($input['general']) && is_array($input['general'])) {
2841 $branding_contract_fields = array(
2842 'white_label_plugin_name' => false,
2843 'white_label_plugin_description' => false,
2844 'white_label_plugin_author' => false,
2845 'white_label_plugin_author_uri' => true,
2846 'white_label_plugin_uri' => true,
2847 );
2848 foreach ($branding_contract_fields as $branding_key => $branding_is_url) {
2849 if (!array_key_exists($branding_key, $input['general']) || !is_string($input['general'][$branding_key])) {
2850 continue;
2851 }
2852 $branding_value = trim(wp_unslash($input['general'][$branding_key]));
2853 if ($branding_value === ''
2854 || Metasync_Activator::is_valid_plugin_header_value($branding_value, $branding_is_url)) {
2855 continue;
2856 }
2857 add_settings_error(
2858 'metasync_messages',
2859 'metasync_branding_' . $branding_key,
2860 $branding_is_url
2861 ? sprintf('%s must be a valid http:// or https:// address.', $branding_key === 'white_label_plugin_author_uri' ? 'Author URL' : 'Plugin URL')
2862 : sprintf('%s contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).', $branding_key === 'white_label_plugin_name' ? 'Plugin Name' : ($branding_key === 'white_label_plugin_description' ? 'Plugin Description' : 'Author')),
2863 'error'
2864 );
2865 unset($input['general'][$branding_key]);
2866 }
2867 }
2868
2869 // Layered merge: stored option, then the raw submission, then the
2870 // sanitized values. Replaces the former shallow array_merge(), which
2871 // let the raw submission overwrite every sanitized section wholesale.
2872 $result = self::merge_option_layers($stored_option, $input, $new_input);
2873
2874 // otto_pixel_uuid is read-only. The field is populated only by
2875 // the trusted Search Atlas / heartbeat flows, and sanitize_option runs
2876 // on every update_option() of this option — including the AJAX save and
2877 // a forged POST that smuggles the key in — so this is the last line of
2878 // defence for the shape of the stored value. An empty string (the
2879 // disconnected state) and a valid UUID pass through; anything else
2880 // (a username or other arbitrary text) falls back to the stored value.
2881 // The disconnect flow is unaffected: it unsets the key and writes
2882 // through write_options_without_stored_merge(), never through here.
2883 $result = self::protect_otto_pixel_uuid($result, $stored_option);
2884
2885 if ($whitelabel_reset) {
2886 $result['whitelabel'] = $new_input['whitelabel'];
2887 }
2888
2889 if ($whitelabel_validation_failed) {
2890 $result['whitelabel'] = Metasync::get_option()['whitelabel'] ?? [];
2891 }
2892
2893 delete_transient('metasync_otto_js_detected');
2894
2895 return $result;
2896 }
2897
2898 /**
2899 * AJAX handler for saving general/whitelabel/advanced settings.
2900 *
2901 * Moved from Metasync_Admin – the admin class now delegates here.
2902 */
2903 public function meta_sync_save_settings()
2904 {
2905 $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2906 if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2907 wp_send_json_error(array('message' => 'Invalid nonce'));
2908 return;
2909 }
2910
2911 if (!Metasync::current_user_has_plugin_access()) {
2912 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
2913 return;
2914 }
2915
2916 self::$defer_settings_recovery_lock_release = true;
2917 if (!$this->acquire_settings_recovery_lock()) {
2918 self::$defer_settings_recovery_lock_release = false;
2919 self::$abort_locked_settings_update = false;
2920 return wp_send_json_error(array('message' => 'Password recovery is being processed. No settings were saved; please try again.'), 409);
2921 }
2922
2923 $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
2924 $password_submitted = isset($_POST['metasync_options']['whitelabel']['settings_password']);
2925 if ($password_submitted && !isset($_POST['metasync_pw_reset_generation'])) {
2926 $this->force_release_settings_recovery_lock();
2927 return wp_send_json_error(array('message' => 'A settings password update requires a current page generation. Reload the page and try again.'), 409);
2928 }
2929 if (isset($_POST['metasync_pw_reset_generation'])) {
2930 self::$options_generation_at_sanitize = absint($_POST['metasync_pw_reset_generation']);
2931 if ((int) self::$options_generation_at_sanitize !== $current_generation) {
2932 $this->force_release_settings_recovery_lock();
2933 return wp_send_json_error(array('message' => 'The settings password changed while this page was open. Reload the page and try again.'), 409);
2934 }
2935 } else {
2936 self::$options_generation_at_sanitize = $current_generation;
2937 }
2938
2939 $text_fields = [
2940 'searchatlas_api_key', 'apikey',
2941 'white_label_plugin_name', 'white_label_plugin_description',
2942 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2943 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2944 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2945 // The OTTO Pixel UUID is deliberately NOT listed here. It is
2946 // read-only and populated only by the trusted Search Atlas /
2947 // heartbeat flows; sanitize() preserves the stored value.
2948 'periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2949 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2950 'otto_sg_optimizer_compat',
2951 'bookingpress_session_compat'
2952 ];
2953
2954 $url_fields = [
2955 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2956 ];
2957
2958 $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status','disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2959
2960 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2961
2962 $metasync_options = Metasync::get_option();
2963 if (!is_array($metasync_options)) {
2964 $metasync_options = array();
2965 }
2966 if (!isset($metasync_options['general']) || !is_array($metasync_options['general'])) {
2967 $metasync_options['general'] = array();
2968 }
2969
2970 $validation_errors = [];
2971
2972 $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2973 $general_tab_submitted = ($active_tab === 'general');
2974 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2975
2976 // Whitelabel field validation issues are collected per-field and reported back
2977 // without blocking the rest of the settings save.
2978 $whitelabel_errors = [];
2979
2980 // Branding field errors use the same contract as the physical
2981 // plugin-header updater; they are blocking because a value that cannot
2982 // be written to metasync.php must not be persisted to the options
2983 // either (that divergence is exactly what WP-636 closes).
2984 $branding_errors = [];
2985
2986 // The five white-label branding fields share the plugin-header
2987 // validation contract, so the activator (which owns that contract)
2988 // must be available before the field loops run.
2989 if (!class_exists('Metasync_Activator')) {
2990 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
2991 }
2992
2993 foreach ($text_fields as $field) {
2994
2995 if (isset($_POST['metasync_options']['general'][$field])) {
2996
2997 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2998 $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2999
3000 // searchatlas_api_key must be clearable to support key removal
3001 $clearable_fields = [
3002 'searchatlas_api_key',
3003 'white_label_plugin_name',
3004 'white_label_plugin_description',
3005 'white_label_plugin_author',
3006 'white_label_plugin_menu_slug',
3007 'white_label_plugin_menu_icon',
3008 'whitelabel_otto_name'
3009 ];
3010
3011 if ($value === '' && !in_array($field, $clearable_fields)) {
3012 continue;
3013 }
3014
3015 if ($field === 'white_label_plugin_name') {
3016 $stored_name = (string) ($metasync_options['general']['white_label_plugin_name'] ?? '');
3017 // Only enforce the 18-char limit when the value actually changes so an
3018 // already-saved longer legacy name can be re-saved without an error.
3019 if (strlen($value) > 18 && $value !== $stored_name && wp_unslash($value) !== $stored_name) {
3020 $whitelabel_errors[$field] = 'Plugin name must not exceed 18 characters';
3021 continue;
3022 }
3023 }
3024
3025 // Branding text must satisfy the plugin-header contract. The
3026 // raw value is checked before sanitisation so multiline or
3027 // control-character input is rejected with an error instead of
3028 // being silently mangled into something else that gets saved.
3029 $branding_text_fields = [
3030 'white_label_plugin_name' => 'Plugin Name',
3031 'white_label_plugin_description' => 'Plugin Description',
3032 'white_label_plugin_author' => 'Author',
3033 ];
3034 if (isset($branding_text_fields[$field])
3035 && $value !== ''
3036 && !Metasync_Activator::is_valid_plugin_header_value($value, false)) {
3037 $branding_errors[$field] = $branding_text_fields[$field]
3038 . ' contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).';
3039 continue;
3040 }
3041
3042 if ($field === 'white_label_plugin_menu_icon') {
3043
3044 if ($value === '') {
3045 $metasync_options['general'][$field] = '';
3046 } elseif (preg_match('/^dashicons-[a-z0-9-]+$/', $value)) {
3047 // Accept dashicon names (e.g. dashicons-location) in addition to image URLs
3048 $metasync_options['general'][$field] = sanitize_text_field($value);
3049 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
3050
3051 $image_extensions = ['png', 'svg'];
3052
3053 $path = wp_parse_url($value, PHP_URL_PATH);
3054 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
3055
3056 if (in_array($extension, $image_extensions, true)) {
3057 $metasync_options['general'][$field] = esc_url_raw($value);
3058 } else {
3059 $whitelabel_errors[$field] = 'Invalid Menu icon format. Only PNG, SVG, or a dashicon name (e.g. dashicons-location) are allowed.';
3060 }
3061 } else {
3062 $whitelabel_errors[$field] = 'Invalid Menu icon URL format.';
3063 }
3064 } elseif ($field === 'white_label_plugin_menu_slug') {
3065 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
3066 // menu slug so it can never break sidebar link generation.
3067 $metasync_options['general'][$field] = sanitize_title($value);
3068 } else {
3069 $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
3070 }
3071 }
3072 }
3073
3074 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
3075 if ($general_tab_submitted) {
3076 foreach ($textarea_fields as $field) {
3077 if (isset($_POST['metasync_options']['general'][$field])) {
3078 $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
3079 } else {
3080 $metasync_options['general'][$field] = '';
3081 }
3082 }
3083 }
3084
3085 if ($general_tab_submitted) {
3086 foreach ($bool_fields as $field) {
3087 if (isset($_POST['metasync_options']['general'][$field])) {
3088 $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
3089 }else {
3090 $metasync_options['general'][$field] = false;
3091 }
3092 }
3093
3094 // SEO priority is a checkbox that stores a string rather than a
3095 // bool, so it cannot ride the loop above. Only the exact opt-in
3096 // value is stored; an unchecked box (or any tampered payload)
3097 // falls back to custom-first, which is the pre-existing behaviour.
3098 $metasync_options['general']['seo_priority'] =
3099 (isset($_POST['metasync_options']['general']['seo_priority'])
3100 && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
3101 ? Metasync_Seo_Precedence::PRIORITY_OTTO
3102 : 'custom';
3103 }
3104
3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
3106 foreach ($url_fields as $field) {
3107 if (isset($_POST['metasync_options']['general'][$field])) {
3108
3109 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
3110 $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
3111
3112 $whitelabel_clearable_url_fields = [
3113 'white_label_plugin_author_uri',
3114 'white_label_plugin_uri'
3115 ];
3116
3117 if ($value === '' && !in_array($field, $whitelabel_clearable_url_fields)) {
3118 continue;
3119 }
3120
3121 if ($value === '' && in_array($field, $whitelabel_clearable_url_fields)) {
3122 $metasync_options['general'][$field] = '';
3123 continue;
3124 }
3125
3126 if (filter_var($value, FILTER_VALIDATE_URL)) {
3127
3128 $parsed_url = wp_parse_url($value);
3129 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
3130
3131 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
3132 // Branding URLs must additionally satisfy the plugin-header
3133 // contract (http/https scheme, no embedded control characters);
3134 // anything else would be saved but silently fail to reach
3135 // metasync.php, leaving options inconsistent with the Plugins
3136 // screen branding.
3137 $branding_url_field_names = [
3138 'white_label_plugin_author_uri' => 'Author URL',
3139 'white_label_plugin_uri' => 'Plugin URL',
3140 ];
3141 if (!Metasync_Activator::is_valid_plugin_header_value($value, true)) {
3142 $branding_errors[$field] = $branding_url_field_names[$field]
3143 . ' must be a valid http:// or https:// address.';
3144 } else {
3145 $metasync_options['general'][$field] = esc_url_raw($value);
3146 }
3147 } else {
3148 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
3149 // Pushed into $branding_errors only: the blocking
3150 // merge below appends every branding message to the
3151 // response errors, so a second direct push would render
3152 // the same message twice.
3153 $branding_errors[$field] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
3154 }
3155 } else {
3156 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
3157 $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.';
3158 }
3159 }
3160 }
3161 }
3162
3163 if ($general_tab_submitted) {
3164 if (isset($_POST['metasync_options']['general']['content_genius_sync_roles']) && is_array($_POST['metasync_options']['general']['content_genius_sync_roles'])) {
3165 $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
3166 } else {
3167 $metasync_options['general']['content_genius_sync_roles'] = array();
3168 }
3169 }
3170
3171 // Breadcrumb settings live on their own admin page, which saves through
3172 // options.php -> sanitize() rather than this AJAX handler. Only touch
3173 // them here if the submitted form actually carried breadcrumb fields,
3174 // otherwise a General-tab save would reset every breadcrumb option.
3175 if ($general_tab_submitted
3176 && isset($_POST['metasync_options']['breadcrumbs'])
3177 && is_array($_POST['metasync_options']['breadcrumbs'])
3178 ) {
3179 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
3180 $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
3181
3182 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
3183 $metasync_options['breadcrumbs'] = array();
3184 }
3185
3186 foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
3187 if (isset($breadcrumbs_input[$breadcrumb_flag])) {
3188 $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
3189 }
3190 }
3191 // An empty separator (Custom radio selected blank) keeps the
3192 // stored value instead of rendering a blank gap between links.
3193 if (isset($breadcrumbs_input['separator'])) {
3194 $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
3195 if ('' !== $breadcrumb_separator) {
3196 $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
3197 }
3198 }
3199 if (isset($breadcrumbs_input['home_label'])) {
3200 $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
3201 }
3202 if (isset($breadcrumbs_input['home_url'])) {
3203 $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
3204 }
3205 if (isset($breadcrumbs_input['prefix_text'])) {
3206 $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
3207 }
3208 if (isset($breadcrumbs_input['archive_label_format'])) {
3209 $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
3210 }
3211 }
3212
3213 // Open Graph / article toggle checkboxes live under common_meta_settings.
3214 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
3215 // write 'false' for any missing toggle to persist opt-outs.
3216 if ($general_tab_submitted) {
3217 $og_toggle_fields = [
3218 'open_graph_meta_tags',
3219 'facebook_meta_tags',
3220 'twitter_meta_tags',
3221 'og_image_dimensions',
3222 'article_timestamps',
3223 'article_author',
3224 'article_section',
3225 'article_tags',
3226 'twitter_image_alt',
3227 ];
3228 if (!isset($metasync_options['common_meta_settings']) || !is_array($metasync_options['common_meta_settings'])) {
3229 $metasync_options['common_meta_settings'] = [];
3230 }
3231 foreach ($og_toggle_fields as $field) {
3232 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
3233 ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
3234 : '';
3235 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
3236 }
3237 }
3238
3239 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
3240 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
3241 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
3242 $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
3243 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
3244 $metasync_options['general']['default_page_builder'] = $builder_value;
3245 } else {
3246 $metasync_options['general']['default_page_builder'] = 'gutenberg';
3247 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3248 /* translators: %s: page builder name. */
3249 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
3250 }
3251 }
3252 }
3253
3254 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
3255 // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3256 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3257 $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3258 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3259 $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
3260 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
3261 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
3262 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
3263 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
3264 $author_uri = isset($gp['white_label_plugin_author_uri']) ? trim((string) $gp['white_label_plugin_author_uri']) : '';
3265 $plugin_uri = isset($gp['white_label_plugin_uri']) ? trim((string) $gp['white_label_plugin_uri']) : '';
3266 $domain = isset($wp['domain']) ? trim((string) $wp['domain']) : '';
3267 $description = isset($gp['white_label_plugin_description']) ? trim((string) $gp['white_label_plugin_description']) : '';
3268
3269 $has_core_field = (!empty($plugin_name) || (!empty($logo_light) && filter_var($logo_light, FILTER_VALIDATE_URL)) || (!empty($logo_dark) && filter_var($logo_dark, FILTER_VALIDATE_URL)) || !empty($author));
3270 $has_optional_only = (!empty($author_uri) || !empty($plugin_uri) || (!empty($domain) && filter_var($domain, FILTER_VALIDATE_URL)) || !empty($description));
3271
3272 if ($has_optional_only && !$has_core_field) {
3273 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
3274 }
3275 }
3276
3277 // Branding errors block the whole save: an invalid value must never
3278 // be persisted, and the response carries the field keys so the client
3279 // can render the error beside each offending input.
3280 foreach ($branding_errors as $branding_error) {
3281 $validation_errors[] = $branding_error;
3282 }
3283
3284 if (!empty($validation_errors)) {
3285 $this->force_release_settings_recovery_lock();
3286 wp_send_json_error([
3287 'errors' => $validation_errors,
3288 'field_errors' => $branding_errors,
3289 ]);
3290 return;
3291 }
3292
3293 // The analytics consent toggle posts at the top level (hidden fallback
3294 // plus checkbox), not under metasync_options, so persist it here. This
3295 // is the "turn it off at any time" path promised in the readme.
3296 if ($general_tab_submitted && isset($_POST['metasync_analytics_opt_in'])) {
3297 $consent = sanitize_key(wp_unslash($_POST['metasync_analytics_opt_in']));
3298 update_option('metasync_analytics_opt_in', $consent === 'yes' ? 'yes' : 'no');
3299 update_option('metasync_analytics_consent_explicit', 'yes');
3300 }
3301
3302 $old_options = Metasync::get_option('general') ?? [];
3303
3304 // White-label branding is persisted twice — into the options and into
3305 // the physical metasync.php header — so the header update is applied
3306 // BEFORE any option write. On failure nothing has been persisted and
3307 // both the file and the stored options still carry the previous
3308 // branding; the save is rejected instead of ending up inconsistent.
3309 $branding_headers_written = false;
3310 $original_plugin_file_content = false;
3311 $plugin_header_file = Metasync_Activator::plugin_header_file_path();
3312 if ($general_tab_submitted || $whitelabel_tab_submitted) {
3313 $original_plugin_file_content = file_get_contents($plugin_header_file);
3314 $headers_applied = Metasync_Activator::apply_plugin_file_headers_for_save(
3315 $metasync_options['general'],
3316 is_array($old_options) ? $old_options : array()
3317 );
3318 if ($headers_applied === false) {
3319 $this->force_release_settings_recovery_lock();
3320 wp_send_json_error([
3321 'errors' => array(
3322 'White-label branding could not be written to the plugin file (metasync.php may be read-only or damaged). Nothing was saved; the previous branding is unchanged.',
3323 ),
3324 ]);
3325 // The return is unreachable in WordPress (wp_send_json_error
3326 // terminates the request) but keeps the handler safe under the
3327 // non-terminating stubs the unit tests run with.
3328 // @phpstan-ignore-next-line deadCode.unreachable
3329 return;
3330 }
3331 if ($headers_applied === true && is_string($original_plugin_file_content)) {
3332 $branding_headers_written = file_get_contents($plugin_header_file) !== $original_plugin_file_content;
3333 }
3334 }
3335
3336 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
3337 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
3338 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
3339 $old_api_key = Metasync::get_searchatlas_api_key();
3340 if ( $old_api_key === false ) {
3341 // Stored key could not be decrypted (salts changed). Treat as no usable key.
3342 $old_api_key = '';
3343 }
3344
3345 $api_key_field_present = isset( $_POST['metasync_options']['general']['searchatlas_api_key'] );
3346 $api_key_validated = null; // null = not checked, true = valid, false = rejected
3347 $is_connected = null;
3348 $api_key_warning = null;
3349
3350 // The submitted key arrives as plaintext; capture it for comparison before
3351 // encrypting it for storage. When the field is absent, preserve the existing
3352 // encrypted value untouched so a normal settings save never disturbs the key.
3353 $proposed_new_api_key = $api_key_field_present ? ( $metasync_options['general']['searchatlas_api_key'] ?? '' ) : '';
3354 if ( $api_key_field_present ) {
3355 // encrypt_api_key('') === '' so this also handles key removal.
3356 $metasync_options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key( $proposed_new_api_key );
3357 } else {
3358 $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
3359 }
3360
3361 // When the API key changes, save it first, then POST heartbeat to register
3362 // the WPWebsiteHeartbeat row on the backend, then GET ping to verify.
3363 // The old flow did GET ping first, which failed because the row didn't exist yet.
3364 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
3365 // Step 1: Save the new (encrypted) key so SyncCustomerParams can use it
3366 Metasync::set_option($metasync_options);
3367 Metasync::invalidate_api_key_cache();
3368
3369 // Step 2: POST heartbeat to register the site (creates WPWebsiteHeartbeat row)
3370 $sync_request = new Metasync_Sync_Requests();
3371 $sync_response = $sync_request->SyncCustomerParams();
3372 $sync_status = Metasync_Sync_Requests::get_response_code($sync_response);
3373
3374 if ( $sync_status == 200 ) {
3375 // Step 3: GET ping to verify registration succeeded
3376 $ping = Metasync_Heartbeat_Manager::instance()->validate_api_key( $proposed_new_api_key );
3377
3378 if ( $ping['connected'] === true ) {
3379 $api_key_validated = true;
3380 $is_connected = true;
3381 // Trusted ping flow: only a real UUID is stored. The
3382 // form itself can no longer submit this field, so this ping is
3383 // one of the few remaining sanctioned write paths for it.
3384 if ( ! empty( $ping['otto_pixel_uuid'] ) && Metasync::is_valid_uuid( $ping['otto_pixel_uuid'] ) ) {
3385 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
3386 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
3387 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3388 Metasync::set_option($metasync_options);
3389 }
3390 } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3391 error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)');
3392 }
3393 } else {
3394 // POST succeeded but ping didn't confirm — keep the key, warn user
3395 $api_key_validated = true;
3396 $is_connected = true;
3397 $api_key_warning = 'API key saved and heartbeat sent. Connection verification will complete on the next heartbeat.';
3398 }
3399 } elseif ( is_wp_error($sync_response) ) {
3400 // Network error during POST — keep the key, warn user
3401 $api_key_warning = 'Could not register with the server (network error). The key was saved — it will be verified on the next heartbeat.';
3402 } else {
3403 // POST returned non-200 — the key is likely invalid, revert to the
3404 // previously stored (already-encrypted) value.
3405 $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
3406 Metasync::set_option($metasync_options);
3407 Metasync::invalidate_api_key_cache();
3408 $api_key_validated = false;
3409 $is_connected = false;
3410 $api_key_warning = empty( $old_api_key )
3411 ? 'The API key could not be verified. Please check it and try again.'
3412 : 'The API key could not be verified. Your API key has been reverted to the previously saved key.';
3413 }
3414 } else {
3415 // Key unchanged, removed, or field absent — just save options.
3416 // $metasync_options already holds the correct stored value: the
3417 // re-encrypted submission (present) or the preserved encrypted value
3418 // (absent); removal stored encrypt_api_key('') === ''.
3419 Metasync::set_option($metasync_options);
3420 Metasync::invalidate_api_key_cache();
3421 }
3422
3423 // Update connection state immediately when validated
3424 if ( $api_key_validated === true ) {
3425 update_option( 'metasync_last_known_connection_state', true );
3426 set_transient( 'metasync_heartbeat_status_cache', [
3427 'status' => true,
3428 'timestamp' => time(),
3429 'cached_until' => time() + 300,
3430 'updated_by' => 'manual_save_ping_connected',
3431 ], 300 );
3432 }
3433
3434 // Compare decrypted plaintext values — the stored value is ciphertext.
3435 Metasync::invalidate_api_key_cache();
3436 $new_api_key = Metasync::get_searchatlas_api_key();
3437 if ( $new_api_key === false ) {
3438 $new_api_key = '';
3439 }
3440
3441 $api_key_changed = $old_api_key !== $new_api_key;
3442 $api_key_added = empty($old_api_key) && !empty($new_api_key);
3443 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
3444
3445 if ( $api_key_removed ) {
3446 Metasync_PostHog::feature('searchatlas_disconnected', array(
3447 'method' => 'key_removed',
3448 'was_connected' => true,
3449 ));
3450 }
3451
3452 if ( $api_key_validated === true ) {
3453 # Use current_time('mysql') so the value is consistent with the
3454 # cron heartbeat writer and renders correctly via time_elapsed_string.
3455 $send_auth_token_timestamp = Metasync::get_option();
3456 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
3457 Metasync::set_option($send_auth_token_timestamp);
3458
3459 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
3460 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - validated via ping');
3461 } else {
3462 // Key unchanged or removed — run sync as before
3463 $sync_request = new Metasync_Sync_Requests();
3464 $response = $sync_request->SyncCustomerParams();
3465 $responseCode = Metasync_Sync_Requests::get_response_code($response);
3466
3467 if ($responseCode == 200) {
3468 # Use current_time('mysql') for consistency with cron heartbeat.
3469 $send_auth_token_timestamp = Metasync::get_option();
3470 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
3471 Metasync::set_option($send_auth_token_timestamp);
3472
3473 if ($api_key_added) {
3474 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
3475 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - new key added');
3476 } elseif ($api_key_removed) {
3477 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
3478 delete_transient('metasync_heartbeat_status_cache');
3479 } elseif ($api_key_changed && !empty($new_api_key)) {
3480 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - key changed');
3481 }
3482 }
3483 }
3484
3485 if (isset($_POST['metasync_options']['whitelabel'])) {
3486
3487 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3488 $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
3489 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
3490
3491 if (isset($whitelabel_data['logo'])) {
3492 $logo_value = trim($whitelabel_data['logo']);
3493
3494 if (!empty($logo_value) && filter_var($logo_value, FILTER_VALIDATE_URL)) {
3495 $metasync_options['whitelabel']['logo'] = esc_url_raw($logo_value);
3496 } else {
3497 $metasync_options['whitelabel']['logo'] = '';
3498 }
3499 }
3500
3501 if (isset($whitelabel_data['logo_light'])) {
3502 $logo_light_value = trim($whitelabel_data['logo_light']);
3503
3504 if (!empty($logo_light_value) && filter_var($logo_light_value, FILTER_VALIDATE_URL)) {
3505 $metasync_options['whitelabel']['logo_light'] = esc_url_raw($logo_light_value);
3506 } else {
3507 $metasync_options['whitelabel']['logo_light'] = '';
3508 }
3509 }
3510
3511 if (isset($whitelabel_data['logo_dark'])) {
3512 $logo_dark_value = trim($whitelabel_data['logo_dark']);
3513
3514 if (!empty($logo_dark_value) && filter_var($logo_dark_value, FILTER_VALIDATE_URL)) {
3515 $metasync_options['whitelabel']['logo_dark'] = esc_url_raw($logo_dark_value);
3516 } else {
3517 $metasync_options['whitelabel']['logo_dark'] = '';
3518 }
3519 }
3520
3521 if (isset($whitelabel_data['domain'])) {
3522 $domain_value = trim($whitelabel_data['domain']);
3523 if (!empty($domain_value) && filter_var($domain_value, FILTER_VALIDATE_URL)) {
3524 $metasync_options['whitelabel']['domain'] = esc_url_raw($domain_value);
3525 } else {
3526 $metasync_options['whitelabel']['domain'] = '';
3527 }
3528 }
3529
3530 if (isset($whitelabel_data['settings_password'])) {
3531 $password_value = (string) $whitelabel_data['settings_password'];
3532
3533 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
3534
3535 if ($hide_settings_enabled && empty($password_value)) {
3536 if (isset($existing_whitelabel['settings_password'])) {
3537 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
3538 }
3539 } elseif ($password_value === '') {
3540 $metasync_options['whitelabel']['settings_password'] = '';
3541 } else {
3542 $encrypted_password = Metasync::encrypt_secret($password_value);
3543 if (Metasync::is_encrypted_secret($encrypted_password) && !hash_equals($password_value, $encrypted_password)) {
3544 $metasync_options['whitelabel']['settings_password'] = $encrypted_password;
3545 } else {
3546 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
3547 $whitelabel_errors['settings_password'] = 'Unable to securely save the password. Please choose a different password.';
3548 }
3549 }
3550 } else {
3551 if (isset($existing_whitelabel['settings_password'])) {
3552 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
3553 }
3554 }
3555
3556 if (isset($whitelabel_data['hide_settings'])) {
3557 $metasync_options['whitelabel']['hide_settings'] = filter_var($whitelabel_data['hide_settings'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
3558 } else {
3559 $metasync_options['whitelabel']['hide_settings'] = 0;
3560 }
3561
3562 if (isset($whitelabel_data['recovery_email'])) {
3563 $recovery_email = trim($whitelabel_data['recovery_email']);
3564 if (!empty($recovery_email)) {
3565 $sanitized_email = sanitize_email($recovery_email);
3566 if (is_email($sanitized_email)) {
3567 $metasync_options['whitelabel']['recovery_email'] = $sanitized_email;
3568 }
3569 } else {
3570 $metasync_options['whitelabel']['recovery_email'] = '';
3571 }
3572 } else {
3573 if (isset($existing_whitelabel['recovery_email'])) {
3574 $metasync_options['whitelabel']['recovery_email'] = $existing_whitelabel['recovery_email'];
3575 }
3576 }
3577
3578 $has_password = !empty($metasync_options['whitelabel']['settings_password']);
3579 $has_recovery_email = !empty($metasync_options['whitelabel']['recovery_email']);
3580 if ($has_password && !$has_recovery_email) {
3581 $metasync_options['whitelabel']['settings_password'] = '';
3582 }
3583
3584 if (isset($whitelabel_data['access_control'])) {
3585 $metasync_options['whitelabel']['access_control'] = Metasync_Access_Control::sanitize_access_control($whitelabel_data['access_control']);
3586 }
3587
3588 if (!empty($metasync_options['whitelabel']['hide_settings']) && empty($metasync_options['whitelabel']['settings_password'])) {
3589 $metasync_options['whitelabel']['hide_settings'] = 0;
3590 }
3591
3592 // Sanitize color palette
3593 if (isset($whitelabel_data['color_palette']) && is_array($whitelabel_data['color_palette'])) {
3594 $allowed_vars = array(
3595 'dashboard-bg', 'dashboard-card-bg', 'dashboard-card-hover',
3596 'dashboard-text-primary', 'dashboard-text-secondary',
3597 'dashboard-accent', 'dashboard-accent-hover',
3598 'dashboard-success', 'dashboard-warning', 'dashboard-error',
3599 'dashboard-border',
3600 'dashboard-gradient-primary-from', 'dashboard-gradient-primary-to',
3601 'dashboard-gradient-accent-from', 'dashboard-gradient-accent-to'
3602 );
3603 $sanitized_palette = array();
3604 foreach (array('dark', 'light') as $theme_key) {
3605 if (isset($whitelabel_data['color_palette'][$theme_key]) && is_array($whitelabel_data['color_palette'][$theme_key])) {
3606 $sanitized_palette[$theme_key] = array();
3607 foreach ($whitelabel_data['color_palette'][$theme_key] as $var_name => $color_value) {
3608 if (in_array($var_name, $allowed_vars, true)) {
3609 $color_value = trim(sanitize_text_field($color_value));
3610 if ($color_value !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $color_value)) {
3611 $sanitized_palette[$theme_key][$var_name] = $color_value;
3612 }
3613 }
3614 }
3615 }
3616 }
3617 $metasync_options['whitelabel']['color_palette'] = $sanitized_palette;
3618 } else {
3619 if (isset($existing_whitelabel['color_palette'])) {
3620 $metasync_options['whitelabel']['color_palette'] = $existing_whitelabel['color_palette'];
3621 }
3622 }
3623
3624 $metasync_options['whitelabel']['updated_at'] = time();
3625
3626 Metasync::set_option($metasync_options);
3627
3628 }
3629
3630 $redirect_url = isset($_GET['tab']) ?
3631 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
3632 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3633
3634 // Also save edge cache settings if present in the payload (section renders inside this form)
3635 if (class_exists('Metasync_Edge_Cache_Settings')) {
3636 Metasync_Edge_Cache_Settings::save_from_post();
3637 }
3638
3639 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3640 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
3641 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3642 if (is_array($llms_input)) {
3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3644 }
3645 }
3646
3647 // The plugin file already carries the new branding; confirm the
3648 // options write landed the same values. On divergence, roll the file
3649 // back to the captured content so the Plugins screen never shows
3650 // branding the stored options contradict, and report the save as
3651 // rejected rather than a success.
3652 if ($branding_headers_written) {
3653 $stored_branding = Metasync::get_option('general');
3654 $stored_branding = is_array($stored_branding) ? $stored_branding : array();
3655 $proposed_branding = $metasync_options['general'];
3656 foreach (Metasync_Activator::BRANDING_HEADER_FIELDS as $branding_key) {
3657 if ((string) ($stored_branding[$branding_key] ?? '') !== (string) ($proposed_branding[$branding_key] ?? '')) {
3658 $restored = Metasync_Activator::restore_plugin_file_content($plugin_header_file, $original_plugin_file_content);
3659 $this->force_release_settings_recovery_lock();
3660 wp_send_json_error([
3661 'errors' => array(
3662 $restored
3663 ? 'White-label branding could not be saved. The previous settings and plugin branding were left unchanged.'
3664 : 'White-label branding could not be saved and the plugin file branding could not be rolled back (metasync.php may be read-only). Re-save once the file is writable so the plugin header matches the stored settings.',
3665 ),
3666 ]);
3667 // The return is unreachable in WordPress (wp_send_json_error
3668 // terminates the request) but keeps the handler safe under the
3669 // non-terminating stubs the unit tests run with.
3670 // @phpstan-ignore-next-line deadCode.unreachable
3671 return;
3672 }
3673 }
3674 }
3675
3676 $response_payload = array(
3677 'message' => 'Settings saved successfully!',
3678 'redirect_url' => $redirect_url,
3679 );
3680
3681 // Per-field whitelabel validation issues (non-blocking): the save succeeded for all
3682 // other fields, but the user must be told which values were rejected.
3683 if (!empty($whitelabel_errors)) {
3684 $response_payload['whitelabel_errors'] = $whitelabel_errors;
3685 }
3686
3687 // Final slug after sanitization so the client redirects to the page WP actually
3688 // registered, even when the submitted value was cleaned up.
3689 if (isset($_POST['metasync_options']['general']['white_label_plugin_menu_slug'])) {
3690 $response_payload['saved_menu_slug'] = $metasync_options['general']['white_label_plugin_menu_slug'] ?? '';
3691 }
3692
3693 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
3694 if ( $api_key_validated !== null ) {
3695 $response_payload['api_key_validated'] = $api_key_validated;
3696 $response_payload['is_connected'] = $is_connected;
3697 }
3698 if ( $api_key_warning !== null ) {
3699 $response_payload['warning'] = $api_key_warning;
3700 }
3701 // Note: the previous key is intentionally NOT returned. The key is now
3702 // managed via one-click auth and shown masked, so the cleartext value
3703 // must never be sent to the browser.
3704 } elseif ( $api_key_field_present && $proposed_new_api_key === '' && $old_api_key !== '' ) {
3705 $response_payload['api_key_removed'] = true;
3706 $response_payload['warning'] = 'The API key has been removed. The plugin will no longer sync with the server.';
3707 }
3708
3709 delete_transient('metasync_otto_js_detected');
3710
3711 $this->force_release_settings_recovery_lock();
3712
3713 wp_send_json_success( $response_payload );
3714 }
3715
3716 /**
3717 * AJAX handler for saving execution settings.
3718 *
3719 * Moved from Metasync_Admin – the admin class now delegates here.
3720 */
3721 public function ajax_save_execution_settings()
3722 {
3723 $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3724 if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
3725 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3726 return;
3727 }
3728
3729 if (!Metasync::current_user_has_plugin_access()) {
3730 wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
3731 return;
3732 }
3733
3734 $existing_settings = get_option('metasync_execution_settings', array());
3735
3736 $settings = array(
3737 'max_execution_time' => isset($_POST['max_execution_time']) ? absint($_POST['max_execution_time']) : 30,
3738 'max_memory_limit' => isset($_POST['max_memory_limit']) ? absint($_POST['max_memory_limit']) : 256,
3739 'log_batch_size' => isset($_POST['log_batch_size']) ? absint($_POST['log_batch_size']) : 1000,
3740 'action_scheduler_batches' => isset($_POST['action_scheduler_batches']) ? absint($_POST['action_scheduler_batches']) : 1,
3741 'otto_rate_limit' => isset($_POST['otto_rate_limit']) ? absint($_POST['otto_rate_limit']) : 10,
3742 'otto_cache_ttl' => isset($existing_settings['otto_cache_ttl']) ? absint($existing_settings['otto_cache_ttl']) : 30,
3743 'queue_cleanup_days' => isset($_POST['queue_cleanup_days']) ? absint($_POST['queue_cleanup_days']) : 31
3744 );
3745
3746 $server_limits = Metasync_Settings_Fields::instance()->get_server_limits();
3747
3748 if ($settings['max_execution_time'] < 1 || $settings['max_execution_time'] > 300) {
3749 wp_send_json_error(array('message' => 'Max Execution Time must be between 1 and 300 seconds.'));
3750 return;
3751 }
3752
3753 if ((int)$server_limits['max_execution_time_raw'] !== -1 && (int)$settings['max_execution_time'] > (int)$server_limits['max_execution_time_raw']) {
3754 wp_send_json_error(array('message' => sprintf('Max Execution Time exceeds server limit of %d seconds. Please reduce the value.', (int)$server_limits['max_execution_time_raw'])));
3755 return;
3756 }
3757
3758 $can_change_memory = Metasync_Settings_Fields::instance()->can_change_memory_limit();
3759 $memory_range = Metasync_Settings_Fields::instance()->get_memory_limit_allowed_range();
3760 if ($can_change_memory && $settings['max_memory_limit'] < $memory_range['min']) {
3761 wp_send_json_error(array('message' => sprintf('Max Memory Limit must be at least %d MB.', $memory_range['min'])));
3762 return;
3763 }
3764
3765 if ($can_change_memory && $memory_range['max'] !== null && $settings['max_memory_limit'] > $memory_range['max']) {
3766 wp_send_json_error(array('message' => sprintf('Max Memory Limit must be between %d and %d MB.', $memory_range['min'], $memory_range['max'])));
3767 return;
3768 }
3769
3770 if (!$can_change_memory) {
3771 $settings['max_memory_limit'] = isset($existing_settings['max_memory_limit'])
3772 ? $existing_settings['max_memory_limit']
3773 : 256;
3774 }
3775
3776 if ($settings['log_batch_size'] < 100 || $settings['log_batch_size'] > 5000) {
3777 wp_send_json_error(array('message' => 'Log Batch Size must be between 100 and 5000 lines.'));
3778 return;
3779 }
3780
3781 if ($settings['action_scheduler_batches'] < 1 || $settings['action_scheduler_batches'] > 10) {
3782 wp_send_json_error(array('message' => 'Action Scheduler Concurrent Batches must be between 1 and 10.'));
3783 return;
3784 }
3785
3786 if ($settings['otto_rate_limit'] < 1 || $settings['otto_rate_limit'] > 60) {
3787 wp_send_json_error(array('message' => 'OTTO API Calls Per Minute must be between 1 and 60.'));
3788 return;
3789 }
3790
3791 if ($settings['queue_cleanup_days'] < 7 || $settings['queue_cleanup_days'] > 90) {
3792 wp_send_json_error(array('message' => 'Queue Auto-Cleanup Days must be between 7 and 90 days.'));
3793 return;
3794 }
3795
3796 // update_option() returns false both on real failure AND when the
3797 // submitted values are identical to what's already stored (a no-op
3798 // write). An unchanged save is not an error, so short-circuit to
3799 // success when nothing actually changed. This also covers the case
3800 // where the form fires twice in quick succession: the second request
3801 // sees no change and would otherwise report a spurious failure.
3802 if ($settings == $existing_settings) {
3803 wp_send_json_success(array(
3804 'message' => 'Execution settings saved successfully!'
3805 ));
3806 return;
3807 }
3808
3809 $saved = update_option('metasync_execution_settings', $settings);
3810
3811 if ($saved !== false) {
3812 wp_send_json_success(array(
3813 'message' => 'Execution settings saved successfully!'
3814 ));
3815 } else {
3816 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3817 }
3818 }
3819
3820 # ------------------------------------------------------------------
3821 # Headless Mode settings on the Advanced tab
3822 # ------------------------------------------------------------------
3823 # Metasync_Headless_Config is the only sanitiser for these four values.
3824 # Nothing here validates a domain, a policy or a prefix — it collects
3825 # what was submitted, hands it to update_settings(), and then shows back
3826 # whatever that stored, so a value the sanitiser refused reads as refused
3827 # instead of being echoed back as though it saved.
3828 # ------------------------------------------------------------------
3829
3830 /**
3831 * Field prefix shared by every control in the Headless Mode block.
3832 *
3833 * The controls sit inside the Advanced tab's outer form, which posts to
3834 * options.php. Naming them outside `metasync_options[...]` keeps them out
3835 * of sanitize() entirely, so an unrelated save of that form can neither
3836 * store them unsanitised nor clear them.
3837 */
3838 const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3839
3840 /**
3841 * Nonce action and request field for the Headless Mode save.
3842 */
3843 const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3844 const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3845
3846 /**
3847 * Render the Headless Mode section of the Advanced settings accordion.
3848 *
3849 * Values come from Metasync_Headless_Config::get_settings(), which is the
3850 * sanitised stored state rather than anything the operator typed.
3851 *
3852 * @return void
3853 */
3854 public function render_headless_mode_section()
3855 {
3856 $settings = Metasync_Headless_Config::get_settings();
3857 $enabled = !empty($settings['enabled']);
3858 $active = Metasync_Headless_Config::is_active();
3859 $prefix = self::HEADLESS_FIELD_PREFIX;
3860
3861 $policy_labels = array(
3862 Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3863 Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3864 Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3865 );
3866 ?>
3867 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3868 <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3869 Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3870 URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3871 </p>
3872
3873 <div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px;">
3874 <p style="color: var(--dashboard-text-secondary); margin: 0;">
3875 Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3876 Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3877 </p>
3878 </div>
3879
3880 <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3881
3882 <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3883 <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3884
3885 <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;">
3886 <label for="<?php echo esc_attr($prefix . 'enabled'); ?>" style="display: flex; align-items: center; gap: 10px; color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">
3887 <?php
3888 # Hidden field first, checkbox second, same name. An unchecked
3889 # checkbox posts nothing at all, and normalize() reads an absent
3890 # `enabled` key as false — so without this pair, a submission that
3891 # simply did not include the checkbox would switch the mode off as
3892 # a side effect. With it, "off" arrives as an explicit 0.
3893 ?>
3894 <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3895 <input type="checkbox"
3896 id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3897 name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3898 value="1"
3899 style="width: 18px; height: 18px; cursor: pointer;"
3900 <?php checked($enabled, true); ?> />
3901 <span>Enable Headless Mode</span>
3902 </label>
3903
3904 <div style="background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 8px; padding: 16px; margin: 16px 0 0 0;">
3905 <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3906 <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3907 When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3908 Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3909 </p>
3910 </div>
3911
3912 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3913 Current state:
3914 <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3915 if ($enabled && !$active) {
3916 ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3917 }
3918 ?>
3919 </p>
3920 </div>
3921
3922 <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;">
3923 <div style="margin-bottom: 24px;">
3924 <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3925 Public frontend URL:
3926 </label>
3927 <input type="url"
3928 id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3929 name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3930 value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3931 placeholder="https://www.example.com"
3932 style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3933 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3934 Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3935 refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3936 prefixes exist and a shared path segment could never be put back on terms, archives or
3937 the homepage. Use a path prefix below instead. A bare host is treated as https.
3938 </p>
3939 </div>
3940
3941 <div style="margin-bottom: 24px;">
3942 <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3943 Trailing Slash Policy:
3944 </label>
3945 <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3946 name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3947 style="max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);">
3948 <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3949 <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3950 <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3951 </option>
3952 <?php } ?>
3953 </select>
3954 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3955 Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3956 becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3957 </p>
3958 </div>
3959
3960 <div>
3961 <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3962 Path Prefixes:
3963 </label>
3964 <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3965 name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3966 rows="4"
3967 placeholder="post = blog"
3968 style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary); font-family: monospace;"><?php echo esc_textarea(self::format_headless_path_prefixes($settings['path_prefixes'])); ?></textarea>
3969 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3970 Map WordPress content types to frontend URL folders:
3971 </p>
3972 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3973 One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3974 Leave empty when your frontend uses the same paths as WordPress.
3975 </p>
3976 </div>
3977
3978 <details style="margin-top: 24px;">
3979 <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3980 <div style="padding-top: 16px;">
3981 <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3982 Background refresh interval (minutes):
3983 </label>
3984 <input type="number"
3985 id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3986 name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3987 value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3988 min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3989 max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3990 step="1"
3991 style="width: 100%; max-width: 200px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3992 <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3993 How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3994 any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3995 the primary, immediate update path. Between
3996 <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3997 <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3998 defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3999 </p>
4000 </div>
4001 </details>
4002 </div>
4003
4004 <button type="button"
4005 id="metasync-headless-settings-save-btn"
4006 class="metasync-btn-primary"
4007 style="background: var(--dashboard-gradient-primary); 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(0, 0, 0, 0.1);">
4008 <span class="save-text">Save Headless Settings</span>
4009 </button>
4010 </form>
4011 </div>
4012 <script>
4013 jQuery(document).ready(function ($) {
4014 // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
4015 // so the browser drops the inner form tag and a submit handler would
4016 // never fire on its own.
4017 var $saveBtn = $('#metasync-headless-settings-save-btn');
4018 var $message = $('#metasync-headless-settings-message');
4019 var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
4020 var headlessDisableWarning = 'Turning off Headless Mode removes the metasyncSeo field from the GraphQL schema. Any frontend querying it will error until it is redeployed. Continue?';
4021 var headlessDomainWarning = 'The metasyncSeo field stays in the GraphQL schema, but every URL it returns will be empty until a frontend domain is set again. Continue?';
4022 var headlessModeSavedSettings = <?php echo wp_json_encode(array(
4023 'enabled' => !empty($settings['enabled']),
4024 'frontend_domain' => $settings['frontend_domain'],
4025 'slash_policy' => $settings['slash_policy'],
4026 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
4027 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
4028 )); ?>;
4029
4030 // This mirrors normalize_domain()'s shape rules so a value the
4031 // server will refuse cannot look active here. Keep this deliberately
4032 // small: it is a confirmation decision, not a client-side validator,
4033 // and the server remains authoritative. Mirrors can drift.
4034 function isUsableFrontendDomain(value) {
4035 // PHP's trim() strips only space, tab, newline, carriage return,
4036 // NUL and vertical tab, while $.trim() and String.trim() strip the
4037 // whole Unicode set. Trimming a non-breaking space or BOM that the
4038 // server keeps would make a pasted domain look usable while
4039 // normalize_domain() rejects it — the guard stays silent and the
4040 // mode deactivates anyway. Match the server charlist exactly.
4041 var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
4042 var parsed;
4043 var authority;
4044 var host;
4045 var path;
4046
4047 if (candidate === '') {
4048 return false;
4049 }
4050
4051 if (candidate.indexOf('//') === -1) {
4052 candidate = 'https://' + candidate.replace(/^\/+/, '');
4053 }
4054
4055 parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
4056 if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
4057 return false;
4058 }
4059
4060 path = parsed[3] || '';
4061 if (path.replace(/^\/+|\/+$/g, '') !== '') {
4062 return false;
4063 }
4064
4065 authority = parsed[2].replace(/^.*@/, '');
4066 if (authority.charAt(0) === '[') {
4067 return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
4068 }
4069
4070 host = authority.replace(/:\d+$/, '');
4071 if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
4072 return false;
4073 }
4074
4075 return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
4076 }
4077
4078 function showMessage(lines, type) {
4079 var $list = $('<div/>');
4080 $.each(lines, function (i, line) {
4081 $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
4082 });
4083
4084 $message
4085 .css({
4086 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
4087 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
4088 'color': type === 'success' ? '#22c55e' : '#ef4444',
4089 'padding': '12px 16px',
4090 'border-radius': '6px',
4091 'font-size': '14px',
4092 'line-height': '1.5'
4093 })
4094 .empty()
4095 .append($list)
4096 .show();
4097 }
4098
4099 // Repaint from what was actually stored, never from what was typed, so a
4100 // refused value reads as refused rather than looking saved.
4101 function applyStoredSettings(settings) {
4102 if (!settings) {
4103 return;
4104 }
4105 headlessModeSavedSettings = $.extend({}, settings);
4106 headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
4107 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
4108 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
4109 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
4110 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
4111 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
4112 }
4113
4114 $saveBtn.on('click', function (e) {
4115 e.preventDefault();
4116
4117 var data = {
4118 action: 'metasync_save_headless_settings',
4119 <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
4120 <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
4121 <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
4122 <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
4123 <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
4124 <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
4125 };
4126
4127 var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
4128 var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
4129 var submittedModeIsActive = submittedModeIsEnabled
4130 && isUsableFrontendDomain(submittedDomain);
4131
4132 if (submittedModeIsEnabled && !submittedModeIsActive) {
4133 showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
4134 $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
4135 return;
4136 }
4137
4138 var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
4139
4140 if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
4141 applyStoredSettings(headlessModeSavedSettings);
4142 return;
4143 }
4144
4145 $saveBtn.prop('disabled', true);
4146 $saveBtn.find('.save-text').text('Saving...');
4147 $message.hide();
4148
4149 $.ajax({
4150 url: window.ajaxurl,
4151 type: 'POST',
4152 data: data,
4153 success: function (response) {
4154 if (response && response.success) {
4155 applyStoredSettings(response.data.settings);
4156 var lines = [response.data.message];
4157 if (response.data.notes && response.data.notes.length) {
4158 lines = lines.concat(response.data.notes);
4159 }
4160 showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
4161 } else {
4162 showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
4163 }
4164 $saveBtn.prop('disabled', false);
4165 $saveBtn.find('.save-text').text('Save Headless Settings');
4166 },
4167 error: function () {
4168 showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
4169 $saveBtn.prop('disabled', false);
4170 $saveBtn.find('.save-text').text('Save Headless Settings');
4171 }
4172 });
4173 });
4174 });
4175 </script>
4176 <?php
4177 }
4178
4179 /**
4180 * Render the stored post-type => prefix map back into the textarea format.
4181 *
4182 * @param mixed $prefixes Stored prefix map.
4183 * @return string One `post_type = prefix` entry per line.
4184 */
4185 public static function format_headless_path_prefixes($prefixes)
4186 {
4187 if (!is_array($prefixes)) {
4188 return '';
4189 }
4190
4191 $lines = array();
4192
4193 foreach ($prefixes as $post_type => $prefix) {
4194 $lines[] = $post_type . ' = ' . $prefix;
4195 }
4196
4197 return implode("\n", $lines);
4198 }
4199
4200 /**
4201 * Read the textarea format back into a post-type => prefix map.
4202 *
4203 * One entry per line, `post_type = prefix`. A plain text format is enough
4204 * for a demo and needs no repeater UI. A line with no `=` names no post
4205 * type, so it is skipped rather than guessed at; everything that does
4206 * survive is still handed to Metasync_Headless_Config::normalize(), which
4207 * is what actually decides whether a post type and prefix are usable.
4208 *
4209 * @param mixed $text Raw textarea contents.
4210 * @return array<string,string> Unsanitised map, keyed by post type.
4211 */
4212 public static function parse_headless_path_prefixes($text)
4213 {
4214 $map = array();
4215
4216 if (!is_string($text)) {
4217 return $map;
4218 }
4219
4220 foreach (preg_split('/\R/', $text) as $line) {
4221 if (strpos($line, '=') === false) {
4222 continue;
4223 }
4224
4225 list($post_type, $prefix) = explode('=', $line, 2);
4226
4227 $post_type = trim($post_type);
4228 if ($post_type === '') {
4229 continue;
4230 }
4231
4232 $map[$post_type] = trim($prefix);
4233 }
4234
4235 return $map;
4236 }
4237
4238 /**
4239 * Merge a Headless Mode submission over the stored settings and persist it.
4240 *
4241 * Additive on purpose: a key the request did not carry keeps whatever is
4242 * already stored. normalize() treats an absent `enabled` as false, so a
4243 * submission that omitted the checkbox would otherwise switch the mode off
4244 * as a side effect of saving something else. The hidden field in front of
4245 * the checkbox is the other half of that guard — it makes "off" an explicit
4246 * `0` rather than an absence, so unticking the box still works.
4247 *
4248 * Sanitising is left entirely to Metasync_Headless_Config.
4249 *
4250 * @param mixed $submitted Raw request data, normally $_POST.
4251 * @return array The settings that were actually stored.
4252 */
4253 public function save_headless_settings($submitted)
4254 {
4255 if (!is_array($submitted)) {
4256 $submitted = array();
4257 }
4258
4259 $prefix = self::HEADLESS_FIELD_PREFIX;
4260 $input = Metasync_Headless_Config::get_settings();
4261
4262 if (isset($submitted[$prefix . 'enabled'])) {
4263 $input['enabled'] = $submitted[$prefix . 'enabled'];
4264 }
4265
4266 if (isset($submitted[$prefix . 'frontend_domain'])) {
4267 $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
4268 }
4269
4270 if (isset($submitted[$prefix . 'slash_policy'])) {
4271 $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
4272 }
4273
4274 if (isset($submitted[$prefix . 'path_prefixes'])) {
4275 $input['path_prefixes'] = self::parse_headless_path_prefixes(
4276 wp_unslash($submitted[$prefix . 'path_prefixes'])
4277 );
4278 }
4279
4280 if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
4281 $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
4282 }
4283
4284 return Metasync_Headless_Config::update_settings($input);
4285 }
4286
4287 /**
4288 * AJAX handler for the Headless Mode block on the Advanced tab.
4289 *
4290 * Same nonce-then-capability shape as the other Advanced tab handlers.
4291 *
4292 * @return void
4293 */
4294 /**
4295 * Did the last write actually land?
4296 *
4297 * update_settings() hands back what it meant to store, not proof that it
4298 * reached the database: a full disk, a failing object cache or a read-only
4299 * replica all produce a silent no-op. Telling an operator their headless
4300 * settings saved when they did not is worse than telling them nothing —
4301 * they would go on to wonder why the frontend sees none of it.
4302 *
4303 * @param array $intended What the save call reported storing.
4304 * @return bool
4305 */
4306 private function headless_settings_persisted(array $intended)
4307 {
4308 Metasync_Headless_Config::flush();
4309
4310 return Metasync_Headless_Config::get_settings() == $intended;
4311 }
4312
4313 public function ajax_save_headless_settings()
4314 {
4315 $prefix = self::HEADLESS_FIELD_PREFIX;
4316
4317 $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4318 if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4319 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4320 } elseif (!Metasync::current_user_has_plugin_access()) {
4321 wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4322 } else {
4323 // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4324 $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4325 ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4326 : '';
4327 // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4328
4329 $stored = $this->save_headless_settings($_POST);
4330
4331 if (!$this->headless_settings_persisted($stored)) {
4332 wp_send_json_error(array(
4333 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4334 ));
4335 }
4336
4337 # Say so when the sanitiser refused something, rather than leaving the
4338 # operator to spot that a field came back empty.
4339 $notes = array();
4340
4341 if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4342 $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4343 }
4344
4345 if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4346 $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4347 }
4348
4349 wp_send_json_success(array(
4350 'message' => 'Headless Mode settings saved.',
4351 'notes' => $notes,
4352 'settings' => array(
4353 'enabled' => !empty($stored['enabled']),
4354 'frontend_domain' => $stored['frontend_domain'],
4355 'slash_policy' => $stored['slash_policy'],
4356 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4357 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4358 ),
4359 ));
4360 }
4361 }
4362
4363 /**
4364 * How many indexation rules a SEO Controls save actually changed.
4365 *
4366 * Older saves stored real booleans, this handler stores 'true'/'false'
4367 * strings, so the stored side is normalised before comparing.
4368 *
4369 * @param array $before Stored seo_controls before the save.
4370 * @param array $after seo_controls as just saved ('true'/'false').
4371 * @param array $fields Field names the save handled.
4372 * @return int
4373 */
4374 public static function count_index_rule_changes(array $before, array $after, array $fields)
4375 {
4376 $changed = 0;
4377 foreach ($fields as $field) {
4378 if (strpos($field, 'index_') !== 0 && $field !== 'noindex_empty_archives') {
4379 continue;
4380 }
4381 $was = filter_var($before[$field] ?? false, FILTER_VALIDATE_BOOLEAN);
4382 $now = ($after[$field] ?? 'false') === 'true';
4383 if ($was !== $now) {
4384 $changed++;
4385 }
4386 }
4387 return $changed;
4388 }
4389
4390 /**
4391 * AJAX handler for saving SEO / Indexation Control settings.
4392 *
4393 * Moved from Metasync_Admin – the admin class now delegates here.
4394 */
4395 public function meta_sync_save_seo_controls()
4396 {
4397 try {
4398 $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4399 if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
4400 wp_send_json_error(array('message' => 'Invalid nonce'));
4401 return;
4402 }
4403
4404 // Same nonce-then-capability shape as the other settings save
4405 // handlers: the nonce is not authorization, so a logged-in user
4406 // without plugin access must still be refused here.
4407 if (!Metasync::current_user_has_plugin_access()) {
4408 wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4409 // The return is unreachable in WordPress (wp_send_json_error
4410 // terminates the request) but keeps the handler safe under the
4411 // non-terminating stubs the unit tests run with.
4412 // @phpstan-ignore-next-line deadCode.unreachable
4413 return;
4414 }
4415
4416 $current_options = Metasync::get_option();
4417
4418 $original_options = json_decode(json_encode($current_options), true);
4419
4420 if (!isset($current_options['seo_controls'])) {
4421 $current_options['seo_controls'] = [];
4422 }
4423
4424 $seo_control_fields = [
4425 'index_date_archives',
4426 'index_tag_archives',
4427 'index_author_archives',
4428 'index_category_archives',
4429 'index_format_archives',
4430 'override_robots_tags',
4431 'add_nofollow_to_external_links',
4432 'enable_googleinstantindex',
4433 'enable_binginstantindex',
4434 'noindex_empty_archives'
4435 ];
4436
4437 foreach ($seo_control_fields as $field) {
4438 if (isset($_POST['metasync_options']['seo_controls'][$field]) && $_POST['metasync_options']['seo_controls'][$field] === 'true') {
4439 $current_options['seo_controls'][$field] = 'true';
4440 } else {
4441 $current_options['seo_controls'][$field] = 'false';
4442 }
4443 }
4444
4445 $bing_save_result = true;
4446 if (isset($_POST['metasync_bing_api_key_inline'])) {
4447 $bing_save_result = $this->save_bing_inline_settings_ajax();
4448 }
4449
4450 // Save Google Instant Indexing post types
4451 if (isset($_POST['metasync_post_types']) && is_array($_POST['metasync_post_types'])) {
4452 $google_post_types = array_values(array_map('sanitize_title', wp_unslash($_POST['metasync_post_types'])));
4453 $google_settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
4454 $google_settings['post_types'] = $google_post_types;
4455 update_option('metasync_options_instant_indexing', $google_settings);
4456 }
4457
4458 $options_changed = (json_encode($original_options) !== json_encode($current_options));
4459
4460 $result = Metasync::set_option($current_options);
4461
4462 if (!$result && !$options_changed) {
4463 $result = true;
4464 }
4465
4466 if ($result && $bing_save_result) {
4467 // Consent-gated analytics: only when an indexation rule really
4468 // changed. Firing from the options sanitize callback counted
4469 // every unrelated write of metasync_options as a change.
4470 $rules_changed = self::count_index_rule_changes(
4471 $original_options['seo_controls'] ?? array(),
4472 $current_options['seo_controls'],
4473 $seo_control_fields
4474 );
4475 if ($rules_changed > 0) {
4476 Metasync_PostHog::feature('indexation_rule_changed', array(
4477 'rules_changed' => $rules_changed,
4478 ));
4479 }
4480
4481 $success_message = 'Indexation Control settings saved successfully!';
4482 if (isset($_POST['metasync_bing_api_key_inline'])) {
4483 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
4484 }
4485 wp_send_json_success(array(
4486 'message' => $success_message,
4487 'saved_data' => $current_options['seo_controls']
4488 ));
4489 } else {
4490 $error_message = 'Failed to save Indexation Control settings';
4491 if (!$bing_save_result) {
4492 $error_message = 'Failed to save Bing Instant Indexing settings';
4493 }
4494 wp_send_json_error(array('message' => $error_message));
4495 }
4496 } catch (Exception $e) {
4497 error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4498 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
4499 }
4500 }
4501
4502 /**
4503 * Save Bing IndexNow inline settings from SEO Controls page.
4504 *
4505 * Moved from Metasync_Admin – the admin class now delegates here.
4506 *
4507 * @return bool True on success, false on failure
4508 */
4509 public function save_bing_inline_settings_ajax()
4510 {
4511 $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4512 $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4513 $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4514 $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4515
4516 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
4517
4518 $new_settings = [
4519 'api_key' => $api_key,
4520 'endpoint' => $endpoint,
4521 'post_types' => array_values($post_types),
4522 'disable_other_plugins' => $disable_other_plugins,
4523 ];
4524
4525 $settings = array_merge($existing_settings, $new_settings);
4526
4527 $settings_changed = (json_encode($existing_settings) !== json_encode($settings));
4528
4529 $result = update_option('metasync_options_bing_instant_indexing', $settings);
4530
4531 if (!$result && !$settings_changed) {
4532 $result = true;
4533 }
4534
4535 if (!empty($api_key)) {
4536 $safe_key = sanitize_file_name( $api_key );
4537 $file_path = ABSPATH . $safe_key . '.txt';
4538 $real_path = realpath( ABSPATH );
4539 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
4540 error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4541 return false;
4542 }
4543 // serve the key virtually via template_redirect instead of
4544 // writing a physical {key}.txt. An on-disk file is served statically
4545 // by the web server and 403'd before PHP on some nginx setups, and
4546 // cannot be written on read-only roots. Register the key (the saved
4547 // api_key is also always part of the served set) and remove any stale
4548 // physical file so the PHP route governs.
4549 if (!class_exists('Metasync_Bing_Instant_Index')) {
4550 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4551 }
4552 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4553
4554 if (file_exists($file_path)) {
4555 @wp_delete_file($file_path);
4556 }
4557
4558 $current_options = Metasync::get_option();
4559 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {
4560 $current_options['seo_controls']['enable_binginstantindex'] = 'true';
4561 Metasync::set_option($current_options);
4562 }
4563 }
4564
4565 return $result;
4566 }
4567
4568 /**
4569 * Sanitize the metasync_llms_txt_settings option.
4570 *
4571 * The Settings API delivers raw form data; we coerce it into a predictable
4572 * shape so the generator and MCP tools can rely on the structure.
4573 *
4574 * @param mixed $input Raw form data.
4575 * @return array
4576 */
4577 public function sanitize_llms_txt_settings($input)
4578 {
4579 if (!is_array($input)) {
4580 $input = array();
4581 }
4582
4583 $existing = get_option('metasync_llms_txt_settings', array());
4584 if (!is_array($existing)) {
4585 $existing = array();
4586 }
4587
4588 $output = $existing;
4589
4590 $output['enabled'] = !empty($input['enabled'])
4591 ? (bool) filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN)
4592 : false;
4593
4594 if (isset($input['post_types']) && is_array($input['post_types'])) {
4595 $output['post_types'] = array_values(array_filter(array_map('sanitize_text_field', $input['post_types'])));
4596 } else {
4597 $output['post_types'] = array('page', 'post');
4598 }
4599
4600 if (isset($input['max_posts'])) {
4601 $max = absint($input['max_posts']);
4602 if ($max < 1) {
4603 $max = 1;
4604 }
4605 if ($max > 500) {
4606 $max = 500;
4607 }
4608 $output['max_posts'] = $max;
4609 }
4610
4611 if (isset($input['excluded_ids'])) {
4612 if (is_array($input['excluded_ids'])) {
4613 $ids = $input['excluded_ids'];
4614 } else {
4615 $ids = preg_split('/[\s,]+/', (string) $input['excluded_ids']);
4616 }
4617 $output['excluded_ids'] = array_values(array_filter(array_map('absint', (array) $ids)));
4618 }
4619
4620 if (isset($input['custom_description'])) {
4621 $output['custom_description'] = sanitize_text_field((string) $input['custom_description']);
4622 }
4623
4624 $output['llms_full_enabled'] = !empty($input['llms_full_enabled'])
4625 ? (bool) filter_var($input['llms_full_enabled'], FILTER_VALIDATE_BOOLEAN)
4626 : false;
4627
4628 if (isset($input['max_posts_full'])) {
4629 $max_full = absint($input['max_posts_full']);
4630 if ($max_full < 1) {
4631 $max_full = 1;
4632 }
4633 if ($max_full > 500) {
4634 $max_full = 500;
4635 }
4636 $output['max_posts_full'] = $max_full;
4637 }
4638
4639 return $output;
4640 }
4641 }
4642