*/
// Abort if this file is accessed directly.
if (!defined('ABSPATH')) {
exit;
}
class Metasync_Post_Meta_Settings
{
/**
* Description written onto every redirection row this meta box owns.
*
* Rows created here live in the shared redirections table alongside rules
* added by hand on the Redirections screen, and carry no other marker. The
* prefix is therefore how the redirect engine tells the two apart, so that
* switching the Redirection feature off stops these rows without touching
* anyone's manually-added rules.
*/
const POST_REDIRECT_DESCRIPTION_PREFIX = 'Post redirection meta box for post #';
private $common;
public function __construct()
{
$this->common = new Metasync_Common();
add_action('admin_init', [$this, 'add_post_meta_data'], 2);
add_action('save_post', [$this, 'common_robots_meta_box_save']);
add_action('save_post', [$this, 'advance_robots_meta_box_save']);
add_action('save_post', [$this, 'redirection_meta_box_save']);
add_action('save_post', [$this, 'canonical_meta_box_save']);
add_action('save_post', [$this, 'video_sitemap_meta_box_save']);
add_action('save_post', [$this, 'seo_meta_box_save']);
}
public function add_post_meta_data()
{
// Don't show meta boxes if user's role doesn't have plugin access
if (!Metasync::current_user_has_plugin_access()) {
return;
}
$plugin_name = Metasync::get_effective_plugin_name();
$general_settings = Metasync::get_option('general', []);
$post_types = array_values(get_post_types(['public' => true], 'names'));
$post_types = array_diff($post_types, ['attachment']);
// Resolve the current post ID in admin_init context: $_GET['post'] on the
// edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue.
$post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0);
// LPS / custom-HTML pages carry their own baked SEO served before wp_head,
// so the editable SEO fields here do nothing and mislead editors.
// otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
// required conditionally, so the function_exists guard is needed at runtime.
$is_custom_or_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
// Only add meta boxes if not disabled in settings.
// On LPS / custom-HTML pages all of these MetaSync boxes are suppressed: they do
// nothing useful there (their output goes into wp_head, which never runs for these
// pages — serve_raw_html() exits first — and a redirect would only break the
// published page). The single SEO read-only notice below carries the messaging.
if (empty($general_settings['disable_common_robots_metabox']) && !$is_custom_or_lps_page) {
add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], $post_types, 'normal', 'default');
}
if (empty($general_settings['disable_advance_robots_metabox']) && !$is_custom_or_lps_page) {
add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], $post_types, 'normal', 'default');
}
if (empty($general_settings['disable_redirection_metabox']) && !$is_custom_or_lps_page) {
add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], $post_types, 'normal', 'default');
}
if (empty($general_settings['disable_canonical_metabox']) && !$is_custom_or_lps_page) {
add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], $post_types, 'normal', 'default');
}
if (empty($general_settings['disable_seo_metabox'])) {
if ($is_custom_or_lps_page) {
// Replace the editable SEO box with a read-only notice on LPS pages.
// __back_compat_meta_box hides it in Gutenberg (the block-editor sidebar
// already renders the same notice), so it shows only in the Classic editor.
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]);
} else {
add_meta_box('metasync-seo-meta', "SEO by $plugin_name", [$this, 'seo_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]);
}
}
// Video Sitemap meta box — only if video sitemap is enabled
$video_settings = get_option('metasync_video_sitemap_settings', []);
if (!empty($video_settings['enabled'])) {
$video_post_types = !empty($video_settings['post_types']) ? (array) $video_settings['post_types'] : ['post', 'page'];
add_meta_box(
'metasync-video-sitemap-meta',
"Video Sitemap by $plugin_name",
[$this, 'video_sitemap_meta_box_display'],
$video_post_types,
'normal',
'default'
);
}
}
public function common_robots_meta_box_display()
{
global $post;
$post_meta_robots = get_post_meta($post->ID, 'metasync_common_robots', true);
// Check new spelling first, fall back to old for backward compatibility
$common_meta_robots = Metasync::get_option('common_robots_meta') ?? Metasync::get_option('common_robots_mata') ?? '';
$common_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots;
wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce');
?>
ID, 'metasync_advance_robots', true);
// Check new spelling first, fall back to old for backward compatibility
$common_meta_robots = Metasync::get_option('advance_robots_meta') ?? Metasync::get_option('advance_robots_mata') ?? '';
$advance_robots = $post_meta_robots ? $post_meta_robots : $common_meta_robots;
$snippet_advance_robots_enable = $advance_robots['max-snippet']['enable'] ?? '';
$snippet_advance_robots_length = $advance_robots['max-snippet']['length'] ?? '';
$video_advance_robots_enable = $advance_robots['max-video-preview']['enable'] ?? '';
$video_advance_robots_length = $advance_robots['max-video-preview']['length'] ?? '';
$image_advance_robots_enable = $advance_robots['max-image-preview']['enable'] ?? '';
$image_advance_robots_length = $advance_robots['max-image-preview']['length'] ?? '';
wp_nonce_field('metasync_advance_robots_nonce', 'metasync_advance_robots_nonce');
?>
ID, 'metasync_post_redirection_meta', true) ?? '';
$enable = $post_redirection['enable'] ?? '';
$type = $post_redirection['type'] ?? '';
$url = $post_redirection['url'] ?? '';
wp_nonce_field('metasync_post_redirection_nonce', 'metasync_post_redirection_nonce');
?>
ID, 'meta_canonical', true);
$post_canonical = Metasync_Canonical_Sanitizer::sanitize($raw_canonical);
// Repair legacy array rows and rows corrupted to the literal "Array"
// A nested array here previously became the string "Array"
// via a single reset() + (string) cast, permanently corrupting the row.
if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) {
if ($post_canonical !== '') {
update_post_meta($post->ID, 'meta_canonical', $post_canonical);
} else {
delete_post_meta($post->ID, 'meta_canonical');
}
} elseif ($post_canonical === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') {
// Stored value the validator doesn't recognize (and is not a
// corruption artifact): show it as-is so a routine post save
// doesn't silently wipe it. It is not emitted while invalid.
$post_canonical = trim($raw_canonical);
}
wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce');
?>
common->sanitize_array($post_data[$field_name]);
}
if (!empty($common_robots))
update_post_meta($post_id, 'metasync_common_robots', $common_robots);
elseif (empty($common_robots) && $old_common_robots)
delete_post_meta($post_id, 'metasync_common_robots', $old_common_robots);
}
public function advance_robots_meta_box_save($post_id)
{
if (!current_user_can('edit_post', $post_id))
return;
// When saving from Gutenberg (REST API context) and the sidebar
// JSON exists, skip — the sidebar auto-save is the source of truth.
if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
return;
}
$post_data = metasync_sanitize_input_array($_POST);
// Check for new field name first, then old for backward compatibility
$field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata';
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'))
return;
$old_advance_robots = get_post_meta($post_id, 'metasync_advance_robots', true);
$advance_robots = $this->common->sanitize_array($post_data[$field_name]);
if (!empty($advance_robots))
update_post_meta($post_id, 'metasync_advance_robots', $advance_robots);
elseif (empty($advance_robots) && $old_advance_robots)
delete_post_meta($post_id, 'metasync_advance_robots', $old_advance_robots);
}
public function redirection_meta_box_save($post_id)
{
if (!current_user_can('edit_post', $post_id))
return;
$post_data = metasync_sanitize_input_array($_POST);
// Check for new field name first, then old for backward compatibility
$field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata';
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'))
return;
$old_post_redirection_meta = get_post_meta($post_id, 'metasync_post_redirection_meta', true);
$post_redirection_meta = $this->common->sanitize_array($post_data[$field_name]);
// Persist the meta box UI state (checkbox / type / destination) so the form repopulates.
if (isset($post_redirection_meta['enable']))
update_post_meta($post_id, 'metasync_post_redirection_meta', $post_redirection_meta);
else
delete_post_meta($post_id, 'metasync_post_redirection_meta', $old_post_redirection_meta);
// sync the actual redirect into the shared redirection table (exact
// match) using the same DB layer as ?page=...-redirections&action=add. This
// keeps a single redirect code path — the existing handle_template_redirect()
// serves it — instead of a duplicate per-post frontend handler.
self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []);
}
/**
* Create, update, or remove a row in the shared redirection table so a redirect
* configured on the post-edit "Redirection" meta box is served by the same engine
* as manually-added redirections (exact match on the post's own URL). The row id we
* create is remembered in post meta so repeated saves update — never duplicate — it,
* and disabling the meta box removes it.
*
* Static: besides the metabox save hook, the REST handlers call this after
* writing metasync_post_redirection_meta, because platform/Gutenberg saves
* never pass through the classic nonce-gated save path.
*
* @param int $post_id The post being saved.
* @param array $meta Sanitized meta box values (enable/type/url).
*/
/**
* Whether a redirection row was created by the per-post Redirection meta box.
*
* @param object $row Row from the redirections table.
* @return bool True for rows this meta box owns.
*/
public static function owns_redirect_row($row)
{
// A row missing the column, or carrying a non-string, is not ours.
// isset() on a non-object is simply false, so no type check is needed.
if (!isset($row->description) || !is_string($row->description)) {
return false;
}
return strncmp(
$row->description,
self::POST_REDIRECT_DESCRIPTION_PREFIX,
strlen(self::POST_REDIRECT_DESCRIPTION_PREFIX)
) === 0;
}
public static function sync_post_redirect_rule($post_id, $meta)
{
// Never act on autosaves or revisions — their permalink isn't the public URL.
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) {
return;
}
// Redirection feature switched off. Return before touching the table so a
// disabled feature neither creates new rows nor deletes the row a post
// already owns — re-enabling must restore the redirect exactly as it was.
if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)) {
return;
}
$db = new Metasync_Redirection_Database();
$existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true);
// The metabox posts the literal string 'true', but REST/platform
// writes pass through sanitize_text_field() and may arrive as '1',
// 'on', or boolean true. Normalize so every spelling behaves alike.
$enabled = isset($meta['enable']) && in_array($meta['enable'], ['true', '1', 1, true, 'on', 'yes'], true);
$type = isset($meta['type']) ? (int) $meta['type'] : 301;
$dest = isset($meta['url']) ? trim((string) $meta['url']) : '';
$is_gone = in_array($type, [410, 451], true);
// Disabled, or a redirect type that needs a destination but has none → drop any
// row we previously created for this post.
if (!$enabled || (!$is_gone && $dest === '')) {
if ($existing_row_id > 0) {
$db->delete([$existing_row_id]);
delete_post_meta($post_id, '_metasync_post_redirect_row_id');
}
return;
}
// Source = this post's own relative URL; visiting it triggers the redirect.
$permalink = get_permalink($post_id);
if (!$permalink) {
return;
}
$parsed = wp_parse_url($permalink);
$source_path = isset($parsed['path']) ? $parsed['path'] : '/';
// Same loop guard as the admin form — creating or updating this post's
// rule must not wire its permalink into a cycle (e.g. another rule
// already redirects the chosen destination back here). On a loop we
// keep the previously stored rule and skip the write. The guard above
// already returned when a non-410/451 type had an empty destination,
// so $dest is known non-empty here.
if (!$is_gone) {
if (!class_exists('Metasync_Redirection')) {
require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
}
$db_ref = $db;
$redirection_helper = new Metasync_Redirection($db_ref);
if ($redirection_helper->validate_no_loop($source_path, $dest) !== null) {
return;
}
}
$data = [
'sources_from' => serialize([$source_path => 'exact']),
'url_redirect_to' => $is_gone ? '' : $dest,
'http_code' => $type,
'status' => 'active',
'pattern_type' => 'exact',
'regex_pattern' => null,
'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id,
];
// Update the row we own if it still exists; otherwise create it and remember its id.
if ($existing_row_id > 0 && $db->find($existing_row_id)) {
$db->update($data, (string) $existing_row_id);
} else {
$new_id = $db->add($data);
if ($new_id) {
update_post_meta($post_id, '_metasync_post_redirect_row_id', (int) $new_id);
}
}
}
public function canonical_meta_box_save($post_id)
{
if (!current_user_can('edit_post', $post_id))
return;
$post_data = metasync_sanitize_input_array($_POST);
// Check for new field name first, then old for backward compatibility
$field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata';
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'))
return;
$old_post_canonical_meta = get_post_meta($post_id, 'meta_canonical', true);
// Canonical is a URL string — extract the first scalar from nested
// arrays, normalize schemeless input, and validate. Unusable input
// (including the corruption literal "Array") clears the meta instead
// of persisting garbage like "http://Array".
$post_canonical_meta = Metasync_Canonical_Sanitizer::sanitize_for_save($post_data[$field_name]);
if (!empty($post_canonical_meta))
update_post_meta($post_id, 'meta_canonical', $post_canonical_meta);
else
delete_post_meta($post_id, 'meta_canonical', $old_post_canonical_meta);
}
public function show_top_admin_bar() {
if ( Metasync::current_user_has_plugin_access() ) {
show_admin_bar( true );
}
}
/**
* Display the Video Sitemap meta box in post editor.
*/
public function video_sitemap_meta_box_display()
{
global $post;
$video_url = get_post_meta($post->ID, '_metasync_video_url', true);
$video_thumbnail = get_post_meta($post->ID, '_metasync_video_thumbnail', true);
$video_title = get_post_meta($post->ID, '_metasync_video_title', true);
$video_desc = get_post_meta($post->ID, '_metasync_video_description', true);
$video_duration = get_post_meta($post->ID, '_metasync_video_duration', true);
wp_nonce_field('metasync_video_sitemap_meta_nonce', 'metasync_video_sitemap_meta_nonce');
?>
'_metasync_video_url',
'metasync_video_thumbnail' => '_metasync_video_thumbnail',
'metasync_video_title' => '_metasync_video_title',
'metasync_video_description' => '_metasync_video_description',
'metasync_video_duration' => '_metasync_video_duration',
];
foreach ($fields as $form_key => $meta_key) {
if (!isset($_POST[$form_key])) {
continue;
}
$value = $_POST[$form_key];
// Sanitize per field type
if (in_array($meta_key, ['_metasync_video_url', '_metasync_video_thumbnail'], true)) {
$value = esc_url_raw($value);
} elseif ($meta_key === '_metasync_video_duration') {
$value = absint($value);
$value = $value > 0 ? $value : '';
} elseif ($meta_key === '_metasync_video_description') {
$value = sanitize_textarea_field($value);
} else {
$value = sanitize_text_field($value);
}
if (!empty($value)) {
update_post_meta($post_id, $meta_key, $value);
} else {
delete_post_meta($post_id, $meta_key);
}
}
}
/**
* Display a read-only notice in place of the SEO meta box on LPS / custom-HTML
* pages, whose SEO is baked into their own HTML bundle and managed by WebStudio.
* No inputs, nonce, or editable fields are rendered.
*/
public function seo_lps_notice_meta_box_display()
{
echo 'The SEO for this page is managed by WebStudio.
';
}
/**
* Display the SEO meta box (SEO Title & Meta Description) in the Classic editor.
*
* Reads/writes the same _metasync_seo_title / _metasync_seo_desc keys used by the
* Gutenberg sidebar. OTTO values are used only as placeholder hints so the
* custom-value > OTTO priority is preserved.
*/
public function seo_meta_box_display()
{
global $post;
$seo_title = get_post_meta($post->ID, '_metasync_seo_title', true);
$seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
// Values brought in by the external SEO importer apply only where OTTO
// has no suggestion, so show them as the placeholder in exactly that
// case. Without this the field looks empty after an import and the user
// concludes nothing was imported. Which tier fills the placeholder is
// Metasync_Seo_Precedence's call, not this screen's.
$title_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_TITLE);
$desc_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
$title_placeholder = $title_fallback['value'];
$desc_placeholder = $desc_fallback['value'];
$title_is_imported = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce');
?>