PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-settings-registration.php

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

3,306 lines 172.0 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 /** @var self|null */
19 private static $instance = null;
20
21 /**
22 * Get singleton instance.
23 *
24 * @return self
25 */
26 public static function instance()
27 {
28 if (self::$instance === null) {
29 self::$instance = new self();
30 }
31 return self::$instance;
32 }
33
34 private function __construct() {}
35
36 /**
37 * Helper – replicate Metasync_Admin::get_effective_menu_title() without
38 * needing an admin instance.
39 */
40 private function get_effective_menu_title()
41 {
42 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
43 if ($whitelabel_company_name) {
44 return $whitelabel_company_name . ' SEO';
45 }
46 return Metasync::get_effective_plugin_name();
47 }
48
49 /**
50 * Handle saving plugin access roles from Advanced Settings.
51 * Moved from Metasync_Admin – only called from settings_page_init().
52 */
53 private function handle_plugin_access_roles_save()
54 {
55 if (isset($_POST['save_plugin_access_roles']) && $_POST['save_plugin_access_roles'] === 'yes') {
56 if (isset($_POST['plugin_access_roles_nonce']) && wp_verify_nonce($_POST['plugin_access_roles_nonce'], 'metasync_plugin_access_roles_nonce')) {
57
58 if (!Metasync::current_user_has_plugin_access()) {
59 wp_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Insufficient permissions')));
60 exit;
61 }
62
63 $metasync_options = Metasync::get_option();
64 if (!is_array($metasync_options)) {
65 $metasync_options = array();
66 }
67 if (!isset($metasync_options['general']) || !is_array($metasync_options['general'])) {
68 $metasync_options['general'] = array();
69 }
70
71 if (isset($_POST['plugin_access_roles']) && is_array($_POST['plugin_access_roles'])) {
72 $metasync_options['general']['plugin_access_roles'] = array_map('sanitize_text_field', $_POST['plugin_access_roles']);
73 } else {
74 $metasync_options['general']['plugin_access_roles'] = array();
75 }
76
77 Metasync::set_option($metasync_options);
78
79 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_saved=1');
80 wp_redirect($redirect_url);
81 exit;
82 } else {
83 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Invalid security token'));
84 wp_redirect($redirect_url);
85 exit;
86 }
87 }
88 }
89
90 /**
91 * Register and add settings – formerly Metasync_Admin::settings_page_init().
92 */
93 public function settings_page_init()
94 {
95 Metasync_Connect_Manager::instance()->handle_session_management_early();
96 Metasync_Connect_Manager::instance()->handle_whitelabel_password_early();
97
98 Metasync_Debug_Manager::instance()->handle_debug_mode_operations();
99 Metasync_Debug_Manager::instance()->handle_error_log_operations();
100 Metasync_Debug_Manager::instance()->handle_clear_all_settings();
101
102 $this->handle_plugin_access_roles_save();
103
104 $SECTION_FEATURES = Metasync_Admin::SECTION_FEATURES;
105 $SECTION_METASYNC = Metasync_Admin::SECTION_METASYNC;
106 $SECTION_SEARCHENGINE = Metasync_Admin::SECTION_SEARCHENGINE;
107 $SECTION_LOCALSEO = Metasync_Admin::SECTION_LOCALSEO;
108 $SECTION_CODESNIPPETS = Metasync_Admin::SECTION_CODESNIPPETS;
109 $SECTION_SITE_SETTINGS = Metasync_Admin::SECTION_SITE_SETTINGS;
110 $SECTION_COMMON_SETTINGS = Metasync_Admin::SECTION_COMMON_SETTINGS;
111 $SECTION_COMMON_META_SETTINGS = Metasync_Admin::SECTION_COMMON_META_SETTINGS;
112 $SECTION_SOCIAL_META = Metasync_Admin::SECTION_SOCIAL_META;
113 $SECTION_SEO_CONTROLS = Metasync_Admin::SECTION_SEO_CONTROLS;
114 $SECTION_SEO_CONTROLS_ADVANCED = Metasync_Admin::SECTION_SEO_CONTROLS_ADVANCED;
115 $SECTION_SEO_CONTROLS_INSTANT_INDEX = Metasync_Admin::SECTION_SEO_CONTROLS_INSTANT_INDEX;
116 $SECTION_PLUGIN_VISIBILITY = Metasync_Admin::SECTION_PLUGIN_VISIBILITY;
117 $SECTION_BREADCRUMBS = Metasync_Admin::SECTION_BREADCRUMBS;
118 $SECTION_LLMS_TXT = Metasync_Admin::SECTION_LLMS_TXT;
119
120 $option_key = Metasync_Admin::option_key;
121 $page_slug = Metasync_Admin::$page_slug;
122
123 # Use whitelabel OTTO name if configured, fallback to 'OTTO'
124 $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
125
126 register_setting(
127 Metasync_Admin::option_group,
128 Metasync_Admin::option_key,
129 array(self::instance(), 'sanitize')
130 );
131
132 // LLMs.txt settings are stored in a dedicated option so MCP tools and
133 // the admin UI share one source of truth.
134 register_setting(
135 Metasync_Admin::option_group,
136 'metasync_llms_txt_settings',
137 array(
138 'sanitize_callback' => array(self::instance(), 'sanitize_llms_txt_settings'),
139 'default' => array(),
140 )
141 );
142
143
144 add_settings_section(
145 $SECTION_METASYNC, // ID
146 '', // Title - removed to prevent duplication with dashboard card
147 function(){}, // Callback
148 $page_slug . '_general' // Page
149 );
150
151 add_settings_section(
152 $SECTION_LLMS_TXT,
153 esc_html__('LLMs.txt Settings', 'metasync'),
154 function () {
155 echo '<p>' . esc_html__('Publish a standards-compliant /llms.txt so AI search engines and assistants can discover your most important pages.', 'metasync') . '</p>';
156 },
157 $page_slug . '_general'
158 );
159
160 add_settings_field(
161 'llms_txt_enabled',
162 esc_html__('Enable /llms.txt', 'metasync'),
163 function () {
164 $settings = get_option('metasync_llms_txt_settings', array());
165 $checked = !empty($settings['enabled']);
166 printf(
167 '<input type="checkbox" id="llms_txt_enabled" name="metasync_llms_txt_settings[enabled]" value="1" %s />',
168 $checked ? 'checked' : ''
169 );
170 echo '<span class="description">' . esc_html__('Serve /llms.txt via a virtual route.', 'metasync') . '</span>';
171 },
172 $page_slug . '_general',
173 $SECTION_LLMS_TXT
174 );
175
176 add_settings_field(
177 'llms_txt_post_types',
178 esc_html__('Post types to include', 'metasync'),
179 function () {
180 $settings = get_option('metasync_llms_txt_settings', array());
181 $selected = isset($settings['post_types']) && is_array($settings['post_types'])
182 ? $settings['post_types']
183 : array('page', 'post');
184 $public_types = get_post_types(array('public' => true), 'objects');
185 echo '<div class="llms-txt-dependent">';
186 foreach ($public_types as $type) {
187 if (in_array($type->name, array('attachment'), true)) {
188 continue;
189 }
190 printf(
191 '<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>',
192 esc_attr($type->name),
193 in_array($type->name, $selected, true) ? 'checked' : '',
194 esc_html($type->labels->singular_name ?: $type->name)
195 );
196 }
197 echo '</div>';
198 },
199 $page_slug . '_general',
200 $SECTION_LLMS_TXT
201 );
202
203 add_settings_field(
204 'llms_txt_max_posts',
205 esc_html__('Max posts to include', 'metasync'),
206 function () {
207 $settings = get_option('metasync_llms_txt_settings', array());
208 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
209 echo '<div class="llms-txt-dependent">';
210 printf(
211 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
212 $value
213 );
214 echo '</div>';
215 },
216 $page_slug . '_general',
217 $SECTION_LLMS_TXT
218 );
219
220 add_settings_field(
221 'llms_txt_excluded_ids',
222 esc_html__('Excluded post/page IDs', 'metasync'),
223 function () {
224 $settings = get_option('metasync_llms_txt_settings', array());
225 $ids = isset($settings['excluded_ids']) && is_array($settings['excluded_ids'])
226 ? implode(', ', array_map('absint', $settings['excluded_ids']))
227 : '';
228 echo '<div class="llms-txt-dependent">';
229 printf(
230 '<input type="text" id="llms_txt_excluded_ids" name="metasync_llms_txt_settings[excluded_ids]" value="%s" size="40" />',
231 esc_attr($ids)
232 );
233 echo '<p class="description">' . esc_html__('Comma-separated list of IDs to exclude from the listing.', 'metasync') . '</p>';
234 echo '</div>';
235 },
236 $page_slug . '_general',
237 $SECTION_LLMS_TXT
238 );
239
240 add_settings_field(
241 'llms_txt_custom_description',
242 esc_html__('Custom description', 'metasync'),
243 function () {
244 $settings = get_option('metasync_llms_txt_settings', array());
245 $value = isset($settings['custom_description']) ? (string) $settings['custom_description'] : '';
246 echo '<div class="llms-txt-dependent">';
247 printf(
248 '<input type="text" id="llms_txt_custom_description" name="metasync_llms_txt_settings[custom_description]" value="%s" size="60" />',
249 esc_attr($value)
250 );
251 echo '<p class="description">' . esc_html__('Overrides the site tagline in the /llms.txt header.', 'metasync') . '</p>';
252 echo '</div>';
253 },
254 $page_slug . '_general',
255 $SECTION_LLMS_TXT
256 );
257
258 add_settings_field(
259 'llms_full_enabled',
260 esc_html__('Enable /llms-full.txt', 'metasync'),
261 function () {
262 $settings = get_option('metasync_llms_txt_settings', array());
263 $checked = !empty($settings['llms_full_enabled']);
264 echo '<div class="llms-txt-dependent">';
265 printf(
266 '<input type="checkbox" id="llms_full_enabled" name="metasync_llms_txt_settings[llms_full_enabled]" value="1" %s />',
267 $checked ? 'checked' : ''
268 );
269 echo '<span class="description">' . esc_html__('Also serve /llms-full.txt with full post bodies converted to markdown.', 'metasync') . '</span>';
270 echo '</div>';
271 },
272 $page_slug . '_general',
273 $SECTION_LLMS_TXT
274 );
275
276 add_settings_field(
277 'llms_txt_max_posts_full',
278 esc_html__('Max posts for full-text', 'metasync'),
279 function () {
280 $settings = get_option('metasync_llms_txt_settings', array());
281 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
282 echo '<div class="llms-full-dependent">';
283 printf(
284 '<input type="number" id="llms_txt_max_posts_full" name="metasync_llms_txt_settings[max_posts_full]" min="1" max="500" value="%d" />',
285 $value
286 );
287 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
288 echo '</div>';
289 },
290 $page_slug . '_general',
291 $SECTION_LLMS_TXT
292 );
293
294
295 add_settings_section(
296 $SECTION_METASYNC, // ID
297 '', // Title - removed to prevent duplication with dashboard card
298 function(){}, // Callback
299 $page_slug . '_branding' // Page
300 );
301
302 add_settings_section(
303 $SECTION_METASYNC, // ID
304 $this->get_effective_menu_title() . ' Caching Settings:', // Title
305 function(){}, // Callback
306 $page_slug . '_otto_cache' // Page
307 );
308
309 add_settings_section(
310 $SECTION_SEO_CONTROLS, // ID
311 '', // Title - removed to prevent duplication with dashboard card
312 function(){}, // Callback
313 $page_slug . '_seo-controls' // Page
314 );
315
316 // Archive Indexation Control Fields
317 add_settings_field(
318 'noindex_empty_archives',
319 'Disallow Empty Archives',
320 function() use ($option_key) {
321 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
322 printf(
323 '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
324 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
325 );
326 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>');
327 },
328 $page_slug . '_seo-controls',
329 $SECTION_SEO_CONTROLS
330 );
331
332 add_settings_field(
333 'index_date_archives',
334 'Disallow Date Archives',
335 function() use ($option_key) {
336 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
337 printf(
338 '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
339 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
340 );
341 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>');
342 },
343 $page_slug . '_seo-controls',
344 $SECTION_SEO_CONTROLS
345 );
346
347 add_settings_field(
348 'index_tag_archives',
349 'Disallow Tag Archives',
350 function() use ($option_key) {
351 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
352 printf(
353 '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
354 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
355 );
356 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>');
357 },
358 $page_slug . '_seo-controls',
359 $SECTION_SEO_CONTROLS
360 );
361
362 add_settings_field(
363 'index_author_archives',
364 'Disallow Author Archives',
365 function() use ($option_key) {
366 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
367 printf(
368 '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
369 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
370 );
371 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>');
372 },
373 $page_slug . '_seo-controls',
374 $SECTION_SEO_CONTROLS
375 );
376
377 add_settings_field(
378 'index_format_archives',
379 'Disallow Format Archives',
380 function() use ($option_key) {
381 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
382 printf(
383 '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
384 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
385 );
386 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>');
387 },
388 $page_slug . '_seo-controls',
389 $SECTION_SEO_CONTROLS
390 );
391
392 add_settings_field(
393 'index_category_archives',
394 'Disallow Category Archives',
395 function() use ($option_key) {
396 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
397 printf(
398 '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
399 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
400 );
401 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>');
402 },
403 $page_slug . '_seo-controls',
404 $SECTION_SEO_CONTROLS
405 );
406
407 add_settings_field(
408 'add_nofollow_to_external_links',
409 'Add No-follow to External Links',
410 function() use ($option_key) {
411 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
412 printf(
413 '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
414 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
415 );
416 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()));
417 },
418 $page_slug . '_seo-controls',
419 $SECTION_SEO_CONTROLS
420 );
421
422 // Advanced SEO Controls Section
423 add_settings_section(
424 $SECTION_SEO_CONTROLS_ADVANCED, // ID
425 '', // Title - will be handled by custom rendering
426 function(){
427 echo '</div>'; // Close main Indexation Control card
428 echo '<div class="dashboard-card" style="margin-top: 20px;">';
429 echo '<h2>Advanced Settings</h2>';
430 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>';
431 },
432 $page_slug . '_seo-controls' // Page
433 );
434
435 add_settings_field(
436 'override_robots_tags',
437 'Override Other Plugins\' Robots Tags',
438 function() use ($option_key) {
439 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
440 printf(
441 '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
442 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
443 );
444 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>');
445 },
446 $page_slug . '_seo-controls',
447 $SECTION_SEO_CONTROLS_ADVANCED
448 );
449
450 // Google Instant Indexing Section
451 add_settings_section(
452 $SECTION_SEO_CONTROLS_INSTANT_INDEX, // ID
453 '', // Title - will be handled by custom rendering
454 function(){
455 echo '</div>'; // Close Advanced Settings card
456 echo '<div class="dashboard-card" style="margin-top: 20px;">';
457 echo '<h2>Google Instant Indexing</h2>';
458 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>';
459 },
460 $page_slug . '_seo-controls' // Page
461 );
462
463 add_settings_field(
464 'enable_googleinstantindex',
465 'Enable Google Instant Indexing',
466 function() use ($option_key) {
467 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
468 printf(
469 '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
470 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
471 );
472 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.');
473 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. A new "Instant Indexing" menu item will appear in the navigation.</span>');
474 },
475 $page_slug . '_seo-controls',
476 $SECTION_SEO_CONTROLS_INSTANT_INDEX
477 );
478
479 add_settings_field(
480 'google_index_api_config',
481 'Google Index API Configuration',
482 array(Metasync_Settings_Fields::instance(), 'render_google_index_section'),
483 $page_slug . '_seo-controls',
484 $SECTION_SEO_CONTROLS_INSTANT_INDEX
485 );
486
487 // Bing Instant Indexing Section
488 add_settings_section(
489 'seo_controls_bing_instant_index', // ID
490 '', // Title - will be handled by custom rendering
491 function(){
492 echo '</div>'; // Close Google Instant Indexing card
493 echo '<div class="dashboard-card" style="margin-top: 20px;">';
494 echo '<h2>Bing Instant Indexing (IndexNow)</h2>';
495 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>';
496 },
497 $page_slug . '_seo-controls' // Page
498 );
499
500 add_settings_field(
501 'enable_binginstantindex',
502 'Enable Bing Instant Indexing',
503 function() use ($option_key) {
504 // Auto-enable if API key is configured
505 $bing_settings = get_option('metasync_options_bing_instant_indexing', []);
506 $has_api_key = !empty($bing_settings['api_key']);
507
508 $enable_binginstantindex = Metasync::get_option('seo_controls')['enable_binginstantindex'] ?? 'false';
509
510 // Auto-check if API key exists
511 if ($has_api_key && $enable_binginstantindex !== 'true') {
512 $enable_binginstantindex = 'true';
513 }
514
515 printf(
516 '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
517 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
518 $has_api_key ? 'data-auto-enabled="true"' : ''
519 );
520 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>');
521 },
522 $page_slug . '_seo-controls',
523 'seo_controls_bing_instant_index'
524 );
525
526 // Bing Index API configuration
527 add_settings_field(
528 'bing_index_api_config',
529 'IndexNow API Configuration',
530 array(Metasync_Settings_Fields::instance(), 'render_bing_index_section'),
531 $page_slug . '_seo-controls',
532 'seo_controls_bing_instant_index'
533 );
534
535 add_settings_field(
536 'searchatlas_api_key',
537 $this->get_effective_menu_title() . ' API Key',
538 array(Metasync_Settings_Fields::instance(), 'searchatlas_api_key_callback'),
539 $page_slug . '_general',
540 $SECTION_METASYNC
541 );
542
543 add_settings_field(
544 'apikey', // ID
545 'Plugin Auth Token', // Title
546 array(Metasync_Settings_Fields::instance(), 'metasync_settings_genkey_callback'), // Callback
547 $page_slug . '_general', // Page
548 $SECTION_METASYNC // Section
549 );
550
551 /**
552 * SERVER SIDE RENDERING SETTING OPTIONS
553 * @see SSR functionality
554 * Note: OTTO SSR is always enabled by default when connected
555 */
556
557 # field to accept the otto pixel
558 add_settings_field(
559 'otto_pixel_uuid',
560 $whitelabel_otto_name . ' Pixel UUID',
561 function() use ($option_key) {
562 $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
563 printf('<input type="text" size="40" value = "'.esc_attr($value).'" name="' . $option_key . '[general][otto_pixel_uuid]"/>');
564 },
565 $page_slug . '_general',
566 $SECTION_METASYNC
567 );
568
569
570 # check box to toggle on and off disabling OTTO for logged in users
571 add_settings_field(
572 'otto_disable_on_loggedin',
573 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
574 function() use ($whitelabel_otto_name, $option_key) {
575 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
576 printf(
577 '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
578 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
579 );
580 printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
581 },
582 $page_slug . '_general',
583 $SECTION_METASYNC
584 );
585
586 # check box to disable OTTO frontend toolbar
587 add_settings_field(
588 'otto_disable_preview_button',
589 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
590 function() use ($whitelabel_otto_name, $option_key) {
591 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
592 printf(
593 '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
594 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
595 );
596 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));
597 },
598 $page_slug . '_general',
599 $SECTION_METASYNC
600 );
601
602 # WP Rocket compatibility mode setting
603 add_settings_field(
604 'otto_wp_rocket_compat',
605 'WP Rocket Compatibility',
606 function() use ($whitelabel_otto_name, $option_key) {
607 $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
608 $wp_rocket_active = class_exists('WP_Rocket');
609
610 echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
611 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
612 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
613 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
614 echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
615 echo '</select>';
616
617 if ($wp_rocket_active) {
618 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
619 }
620
621 echo '<p class="description" style="margin-top: 8px;">';
622 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>';
623 echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
624 echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
625 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.';
626 echo '</p>';
627 },
628 $page_slug . '_general',
629 $SECTION_METASYNC
630 );
631
632 /**
633 * BOT DETECTION SETTINGS FOR OTTO
634 * @since 1.0.0
635 */
636
637 # Checkbox to disable OTTO for bot traffic
638 add_settings_field(
639 'otto_disable_for_bots',
640 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
641 function() use ($whitelabel_otto_name, $option_key) {
642 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
643 printf(
644 '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
645 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
646 );
647 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));
648 },
649 $page_slug . '_general',
650 $SECTION_METASYNC
651 );
652
653 # Textarea for bot whitelist
654 add_settings_field(
655 'otto_bot_whitelist',
656 'Bot Whitelist',
657 function() use ($whitelabel_otto_name, $option_key) {
658 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
659 printf(
660 '<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>',
661 esc_textarea($otto_bot_whitelist)
662 );
663 echo '<p class="description">';
664 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>';
665 echo '<strong>Example:</strong><br>';
666 echo 'Googlebot<br>';
667 echo 'Bingbot';
668 echo '</p>';
669 },
670 $page_slug . '_general',
671 $SECTION_METASYNC
672 );
673
674 # Textarea for bot blacklist
675 add_settings_field(
676 'otto_bot_blacklist',
677 'Bot Blacklist',
678 function() use ($whitelabel_otto_name, $option_key) {
679 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
680 printf(
681 '<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>',
682 esc_textarea($otto_bot_blacklist)
683 );
684 echo '<p class="description">';
685 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>';
686 echo '<strong>Example:</strong><br>';
687 echo 'BadBot<br>';
688 echo 'MaliciousCrawler';
689 echo '</p>';
690 },
691 $page_slug . '_general',
692 $SECTION_METASYNC
693 );
694
695
696 # Link button to Bot Statistics page
697 add_settings_field(
698 'otto_bot_statistics_link',
699 'Bot Detection Statistics',
700 function() use ($page_slug) {
701 printf(
702 '<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>',
703 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
704 );
705 printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
706 },
707 $page_slug . '_general',
708 $SECTION_METASYNC
709 );
710
711 # END BOT DETECTION SETTINGS
712
713 add_settings_field(
714 'periodic_clear_ottopage_cache',
715 'Clear Page Cache',
716 function() use ($option_key) {
717 $periodic_clear_ottopage_cache = Metasync::get_option('general')['periodic_clear_ottopage_cache'] ?? 'default';
718 printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopage_cache]" id="heading_style">');
719 printf('<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>');
720 printf('<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>');
721 printf('<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>');
722 printf('<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>');
723 printf('</select>');
724
725 # get last cleared timestamp
726 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
727
728 if($timestamp > 0){
729 printf(
730 '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
731 );
732 }
733
734 },
735 $page_slug . '_otto_cache',
736 $SECTION_METASYNC
737 );
738
739 add_settings_field(
740 'periodic_clear_ottopost_cache',
741 'Clear Post Cache',
742 function() use ($option_key) {
743 $periodic_clear_ottopost_cache = Metasync::get_option('general')['periodic_clear_ottopost_cache'] ?? 'default';
744 printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopost_cache]" id="heading_style">');
745 printf('<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>');
746 printf('<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>');
747 printf('<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>');
748 printf('<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>');
749 printf('</select>');
750
751 # get last cleared timestamp
752 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
753
754 if($timestamp > 0){
755 printf(
756 '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
757 );
758 }
759 },
760 $page_slug . '_otto_cache',
761 $SECTION_METASYNC
762 );
763
764 add_settings_field(
765 'periodic_clear_otto_cache',
766 'Clear all cache',
767 function() use ($option_key) {
768 $periodic_clear_otto_cache = Metasync::get_option('general')['periodic_clear_otto_cache'] ?? 'default';
769 printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_otto_cache]" id="heading_style">');
770 printf('<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>');
771 printf('<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>');
772 printf('<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>');
773 printf('<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>');
774 printf('</select>');
775
776 # get last cleared timestamp
777 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
778
779 if($timestamp > 0){
780 printf(
781 '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
782 );
783 }
784
785 },
786 $page_slug . '_otto_cache',
787 $SECTION_METASYNC
788 );
789
790 # END SERVER SIDE RENDERING SETTINGS
791
792 // Meta Box Visibility Controls
793 add_settings_field(
794 'disable_common_robots_metabox',
795 'Disable Common Robots Meta Box',
796 function() use ($option_key) {
797 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
798 printf(
799 '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
800 $disabled ? 'checked' : ''
801 );
802 printf('<span class="description"> Hide the Common Robots Meta box on post/page edit screens</span>');
803 },
804 $page_slug . '_general',
805 $SECTION_METASYNC
806 );
807
808 add_settings_field(
809 'disable_advance_robots_metabox',
810 'Disable Advance Robots Meta Box',
811 function() use ($option_key) {
812 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
813 printf(
814 '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
815 $disabled ? 'checked' : ''
816 );
817 printf('<span class="description"> Hide the Advance Robots Meta box on post/page edit screens</span>');
818 },
819 $page_slug . '_general',
820 $SECTION_METASYNC
821 );
822
823 add_settings_field(
824 'disable_redirection_metabox',
825 'Disable Redirection Meta Box',
826 function() use ($option_key) {
827 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
828 printf(
829 '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
830 $disabled ? 'checked' : ''
831 );
832 printf('<span class="description"> Hide the Redirection meta box on post/page edit screens</span>');
833 },
834 $page_slug . '_general',
835 $SECTION_METASYNC
836 );
837
838 add_settings_field(
839 'disable_canonical_metabox',
840 'Disable Canonical Meta Box',
841 function() use ($option_key) {
842 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
843 printf(
844 '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
845 $disabled ? 'checked' : ''
846 );
847 printf('<span class="description"> Hide the Canonical meta box on post/page edit screens</span>');
848 },
849 $page_slug . '_general',
850 $SECTION_METASYNC
851 );
852
853 add_settings_field(
854 'disable_social_opengraph_metabox',
855 'Disable Social Media & Open Graph Meta Box',
856 function() use ($option_key) {
857 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
858 printf(
859 '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
860 $disabled ? 'checked' : ''
861 );
862 printf('<span class="description"> Hide the Social Media & Open Graph meta box on post/page edit screens</span>');
863 },
864 $page_slug . '_general',
865 $SECTION_METASYNC
866 );
867
868 add_settings_field(
869 'disable_schema_markup_metabox',
870 'Disable Schema Markup Meta Box',
871 function() use ($option_key) {
872 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
873 printf(
874 '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
875 $disabled ? 'checked' : ''
876 );
877 printf('<span class="description"> Hide the Schema Markup meta box on post/page edit screens</span>');
878 },
879 $page_slug . '_general',
880 $SECTION_METASYNC
881 );
882
883 add_settings_field(
884 'disable_seo_metabox',
885 'Disable SEO Meta Box',
886 function() use ($option_key) {
887 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
888 printf(
889 '<input type="checkbox" id="disable_seo_metabox" name="' . $option_key . '[general][disable_seo_metabox]" value="1" %s />',
890 $disabled ? 'checked' : ''
891 );
892 printf('<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>');
893 },
894 $page_slug . '_general',
895 $SECTION_METASYNC
896 );
897
898 add_settings_field(
899 'open_external_links',
900 'Open External Links in New Tab/Window',
901 function() use ($option_key) {
902 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
903 printf(
904 '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
905 $enabled ? 'checked' : ''
906 );
907 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()));
908 },
909 $page_slug . '_general',
910 $SECTION_METASYNC
911 );
912
913 add_settings_field(
914 'permalink_structure',
915 'The Permalink setting of website',
916 function() {
917
918 $current_permalink_structure = get_option('permalink_structure');
919 $current_rewrite_rules = get_option('rewrite_rules');
920 // Check if the current permalink structure is set to "Plain"
921 if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
922 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>');
923 } else {
924 printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
925 }
926 },
927 $page_slug . '_general',
928 $SECTION_METASYNC
929 );
930
931 # Add Hide Dashboard Framework setting
932 add_settings_field(
933 'hide_dashboard_framework',
934 'Hide Dashboard',
935 function() use ($option_key) {
936 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
937 printf(
938 '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
939 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
940 );
941 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
942 },
943 $page_slug . '_general',
944 $SECTION_METASYNC
945 );
946
947 // REMOVED (CVE-2025-14386): disable_single_signup_login setting was removed
948
949 # Adding the "Show Admin Bar Status" setting
950 add_settings_field(
951 'show_admin_bar_status',
952 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
953 function() use ($option_key) {
954 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
955 printf(
956 '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
957 $show_admin_bar ? 'checked' : ''
958 );
959 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
960 },
961 $page_slug . '_general',
962 $SECTION_METASYNC
963 );
964
965 # Adding the "Enable Auto-updates" setting
966 add_settings_field(
967 'enable_auto_updates',
968 'Enable Automatic Updates',
969 function() use ($option_key) {
970 $enable_auto_updates = Metasync::get_option('general')['enable_auto_updates'] ?? false;
971 printf(
972 '<input type="checkbox" id="enable_auto_updates" name="' . $option_key . '[general][enable_auto_updates]" value="true" %s />',
973 $enable_auto_updates ? 'checked' : ''
974 );
975 printf('<span class="description">Allow WordPress to automatically update this plugin when new versions are available.</span>');
976 },
977 $page_slug . '_general',
978 $SECTION_METASYNC
979 );
980
981 # Add User Role Sync Setting
982 add_settings_field(
983 'content_genius_sync_roles',
984 'Content Genius User Roles to Sync',
985 function() use ($option_key) {
986 $selected_roles = Metasync::get_option('general')['content_genius_sync_roles'] ?? array();
987
988 # If it's a string (single role from old version), convert to array
989 if (!is_array($selected_roles)) {
990 $selected_roles = array($selected_roles);
991 }
992
993 # Get all WordPress user roles safely
994 if (!function_exists('wp_roles')) {
995 require_once(ABSPATH . 'wp-admin/includes/user.php');
996 }
997 global $wp_roles;
998
999 # Safety check for $wp_roles
1000 if (!isset($wp_roles) || !is_object($wp_roles)) {
1001 $wp_roles = new WP_Roles();
1002 }
1003
1004 $all_roles = $wp_roles->roles;
1005
1006 # Start container
1007 ?>
1008 <div class="metasync-role-selector-container">
1009
1010 <!-- All Roles Option -->
1011 <label class="metasync-role-option-all">
1012 <input type="checkbox"
1013 name="<?php echo esc_attr($option_key); ?>[general][content_genius_sync_roles][]"
1014 value="all"
1015 <?php checked(empty($selected_roles) || in_array('all', $selected_roles), true); ?> />
1016 <strong>All Roles</strong>
1017 </label>
1018
1019 <!-- Divider -->
1020 <hr class="metasync-role-divider">
1021
1022 <?php
1023 # Display each role as a checkbox
1024 if (!empty($all_roles) && is_array($all_roles)) {
1025 foreach ($all_roles as $role_key => $role_details) {
1026 # Safety check for role data
1027 if (!isset($role_details['name'])) {
1028 continue;
1029 }
1030
1031 # Only list roles that can author content (have the edit_posts capability).
1032 # Keep any role already in the saved selection even if it lacks the cap,
1033 # so existing configurations are preserved and not silently dropped on re-save.
1034 $caps = isset($role_details['capabilities']) ? $role_details['capabilities'] : array();
1035 if (empty($caps['edit_posts']) && !in_array($role_key, $selected_roles)) {
1036 continue;
1037 }
1038
1039 $is_checked = in_array($role_key, $selected_roles);
1040 $role_name = translate_user_role($role_details['name']);
1041 ?>
1042 <label class="metasync-role-option">
1043 <input type="checkbox"
1044 name="<?php echo esc_attr($option_key); ?>[general][content_genius_sync_roles][]"
1045 value="<?php echo esc_attr($role_key); ?>"
1046 <?php checked($is_checked, true); ?> />
1047 <span class="metasync-role-label"><?php echo esc_html($role_name); ?></span>
1048 </label>
1049 <?php
1050 }
1051 }
1052 ?>
1053
1054 </div>
1055
1056 <!-- Description -->
1057 <p class="description" style="margin-top: 10px;">
1058 <strong>How it works:</strong>
1059 Select which user roles should be synced with Content Genius.
1060 If <strong>"All Roles"</strong> is selected or none are selected, all users will be synced.
1061 Otherwise, only users with the selected roles will be included in the sync.
1062 </p>
1063 <?php
1064 },
1065 $page_slug . '_general',
1066 $SECTION_METASYNC
1067 );
1068
1069 # Default Page Builder setting
1070 add_settings_field(
1071 'default_page_builder',
1072 'Default Page Builder',
1073 function() use ($option_key) {
1074 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
1075 $current = Metasync::get_option('general')['default_page_builder'] ?? '';
1076 $builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
1077 $auto_detected = Metasync_HTML_To_Builder_Converter::auto_detect_builder();
1078
1079 echo '<div class="metasync-page-builder-setting">';
1080 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1081
1082 foreach ($builders as $key => $builder) {
1083 $is_detected = $builder['detected'];
1084 $label = esc_html($builder['label']);
1085 if ($is_detected && $key !== 'gutenberg') {
1086 $label .= ' — Detected';
1087 } elseif (!$is_detected) {
1088 $label .= ' — Not Detected';
1089 }
1090 if ($key === 'gutenberg' && empty($current)) {
1091 $label .= ' — Default';
1092 }
1093 printf(
1094 '<option value="%s" %s%s>%s</option>',
1095 esc_attr($key),
1096 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1097 $is_detected ? '' : ' disabled',
1098 $label
1099 );
1100 }
1101
1102 echo '</select>';
1103 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>';
1104
1105 # Show current auto-detection result
1106 if (!empty($builders[$auto_detected]) && $auto_detected !== 'gutenberg') {
1107 printf(
1108 '<p class="description" style="margin-top: 8px; color: #0073aa;">🔍 Auto-detected: <strong>%s</strong> is active on this site.</p>',
1109 esc_html($builders[$auto_detected]['label'])
1110 );
1111 }
1112
1113 # Show description for the selected builder
1114 echo '<div id="metasync-builder-descriptions" style="margin-top: 10px;">';
1115 foreach ($builders as $key => $builder) {
1116 $is_visible = (empty($current) ? 'gutenberg' : $current) === $key;
1117 printf(
1118 '<p class="description metasync-builder-desc" data-builder="%s" style="%s">%s</p>',
1119 esc_attr($key),
1120 $is_visible ? '' : 'display:none;',
1121 esc_html($builder['description'])
1122 );
1123 }
1124 echo '</div>';
1125
1126 # Inline JS to toggle description on select change
1127 ?>
1128 <script>
1129 document.getElementById('default_page_builder')?.addEventListener('change', function() {
1130 document.querySelectorAll('.metasync-builder-desc').forEach(function(el) {
1131 el.style.display = el.dataset.builder === this.value ? '' : 'none';
1132 }.bind(this));
1133 });
1134 </script>
1135 <?php
1136 },
1137 $page_slug . '_general',
1138 $SECTION_METASYNC
1139 );
1140
1141 add_settings_field(
1142 'import_external_data',
1143 'Import settings and data from SEO Plugins',
1144 function() {
1145 printf(
1146 '<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>',
1147 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1148 );
1149 printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1150 },
1151 $page_slug . '_general',
1152 $SECTION_METASYNC
1153 );
1154
1155
1156
1157 /*
1158 add field to save color for elementor and assign custom color to the heading
1159 */
1160 if(is_admin()){
1161 add_settings_field(
1162 'white_label_plugin_name',
1163 '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).'),
1164 function() use ($option_key) {
1165 $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1166 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />');
1167 printf('<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>');
1168 },
1169 $page_slug . '_branding',
1170 $SECTION_METASYNC
1171 );
1172
1173 add_settings_field(
1174 'whitelabel_otto_name',
1175 '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.'),
1176 function() use ($option_key) {
1177 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1178 printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1179 $example_name = !empty($value) ? $value : 'OTTO';
1180 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));
1181 },
1182 $page_slug . '_branding',
1183 $SECTION_METASYNC
1184 );
1185
1186 add_settings_field(
1187 'whitelabel_logo_light_url',
1188 '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.'),
1189 function() use ($option_key) {
1190 $whitelabel_settings = Metasync::get_whitelabel_settings();
1191 $value = $whitelabel_settings['logo_light'] ?? '';
1192 printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1193 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()));
1194 },
1195 $page_slug . '_branding',
1196 $SECTION_METASYNC
1197 );
1198
1199 add_settings_field(
1200 'whitelabel_logo_dark_url',
1201 '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.'),
1202 function() use ($option_key) {
1203 $whitelabel_settings = Metasync::get_whitelabel_settings();
1204 $value = $whitelabel_settings['logo_dark'] ?? '';
1205 printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1206 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()));
1207 },
1208 $page_slug . '_branding',
1209 $SECTION_METASYNC
1210 );
1211
1212 add_settings_field(
1213 'whitelabel_domain_url',
1214 '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.'),
1215 function() use ($option_key) {
1216 $whitelabel_settings = Metasync::get_whitelabel_settings();
1217 $value = $whitelabel_settings['domain'] ?? '';
1218 printf('<input type="url" name="' . $option_key . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />');
1219 printf('<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>');
1220 },
1221 $page_slug . '_branding',
1222 $SECTION_METASYNC
1223 );
1224 add_settings_field(
1225 'white_label_plugin_description',
1226 'Plugin Description',
1227 function() use ($option_key) {
1228 $value = Metasync::get_option('general')['white_label_plugin_description'] ?? '';
1229 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />');
1230 },
1231 $page_slug . '_branding',
1232 $SECTION_METASYNC
1233 );
1234
1235 add_settings_field(
1236 'white_label_plugin_author',
1237 'Author',
1238 function() use ($option_key) {
1239 $value = Metasync::get_option('general')['white_label_plugin_author'] ?? '';
1240 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />');
1241 },
1242 $page_slug . '_branding',
1243 $SECTION_METASYNC
1244 );
1245
1246 add_settings_field(
1247 'white_label_plugin_author_uri',
1248 'Author URI',
1249 function() use ($option_key) {
1250 $value = Metasync::get_option('general')['white_label_plugin_author_uri'] ?? '';
1251 # printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1252 # Fixed printf usage
1253 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1254
1255 },
1256 $page_slug . '_branding',
1257 $SECTION_METASYNC
1258 );
1259 add_settings_field(
1260 'white_label_plugin_uri',
1261 'Plugin URI',
1262 function() use ($option_key) {
1263 $value = Metasync::get_option('general')['white_label_plugin_uri'] ?? ''; // New option for Plugin URI
1264 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />');
1265 },
1266 $page_slug . '_branding',
1267 $SECTION_METASYNC
1268 );
1269 register_setting(Metasync_Admin::option_group,
1270 Metasync_Admin::option_key,
1271 array(self::instance(), 'sanitize')
1272 );
1273
1274 // DEPRECATED: Menu Name and Menu Title fields removed
1275 add_settings_field(
1276 'white_label_plugin_menu_slug',
1277 'Menu Slug',
1278 function() use ($option_key) {
1279 $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1280 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />');
1281 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>');
1282 },
1283 $page_slug . '_branding',
1284 $SECTION_METASYNC
1285 );
1286 add_settings_field(
1287 'white_label_plugin_menu_icon',
1288 'Menu Icon',
1289 function() use ($option_key) {
1290 $value = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
1291 printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />');
1292 },
1293 $page_slug . '_branding',
1294 $SECTION_METASYNC
1295 );
1296 }
1297 // HIDDEN: Choose Style Option setting
1298 /*
1299 add_settings_field(
1300 'enabled_plugin_css',
1301 'Choose Style Option',
1302 function() use ($option_key) {
1303 $enabled_plugin_css = Metasync::get_option('general')['enabled_plugin_css'] ?? '';
1304
1305 // Output radio button for Default Style.css active
1306
1307 printf(
1308 '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1309 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1310 );
1311 printf('<label for="enable_default">Default</label><br>');
1312
1313
1314 // Output radio button for Metasync Style
1315 printf(
1316 '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1317 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1318 );
1319 printf('<label for="enable_metasync">Metasync Style</label>');
1320
1321 printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1322 },
1323 $page_slug . '_branding',
1324 $SECTION_METASYNC
1325 );
1326 */
1327
1328 add_settings_field(
1329 'enabled_elementor_plugin_css',
1330 'Choose Elementor Font Style',
1331 function() use ($option_key) {
1332 $enabled_elementor_plugin_css = Metasync::get_option('general')['enabled_elementor_plugin_css'] ?? 'default';
1333 printf('<select name="' . $option_key . '[general][enabled_elementor_plugin_css]" id="heading_style">');
1334 printf('<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>');
1335 printf('<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>');
1336 printf('</select>');
1337 },
1338 $page_slug . '_branding',
1339 $SECTION_METASYNC
1340 );
1341 add_settings_field(
1342 'enabled_elementor_plugin_css_color',
1343 'Choose Elementor Font Color',
1344 function() use ($option_key) {
1345 $enabled_elementor_plugin_css_color = Metasync::get_option('general')['enabled_elementor_plugin_css_color'] ?? '#000000';
1346 printf('<input type="color" id="elementor_default_color_metasync" name="' . $option_key . '[general][enabled_elementor_plugin_css_color]" value="'.$enabled_elementor_plugin_css_color.'">');
1347 },
1348 $page_slug . '_branding',
1349 $SECTION_METASYNC
1350 );
1351
1352 add_settings_field(
1353 'whitelabel_settings_password',
1354 '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.'),
1355 function() use ($option_key) {
1356 // Decrypted for display — this field only renders after the
1357 // whitelabel password gate has been passed.
1358 $value = Metasync::get_whitelabel_password();
1359 printf('<input type="password" name="' . $option_key . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />');
1360 printf('<p class="description">Set a custom password to protect the branding settings section.</p>');
1361 },
1362 $page_slug . '_branding',
1363 $SECTION_METASYNC
1364 );
1365
1366 add_settings_field(
1367 'whitelabel_recovery_email',
1368 '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.'),
1369 function() use ($option_key) {
1370 $whitelabel_settings = Metasync::get_whitelabel_settings();
1371 $value = $whitelabel_settings['recovery_email'] ?? '';
1372 $has_password = !empty($whitelabel_settings['settings_password']);
1373 printf('<input type="email" name="' . $option_key . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1374 printf('<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>');
1375 },
1376 $page_slug . '_branding',
1377 $SECTION_METASYNC
1378 );
1379
1380 add_settings_field(
1381 'whitelabel_quick_links',
1382 'Quick Links',
1383 function() use ($option_key) {
1384 $whitelabel_settings = Metasync::get_whitelabel_settings();
1385 $links = isset($whitelabel_settings['quick_links']) && is_array($whitelabel_settings['quick_links'])
1386 ? $whitelabel_settings['quick_links']
1387 : [];
1388 // Ensure at least 5 rows
1389 while (count($links) < 5) {
1390 $links[] = ['label' => '', 'url' => '', 'external' => false];
1391 }
1392 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>';
1393 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>';
1394 echo '<table class="widefat metasync-quick-links-table" style="max-width:560px;">';
1395 echo '<thead><tr><th>Label</th><th>URL</th><th>Open in new tab</th></tr></thead><tbody>';
1396 foreach (array_slice($links, 0, 5) as $i => $link) {
1397 $label = esc_attr($link['label'] ?? '');
1398 $url = esc_attr($link['url'] ?? '');
1399 $external = !empty($link['external']);
1400 printf(
1401 '<tr>
1402 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1403 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1404 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1405 </tr>',
1406 esc_attr($option_key), $i, $label,
1407 esc_attr($option_key), $i, $url,
1408 esc_attr($option_key), $i, checked($external, true, false)
1409 );
1410 }
1411 echo '</tbody></table>';
1412 },
1413 $page_slug . '_branding',
1414 $SECTION_METASYNC
1415 );
1416
1417 // ======================================================================
1418 // Schema Markup Settings — dedicated page (SEO → Schema Markup)
1419 // ======================================================================
1420 $schema_page = $page_slug . '_schema-markup';
1421
1422 add_settings_section(
1423 'metasync_schema_org',
1424 'Organization Schema (Site-Wide)',
1425 function() {
1426 echo '<p>This Organization schema is injected on every page of your site.</p>';
1427 },
1428 $schema_page
1429 );
1430
1431 add_settings_field(
1432 'schema_org_name',
1433 'Organization Name',
1434 function() use ($option_key) {
1435 $schema = Metasync::get_option('schema') ?? [];
1436 $value = $schema['org_name'] ?? '';
1437 printf('<input type="text" name="' . $option_key . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1438 },
1439 $schema_page,
1440 'metasync_schema_org'
1441 );
1442
1443 add_settings_field(
1444 'schema_org_url',
1445 'Organization URL',
1446 function() use ($option_key) {
1447 $schema = Metasync::get_option('schema') ?? [];
1448 $value = $schema['org_url'] ?? '';
1449 printf('<input type="url" name="' . $option_key . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1450 },
1451 $schema_page,
1452 'metasync_schema_org'
1453 );
1454
1455 add_settings_field(
1456 'schema_org_logo',
1457 '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.'),
1458 function() use ($option_key) {
1459 $schema = Metasync::get_option('schema') ?? [];
1460 $value = $schema['org_logo'] ?? '';
1461 printf('<input type="url" name="' . $option_key . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1462 },
1463 $schema_page,
1464 'metasync_schema_org'
1465 );
1466
1467 add_settings_field(
1468 'schema_org_contact_telephone',
1469 'Contact Telephone',
1470 function() use ($option_key) {
1471 $schema = Metasync::get_option('schema') ?? [];
1472 $value = $schema['org_contact_telephone'] ?? '';
1473 printf('<input type="text" name="' . $option_key . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1474 },
1475 $schema_page,
1476 'metasync_schema_org'
1477 );
1478
1479 add_settings_field(
1480 'schema_org_contact_type',
1481 '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.'),
1482 function() use ($option_key) {
1483 $schema = Metasync::get_option('schema') ?? [];
1484 $value = $schema['org_contact_type'] ?? '';
1485 printf('<input type="text" name="' . $option_key . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1486 },
1487 $schema_page,
1488 'metasync_schema_org'
1489 );
1490
1491 add_settings_field(
1492 'schema_org_same_as',
1493 '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.'),
1494 function() use ($option_key) {
1495 $schema = Metasync::get_option('schema') ?? [];
1496 $value = $schema['org_same_as'] ?? '';
1497 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));
1498 echo '<p class="description">Enter one social profile URL per line.</p>';
1499 },
1500 $schema_page,
1501 'metasync_schema_org'
1502 );
1503
1504 add_settings_section(
1505 'metasync_schema_website',
1506 'WebSite Schema (Homepage Only)',
1507 function() {
1508 echo '<p>This WebSite schema is injected on the homepage only. Enables the Google Sitelinks Searchbox.</p>';
1509 },
1510 $schema_page
1511 );
1512
1513 add_settings_field(
1514 'schema_website_name',
1515 'WebSite Name',
1516 function() use ($option_key) {
1517 $schema = Metasync::get_option('schema') ?? [];
1518 $value = $schema['website_name'] ?? '';
1519 printf('<input type="text" name="' . $option_key . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1520 },
1521 $schema_page,
1522 'metasync_schema_website'
1523 );
1524
1525 add_settings_field(
1526 'schema_website_url',
1527 'WebSite URL',
1528 function() use ($option_key) {
1529 $schema = Metasync::get_option('schema') ?? [];
1530 $value = $schema['website_url'] ?? '';
1531 printf('<input type="url" name="' . $option_key . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1532 },
1533 $schema_page,
1534 'metasync_schema_website'
1535 );
1536
1537 add_settings_field(
1538 'schema_website_searchbox',
1539 '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.'),
1540 function() use ($option_key) {
1541 $schema = Metasync::get_option('schema') ?? [];
1542 $value = $schema['website_searchbox'] ?? false;
1543 printf(
1544 '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1545 checked($value, true, false)
1546 );
1547 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1548 },
1549 $schema_page,
1550 'metasync_schema_website'
1551 );
1552
1553 add_settings_section(
1554 'metasync_schema_compat',
1555 'Compatibility',
1556 function() {
1557 echo '<p>Control how MetaSync interacts with other plugins that output structured data.</p>';
1558 },
1559 $schema_page
1560 );
1561
1562 add_settings_field(
1563 'schema_override_woocommerce',
1564 '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.'),
1565 function() use ($option_key) {
1566 $schema = Metasync::get_option('schema') ?? [];
1567 $value = $schema['override_woocommerce_schema'] ?? false;
1568 printf(
1569 '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1570 checked($value, true, false)
1571 );
1572 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>';
1573 },
1574 $schema_page,
1575 'metasync_schema_compat'
1576 );
1577
1578 // ----------------------------------------------------------------
1579 // Site Verification page
1580 // ----------------------------------------------------------------
1581 add_settings_section(
1582 $SECTION_SEARCHENGINE,
1583 '',
1584 function() {},
1585 $page_slug . '_searchengines-verification'
1586 );
1587
1588 add_settings_field(
1589 'google_site_verification',
1590 '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.'),
1591 array( Metasync_Settings_Fields::instance(), 'google_site_verification_callback' ),
1592 $page_slug . '_searchengines-verification',
1593 $SECTION_SEARCHENGINE
1594 );
1595
1596 add_settings_field(
1597 'bing_site_verification',
1598 '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.'),
1599 array( Metasync_Settings_Fields::instance(), 'bing_site_verification_callback' ),
1600 $page_slug . '_searchengines-verification',
1601 $SECTION_SEARCHENGINE
1602 );
1603
1604 add_settings_field(
1605 'yandex_site_verification',
1606 '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.'),
1607 array( Metasync_Settings_Fields::instance(), 'yandex_site_verification_callback' ),
1608 $page_slug . '_searchengines-verification',
1609 $SECTION_SEARCHENGINE
1610 );
1611
1612 add_settings_field(
1613 'pinterest_site_verification',
1614 '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.'),
1615 array( Metasync_Settings_Fields::instance(), 'pinterest_site_verification_callback' ),
1616 $page_slug . '_searchengines-verification',
1617 $SECTION_SEARCHENGINE
1618 );
1619
1620 add_settings_field(
1621 'baidu_site_verification',
1622 '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.'),
1623 function() use ($option_key) {
1624 $value = Metasync::get_option('searchengines')['baidu_site_verification'] ?? '';
1625 printf( '<input type="text" id="baidu_site_verification" name="' . $option_key . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1626 },
1627 $page_slug . '_searchengines-verification',
1628 $SECTION_SEARCHENGINE
1629 );
1630
1631 add_settings_field(
1632 'alexa_site_verification',
1633 '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.'),
1634 function() use ($option_key) {
1635 $value = Metasync::get_option('searchengines')['alexa_site_verification'] ?? '';
1636 printf( '<input type="text" id="alexa_site_verification" name="' . $option_key . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1637 },
1638 $page_slug . '_searchengines-verification',
1639 $SECTION_SEARCHENGINE
1640 );
1641
1642 add_settings_field(
1643 'norton_save_site_verification',
1644 '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.'),
1645 function() use ($option_key) {
1646 $value = Metasync::get_option('searchengines')['norton_save_site_verification'] ?? '';
1647 printf( '<input type="text" id="norton_save_site_verification" name="' . $option_key . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1648 },
1649 $page_slug . '_searchengines-verification',
1650 $SECTION_SEARCHENGINE
1651 );
1652
1653 // ----------------------------------------------------------------
1654 // Code Snippets page
1655 // ----------------------------------------------------------------
1656 add_settings_section(
1657 $SECTION_CODESNIPPETS,
1658 '',
1659 function() {},
1660 $page_slug . '_code-snippets'
1661 );
1662
1663 add_settings_field(
1664 'header_snippet',
1665 'Header Code',
1666 array( Metasync_Settings_Fields::instance(), 'header_snippets_callback' ),
1667 $page_slug . '_code-snippets',
1668 $SECTION_CODESNIPPETS
1669 );
1670
1671 add_settings_field(
1672 'footer_snippet',
1673 'Footer Code',
1674 array( Metasync_Settings_Fields::instance(), 'footer_snippets_callback' ),
1675 $page_slug . '_code-snippets',
1676 $SECTION_CODESNIPPETS
1677 );
1678
1679 // ----------------------------------------------------------------
1680 // Local Business page
1681 // ----------------------------------------------------------------
1682 add_settings_section(
1683 $SECTION_LOCALSEO,
1684 '',
1685 function() {},
1686 $page_slug . '_local-seo'
1687 );
1688
1689 $fields_instance = Metasync_Settings_Fields::instance();
1690
1691 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 );
1692 add_settings_field( 'local_seo_name', 'Name', array( $fields_instance, 'local_seo_name_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1693 add_settings_field( 'local_seo_logo', 'Logo', array( $fields_instance, 'local_seo_logo_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1694 add_settings_field( 'local_seo_url', 'Website URL', array( $fields_instance, 'local_seo_url_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1695 add_settings_field( 'local_seo_email', 'Email', array( $fields_instance, 'local_seo_email_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1696 add_settings_field( 'local_seo_phone', 'Phone', array( $fields_instance, 'local_seo_phone_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1697 add_settings_field( 'local_seo_address', 'Address', array( $fields_instance, 'local_seo_address_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1698 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 );
1699 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 );
1700 add_settings_field( 'local_seo_phone_numbers', 'Phone Numbers', array( $fields_instance, 'local_seo_phone_numbers_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1701 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 );
1702 add_settings_field( 'local_seo_about_page', 'About Page', array( $fields_instance, 'local_seo_about_page_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1703 add_settings_field( 'local_seo_contact_page', 'Contact Page', array( $fields_instance, 'local_seo_contact_page_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1704 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 );
1705 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 );
1706
1707 // ----------------------------------------------------------------
1708 // Breadcrumbs page
1709 // ----------------------------------------------------------------
1710 add_settings_section(
1711 $SECTION_BREADCRUMBS,
1712 '',
1713 function() {},
1714 $page_slug . '_breadcrumbs'
1715 );
1716
1717 add_settings_field(
1718 'breadcrumbs_enabled',
1719 'Enable Breadcrumbs',
1720 function() use ($option_key) {
1721 $value = Metasync::get_option('breadcrumbs')['enabled'] ?? true;
1722 printf(
1723 '<input type="checkbox" id="breadcrumbs_enabled" name="' . $option_key . '[breadcrumbs][enabled]" value="1" %s />',
1724 checked(1, $value, false)
1725 );
1726 echo '<p class="description">Output breadcrumb trail HTML on your site.</p>';
1727 },
1728 $page_slug . '_breadcrumbs',
1729 $SECTION_BREADCRUMBS
1730 );
1731
1732 add_settings_field(
1733 'breadcrumbs_separator',
1734 'Separator',
1735 function() use ($option_key) {
1736 $value = Metasync::get_option('breadcrumbs')['separator'] ?? '&raquo;';
1737 printf(
1738 '<input type="text" id="breadcrumbs_separator" name="' . $option_key . '[breadcrumbs][separator]" value="%s" size="10" />',
1739 esc_attr($value)
1740 );
1741 echo '<p class="description">Character or HTML entity shown between crumbs (e.g. &raquo; or /).</p>';
1742 },
1743 $page_slug . '_breadcrumbs',
1744 $SECTION_BREADCRUMBS
1745 );
1746
1747 add_settings_field(
1748 'breadcrumbs_home_label',
1749 'Home Label',
1750 function() use ($option_key) {
1751 $value = Metasync::get_option('breadcrumbs')['home_label'] ?? 'Home';
1752 printf(
1753 '<input type="text" id="breadcrumbs_home_label" name="' . $option_key . '[breadcrumbs][home_label]" value="%s" size="30" />',
1754 esc_attr($value)
1755 );
1756 },
1757 $page_slug . '_breadcrumbs',
1758 $SECTION_BREADCRUMBS
1759 );
1760
1761 add_settings_field(
1762 'breadcrumbs_home_url',
1763 'Home URL',
1764 function() use ($option_key) {
1765 $value = Metasync::get_option('breadcrumbs')['home_url'] ?? '';
1766 printf(
1767 '<input type="url" id="breadcrumbs_home_url" name="' . $option_key . '[breadcrumbs][home_url]" value="%s" size="50" />',
1768 esc_attr($value)
1769 );
1770 echo '<p class="description">Leave blank to use the site home URL.</p>';
1771 },
1772 $page_slug . '_breadcrumbs',
1773 $SECTION_BREADCRUMBS
1774 );
1775
1776 add_settings_field(
1777 'breadcrumbs_show_current_page',
1778 'Show Current Page',
1779 function() use ($option_key) {
1780 $value = Metasync::get_option('breadcrumbs')['show_current_page'] ?? true;
1781 printf(
1782 '<input type="checkbox" id="breadcrumbs_show_current_page" name="' . $option_key . '[breadcrumbs][show_current_page]" value="1" %s />',
1783 checked(1, $value, false)
1784 );
1785 echo '<p class="description">Include the current page as the last (non-linked) crumb.</p>';
1786 },
1787 $page_slug . '_breadcrumbs',
1788 $SECTION_BREADCRUMBS
1789 );
1790
1791 add_settings_field(
1792 'breadcrumbs_prefix_text',
1793 'Prefix Text',
1794 function() use ($option_key) {
1795 $value = Metasync::get_option('breadcrumbs')['prefix_text'] ?? '';
1796 printf(
1797 '<input type="text" id="breadcrumbs_prefix_text" name="' . $option_key . '[breadcrumbs][prefix_text]" value="%s" size="30" />',
1798 esc_attr($value)
1799 );
1800 echo '<p class="description">Optional text before the breadcrumb trail (e.g. "You are here:").</p>';
1801 },
1802 $page_slug . '_breadcrumbs',
1803 $SECTION_BREADCRUMBS
1804 );
1805
1806 add_settings_field(
1807 'breadcrumbs_archive_label_format',
1808 'Archive Label Format',
1809 function() use ($option_key) {
1810 $value = Metasync::get_option('breadcrumbs')['archive_label_format'] ?? '{name}';
1811 printf(
1812 '<input type="text" id="breadcrumbs_archive_label_format" name="' . $option_key . '[breadcrumbs][archive_label_format]" value="%s" size="30" />',
1813 esc_attr($value)
1814 );
1815 echo '<p class="description">Format for archive crumb labels. Use <code>{name}</code> as placeholder for the archive name.</p>';
1816 },
1817 $page_slug . '_breadcrumbs',
1818 $SECTION_BREADCRUMBS
1819 );
1820
1821 # Open Graph / Article meta toggles — surface each new tag family as its
1822 # own opt-out checkbox on the main Settings page (Open Graph accordion).
1823 add_settings_field(
1824 'og_image_dimensions',
1825 'OG Image Dimensions',
1826 array(Metasync_Settings_Fields::instance(), 'og_image_dimensions_callback'),
1827 $page_slug . '_general',
1828 $SECTION_METASYNC
1829 );
1830
1831 add_settings_field(
1832 'article_timestamps',
1833 'Article Timestamps',
1834 array(Metasync_Settings_Fields::instance(), 'article_timestamps_callback'),
1835 $page_slug . '_general',
1836 $SECTION_METASYNC
1837 );
1838
1839 add_settings_field(
1840 'article_author',
1841 'Article Author',
1842 array(Metasync_Settings_Fields::instance(), 'article_author_callback'),
1843 $page_slug . '_general',
1844 $SECTION_METASYNC
1845 );
1846
1847 add_settings_field(
1848 'article_section',
1849 'Article Section',
1850 array(Metasync_Settings_Fields::instance(), 'article_section_callback'),
1851 $page_slug . '_general',
1852 $SECTION_METASYNC
1853 );
1854
1855 add_settings_field(
1856 'article_tags',
1857 'Article Tags',
1858 array(Metasync_Settings_Fields::instance(), 'article_tags_callback'),
1859 $page_slug . '_general',
1860 $SECTION_METASYNC
1861 );
1862
1863 add_settings_field(
1864 'twitter_image_alt',
1865 'Twitter Image Alt',
1866 array(Metasync_Settings_Fields::instance(), 'twitter_image_alt_callback'),
1867 $page_slug . '_general',
1868 $SECTION_METASYNC
1869 );
1870
1871 }
1872
1873 /**
1874 * Sanitize each setting field as needed.
1875 *
1876 * Formerly Metasync_Admin::sanitize().
1877 *
1878 * @param array $input Contains all settings fields as array keys
1879 * @return array Sanitized settings
1880 */
1881 public function sanitize($input)
1882 {
1883 $new_input = Metasync::get_option();
1884 $whitelabel_validation_failed = false;
1885
1886 # Determine which tab is being submitted (same logic as AJAX handler)
1887 # Check POST first (from AJAX), then GET (from form action URL)
1888 $active_tab = 'general';
1889 if (isset($_POST['active_tab'])) {
1890 $active_tab = sanitize_text_field($_POST['active_tab']);
1891 } elseif (isset($_GET['tab'])) {
1892 $active_tab = sanitize_text_field($_GET['tab']);
1893 } elseif (isset($_POST['_wp_http_referer'])) {
1894 # Parse referer URL to get tab parameter
1895 $referer = wp_parse_url($_POST['_wp_http_referer']);
1896 if (isset($referer['query'])) {
1897 parse_str($referer['query'], $referer_params);
1898 if (isset($referer_params['tab'])) {
1899 $active_tab = sanitize_text_field($referer_params['tab']);
1900 }
1901 }
1902 }
1903 $general_tab_submitted = ($active_tab === 'general');
1904
1905 // General Settings
1906 if (isset($input['general']['apikey'])) {
1907 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
1908 }
1909
1910 // Meta Box Visibility Settings
1911 if (isset($input['general']) && $general_tab_submitted) {
1912 $checkbox_fields = [
1913 'disable_common_robots_metabox',
1914 'disable_advance_robots_metabox',
1915 'disable_redirection_metabox',
1916 'disable_canonical_metabox',
1917 'disable_social_opengraph_metabox',
1918 'disable_schema_markup_metabox',
1919 'disable_seo_metabox',
1920 'open_external_links'
1921 ];
1922
1923 foreach ($checkbox_fields as $field) {
1924 if (isset($input['general'][$field])) {
1925 $new_input['general'][$field] = filter_var($input['general'][$field], FILTER_VALIDATE_BOOLEAN);
1926 } else {
1927 $new_input['general'][$field] = false;
1928 }
1929 // The final array_merge($new_input, $input) is shallow, so
1930 // $input['general'] replaces $new_input['general'] wholesale.
1931 // Write the sanitized boolean back into $input so it survives.
1932 $input['general'][$field] = $new_input['general'][$field];
1933 }
1934 }
1935
1936 // Site Verification Settings
1937 if (isset($input['searchengines']['bing_site_verification'])) {
1938 $new_input['searchengines']['bing_site_verification'] = sanitize_text_field($input['searchengines']['bing_site_verification']);
1939 }
1940 if (isset($input['searchengines']['baidu_site_verification'])) {
1941 $new_input['searchengines']['baidu_site_verification'] = sanitize_text_field($input['searchengines']['baidu_site_verification']);
1942 }
1943 if (isset($input['searchengines']['alexa_site_verification'])) {
1944 $new_input['searchengines']['alexa_site_verification'] = sanitize_text_field($input['searchengines']['alexa_site_verification']);
1945 }
1946 if (isset($input['searchengines']['yandex_site_verification'])) {
1947 $new_input['searchengines']['yandex_site_verification'] = sanitize_text_field($input['searchengines']['yandex_site_verification']);
1948 }
1949 if (isset($input['searchengines']['google_site_verification'])) {
1950 $new_input['searchengines']['google_site_verification'] = sanitize_text_field($input['searchengines']['google_site_verification']);
1951 }
1952 if (isset($input['searchengines']['pinterest_site_verification'])) {
1953 $new_input['searchengines']['pinterest_site_verification'] = sanitize_text_field($input['searchengines']['pinterest_site_verification']);
1954 }
1955 if (isset($input['searchengines']['norton_save_site_verification'])) {
1956 $new_input['searchengines']['norton_save_site_verification'] = sanitize_text_field($input['searchengines']['norton_save_site_verification']);
1957 }
1958
1959 // Local Business SEO Settings
1960 if (isset($input['localseo']['local_seo_person_organization'])) {
1961 $new_input['localseo']['local_seo_person_organization'] = sanitize_text_field($input['localseo']['local_seo_person_organization']);
1962 }
1963 if (isset($input['localseo']['local_seo_name'])) {
1964 $new_input['localseo']['local_seo_name'] = sanitize_text_field($input['localseo']['local_seo_name']);
1965 }
1966 if (isset($input['localseo']['local_seo_logo'])) {
1967 $new_input['localseo']['local_seo_logo'] = sanitize_url($input['localseo']['local_seo_logo']);
1968 }
1969 if (isset($input['localseo']['local_seo_url'])) {
1970 $new_input['localseo']['local_seo_url'] = sanitize_url($input['localseo']['local_seo_url']);
1971 }
1972 if (isset($input['localseo']['local_seo_email'])) {
1973 $new_input['localseo']['local_seo_email'] = sanitize_email($input['localseo']['local_seo_email']);
1974 }
1975 if (isset($input['localseo']['local_seo_phone'])) {
1976 $new_input['localseo']['local_seo_phone'] = sanitize_text_field($input['localseo']['local_seo_phone']);
1977 }
1978 if (isset($input['localseo']['address']['street'])) {
1979 $new_input['localseo']['address']['street'] = sanitize_text_field($input['localseo']['address']['street']);
1980 }
1981 if (isset($input['localseo']['address']['locality'])) {
1982 $new_input['localseo']['address']['locality'] = sanitize_text_field($input['localseo']['address']['locality']);
1983 }
1984 if (isset($input['localseo']['address']['region'])) {
1985 $new_input['localseo']['address']['region'] = sanitize_text_field($input['localseo']['address']['region']);
1986 }
1987 if (isset($input['localseo']['address']['postalcode'])) {
1988 $new_input['localseo']['address']['postalcode'] = sanitize_text_field($input['localseo']['address']['postalcode']);
1989 }
1990 if (isset($input['localseo']['address']['country'])) {
1991 $new_input['localseo']['address']['country'] = sanitize_text_field($input['localseo']['address']['country']);
1992 }
1993 if (isset($input['localseo']['local_seo_business_type'])) {
1994 $new_input['localseo']['local_seo_business_type'] = sanitize_text_field($input['localseo']['local_seo_business_type']);
1995 }
1996 if (isset($input['localseo']['local_seo_hours_format'])) {
1997 $new_input['localseo']['local_seo_hours_format'] = sanitize_text_field($input['localseo']['local_seo_hours_format']);
1998 }
1999 if (isset($input['localseo']['days'])) {
2000 $new_input['localseo']['days'] = array_map('sanitize_text_field', (array) $input['localseo']['days']);
2001 }
2002 if (isset($input['localseo']['times'])) {
2003 $new_input['localseo']['times'] = array_map('sanitize_text_field', (array) $input['localseo']['times']);
2004 }
2005 if (isset($input['localseo']['phonetype'])) {
2006 $new_input['localseo']['phonetype'] = array_map('sanitize_text_field', (array) $input['localseo']['phonetype']);
2007 }
2008 if (isset($input['localseo']['phonenumber'])) {
2009 $new_input['localseo']['phonenumber'] = array_map('sanitize_text_field', (array) $input['localseo']['phonenumber']);
2010 }
2011 if (isset($input['localseo']['local_seo_price_range'])) {
2012 $new_input['localseo']['local_seo_price_range'] = sanitize_text_field($input['localseo']['local_seo_price_range']);
2013 }
2014 if (isset($input['localseo']['local_seo_about_page'])) {
2015 $new_input['localseo']['local_seo_about_page'] = sanitize_text_field($input['localseo']['local_seo_about_page']);
2016 }
2017 if (isset($input['localseo']['local_seo_contact_page'])) {
2018 $new_input['localseo']['local_seo_contact_page'] = sanitize_text_field($input['localseo']['local_seo_contact_page']);
2019 }
2020 if (isset($input['localseo']['local_seo_map_key'])) {
2021 $new_input['localseo']['local_seo_map_key'] = sanitize_text_field($input['localseo']['local_seo_map_key']);
2022 }
2023 if (isset($input['localseo']['local_seo_geo_coordinates'])) {
2024 $new_input['localseo']['local_seo_geo_coordinates'] = sanitize_text_field($input['localseo']['local_seo_geo_coordinates']);
2025 }
2026
2027 // Breadcrumbs Settings
2028 if (isset($input['breadcrumbs'])) {
2029 $new_input['breadcrumbs']['enabled'] = !empty($input['breadcrumbs']['enabled']);
2030 $new_input['breadcrumbs']['show_current_page'] = !empty($input['breadcrumbs']['show_current_page']);
2031 $new_input['breadcrumbs']['disable_schema'] = !empty($input['breadcrumbs']['disable_schema']);
2032 if (isset($input['breadcrumbs']['separator'])) {
2033 $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2034 }
2035 if (isset($input['breadcrumbs']['home_label'])) {
2036 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2037 }
2038 if (isset($input['breadcrumbs']['home_url'])) {
2039 $new_input['breadcrumbs']['home_url'] = esc_url_raw($input['breadcrumbs']['home_url']);
2040 }
2041 if (isset($input['breadcrumbs']['prefix_text'])) {
2042 $new_input['breadcrumbs']['prefix_text'] = sanitize_text_field($input['breadcrumbs']['prefix_text']);
2043 }
2044 if (isset($input['breadcrumbs']['archive_label_format'])) {
2045 $new_input['breadcrumbs']['archive_label_format'] = sanitize_text_field($input['breadcrumbs']['archive_label_format']);
2046 }
2047 // Remove raw breadcrumbs so sanitized values (especially checkbox
2048 // booleans) survive the shallow array_merge($new_input, $input).
2049 unset($input['breadcrumbs']);
2050 }
2051
2052 // Code Snippets Settings
2053 if (isset($input['codesnippets']['header_snippet'])) {
2054 $new_input['codesnippets']['header_snippet'] = sanitize_text_field($input['codesnippets']['header_snippet']);
2055 }
2056 if (isset($input['codesnippets']['footer_snippet'])) {
2057 $new_input['codesnippets']['footer_snippet'] = sanitize_text_field($input['codesnippets']['footer_snippet']);
2058 }
2059
2060 // Common Setting - Global Settings
2061 $common_robots_field = isset($input['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
2062
2063 if (isset($input[$common_robots_field]['noindex'])) {
2064 $new_input['common_robots_meta']['noindex'] = boolval($input[$common_robots_field]['noindex']);
2065 }
2066 if (isset($input[$common_robots_field]['nofollow'])) {
2067 $new_input['common_robots_meta']['nofollow'] = boolval($input[$common_robots_field]['nofollow']);
2068 }
2069 if (isset($input[$common_robots_field]['noarchive'])) {
2070 $new_input['common_robots_meta']['noarchive'] = boolval($input[$common_robots_field]['noarchive']);
2071 }
2072 if (isset($input[$common_robots_field]['noimageindex'])) {
2073 $new_input['common_robots_meta']['noimageindex'] = boolval($input[$common_robots_field]['noimageindex']);
2074 }
2075 if (isset($input[$common_robots_field]['nosnippet'])) {
2076 $new_input['common_robots_meta']['nosnippet'] = boolval($input[$common_robots_field]['nosnippet']);
2077 }
2078
2079 // Advance Setting - Global Settings
2080 $advance_robots_field = isset($input['advance_robots_meta']) ? 'advance_robots_meta' : 'advance_robots_mata';
2081
2082 if (isset($input[$advance_robots_field]['max-snippet']['enable'])) {
2083 $new_input['advance_robots_meta']['max-snippet']['enable'] = boolval($input[$advance_robots_field]['max-snippet']['enable']);
2084 }
2085 if (isset($input[$advance_robots_field]['max-snippet']['length'])) {
2086 $new_input['advance_robots_meta']['max-snippet']['length'] = sanitize_text_field($input[$advance_robots_field]['max-snippet']['length']);
2087 }
2088 if (isset($input[$advance_robots_field]['max-video-preview']['enable'])) {
2089 $new_input['advance_robots_meta']['max-video-preview']['enable'] = boolval($input[$advance_robots_field]['max-video-preview']['enable']);
2090 }
2091 if (isset($input[$advance_robots_field]['max-video-preview']['length'])) {
2092 $new_input['advance_robots_meta']['max-video-preview']['length'] = sanitize_text_field($input[$advance_robots_field]['max-video-preview']['length']);
2093 }
2094 if (isset($input[$advance_robots_field]['max-image-preview']['enable'])) {
2095 $new_input['advance_robots_meta']['max-image-preview']['enable'] = boolval($input[$advance_robots_field]['max-image-preview']['enable']);
2096 }
2097 if (isset($input[$advance_robots_field]['max-image-preview']['length'])) {
2098 $new_input['advance_robots_meta']['max-image-preview']['length'] = sanitize_text_field($input[$advance_robots_field]['max-image-preview']['length']);
2099 }
2100
2101 // Social meta settings
2102 if (isset($input['social_meta']['facebook_page_url'])) {
2103 $new_input['social_meta']['facebook_page_url'] = sanitize_text_field($input['social_meta']['facebook_page_url']);
2104 }
2105 if (isset($input['social_meta']['facebook_authorship'])) {
2106 $new_input['social_meta']['facebook_authorship'] = sanitize_text_field($input['social_meta']['facebook_authorship']);
2107 }
2108 if (isset($input['social_meta']['facebook_admin'])) {
2109 $new_input['social_meta']['facebook_admin'] = sanitize_text_field($input['social_meta']['facebook_admin']);
2110 }
2111 if (isset($input['social_meta']['facebook_app'])) {
2112 $new_input['social_meta']['facebook_app'] = sanitize_text_field($input['social_meta']['facebook_app']);
2113 }
2114 if (isset($input['social_meta']['facebook_secret'])) {
2115 $new_input['social_meta']['facebook_secret'] = sanitize_text_field($input['social_meta']['facebook_secret']);
2116 }
2117 if (isset($input['social_meta']['twitter_username'])) {
2118 $new_input['social_meta']['twitter_username'] = sanitize_text_field($input['social_meta']['twitter_username']);
2119 }
2120
2121 // Common Meta Settings — Open Graph / article toggle checkboxes.
2122 // Unchecked HTML checkboxes are absent from $input, so an explicit
2123 // 'true'/'false' round-trip is required to persist opt-outs.
2124 if (isset($input['common_meta_settings'])) {
2125 $og_toggle_fields = [
2126 'og_image_dimensions',
2127 'article_timestamps',
2128 'article_author',
2129 'article_section',
2130 'article_tags',
2131 'twitter_image_alt',
2132 ];
2133 foreach ($og_toggle_fields as $field) {
2134 $new_input['common_meta_settings'][$field] = (isset($input['common_meta_settings'][$field]) && $input['common_meta_settings'][$field] === 'true') ? 'true' : 'false';
2135 }
2136 // Remove raw common_meta_settings so sanitized toggle values
2137 // survive the shallow array_merge($new_input, $input).
2138 unset($input['common_meta_settings']);
2139 }
2140
2141 # Handle whitelabel URL fields with improved empty value handling
2142 if (isset($input['whitelabel'])) {
2143 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2144
2145 $general_input = $input['general'] ?? [];
2146 $plugin_name = isset($general_input['white_label_plugin_name']) ? trim((string) $general_input['white_label_plugin_name']) : '';
2147 $logo_light = isset($input['whitelabel']['logo_light']) ? trim((string) $input['whitelabel']['logo_light']) : '';
2148 $logo_dark = isset($input['whitelabel']['logo_dark']) ? trim((string) $input['whitelabel']['logo_dark']) : '';
2149 $author = isset($general_input['white_label_plugin_author']) ? trim((string) $general_input['white_label_plugin_author']) : '';
2150 $author_uri = isset($general_input['white_label_plugin_author_uri']) ? trim((string) $general_input['white_label_plugin_author_uri']) : '';
2151 $plugin_uri = isset($general_input['white_label_plugin_uri']) ? trim((string) $general_input['white_label_plugin_uri']) : '';
2152 $domain = isset($input['whitelabel']['domain']) ? trim((string) $input['whitelabel']['domain']) : '';
2153 $description = isset($general_input['white_label_plugin_description']) ? trim((string) $general_input['white_label_plugin_description']) : '';
2154
2155 $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));
2156 $has_optional_only = (!empty($author_uri) || !empty($plugin_uri) || (!empty($domain) && filter_var($domain, FILTER_VALIDATE_URL)) || !empty($description));
2157
2158 if ($has_optional_only && !$has_core_field) {
2159 add_settings_error(
2160 'metasync_options',
2161 'whitelabel_partial',
2162 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.',
2163 'error'
2164 );
2165 $new_input['whitelabel'] = $existing_whitelabel;
2166 $whitelabel_validation_failed = true;
2167 } else {
2168 $new_input['whitelabel'] = $existing_whitelabel;
2169
2170 if (isset($input['whitelabel']['logo'])) {
2171 $logo_value = trim($input['whitelabel']['logo']);
2172
2173 if (!empty($logo_value) && filter_var($logo_value, FILTER_VALIDATE_URL)) {
2174 $new_input['whitelabel']['logo'] = esc_url_raw($logo_value);
2175
2176 } else {
2177 $new_input['whitelabel']['logo'] = '';
2178
2179 }
2180 }
2181
2182 if (isset($input['whitelabel']['logo_light'])) {
2183 $logo_light_value = trim($input['whitelabel']['logo_light']);
2184
2185 if (!empty($logo_light_value) && filter_var($logo_light_value, FILTER_VALIDATE_URL)) {
2186 $new_input['whitelabel']['logo_light'] = esc_url_raw($logo_light_value);
2187 } else {
2188 $new_input['whitelabel']['logo_light'] = '';
2189 }
2190 }
2191
2192 if (isset($input['whitelabel']['logo_dark'])) {
2193 $logo_dark_value = trim($input['whitelabel']['logo_dark']);
2194
2195 if (!empty($logo_dark_value) && filter_var($logo_dark_value, FILTER_VALIDATE_URL)) {
2196 $new_input['whitelabel']['logo_dark'] = esc_url_raw($logo_dark_value);
2197 } else {
2198 $new_input['whitelabel']['logo_dark'] = '';
2199 }
2200 }
2201
2202 if (isset($input['whitelabel']['domain'])) {
2203 $domain_value = trim($input['whitelabel']['domain']);
2204
2205 if (!empty($domain_value) && filter_var($domain_value, FILTER_VALIDATE_URL)) {
2206 $new_input['whitelabel']['domain'] = esc_url_raw($domain_value);
2207
2208 } else {
2209 $old_domain = $existing_whitelabel['domain'] ?? '';
2210 $new_input['whitelabel']['domain'] = '';
2211
2212 if (!empty($old_domain)) {
2213 $new_input['_trigger_heartbeat_after_save'] = 'Domain cleared from: ' . $old_domain;
2214 }
2215 }
2216 }
2217
2218 if (isset($input['whitelabel']['settings_password'])) {
2219 $password_value = trim($input['whitelabel']['settings_password']);
2220 $new_input['whitelabel']['settings_password'] = Metasync::encrypt_secret(sanitize_text_field($password_value));
2221 } else {
2222 if (isset($existing_whitelabel['settings_password'])) {
2223 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2224 }
2225 }
2226
2227 if (isset($input['whitelabel']['recovery_email'])) {
2228 $recovery_email = trim($input['whitelabel']['recovery_email']);
2229 if (!empty($recovery_email)) {
2230 $sanitized_email = sanitize_email($recovery_email);
2231 if (is_email($sanitized_email)) {
2232 $new_input['whitelabel']['recovery_email'] = $sanitized_email;
2233 } else {
2234 add_settings_error(
2235 'metasync_messages',
2236 'metasync_message',
2237 __('Invalid recovery email address.', 'metasync'),
2238 'error'
2239 );
2240 }
2241 } else {
2242 $new_input['whitelabel']['recovery_email'] = '';
2243 }
2244 } else {
2245 if (isset($existing_whitelabel['recovery_email'])) {
2246 $new_input['whitelabel']['recovery_email'] = $existing_whitelabel['recovery_email'];
2247 }
2248 }
2249
2250 $has_password = !empty($new_input['whitelabel']['settings_password']);
2251 $has_recovery_email = !empty($new_input['whitelabel']['recovery_email']);
2252 if ($has_password && !$has_recovery_email) {
2253 add_settings_error(
2254 'metasync_messages',
2255 'metasync_message',
2256 __('Password Recovery Email is required when a password is set.', 'metasync'),
2257 'error'
2258 );
2259 $new_input['whitelabel']['settings_password'] = '';
2260 }
2261
2262 if (isset($input['whitelabel']['access_control'])) {
2263 $new_input['whitelabel']['access_control'] = Metasync_Access_Control::sanitize_access_control($input['whitelabel']['access_control']);
2264 }
2265
2266 // Sanitize quick_links (up to 5 entries)
2267 if (isset($input['whitelabel']['quick_links']) && is_array($input['whitelabel']['quick_links'])) {
2268 $sanitized_links = [];
2269 foreach (array_slice($input['whitelabel']['quick_links'], 0, 5) as $link) {
2270 $url = isset($link['url']) ? esc_url_raw(trim((string) $link['url'])) : '';
2271 if (empty($url)) {
2272 continue; // skip blank rows
2273 }
2274 $sanitized_links[] = [
2275 'label' => sanitize_text_field($link['label'] ?? ''),
2276 'url' => $url,
2277 'external' => !empty($link['external']),
2278 ];
2279 }
2280 $new_input['whitelabel']['quick_links'] = $sanitized_links;
2281 } else {
2282 // Preserve existing links when not submitted
2283 if (isset($existing_whitelabel['quick_links'])) {
2284 $new_input['whitelabel']['quick_links'] = $existing_whitelabel['quick_links'];
2285 }
2286 }
2287
2288 // Sanitize color palette
2289 if (isset($input['whitelabel']['color_palette']) && is_array($input['whitelabel']['color_palette'])) {
2290 $allowed_vars = array(
2291 'dashboard-bg', 'dashboard-card-bg', 'dashboard-card-hover',
2292 'dashboard-text-primary', 'dashboard-text-secondary',
2293 'dashboard-accent', 'dashboard-accent-hover',
2294 'dashboard-success', 'dashboard-warning', 'dashboard-error',
2295 'dashboard-border',
2296 'dashboard-gradient-primary-from', 'dashboard-gradient-primary-to',
2297 'dashboard-gradient-accent-from', 'dashboard-gradient-accent-to'
2298 );
2299 $sanitized_palette = array();
2300 foreach (array('dark', 'light') as $theme_key) {
2301 if (isset($input['whitelabel']['color_palette'][$theme_key]) && is_array($input['whitelabel']['color_palette'][$theme_key])) {
2302 $sanitized_palette[$theme_key] = array();
2303 foreach ($input['whitelabel']['color_palette'][$theme_key] as $var_name => $color_value) {
2304 if (in_array($var_name, $allowed_vars, true)) {
2305 $color_value = trim(sanitize_text_field($color_value));
2306 if ($color_value !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $color_value)) {
2307 $sanitized_palette[$theme_key][$var_name] = $color_value;
2308 }
2309 }
2310 }
2311 }
2312 }
2313 $new_input['whitelabel']['color_palette'] = $sanitized_palette;
2314 } else {
2315 if (isset($existing_whitelabel['color_palette'])) {
2316 $new_input['whitelabel']['color_palette'] = $existing_whitelabel['color_palette'];
2317 }
2318 }
2319
2320 $new_input['whitelabel']['updated_at'] = time();
2321 }
2322 } else {
2323 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2324 $has_existing_whitelabel = !empty($existing_whitelabel['domain']) || !empty($existing_whitelabel['logo']);
2325
2326 if ($has_existing_whitelabel) {
2327 $new_input['whitelabel'] = [
2328 'is_whitelabel' => false,
2329 'domain' => '',
2330 'logo' => '',
2331 'company_name' => '',
2332 'updated_at' => time()
2333 ];
2334
2335 if (!empty($existing_whitelabel['domain'])) {
2336 delete_transient('metasync_heartbeat_status_cache');
2337 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel settings cleared - domain changed to default');
2338 }
2339 }
2340 }
2341
2342 // Indexation Control Settings
2343 if (isset($input['seo_controls']['index_date_archives'])) {
2344 $new_input['seo_controls']['index_date_archives'] = boolval($input['seo_controls']['index_date_archives']);
2345 }
2346 if (isset($input['seo_controls']['index_tag_archives'])) {
2347 $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']);
2348 }
2349 if (isset($input['seo_controls']['index_author_archives'])) {
2350 $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']);
2351 }
2352 if (isset($input['seo_controls']['index_format_archives'])) {
2353 $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']);
2354 }
2355
2356 // Handle post-save heartbeat trigger for domain changes
2357 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2358 $context = $new_input['_trigger_heartbeat_after_save'];
2359 unset($new_input['_trigger_heartbeat_after_save']);
2360
2361 add_action('updated_option_metasync_options', function() use ($context) {
2362 delete_transient('metasync_heartbeat_status_cache');
2363 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2364 });
2365 }
2366
2367 $result = array_merge($new_input, $input);
2368
2369 if ($whitelabel_validation_failed) {
2370 $result['whitelabel'] = Metasync::get_option()['whitelabel'] ?? [];
2371 }
2372
2373 delete_transient('metasync_otto_js_detected');
2374
2375 return $result;
2376 }
2377
2378 /**
2379 * AJAX handler for saving general/whitelabel/advanced settings.
2380 *
2381 * Moved from Metasync_Admin – the admin class now delegates here.
2382 */
2383 public function meta_sync_save_settings()
2384 {
2385 if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2386 wp_send_json_error(array('message' => 'Invalid nonce'));
2387 return;
2388 }
2389
2390 if (!Metasync::current_user_has_plugin_access()) {
2391 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
2392 return;
2393 }
2394
2395 $text_fields = [
2396 'searchatlas_api_key', 'apikey',
2397 'white_label_plugin_name', 'white_label_plugin_description',
2398 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2399 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2400 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2401 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2402 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat'
2403 ];
2404
2405 $url_fields = [
2406 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2407 ];
2408
2409 $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_seo_metabox', 'open_external_links'];
2410
2411 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2412
2413 $metasync_options = Metasync::get_option();
2414 if (!is_array($metasync_options)) {
2415 $metasync_options = array();
2416 }
2417 if (!isset($metasync_options['general']) || !is_array($metasync_options['general'])) {
2418 $metasync_options['general'] = array();
2419 }
2420
2421 $validation_errors = [];
2422
2423 $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2424 $general_tab_submitted = ($active_tab === 'general');
2425 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2426
2427 // Whitelabel field validation issues are collected per-field and reported back
2428 // without blocking the rest of the settings save.
2429 $whitelabel_errors = [];
2430
2431 foreach ($text_fields as $field) {
2432
2433 if (isset($_POST['metasync_options']['general'][$field])) {
2434
2435 $value = trim($_POST['metasync_options']['general'][$field]);
2436
2437 // searchatlas_api_key must be clearable to support key removal
2438 $clearable_fields = [
2439 'searchatlas_api_key',
2440 'white_label_plugin_name',
2441 'white_label_plugin_description',
2442 'white_label_plugin_author',
2443 'white_label_plugin_menu_slug',
2444 'white_label_plugin_menu_icon',
2445 'whitelabel_otto_name',
2446 'otto_pixel_uuid'
2447 ];
2448
2449 if ($value === '' && !in_array($field, $clearable_fields)) {
2450 continue;
2451 }
2452
2453 if ($field === 'white_label_plugin_name') {
2454 $stored_name = (string) ($metasync_options['general']['white_label_plugin_name'] ?? '');
2455 // Only enforce the 18-char limit when the value actually changes so an
2456 // already-saved longer legacy name can be re-saved without an error.
2457 if (strlen($value) > 18 && $value !== $stored_name && wp_unslash($value) !== $stored_name) {
2458 $whitelabel_errors[$field] = 'Plugin name must not exceed 18 characters';
2459 continue;
2460 }
2461 }
2462
2463 if ($field === 'white_label_plugin_menu_icon') {
2464
2465 if ($value === '') {
2466 $metasync_options['general'][$field] = '';
2467 } elseif (preg_match('/^dashicons-[a-z0-9-]+$/', $value)) {
2468 // Accept dashicon names (e.g. dashicons-location) in addition to image URLs
2469 $metasync_options['general'][$field] = sanitize_text_field($value);
2470 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2471
2472 $image_extensions = ['png', 'svg'];
2473
2474 $path = parse_url($value, PHP_URL_PATH);
2475 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2476
2477 if (in_array($extension, $image_extensions, true)) {
2478 $metasync_options['general'][$field] = esc_url_raw($value);
2479 } else {
2480 $whitelabel_errors[$field] = 'Invalid Menu icon format. Only PNG, SVG, or a dashicon name (e.g. dashicons-location) are allowed.';
2481 }
2482 } else {
2483 $whitelabel_errors[$field] = 'Invalid Menu icon URL format.';
2484 }
2485 } elseif ($field === 'white_label_plugin_menu_slug') {
2486 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2487 // menu slug so it can never break sidebar link generation.
2488 $metasync_options['general'][$field] = sanitize_title($value);
2489 } else {
2490 $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
2491 }
2492 }
2493 }
2494
2495 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2496 if ($general_tab_submitted) {
2497 foreach ($textarea_fields as $field) {
2498 if (isset($_POST['metasync_options']['general'][$field])) {
2499 $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
2500 } else {
2501 $metasync_options['general'][$field] = '';
2502 }
2503 }
2504 }
2505
2506 if ($general_tab_submitted) {
2507 foreach ($bool_fields as $field) {
2508 if (isset($_POST['metasync_options']['general'][$field])) {
2509 $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
2510 }else {
2511 $metasync_options['general'][$field] = false;
2512 }
2513 }
2514 }
2515
2516 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2517 foreach ($url_fields as $field) {
2518 if (isset($_POST['metasync_options']['general'][$field])) {
2519
2520 $value = trim($_POST['metasync_options']['general'][$field]);
2521
2522 $whitelabel_clearable_url_fields = [
2523 'white_label_plugin_author_uri',
2524 'white_label_plugin_uri'
2525 ];
2526
2527 if ($value === '' && !in_array($field, $whitelabel_clearable_url_fields)) {
2528 continue;
2529 }
2530
2531 if ($value === '' && in_array($field, $whitelabel_clearable_url_fields)) {
2532 $metasync_options['general'][$field] = '';
2533 continue;
2534 }
2535
2536 if (filter_var($value, FILTER_VALIDATE_URL)) {
2537
2538 $parsed_url = parse_url($value);
2539 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2540
2541 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2542 $metasync_options['general'][$field] = esc_url_raw($value);
2543 } else {
2544 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2545 $validation_errors[] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
2546 }
2547 } else {
2548 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2549 $validation_errors[] = 'Invalid ' . $field_name . ' format.';
2550 }
2551 }
2552 }
2553 }
2554
2555 if ($general_tab_submitted) {
2556 if (isset($_POST['metasync_options']['general']['content_genius_sync_roles']) && is_array($_POST['metasync_options']['general']['content_genius_sync_roles'])) {
2557 $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', $_POST['metasync_options']['general']['content_genius_sync_roles']);
2558 } else {
2559 $metasync_options['general']['content_genius_sync_roles'] = array();
2560 }
2561 }
2562
2563 if ($general_tab_submitted) {
2564 $breadcrumbs_input = isset($_POST['metasync_options']['breadcrumbs']) && is_array($_POST['metasync_options']['breadcrumbs'])
2565 ? $_POST['metasync_options']['breadcrumbs']
2566 : array();
2567
2568 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2569 $metasync_options['breadcrumbs'] = array();
2570 }
2571
2572 $metasync_options['breadcrumbs']['enabled'] = !empty($breadcrumbs_input['enabled']);
2573 $metasync_options['breadcrumbs']['disable_schema'] = !empty($breadcrumbs_input['disable_schema']);
2574 $metasync_options['breadcrumbs']['show_current_page'] = !empty($breadcrumbs_input['show_current_page']);
2575 $metasync_options['breadcrumbs']['separator'] = isset($breadcrumbs_input['separator']) ? sanitize_text_field($breadcrumbs_input['separator']) : '»';
2576 $metasync_options['breadcrumbs']['home_label'] = isset($breadcrumbs_input['home_label']) ? sanitize_text_field($breadcrumbs_input['home_label']) : 'Home';
2577 $metasync_options['breadcrumbs']['home_url'] = isset($breadcrumbs_input['home_url']) ? esc_url_raw($breadcrumbs_input['home_url']) : '';
2578 $metasync_options['breadcrumbs']['prefix_text'] = isset($breadcrumbs_input['prefix_text']) ? sanitize_text_field($breadcrumbs_input['prefix_text']) : '';
2579 $metasync_options['breadcrumbs']['archive_label_format'] = isset($breadcrumbs_input['archive_label_format']) ? sanitize_text_field($breadcrumbs_input['archive_label_format']) : '{name}';
2580 }
2581
2582 // Open Graph / article toggle checkboxes live under common_meta_settings.
2583 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
2584 // write 'false' for any missing toggle to persist opt-outs.
2585 if ($general_tab_submitted) {
2586 $og_toggle_fields = [
2587 'open_graph_meta_tags',
2588 'facebook_meta_tags',
2589 'twitter_meta_tags',
2590 'og_image_dimensions',
2591 'article_timestamps',
2592 'article_author',
2593 'article_section',
2594 'article_tags',
2595 'twitter_image_alt',
2596 ];
2597 if (!isset($metasync_options['common_meta_settings']) || !is_array($metasync_options['common_meta_settings'])) {
2598 $metasync_options['common_meta_settings'] = [];
2599 }
2600 foreach ($og_toggle_fields as $field) {
2601 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
2602 ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
2603 : '';
2604 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
2605 }
2606 }
2607
2608 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
2609 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
2610 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
2611 $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
2612 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
2613 $metasync_options['general']['default_page_builder'] = $builder_value;
2614 } else {
2615 $metasync_options['general']['default_page_builder'] = 'gutenberg';
2616 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
2617 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
2618 }
2619 }
2620 }
2621
2622 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
2623 $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? $_POST['metasync_options']['general'] : [];
2624 $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? $_POST['metasync_options']['whitelabel'] : [];
2625 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
2626 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
2627 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
2628 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
2629 $author_uri = isset($gp['white_label_plugin_author_uri']) ? trim((string) $gp['white_label_plugin_author_uri']) : '';
2630 $plugin_uri = isset($gp['white_label_plugin_uri']) ? trim((string) $gp['white_label_plugin_uri']) : '';
2631 $domain = isset($wp['domain']) ? trim((string) $wp['domain']) : '';
2632 $description = isset($gp['white_label_plugin_description']) ? trim((string) $gp['white_label_plugin_description']) : '';
2633
2634 $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));
2635 $has_optional_only = (!empty($author_uri) || !empty($plugin_uri) || (!empty($domain) && filter_var($domain, FILTER_VALIDATE_URL)) || !empty($description));
2636
2637 if ($has_optional_only && !$has_core_field) {
2638 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
2639 }
2640 }
2641
2642 if (!empty($validation_errors)) {
2643 wp_send_json_error([
2644 'errors' => $validation_errors
2645 ]);
2646 return;
2647 }
2648
2649 $old_options = Metasync::get_option('general') ?? [];
2650 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
2651 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
2652 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
2653 $old_api_key = Metasync::get_searchatlas_api_key();
2654 if ( $old_api_key === false ) {
2655 // Stored key could not be decrypted (salts changed). Treat as no usable key.
2656 $old_api_key = '';
2657 }
2658
2659 $api_key_field_present = isset( $_POST['metasync_options']['general']['searchatlas_api_key'] );
2660 $api_key_validated = null; // null = not checked, true = valid, false = rejected
2661 $is_connected = null;
2662 $api_key_warning = null;
2663
2664 // The submitted key arrives as plaintext; capture it for comparison before
2665 // encrypting it for storage. When the field is absent, preserve the existing
2666 // encrypted value untouched so a normal settings save never disturbs the key.
2667 $proposed_new_api_key = $api_key_field_present ? ( $metasync_options['general']['searchatlas_api_key'] ?? '' ) : '';
2668 if ( $api_key_field_present ) {
2669 // encrypt_api_key('') === '' so this also handles key removal.
2670 $metasync_options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key( $proposed_new_api_key );
2671 } else {
2672 $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
2673 }
2674
2675 // When the API key changes, save it first, then POST heartbeat to register
2676 // the WPWebsiteHeartbeat row on the backend, then GET ping to verify.
2677 // The old flow did GET ping first, which failed because the row didn't exist yet.
2678 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
2679 // Step 1: Save the new (encrypted) key so SyncCustomerParams can use it
2680 Metasync::set_option($metasync_options);
2681 Metasync::invalidate_api_key_cache();
2682
2683 // Step 2: POST heartbeat to register the site (creates WPWebsiteHeartbeat row)
2684 $sync_request = new Metasync_Sync_Requests();
2685 $sync_response = $sync_request->SyncCustomerParams();
2686 $sync_status = Metasync_Sync_Requests::get_response_code($sync_response);
2687
2688 if ( $sync_status == 200 ) {
2689 // Step 3: GET ping to verify registration succeeded
2690 $ping = Metasync_Heartbeat_Manager::instance()->validate_api_key( $proposed_new_api_key );
2691
2692 if ( $ping['connected'] === true ) {
2693 $api_key_validated = true;
2694 $is_connected = true;
2695 if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
2696 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
2697 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
2698 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
2699 Metasync::set_option($metasync_options);
2700 }
2701 }
2702 } else {
2703 // POST succeeded but ping didn't confirm — keep the key, warn user
2704 $api_key_validated = true;
2705 $is_connected = true;
2706 $api_key_warning = 'API key saved and heartbeat sent. Connection verification will complete on the next heartbeat.';
2707 }
2708 } elseif ( is_wp_error($sync_response) ) {
2709 // Network error during POST — keep the key, warn user
2710 $api_key_warning = 'Could not register with the server (network error). The key was saved — it will be verified on the next heartbeat.';
2711 } else {
2712 // POST returned non-200 — the key is likely invalid, revert to the
2713 // previously stored (already-encrypted) value.
2714 $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
2715 Metasync::set_option($metasync_options);
2716 Metasync::invalidate_api_key_cache();
2717 $api_key_validated = false;
2718 $is_connected = false;
2719 $api_key_warning = empty( $old_api_key )
2720 ? 'The API key could not be verified. Please check it and try again.'
2721 : 'The API key could not be verified. Your API key has been reverted to the previously saved key.';
2722 }
2723 } else {
2724 // Key unchanged, removed, or field absent — just save options.
2725 // $metasync_options already holds the correct stored value: the
2726 // re-encrypted submission (present) or the preserved encrypted value
2727 // (absent); removal stored encrypt_api_key('') === ''.
2728 Metasync::set_option($metasync_options);
2729 Metasync::invalidate_api_key_cache();
2730 }
2731
2732 // Update connection state immediately when validated
2733 if ( $api_key_validated === true ) {
2734 update_option( 'metasync_last_known_connection_state', true );
2735 set_transient( 'metasync_heartbeat_status_cache', [
2736 'status' => true,
2737 'timestamp' => time(),
2738 'cached_until' => time() + 300,
2739 'updated_by' => 'manual_save_ping_connected',
2740 ], 300 );
2741 }
2742
2743 // Compare decrypted plaintext values — the stored value is ciphertext.
2744 Metasync::invalidate_api_key_cache();
2745 $new_api_key = Metasync::get_searchatlas_api_key();
2746 if ( $new_api_key === false ) {
2747 $new_api_key = '';
2748 }
2749
2750 $api_key_changed = $old_api_key !== $new_api_key;
2751 $api_key_added = empty($old_api_key) && !empty($new_api_key);
2752 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
2753
2754 if ( $api_key_validated === true ) {
2755 # Use current_time('mysql') so the value is consistent with the
2756 # cron heartbeat writer and renders correctly via time_elapsed_string.
2757 $send_auth_token_timestamp = Metasync::get_option();
2758 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2759 Metasync::set_option($send_auth_token_timestamp);
2760
2761 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
2762 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - validated via ping');
2763 } else {
2764 // Key unchanged or removed — run sync as before
2765 $sync_request = new Metasync_Sync_Requests();
2766 $response = $sync_request->SyncCustomerParams();
2767 $responseCode = Metasync_Sync_Requests::get_response_code($response);
2768
2769 if ($responseCode == 200) {
2770 # Use current_time('mysql') for consistency with cron heartbeat.
2771 $send_auth_token_timestamp = Metasync::get_option();
2772 $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2773 Metasync::set_option($send_auth_token_timestamp);
2774
2775 if ($api_key_added) {
2776 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
2777 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - new key added');
2778 } elseif ($api_key_removed) {
2779 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
2780 delete_transient('metasync_heartbeat_status_cache');
2781 } elseif ($api_key_changed && !empty($new_api_key)) {
2782 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - key changed');
2783 }
2784 }
2785 }
2786
2787 if (isset($_POST['metasync_options']['whitelabel'])) {
2788
2789 $whitelabel_data = $_POST['metasync_options']['whitelabel'];
2790 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
2791
2792 if (isset($whitelabel_data['logo'])) {
2793 $logo_value = trim($whitelabel_data['logo']);
2794
2795 if (!empty($logo_value) && filter_var($logo_value, FILTER_VALIDATE_URL)) {
2796 $metasync_options['whitelabel']['logo'] = esc_url_raw($logo_value);
2797 } else {
2798 $metasync_options['whitelabel']['logo'] = '';
2799 }
2800 }
2801
2802 if (isset($whitelabel_data['logo_light'])) {
2803 $logo_light_value = trim($whitelabel_data['logo_light']);
2804
2805 if (!empty($logo_light_value) && filter_var($logo_light_value, FILTER_VALIDATE_URL)) {
2806 $metasync_options['whitelabel']['logo_light'] = esc_url_raw($logo_light_value);
2807 } else {
2808 $metasync_options['whitelabel']['logo_light'] = '';
2809 }
2810 }
2811
2812 if (isset($whitelabel_data['logo_dark'])) {
2813 $logo_dark_value = trim($whitelabel_data['logo_dark']);
2814
2815 if (!empty($logo_dark_value) && filter_var($logo_dark_value, FILTER_VALIDATE_URL)) {
2816 $metasync_options['whitelabel']['logo_dark'] = esc_url_raw($logo_dark_value);
2817 } else {
2818 $metasync_options['whitelabel']['logo_dark'] = '';
2819 }
2820 }
2821
2822 if (isset($whitelabel_data['domain'])) {
2823 $domain_value = trim($whitelabel_data['domain']);
2824 if (!empty($domain_value) && filter_var($domain_value, FILTER_VALIDATE_URL)) {
2825 $metasync_options['whitelabel']['domain'] = esc_url_raw($domain_value);
2826 } else {
2827 $metasync_options['whitelabel']['domain'] = '';
2828 }
2829 }
2830
2831 if (isset($whitelabel_data['settings_password'])) {
2832 $password_value = trim($whitelabel_data['settings_password']);
2833
2834 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
2835
2836 if ($hide_settings_enabled && empty($password_value)) {
2837 if (isset($existing_whitelabel['settings_password'])) {
2838 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2839 }
2840 } else {
2841 $metasync_options['whitelabel']['settings_password'] = Metasync::encrypt_secret(sanitize_text_field($password_value));
2842 }
2843 } else {
2844 if (isset($existing_whitelabel['settings_password'])) {
2845 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2846 }
2847 }
2848
2849 if (isset($whitelabel_data['hide_settings'])) {
2850 $metasync_options['whitelabel']['hide_settings'] = filter_var($whitelabel_data['hide_settings'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
2851 } else {
2852 $metasync_options['whitelabel']['hide_settings'] = 0;
2853 }
2854
2855 if (isset($whitelabel_data['recovery_email'])) {
2856 $recovery_email = trim($whitelabel_data['recovery_email']);
2857 if (!empty($recovery_email)) {
2858 $sanitized_email = sanitize_email($recovery_email);
2859 if (is_email($sanitized_email)) {
2860 $metasync_options['whitelabel']['recovery_email'] = $sanitized_email;
2861 }
2862 } else {
2863 $metasync_options['whitelabel']['recovery_email'] = '';
2864 }
2865 } else {
2866 if (isset($existing_whitelabel['recovery_email'])) {
2867 $metasync_options['whitelabel']['recovery_email'] = $existing_whitelabel['recovery_email'];
2868 }
2869 }
2870
2871 $has_password = !empty($metasync_options['whitelabel']['settings_password']);
2872 $has_recovery_email = !empty($metasync_options['whitelabel']['recovery_email']);
2873 if ($has_password && !$has_recovery_email) {
2874 $metasync_options['whitelabel']['settings_password'] = '';
2875 }
2876
2877 if (isset($whitelabel_data['access_control'])) {
2878 $metasync_options['whitelabel']['access_control'] = Metasync_Access_Control::sanitize_access_control($whitelabel_data['access_control']);
2879 }
2880
2881 if (!empty($metasync_options['whitelabel']['hide_settings']) && empty($metasync_options['whitelabel']['settings_password'])) {
2882 $metasync_options['whitelabel']['hide_settings'] = 0;
2883 }
2884
2885 // Sanitize color palette
2886 if (isset($whitelabel_data['color_palette']) && is_array($whitelabel_data['color_palette'])) {
2887 $allowed_vars = array(
2888 'dashboard-bg', 'dashboard-card-bg', 'dashboard-card-hover',
2889 'dashboard-text-primary', 'dashboard-text-secondary',
2890 'dashboard-accent', 'dashboard-accent-hover',
2891 'dashboard-success', 'dashboard-warning', 'dashboard-error',
2892 'dashboard-border',
2893 'dashboard-gradient-primary-from', 'dashboard-gradient-primary-to',
2894 'dashboard-gradient-accent-from', 'dashboard-gradient-accent-to'
2895 );
2896 $sanitized_palette = array();
2897 foreach (array('dark', 'light') as $theme_key) {
2898 if (isset($whitelabel_data['color_palette'][$theme_key]) && is_array($whitelabel_data['color_palette'][$theme_key])) {
2899 $sanitized_palette[$theme_key] = array();
2900 foreach ($whitelabel_data['color_palette'][$theme_key] as $var_name => $color_value) {
2901 if (in_array($var_name, $allowed_vars, true)) {
2902 $color_value = trim(sanitize_text_field($color_value));
2903 if ($color_value !== '' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $color_value)) {
2904 $sanitized_palette[$theme_key][$var_name] = $color_value;
2905 }
2906 }
2907 }
2908 }
2909 }
2910 $metasync_options['whitelabel']['color_palette'] = $sanitized_palette;
2911 } else {
2912 if (isset($existing_whitelabel['color_palette'])) {
2913 $metasync_options['whitelabel']['color_palette'] = $existing_whitelabel['color_palette'];
2914 }
2915 }
2916
2917 $metasync_options['whitelabel']['updated_at'] = time();
2918
2919 Metasync::set_option($metasync_options);
2920
2921 }
2922
2923 $redirect_url = isset($_GET['tab']) ?
2924 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
2925 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
2926
2927 // Also save edge cache settings if present in the payload (section renders inside this form)
2928 if (class_exists('Metasync_Edge_Cache_Settings')) {
2929 Metasync_Edge_Cache_Settings::save_from_post();
2930 }
2931
2932 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
2933 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
2934 if (is_array($llms_input)) {
2935 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
2936 }
2937 }
2938
2939 $response_payload = array(
2940 'message' => 'Settings saved successfully!',
2941 'redirect_url' => $redirect_url,
2942 );
2943
2944 // Per-field whitelabel validation issues (non-blocking): the save succeeded for all
2945 // other fields, but the user must be told which values were rejected.
2946 if (!empty($whitelabel_errors)) {
2947 $response_payload['whitelabel_errors'] = $whitelabel_errors;
2948 }
2949
2950 // Final slug after sanitization so the client redirects to the page WP actually
2951 // registered, even when the submitted value was cleaned up.
2952 if (isset($_POST['metasync_options']['general']['white_label_plugin_menu_slug'])) {
2953 $response_payload['saved_menu_slug'] = $metasync_options['general']['white_label_plugin_menu_slug'] ?? '';
2954 }
2955
2956 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
2957 if ( $api_key_validated !== null ) {
2958 $response_payload['api_key_validated'] = $api_key_validated;
2959 $response_payload['is_connected'] = $is_connected;
2960 }
2961 if ( $api_key_warning !== null ) {
2962 $response_payload['warning'] = $api_key_warning;
2963 }
2964 // Note: the previous key is intentionally NOT returned. The key is now
2965 // managed via one-click auth and shown masked, so the cleartext value
2966 // must never be sent to the browser.
2967 } elseif ( $api_key_field_present && $proposed_new_api_key === '' && $old_api_key !== '' ) {
2968 $response_payload['api_key_removed'] = true;
2969 $response_payload['warning'] = 'The API key has been removed. The plugin will no longer sync with the server.';
2970 }
2971
2972 delete_transient('metasync_otto_js_detected');
2973
2974 wp_send_json_success( $response_payload );
2975 }
2976
2977 /**
2978 * AJAX handler for saving execution settings.
2979 *
2980 * Moved from Metasync_Admin – the admin class now delegates here.
2981 */
2982 public function ajax_save_execution_settings()
2983 {
2984 if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
2985 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
2986 return;
2987 }
2988
2989 if (!Metasync::current_user_has_plugin_access()) {
2990 wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
2991 return;
2992 }
2993
2994 $existing_settings = get_option('metasync_execution_settings', array());
2995
2996 $settings = array(
2997 'max_execution_time' => isset($_POST['max_execution_time']) ? absint($_POST['max_execution_time']) : 30,
2998 'max_memory_limit' => isset($_POST['max_memory_limit']) ? absint($_POST['max_memory_limit']) : 256,
2999 'log_batch_size' => isset($_POST['log_batch_size']) ? absint($_POST['log_batch_size']) : 1000,
3000 'action_scheduler_batches' => isset($_POST['action_scheduler_batches']) ? absint($_POST['action_scheduler_batches']) : 1,
3001 'otto_rate_limit' => isset($_POST['otto_rate_limit']) ? absint($_POST['otto_rate_limit']) : 10,
3002 'otto_cache_ttl' => isset($existing_settings['otto_cache_ttl']) ? absint($existing_settings['otto_cache_ttl']) : 30,
3003 'queue_cleanup_days' => isset($_POST['queue_cleanup_days']) ? absint($_POST['queue_cleanup_days']) : 31
3004 );
3005
3006 $server_limits = Metasync_Settings_Fields::instance()->get_server_limits();
3007
3008 if ($settings['max_execution_time'] < 1 || $settings['max_execution_time'] > 300) {
3009 wp_send_json_error(array('message' => 'Max Execution Time must be between 1 and 300 seconds.'));
3010 return;
3011 }
3012
3013 if ((int)$server_limits['max_execution_time_raw'] !== -1 && (int)$settings['max_execution_time'] > (int)$server_limits['max_execution_time_raw']) {
3014 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'])));
3015 return;
3016 }
3017
3018 $can_change_memory = Metasync_Settings_Fields::instance()->can_change_memory_limit();
3019 $memory_range = Metasync_Settings_Fields::instance()->get_memory_limit_allowed_range();
3020 if ($can_change_memory && $settings['max_memory_limit'] < $memory_range['min']) {
3021 wp_send_json_error(array('message' => sprintf('Max Memory Limit must be at least %d MB.', $memory_range['min'])));
3022 return;
3023 }
3024
3025 if ($can_change_memory && $memory_range['max'] !== null && $settings['max_memory_limit'] > $memory_range['max']) {
3026 wp_send_json_error(array('message' => sprintf('Max Memory Limit must be between %d and %d MB.', $memory_range['min'], $memory_range['max'])));
3027 return;
3028 }
3029
3030 if (!$can_change_memory) {
3031 $settings['max_memory_limit'] = isset($existing_settings['max_memory_limit'])
3032 ? $existing_settings['max_memory_limit']
3033 : 256;
3034 }
3035
3036 if ($settings['log_batch_size'] < 100 || $settings['log_batch_size'] > 5000) {
3037 wp_send_json_error(array('message' => 'Log Batch Size must be between 100 and 5000 lines.'));
3038 return;
3039 }
3040
3041 if ($settings['action_scheduler_batches'] < 1 || $settings['action_scheduler_batches'] > 10) {
3042 wp_send_json_error(array('message' => 'Action Scheduler Concurrent Batches must be between 1 and 10.'));
3043 return;
3044 }
3045
3046 if ($settings['otto_rate_limit'] < 1 || $settings['otto_rate_limit'] > 60) {
3047 wp_send_json_error(array('message' => 'OTTO API Calls Per Minute must be between 1 and 60.'));
3048 return;
3049 }
3050
3051 if ($settings['queue_cleanup_days'] < 7 || $settings['queue_cleanup_days'] > 90) {
3052 wp_send_json_error(array('message' => 'Queue Auto-Cleanup Days must be between 7 and 90 days.'));
3053 return;
3054 }
3055
3056 // update_option() returns false both on real failure AND when the
3057 // submitted values are identical to what's already stored (a no-op
3058 // write). An unchanged save is not an error, so short-circuit to
3059 // success when nothing actually changed. This also covers the case
3060 // where the form fires twice in quick succession: the second request
3061 // sees no change and would otherwise report a spurious failure.
3062 if ($settings == $existing_settings) {
3063 wp_send_json_success(array(
3064 'message' => 'Execution settings saved successfully!'
3065 ));
3066 return;
3067 }
3068
3069 $saved = update_option('metasync_execution_settings', $settings);
3070
3071 if ($saved !== false) {
3072 wp_send_json_success(array(
3073 'message' => 'Execution settings saved successfully!'
3074 ));
3075 } else {
3076 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3077 }
3078 }
3079
3080 /**
3081 * AJAX handler for saving SEO / Indexation Control settings.
3082 *
3083 * Moved from Metasync_Admin – the admin class now delegates here.
3084 */
3085 public function meta_sync_save_seo_controls()
3086 {
3087 try {
3088 if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
3089 wp_send_json_error(array('message' => 'Invalid nonce'));
3090 return;
3091 }
3092
3093 $current_options = Metasync::get_option();
3094
3095 $original_options = json_decode(json_encode($current_options), true);
3096
3097 if (!isset($current_options['seo_controls'])) {
3098 $current_options['seo_controls'] = [];
3099 }
3100
3101 $seo_control_fields = [
3102 'index_date_archives',
3103 'index_tag_archives',
3104 'index_author_archives',
3105 'index_category_archives',
3106 'index_format_archives',
3107 'override_robots_tags',
3108 'add_nofollow_to_external_links',
3109 'enable_googleinstantindex',
3110 'enable_binginstantindex',
3111 'noindex_empty_archives'
3112 ];
3113
3114 foreach ($seo_control_fields as $field) {
3115 if (isset($_POST['metasync_options']['seo_controls'][$field]) && $_POST['metasync_options']['seo_controls'][$field] === 'true') {
3116 $current_options['seo_controls'][$field] = 'true';
3117 } else {
3118 $current_options['seo_controls'][$field] = 'false';
3119 }
3120 }
3121
3122 $bing_save_result = true;
3123 if (isset($_POST['metasync_bing_api_key_inline'])) {
3124 $bing_save_result = $this->save_bing_inline_settings_ajax();
3125 }
3126
3127 // Save Google Instant Indexing post types
3128 if (isset($_POST['metasync_post_types']) && is_array($_POST['metasync_post_types'])) {
3129 $google_post_types = array_values(array_map('sanitize_title', wp_unslash($_POST['metasync_post_types'])));
3130 $google_settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3131 $google_settings['post_types'] = $google_post_types;
3132 update_option('metasync_options_instant_indexing', $google_settings);
3133 }
3134
3135 $options_changed = (json_encode($original_options) !== json_encode($current_options));
3136
3137 $result = Metasync::set_option($current_options);
3138
3139 if (!$result && !$options_changed) {
3140 $result = true;
3141 }
3142
3143 if ($result && $bing_save_result) {
3144 $success_message = 'Indexation Control settings saved successfully!';
3145 if (isset($_POST['metasync_bing_api_key_inline'])) {
3146 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
3147 }
3148 wp_send_json_success(array(
3149 'message' => $success_message,
3150 'saved_data' => $current_options['seo_controls']
3151 ));
3152 } else {
3153 $error_message = 'Failed to save Indexation Control settings';
3154 if (!$bing_save_result) {
3155 $error_message = 'Failed to save Bing Instant Indexing settings';
3156 }
3157 wp_send_json_error(array('message' => $error_message));
3158 }
3159 } catch (Exception $e) {
3160 error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3161 error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
3162 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3163 }
3164 }
3165
3166 /**
3167 * Save Bing IndexNow inline settings from SEO Controls page.
3168 *
3169 * Moved from Metasync_Admin – the admin class now delegates here.
3170 *
3171 * @return bool True on success, false on failure
3172 */
3173 public function save_bing_inline_settings_ajax()
3174 {
3175 $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : '';
3176 $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow';
3177 $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'])) : [];
3178 $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false;
3179
3180 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3181
3182 $new_settings = [
3183 'api_key' => $api_key,
3184 'endpoint' => $endpoint,
3185 'post_types' => array_values($post_types),
3186 'disable_other_plugins' => $disable_other_plugins,
3187 ];
3188
3189 $settings = array_merge($existing_settings, $new_settings);
3190
3191 $settings_changed = (json_encode($existing_settings) !== json_encode($settings));
3192
3193 $result = update_option('metasync_options_bing_instant_indexing', $settings);
3194
3195 if (!$result && !$settings_changed) {
3196 $result = true;
3197 }
3198
3199 if (!empty($api_key)) {
3200 $safe_key = sanitize_file_name( $api_key );
3201 $file_path = ABSPATH . $safe_key . '.txt';
3202 $real_path = realpath( ABSPATH );
3203 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3204 error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
3205 return false;
3206 }
3207 // serve the key virtually via template_redirect instead of
3208 // writing a physical {key}.txt. An on-disk file is served statically
3209 // by the web server and 403'd before PHP on some nginx setups, and
3210 // cannot be written on read-only roots. Register the key (the saved
3211 // api_key is also always part of the served set) and remove any stale
3212 // physical file so the PHP route governs.
3213 if (!class_exists('Metasync_Bing_Instant_Index')) {
3214 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
3215 }
3216 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3217
3218 if (file_exists($file_path)) {
3219 @unlink($file_path);
3220 }
3221
3222 $current_options = Metasync::get_option();
3223 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {
3224 $current_options['seo_controls']['enable_binginstantindex'] = 'true';
3225 Metasync::set_option($current_options);
3226 }
3227 }
3228
3229 return $result;
3230 }
3231
3232 /**
3233 * Sanitize the metasync_llms_txt_settings option.
3234 *
3235 * The Settings API delivers raw form data; we coerce it into a predictable
3236 * shape so the generator and MCP tools can rely on the structure.
3237 *
3238 * @param mixed $input Raw form data.
3239 * @return array
3240 */
3241 public function sanitize_llms_txt_settings($input)
3242 {
3243 if (!is_array($input)) {
3244 $input = array();
3245 }
3246
3247 $existing = get_option('metasync_llms_txt_settings', array());
3248 if (!is_array($existing)) {
3249 $existing = array();
3250 }
3251
3252 $output = $existing;
3253
3254 $output['enabled'] = !empty($input['enabled'])
3255 ? (bool) filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN)
3256 : false;
3257
3258 if (isset($input['post_types']) && is_array($input['post_types'])) {
3259 $output['post_types'] = array_values(array_filter(array_map('sanitize_text_field', $input['post_types'])));
3260 } else {
3261 $output['post_types'] = array('page', 'post');
3262 }
3263
3264 if (isset($input['max_posts'])) {
3265 $max = absint($input['max_posts']);
3266 if ($max < 1) {
3267 $max = 1;
3268 }
3269 if ($max > 500) {
3270 $max = 500;
3271 }
3272 $output['max_posts'] = $max;
3273 }
3274
3275 if (isset($input['excluded_ids'])) {
3276 if (is_array($input['excluded_ids'])) {
3277 $ids = $input['excluded_ids'];
3278 } else {
3279 $ids = preg_split('/[\s,]+/', (string) $input['excluded_ids']);
3280 }
3281 $output['excluded_ids'] = array_values(array_filter(array_map('absint', (array) $ids)));
3282 }
3283
3284 if (isset($input['custom_description'])) {
3285 $output['custom_description'] = sanitize_text_field((string) $input['custom_description']);
3286 }
3287
3288 $output['llms_full_enabled'] = !empty($input['llms_full_enabled'])
3289 ? (bool) filter_var($input['llms_full_enabled'], FILTER_VALIDATE_BOOLEAN)
3290 : false;
3291
3292 if (isset($input['max_posts_full'])) {
3293 $max_full = absint($input['max_posts_full']);
3294 if ($max_full < 1) {
3295 $max_full = 1;
3296 }
3297 if ($max_full > 500) {
3298 $max_full = 500;
3299 }
3300 $output['max_posts_full'] = $max_full;
3301 }
3302
3303 return $output;
3304 }
3305 }
3306