PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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.0, at includes/class-metasync-settings-registration.php

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