PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-post-meta-setting.php +323 -39 2.6.5 → 2.7.2 View file →
@@ -17,8 +17,19 @@
17 17 }
18 18
19 19 class Metasync_Post_Meta_Settings
20 20 {
21 + /**
22 + * Description written onto every redirection row this meta box owns.
23 + *
24 + * Rows created here live in the shared redirections table alongside rules
25 + * added by hand on the Redirections screen, and carry no other marker. The
26 + * prefix is therefore how the redirect engine tells the two apart, so that
27 + * switching the Redirection feature off stops these rows without touching
28 + * anyone's manually-added rules.
29 + */
30 + const POST_REDIRECT_DESCRIPTION_PREFIX = 'Post redirection meta box for post #';
31 +
21 32 private $common;
22 33
23 34 public function __construct()
24 35 {
@@ -29,8 +40,9 @@
29 40 add_action('save_post', [$this, 'advance_robots_meta_box_save']);
30 41 add_action('save_post', [$this, 'redirection_meta_box_save']);
31 42 add_action('save_post', [$this, 'canonical_meta_box_save']);
32 43 add_action('save_post', [$this, 'video_sitemap_meta_box_save']);
44 + add_action('save_post', [$this, 'seo_meta_box_save']);
33 45 }
34 46
35 47 public function add_post_meta_data()
36 48 {
@@ -40,26 +52,53 @@
40 52 }
41 53
42 54 $plugin_name = Metasync::get_effective_plugin_name();
43 55 $general_settings = Metasync::get_option('general', []);
56 + $post_types = array_values(get_post_types(['public' => true], 'names'));
57 + $post_types = array_diff($post_types, ['attachment']);
44 58
45 - // Only add meta boxes if not disabled in settings
46 - if (empty($general_settings['disable_common_robots_metabox'])) {
47 - add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], ['page', 'post'], 'normal', 'default');
59 + // Resolve the current post ID in admin_init context: $_GET['post'] on the
60 + // edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue.
61 + $post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only post ID to pick meta boxes
62 +
63 + // LPS / custom-HTML pages carry their own baked SEO served before wp_head,
64 + // so the editable SEO fields here do nothing and mislead editors.
65 + // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
66 + // required conditionally, so the function_exists guard is needed at runtime.
67 + $is_custom_or_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
68 +
69 + // Only add meta boxes if not disabled in settings.
70 + // On LPS / custom-HTML pages all of these MetaSync boxes are suppressed: they do
71 + // nothing useful there (their output goes into wp_head, which never runs for these
72 + // pages — serve_raw_html() exits first — and a redirect would only break the
73 + // published page). The single SEO read-only notice below carries the messaging.
74 + if (empty($general_settings['disable_common_robots_metabox']) && !$is_custom_or_lps_page) {
75 + add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], $post_types, 'normal', 'default');
48 76 }
49 77
50 - if (empty($general_settings['disable_advance_robots_metabox'])) {
51 - add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], ['page', 'post'], 'normal', 'default');
78 + if (empty($general_settings['disable_advance_robots_metabox']) && !$is_custom_or_lps_page) {
79 + add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], $post_types, 'normal', 'default');
52 80 }
53 81
54 - if (empty($general_settings['disable_redirection_metabox'])) {
55 - add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], ['page', 'post'], 'normal', 'default');
82 + if (empty($general_settings['disable_redirection_metabox']) && !$is_custom_or_lps_page) {
83 + add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], $post_types, 'normal', 'default');
56 84 }
57 85
58 - if (empty($general_settings['disable_canonical_metabox'])) {
59 - add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], ['page', 'post'], 'normal', 'default');
86 + if (empty($general_settings['disable_canonical_metabox']) && !$is_custom_or_lps_page) {
87 + add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], $post_types, 'normal', 'default');
60 88 }
61 89
90 + if (empty($general_settings['disable_seo_metabox'])) {
91 + if ($is_custom_or_lps_page) {
92 + // Replace the editable SEO box with a read-only notice on LPS pages.
93 + // __back_compat_meta_box hides it in Gutenberg (the block-editor sidebar
94 + // already renders the same notice), so it shows only in the Classic editor.
95 + add_meta_box('metasync-seo-lps-notice', "SEO by $plugin_name", [$this, 'seo_lps_notice_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]);
96 + } else {
97 + add_meta_box('metasync-seo-meta', "SEO by $plugin_name", [$this, 'seo_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]);
98 + }
99 + }
100 +
62 101 // Video Sitemap meta box — only if video sitemap is enabled
63 102 $video_settings = get_option('metasync_video_sitemap_settings', []);
64 103 if (!empty($video_settings['enabled'])) {
65 104 $video_post_types = !empty($video_settings['post_types']) ? (array) $video_settings['post_types'] : ['post', 'page'];
@@ -84,16 +123,8 @@
84 123 wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce');
85 124 ?>
86 125 <ul class="checkbox-list">
87 126 <li>
88 - <input type="checkbox" name="common_robots_meta[index]" id="robots_common1" value="index" <?php isset($common_robots['index']) ? checked('index', $common_robots['index']) : '' ?>>
89 - <label for="robots_common1">Index </br>
90 - <span class="description">
91 - <span>Search engines to index and show these pages in the search results.</span>
92 - </span>
93 - </label>
94 - </li>
95 - <li>
96 127 <input type="checkbox" name="common_robots_meta[noindex]" id="robots_common2" value="noindex" <?php isset($common_robots['noindex']) ? checked('noindex', $common_robots['noindex']) : '' ?>>
97 128 <label for="robots_common2">No Index </br>
98 129 <span class="description">
99 130 <span>Prevents search engines from indexing and displaying these pages in search results.</span>
@@ -176,9 +207,9 @@
176 207 <li>
177 208 <label for="advanced_robots_image">
178 209 <input type="checkbox" name="advanced_robots_meta[max-image-preview][enable]" id="advanced_robots_image" value="1" <?php checked('1', esc_attr($image_advance_robots_enable)) ?>>
179 210 Image Preview </br>
180 - <select class="input-length" name="advanced_robots_meta[max-image-preview][length]' ?>" id="advanced_robots_image_value">
211 + <select class="input-length" name="advanced_robots_meta[max-image-preview][length]" id="advanced_robots_image_value">
181 212 <option value="large" <?php selected(esc_attr($image_advance_robots_length), 'large'); ?>>Large</option>
182 213 <option value="standard" <?php selected(esc_attr($image_advance_robots_length), 'standard'); ?>>Standard</option>
183 214 <option value="none" <?php selected(esc_attr($image_advance_robots_length), 'none'); ?>>None</option>
184 215 </select>
@@ -225,22 +256,31 @@
225 256 public function post_canonical_display()
226 257 {
227 258 global $post;
228 259
229 - $post_canonical = get_post_meta($post->ID, 'meta_canonical', true) ?? '';
230 - // Fix legacy array values stored by sanitize_array()
231 - if (is_array($post_canonical)) {
232 - $post_canonical = reset($post_canonical) ?: '';
233 - // Repair the stored value so it won't recur
234 - if (!empty($post_canonical)) {
235 - update_post_meta($post->ID, 'meta_canonical', (string) $post_canonical);
260 + $raw_canonical = get_post_meta($post->ID, 'meta_canonical', true);
261 + $post_canonical = Metasync_Canonical_Sanitizer::sanitize($raw_canonical);
262 +
263 + // Repair legacy array rows and rows corrupted to the literal "Array"
264 + // A nested array here previously became the string "Array"
265 + // via a single reset() + (string) cast, permanently corrupting the row.
266 + if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) {
267 + if ($post_canonical !== '') {
268 + update_post_meta($post->ID, 'meta_canonical', $post_canonical);
269 + } else {
270 + delete_post_meta($post->ID, 'meta_canonical');
236 271 }
272 + } elseif ($post_canonical === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') {
273 + // Stored value the validator doesn't recognize (and is not a
274 + // corruption artifact): show it as-is so a routine post save
275 + // doesn't silently wipe it. It is not emitted while invalid.
276 + $post_canonical = trim($raw_canonical);
237 277 }
238 278 wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce');
239 279 ?>
240 280 <ul>
241 281 <li> Canonical URL:
242 - <input type="text" class="regular-text" name="post_canonical_url_meta" placeholder="<?php echo get_permalink($post->ID) ?>" value="<?php echo esc_attr($post_canonical); ?>">
282 + <input type="text" class="regular-text" name="post_canonical_url_meta" placeholder="<?php echo esc_url(get_permalink($post->ID)); ?>" value="<?php echo esc_attr($post_canonical); ?>">
243 283 </li>
244 284 </ul>
245 285 <?php
246 286 }
@@ -249,9 +289,9 @@
249 289 {
250 290 if (!current_user_can('edit_post', $post_id))
251 291 return;
252 292
253 - // WP-197: When saving from Gutenberg (REST API context) and the sidebar
293 + // When saving from Gutenberg (REST API context) and the sidebar
254 294 // JSON exists, skip — the sidebar auto-save is the source of truth.
255 295 // Classic editor form submits (non-REST) always proceed so classic-only
256 296 // users can still save via the meta boxes.
257 297 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
@@ -257,13 +297,16 @@
257 297 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
258 298 return;
259 299 }
260 300
261 - $post_data = metasync_sanitize_input_array($_POST);
301 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
262 302 // Check for new field name first, then old for backward compatibility
263 303 $field_name = isset($post_data['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
264 304
265 - if (!isset($post_data['metasync_common_robots_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_common_robots_nonce'], 'metasync_common_robots_nonce'))
305 + // Unchecked checkboxes are omitted from the request. Require the nonce,
306 + // but allow an absent checkbox group so a user can clear the last saved
307 + // directive (for example, by unticking No Index).
308 + if (!isset($post_data['metasync_common_robots_nonce']) || !wp_verify_nonce($post_data['metasync_common_robots_nonce'], 'metasync_common_robots_nonce'))
266 309 return;
267 310
268 311 $old_common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
269 312
@@ -282,15 +325,15 @@
282 325 {
283 326 if (!current_user_can('edit_post', $post_id))
284 327 return;
285 328
286 - // WP-197: When saving from Gutenberg (REST API context) and the sidebar
329 + // When saving from Gutenberg (REST API context) and the sidebar
287 330 // JSON exists, skip — the sidebar auto-save is the source of truth.
288 331 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
289 332 return;
290 333 }
291 334
292 - $post_data = metasync_sanitize_input_array($_POST);
335 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
293 336 // Check for new field name first, then old for backward compatibility
294 337 $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata';
295 338
296 339 if (!isset($post_data['metasync_advance_robots_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_advance_robots_nonce'], 'metasync_advance_robots_nonce'))
@@ -310,9 +353,9 @@
310 353 {
311 354 if (!current_user_can('edit_post', $post_id))
312 355 return;
313 356
314 - $post_data = metasync_sanitize_input_array($_POST);
357 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
315 358 // Check for new field name first, then old for backward compatibility
316 359 $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata';
317 360
318 361 if (!isset($post_data['metasync_post_redirection_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_redirection_nonce'], 'metasync_post_redirection_nonce'))
@@ -321,20 +364,156 @@
321 364 $old_post_redirection_meta = get_post_meta($post_id, 'metasync_post_redirection_meta', true);
322 365
323 366 $post_redirection_meta = $this->common->sanitize_array($post_data[$field_name]);
324 367
368 + // Persist the meta box UI state (checkbox / type / destination) so the form repopulates.
325 369 if (isset($post_redirection_meta['enable']))
326 370 update_post_meta($post_id, 'metasync_post_redirection_meta', $post_redirection_meta);
327 371 else
328 372 delete_post_meta($post_id, 'metasync_post_redirection_meta', $old_post_redirection_meta);
373 +
374 + // sync the actual redirect into the shared redirection table (exact
375 + // match) using the same DB layer as ?page=...-redirections&action=add. This
376 + // keeps a single redirect code path — the existing handle_template_redirect()
377 + // serves it — instead of a duplicate per-post frontend handler.
378 + self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []);
329 379 }
330 380
381 + /**
382 + * Create, update, or remove a row in the shared redirection table so a redirect
383 + * configured on the post-edit "Redirection" meta box is served by the same engine
384 + * as manually-added redirections (exact match on the post's own URL). The row id we
385 + * create is remembered in post meta so repeated saves update — never duplicate — it,
386 + * and disabling the meta box removes it.
387 + *
388 + * Static: besides the metabox save hook, the REST handlers call this after
389 + * writing metasync_post_redirection_meta, because platform/Gutenberg saves
390 + * never pass through the classic nonce-gated save path.
391 + *
392 + * @param int $post_id The post being saved.
393 + * @param array $meta Sanitized meta box values (enable/type/url).
394 + */
395 + /**
396 + * Whether a redirection row was created by the per-post Redirection meta box.
397 + *
398 + * @param object $row Row from the redirections table.
399 + * @return bool True for rows this meta box owns.
400 + */
401 + public static function owns_redirect_row($row)
402 + {
403 + // A row missing the column, or carrying a non-string, is not ours.
404 + // isset() on a non-object is simply false, so no type check is needed.
405 + if (!isset($row->description) || !is_string($row->description)) {
406 + return false;
407 + }
408 +
409 + return strncmp(
410 + $row->description,
411 + self::POST_REDIRECT_DESCRIPTION_PREFIX,
412 + strlen(self::POST_REDIRECT_DESCRIPTION_PREFIX)
413 + ) === 0;
414 + }
415 +
416 + public static function sync_post_redirect_rule($post_id, $meta)
417 + {
418 + // Never act on autosaves or revisions — their permalink isn't the public URL.
419 + if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) {
420 + return;
421 + }
422 +
423 + // Redirection feature switched off. Return before touching the table so a
424 + // disabled feature neither creates new rows nor deletes the row a post
425 + // already owns — re-enabling must restore the redirect exactly as it was.
426 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)) {
427 + return;
428 + }
429 +
430 + $db = new Metasync_Redirection_Database();
431 + $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true);
432 +
433 + // The metabox posts the literal string 'true', but REST/platform
434 + // writes pass through sanitize_text_field() and may arrive as '1',
435 + // 'on', or boolean true. Normalize so every spelling behaves alike.
436 + $enabled = isset($meta['enable']) && in_array($meta['enable'], ['true', '1', 1, true, 'on', 'yes'], true);
437 + $type = isset($meta['type']) ? (int) $meta['type'] : 301;
438 + $dest = isset($meta['url']) ? trim((string) $meta['url']) : '';
439 + $is_gone = in_array($type, [410, 451], true);
440 +
441 + // Disabled, or a redirect type that needs a destination but has none → drop any
442 + // row we previously created for this post.
443 + if (!$enabled || (!$is_gone && $dest === '')) {
444 + if ($existing_row_id > 0) {
445 + $db->delete([$existing_row_id]);
446 + delete_post_meta($post_id, '_metasync_post_redirect_row_id');
447 + }
448 + return;
449 + }
450 +
451 + // Source = this post's own relative URL; visiting it triggers the redirect.
452 + $permalink = get_permalink($post_id);
453 + if (!$permalink) {
454 + return;
455 + }
456 + $parsed = wp_parse_url($permalink);
457 + $source_path = isset($parsed['path']) ? $parsed['path'] : '/';
458 +
459 + // Same loop guard as the admin form — creating or updating this post's
460 + // rule must not wire its permalink into a cycle (e.g. another rule
461 + // already redirects the chosen destination back here). On a loop we
462 + // keep the previously stored rule and skip the write. The guard above
463 + // already returned when a non-410/451 type had an empty destination,
464 + // so $dest is known non-empty here.
465 + if (!$is_gone) {
466 + // Destination guard — same checks as the admin form and MCP tools.
467 + // Backslashes and protocol-relative hosts bypass host validation in
468 + // browsers while looking internal to wp_validate_redirect. On a
469 + // bad destination we keep the previously stored rule and skip the
470 + // write, same skip-write semantics as the loop guard below.
471 + if (!class_exists('Metasync_Redirection_Validator')) {
472 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
473 + }
474 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($dest)
475 + || (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($dest, '') !== $dest)) {
476 + return;
477 + }
478 +
479 + if (!class_exists('Metasync_Redirection')) {
480 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
481 + }
482 + $db_ref = $db;
483 + $redirection_helper = new Metasync_Redirection($db_ref);
484 + if ($redirection_helper->validate_no_loop($source_path, $dest) !== null) {
485 + return;
486 + }
487 + }
488 +
489 + $data = [
490 + 'sources_from' => serialize([$source_path => 'exact']),
491 + 'url_redirect_to' => $is_gone ? '' : $dest,
492 + 'http_code' => $type,
493 + 'status' => 'active',
494 + 'pattern_type' => 'exact',
495 + 'regex_pattern' => null,
496 + 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id,
497 + ];
498 +
499 + // Update the row we own if it still exists; otherwise create it and remember its id.
500 + if ($existing_row_id > 0 && $db->find($existing_row_id)) {
501 + $db->update($data, (string) $existing_row_id);
502 + } else {
503 + $new_id = $db->add($data);
504 + if ($new_id) {
505 + update_post_meta($post_id, '_metasync_post_redirect_row_id', (int) $new_id);
506 + }
507 + }
508 + }
509 +
331 510 public function canonical_meta_box_save($post_id)
332 511 {
333 512 if (!current_user_can('edit_post', $post_id))
334 513 return;
335 514
336 - $post_data = metasync_sanitize_input_array($_POST);
515 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
337 516 // Check for new field name first, then old for backward compatibility
338 517 $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata';
339 518
340 519 if (!isset($post_data['metasync_post_canonical_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_canonical_nonce'], 'metasync_post_canonical_nonce'))
@@ -341,14 +520,13 @@
341 520 return;
342 521
343 522 $old_post_canonical_meta = get_post_meta($post_id, 'meta_canonical', true);
344 523
345 - // Canonical is a URL string — sanitize as URL, not array
346 - $raw_value = $post_data[$field_name];
347 - if (is_array($raw_value)) {
348 - $raw_value = reset($raw_value); // extract first element if array
349 - }
350 - $post_canonical_meta = esc_url_raw(trim((string) $raw_value));
524 + // Canonical is a URL string — extract the first scalar from nested
525 + // arrays, normalize schemeless input, and validate. Unusable input
526 + // (including the corruption literal "Array") clears the meta instead
527 + // of persisting garbage like "http://Array".
528 + $post_canonical_meta = Metasync_Canonical_Sanitizer::sanitize_for_save($post_data[$field_name]);
351 529
352 530 if (!empty($post_canonical_meta))
353 531 update_post_meta($post_id, 'meta_canonical', $post_canonical_meta);
354 532 else
@@ -453,8 +631,114 @@
453 631 if (!empty($value)) {
454 632 update_post_meta($post_id, $meta_key, $value);
455 633 } else {
456 634 delete_post_meta($post_id, $meta_key);
635 + }
636 + }
637 + }
638 +
639 + /**
640 + * Display a read-only notice in place of the SEO meta box on LPS / custom-HTML
641 + * pages, whose SEO is baked into their own HTML bundle and managed by WebStudio.
642 + * No inputs, nonce, or editable fields are rendered.
643 + */
644 + public function seo_lps_notice_meta_box_display()
645 + {
646 + echo '<p><strong>The SEO for this page is managed by WebStudio.</strong></p>';
647 + }
648 +
649 + /**
650 + * Display the SEO meta box (SEO Title & Meta Description) in the Classic editor.
651 + *
652 + * Reads/writes the same _metasync_seo_title / _metasync_seo_desc keys used by the
653 + * Gutenberg sidebar. OTTO values are used only as placeholder hints so the
654 + * custom-value > OTTO priority is preserved.
655 + */
656 + public function seo_meta_box_display()
657 + {
658 + global $post;
659 + $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true);
660 + $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
661 +
662 + // Values brought in by the external SEO importer apply only where OTTO
663 + // has no suggestion, so show them as the placeholder in exactly that
664 + // case. Without this the field looks empty after an import and the user
665 + // concludes nothing was imported. Which tier fills the placeholder is
666 + // Metasync_Seo_Precedence's call, not this screen's.
667 + $title_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_TITLE);
668 + $desc_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
669 +
670 + $title_placeholder = $title_fallback['value'];
671 + $desc_placeholder = $desc_fallback['value'];
672 +
673 + $title_is_imported = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
674 +
675 + wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce');
676 + ?>
677 + <p style="color: #666; margin-bottom: 12px;">
678 + <?php /* translators: %s: OTTO name (whitelabel). */ ?>
679 + <?php printf(esc_html__('Set the SEO Title and Meta Description for this post. Leave the field blank to use %s suggestion.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
680 + </p>
681 + <?php if ($title_is_imported) : ?>
682 + <p style="color: #666; margin-bottom: 12px;">
683 + <?php /* translators: %s: OTTO name (whitelabel). */ ?>
684 + <?php printf(esc_html__('The greyed-out value below was imported from another SEO plugin. It is used only until %s has a suggestion for this page.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
685 + </p>
686 + <?php endif; ?>
687 + <table class="form-table" style="margin: 0;">
688 + <tr>
689 + <th scope="row"><label for="metasync_seo_title"><?php esc_html_e('SEO Title', 'metasync'); ?></label></th>
690 + <td><input type="text" id="metasync_seo_title" name="metasync_seo_title" value="<?php echo esc_attr($seo_title); ?>" class="large-text" placeholder="<?php echo esc_attr($title_placeholder); ?>" /></td>
691 + </tr>
692 + <tr>
693 + <th scope="row"><label for="metasync_seo_desc"><?php esc_html_e('Meta Description', 'metasync'); ?></label></th>
694 + <td><textarea id="metasync_seo_desc" name="metasync_seo_desc" class="large-text" rows="3" placeholder="<?php echo esc_attr($desc_placeholder); ?>"><?php echo esc_textarea($seo_desc); ?></textarea></td>
695 + </tr>
696 + </table>
697 + <?php
698 + }
699 +
700 + /**
701 + * Save the SEO meta box data.
702 + *
703 + * The nonce field is never present in Gutenberg REST saves, so the nonce check
704 + * alone prevents this handler from running in that context — leaving the sidebar
705 + * as the source of truth there.
706 + *
707 + * @param int $post_id The post ID.
708 + */
709 + public function seo_meta_box_save($post_id)
710 + {
711 + if (!isset($_POST['metasync_seo_meta_nonce']) ||
712 + !wp_verify_nonce($_POST['metasync_seo_meta_nonce'], 'metasync_seo_meta_nonce')) {
713 + return;
714 + }
715 +
716 + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
717 + return;
718 + }
719 +
720 + if (!current_user_can('edit_post', $post_id)) {
721 + return;
722 + }
723 +
724 + if (isset($_POST['metasync_seo_title'])) {
725 + $seo_title = sanitize_text_field(wp_unslash($_POST['metasync_seo_title']));
726 + if (!empty($seo_title)) {
727 + // update_post_meta() unslashes again, so re-slash to keep
728 + // literal backslashes and quotes in the stored value.
729 + update_post_meta($post_id, '_metasync_seo_title', wp_slash($seo_title));
730 + } else {
731 + delete_post_meta($post_id, '_metasync_seo_title');
732 + }
733 + }
734 +
735 + if (isset($_POST['metasync_seo_desc'])) {
736 + $seo_desc = sanitize_textarea_field(wp_unslash($_POST['metasync_seo_desc']));
737 + if (!empty($seo_desc)) {
738 + update_post_meta($post_id, '_metasync_seo_desc', wp_slash($seo_desc));
739 + } else {
740 + delete_post_meta($post_id, '_metasync_seo_desc');
457 741 }
458 742 }
459 743 }
460 744 }