# 404-solution/4.3.0/includes/frontend/PostEditorIntegration.php

404 Solution, version 4.3.0. 482 lines.

- Page: https://pluginprobe.com/plugins/404-solution/4.3.0/code/includes/frontend/PostEditorIntegration.php
- Raw: https://pluginprobe.com/plugins/404-solution/4.3.0/raw/includes/frontend/PostEditorIntegration.php
- Modified: 2026-06-23T05:55:18+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/404-solution/4.3.0/code/includes/frontend/PostEditorIntegration.php#L10-L20`.

```php
<?php


if (!defined('ABSPATH')) {
    exit;
}

/**
 * Post Editor Integration for 404 Solution
 *
 * Adds "Create redirect from old URL to new URL" checkbox to:
 * - Quick Edit on Posts/Pages list
 * - Gutenberg block editor sidebar
 * - Classic Editor meta box
 *
 * This allows users to override the global auto_slugs setting on a per-edit basis.
 */
class ABJ_404_Solution_PostEditorIntegration {

    /** @var self|null */
    private static $instance = null;
    /**
     * Test seam: install or clear the cached singleton instance without
     * private-field reflection. Pass null to reset between tests; pass a
     * configured instance (or double) to install it. Mirrors the setInstance()
     * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
     *
     * @param self|null $instance
     * @return void
     */
    public static function setInstance($instance) {
        self::$instance = $instance;
    }


    /**
     * Track post IDs already processed by saveExclusionMeta within the current request.
     * WordPress fires save_post multiple times per save; this prevents redundant
     * update_post_meta writes (Pattern 11).
     * @var array<int, bool>
     */
    private static $processedExclusionPosts = [];

    /**
     * Clear the per-request save-post dedup map. The map is naturally empty at
     * the start of every real WordPress request; this seam lets tests restore
     * that fresh-per-request state between cases (each test is a fresh
     * "request") without reaching into the private static via reflection.
     *
     * @return void
     */
    public static function resetProcessedExclusionPostsForTests() {
        self::$processedExclusionPosts = [];
    }

    /** @return self */
    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Initialize all editor integrations
     */
    /** @return void */
    public static function init() {
        $me = self::getInstance();

        // Quick Edit integration
        add_filter('manage_posts_columns', array($me, 'addRedirectColumn'), 10, 2);
        add_filter('manage_pages_columns', array($me, 'addRedirectColumn'));
        add_action('manage_posts_custom_column', array($me, 'renderRedirectColumn'), 10, 2);
        add_action('manage_pages_custom_column', array($me, 'renderRedirectColumn'), 10, 2);
        add_action('quick_edit_custom_box', array($me, 'renderQuickEditCheckbox'), 10, 2);
        add_action('admin_enqueue_scripts', array($me, 'enqueueQuickEditScript'));

        // Classic Editor meta box
        add_action('add_meta_boxes', array($me, 'addMetaBox'));
        add_action('save_post', array($me, 'saveExclusionMeta'));

        // Gutenberg sidebar panel
        add_action('init', array($me, 'registerPostMeta'));
        add_action('enqueue_block_editor_assets', array($me, 'enqueueGutenbergScript'));

        // Term meta for exclusion on category/tag edit screens
        add_action('init', array($me, 'registerTermMeta'));
    }

    /**
     * Get the default value for the redirect checkbox based on global settings
     */
    /** @return bool */
    public function getDefaultRedirectSetting() {
        $options = abj_service('options_repository')->getOptions();
        return @$options['auto_slugs'] == '1';
    }

    // ==================== Quick Edit Integration ====================

    /**
     * Add a hidden column to store redirect default for Quick Edit JavaScript
     */
    /**
     * @param array<string, string> $columns
     * @param string|null $post_type
     * @return array<string, string>
     */
    public function addRedirectColumn($columns, $post_type = null) {
        // Add our column (it will be hidden via CSS)
        $columns['abj404_redirect'] = '';
        return $columns;
    }

    /**
     * Render the hidden column data (used by Quick Edit JavaScript)
     */
    /**
     * @param string $column
     * @param int $post_id
     * @return void
     */
    public function renderRedirectColumn($column, $post_id) {
        if ($column === 'abj404_redirect') {
            $default = $this->getDefaultRedirectSetting() ? '1' : '0';
            echo $this->renderTemplate('postEditorRedirectDefaultSpan.html', array(
                '{default}' => esc_attr($default),
            ));
        }
    }

    /**
     * Render the Quick Edit checkbox
     */
    /**
     * @param string $column_name
     * @param string $post_type
     * @return void
     */
    public function renderQuickEditCheckbox($column_name, $post_type) {
        if ($column_name !== 'abj404_redirect') {
            return;
        }

        // Only show for post types that can have slugs
        if (!post_type_supports($post_type, 'slug') && !in_array($post_type, array('post', 'page'))) {
            return;
        }

        static $nonce_printed = false;
        if (!$nonce_printed) {
            wp_nonce_field('abj404_quick_edit', 'abj404_quick_edit_nonce');
            $nonce_printed = true;
        }
        echo $this->renderTemplate('postEditorQuickEditCheckbox.html', array(
            '{label}' => esc_html__('Create redirect from old URL to new URL', '404-solution'),
        ));
    }

    /**
     * Enqueue Quick Edit JavaScript on post list screens
     */
    /**
     * @param string $hook
     * @return void
     */
    public function enqueueQuickEditScript($hook) {
        if ($hook !== 'edit.php') {
            return;
        }

        // ABJ404_URL (plugin root) rather than plugin_dir_url(__FILE__): this
        // file lives in includes/frontend/ but the JS lives in includes/js/,
        // so a relative-to-__FILE__ URL points at a non-existent path (i961).
        wp_enqueue_script(
            'abj404-quick-edit-redirect',
            ABJ404_URL . 'includes/js/quick-edit-redirect.js',
            array('jquery', 'inline-edit-post'),
            ABJ404_VERSION,
            true
        );

        // Hide the redirect column (we only use it for data storage)
        wp_add_inline_style('wp-admin', '.column-abj404_redirect { display: none; }');
    }

    // ==================== Classic Editor Meta Box ====================

    /**
     * Add meta box to Classic Editor only (not Gutenberg)
     * Gutenberg has its own integration via gutenberg-redirect.js
     */
    /** @return void */
    public function addMetaBox() {
        $post_types = get_post_types(array('public' => true), 'names');

        foreach ($post_types as $post_type) {
            add_meta_box(
                'abj404_redirect_meta_box',
                __('404 Solution', '404-solution'),
                array($this, 'renderMetaBox'),
                $post_type,
                'side',
                'default',
                array(
                    // Prevent this meta box from appearing in Gutenberg
                    // We have a custom Gutenberg integration that shows the checkbox
                    // only when the slug is modified
                    '__block_editor_compatible_meta_box' => false,
                    '__back_compat_meta_box' => true
                )
            );
        }
    }

    /**
     * Render the Classic Editor meta box content
     */
    /**
     * @param \WP_Post $post
     * @return void
     */
    public function renderMetaBox($post) {
        // Only show for published posts (new posts have no old URL to redirect from)
        if ($post->post_status !== 'publish') {
            echo $this->renderTemplate('postEditorUnavailableDescription.html', array(
                '{message}' => esc_html__('Redirect options are available after the post is published.', '404-solution'),
            ));
            return;
        }

        $default = $this->getDefaultRedirectSetting();
        $excludeMeta = get_post_meta($post->ID, '_abj404_exclude', true);

        wp_nonce_field('abj404_meta_box', 'abj404_meta_box_nonce');
        echo $this->renderTemplate('postEditorMetaBox.html', array(
            '{create_checked}'      => $default ? 'checked="checked"' : '',
            '{create_label}'        => esc_html__('Create redirect from old URL to new URL', '404-solution'),
            '{create_description}'  => esc_html__('If you change the permalink/slug, a redirect will be created from the old URL to the new one.', '404-solution'),
            '{exclude_checked}'     => $excludeMeta === '1' ? 'checked="checked"' : '',
            '{exclude_label}'       => esc_html__('Exclude from 404 redirect suggestions', '404-solution'),
            '{exclude_description}' => esc_html__('When checked, this post will not be suggested as a redirect target for 404 errors.', '404-solution'),
        ));
    }

    // ==================== Gutenberg Integration ====================

    /**
     * Register post meta for Gutenberg REST API access
     */
    /** @return void */
    public function registerPostMeta() {
        register_post_meta('', '_abj404_create_redirect', array(
            'show_in_rest' => true,
            'single' => true,
            'type' => 'string',
            'default' => '',
            'auth_callback' => function() {
                return current_user_can('edit_posts');
            },
            'sanitize_callback' => function($value) {
                return $value === '1' ? '1' : ($value === '0' ? '0' : '');
            }
        ));

        register_post_meta('', '_abj404_exclude', array(
            'show_in_rest' => true,
            'single' => true,
            'type' => 'string',
            'default' => '',
            'auth_callback' => function() {
                return current_user_can('edit_posts');
            },
            'sanitize_callback' => array(__CLASS__, 'sanitizeExclusionMeta'),
        ));
    }

    /**
     * Sanitize the exclusion meta value: only '1' is truthy, everything else becomes ''.
     *
     * @param mixed $value
     * @return string
     */
    public static function sanitizeExclusionMeta($value) {
        return $value === '1' ? '1' : '';
    }

    /**
     * Enqueue Gutenberg sidebar script
     */
    /** @return void */
    public function enqueueGutenbergScript() {
        // Only load on post edit screens.
        // get_current_screen() lives in wp-admin/includes/screen.php; guard adjacent
        // because this hook may fire from contexts where wp-admin includes aren't loaded.
        if (!function_exists('get_current_screen')) {
            return;
        }
        $screen = get_current_screen();
        if (!$screen || $screen->base !== 'post') {
            return;
        }

        // See enqueueQuickEditScript for the ABJ404_URL rationale (i961).
        wp_enqueue_script(
            'abj404-gutenberg-redirect',
            ABJ404_URL . 'includes/js/gutenberg-redirect.js',
            array('wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n'),
            ABJ404_VERSION,
            true
        );

        // Pass default setting and translations to JavaScript
        wp_localize_script('abj404-gutenberg-redirect', 'abj404GutenbergRedirect', array(
            'defaultEnabled' => $this->getDefaultRedirectSetting(),
            'i18n' => array(
                'checkboxLabel' => __('Create redirect from old URL to new URL', '404-solution'),
                'slugChangedNotice' => __('Slug changed:', '404-solution'),
                'excludeLabel' => __('Exclude from 404 redirect suggestions', '404-solution'),
                'excludeHelp' => __('When checked, this post will not be suggested as a redirect target for 404 errors.', '404-solution'),
            )
        ));
    }

    // ==================== Post Exclusion Save ====================

    /**
     * Save _abj404_exclude post meta on post save.
     *
     * @param int $post_id
     * @return void
     */
    public function saveExclusionMeta($post_id) {
        // Prevent duplicate processing within same request (WordPress fires save_post 2-4 times per save).
        if (isset(self::$processedExclusionPosts[$post_id])) {
            return;
        }
        if (!isset($_POST['abj404_meta_box_nonce'])) {
            return;
        }
        if (!wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box')) {
            return;
        }
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (!current_user_can('edit_post', $post_id)) {
            return;
        }

        $value = isset($_POST['abj404_exclude']) && $_POST['abj404_exclude'] === '1' ? '1' : '';
        update_post_meta($post_id, '_abj404_exclude', $value);
        self::$processedExclusionPosts[$post_id] = true;
    }

    // ==================== Term Meta Integration ====================

    /**
     * Register term meta and hook edit/save for all public taxonomies.
     *
     * @return void
     */
    public function registerTermMeta() {
        $taxonomies = get_taxonomies(array('public' => true), 'names');
        foreach ($taxonomies as $taxonomy) {
            register_term_meta($taxonomy, '_abj404_exclude', array(
                'show_in_rest' => true,
                'single' => true,
                'type' => 'string',
                'default' => '',
                'sanitize_callback' => array(__CLASS__, 'sanitizeExclusionMeta'),
            ));

            add_action("{$taxonomy}_edit_form_fields", array($this, 'renderTermExclusionField'), 10, 2);
            add_action("edited_{$taxonomy}", array($this, 'saveTermExclusionMeta'));
            add_action("created_{$taxonomy}", array($this, 'saveTermExclusionMeta'));
        }
    }

    /**
     * Render the exclusion checkbox on the term edit form.
     *
     * @param \WP_Term $term
     * @param string $taxonomy
     * @return void
     */
    public function renderTermExclusionField($term, $taxonomy = '') {
        $value = get_term_meta($term->term_id, '_abj404_exclude', true);
        $checked = ($value === '1') ? 'checked="checked"' : '';
        wp_nonce_field('abj404_term_exclude', 'abj404_term_exclude_nonce');
        echo $this->renderTemplate('postEditorTermExclusionField.html', array(
            '{heading}'     => esc_html__('404 Solution', '404-solution'),
            '{checked}'     => $checked,
            '{label}'       => esc_html__('Exclude from 404 redirect suggestions', '404-solution'),
            '{description}' => esc_html__('When checked, this term will not be suggested as a redirect target for 404 errors.', '404-solution'),
        ));
    }

    /**
     * Save term exclusion meta on term create/edit.
     *
     * @param int $term_id
     * @return void
     */
    public function saveTermExclusionMeta($term_id) {
        if (!isset($_POST['abj404_term_exclude_nonce'])) {
            return;
        }
        if (!wp_verify_nonce($_POST['abj404_term_exclude_nonce'], 'abj404_term_exclude')) {
            return;
        }

        $value = isset($_POST['abj404_exclude']) && $_POST['abj404_exclude'] === '1' ? '1' : '';
        update_term_meta($term_id, '_abj404_exclude', $value);
    }

    // ==================== Save Handler Helper ====================

    /**
     * Check if redirect should be created for this post save
     * Called by SlugChangeHandler
     *
     * @param int $post_id Post ID
     * @param array<string, mixed> $options Plugin options
     * @return bool Whether to create redirect
     */
    public static function shouldCreateRedirect($post_id, $options) {
        // Check Quick Edit / Classic Editor POST data
        if (isset($_POST['abj404_create_redirect'])) {
            // Verify nonce for Quick Edit
            if (isset($_POST['abj404_quick_edit_nonce']) &&
                wp_verify_nonce($_POST['abj404_quick_edit_nonce'], 'abj404_quick_edit')) {
                return $_POST['abj404_create_redirect'] === '1';
            }
            // Verify nonce for Classic Editor
            if (isset($_POST['abj404_meta_box_nonce']) &&
                wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box')) {
                return $_POST['abj404_create_redirect'] === '1';
            }
        }

        // Check for unchecked checkbox in Quick Edit (checkbox not in POST = unchecked)
        if (isset($_POST['abj404_quick_edit_nonce']) &&
            wp_verify_nonce($_POST['abj404_quick_edit_nonce'], 'abj404_quick_edit') &&
            !isset($_POST['abj404_create_redirect'])) {
            return false;
        }

        // Check for unchecked checkbox in Classic Editor (checkbox not in POST = unchecked)
        if (isset($_POST['abj404_meta_box_nonce']) &&
            wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box') &&
            !isset($_POST['abj404_create_redirect'])) {
            return false;
        }

        // Check Gutenberg post meta
        $meta = get_post_meta($post_id, '_abj404_create_redirect', true);
        if ($meta !== '' && $meta !== null) {
            // Clear the meta after reading (one-time use per edit session)
            delete_post_meta($post_id, '_abj404_create_redirect');
            return $meta === '1';
        }

        // Fall back to global setting
        return @$options['auto_slugs'] == '1';
    }

    /**
     * @param string $templateName
     * @param array<string, string> $replacements
     * @return string
     */
    private function renderTemplate(string $templateName, array $replacements): string {
        $template = ABJ_404_Solution_FileSystemService::readFileContents(
            dirname(__DIR__) . '/html/' . $templateName,
            false
        );
        return str_replace(array_keys($replacements), array_values($replacements), $template);
    }
}

```
