PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / PostEditorIntegration.php

PostEditorIntegration.php in 404 Solution 4.1.13, at includes/PostEditorIntegration.php

470 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Post Editor Integration for 404 Solution
10 *
11 * Adds "Create redirect from old URL to new URL" checkbox to:
12 * - Quick Edit on Posts/Pages list
13 * - Gutenberg block editor sidebar
14 * - Classic Editor meta box
15 *
16 * This allows users to override the global auto_slugs setting on a per-edit basis.
17 */
18 class ABJ_404_Solution_PostEditorIntegration {
19
20 /** @var self|null */
21 private static $instance = null;
22
23 /**
24 * Track post IDs already processed by saveExclusionMeta within the current request.
25 * WordPress fires save_post multiple times per save; this prevents redundant
26 * update_post_meta writes (Pattern 11).
27 * @var array<int, bool>
28 */
29 private static $processedExclusionPosts = [];
30
31 /** @return self */
32 public static function getInstance() {
33 if (self::$instance === null) {
34 self::$instance = new self();
35 }
36 return self::$instance;
37 }
38
39 /**
40 * Initialize all editor integrations
41 */
42 /** @return void */
43 public static function init() {
44 $me = self::getInstance();
45
46 // Quick Edit integration
47 add_filter('manage_posts_columns', array($me, 'addRedirectColumn'), 10, 2);
48 add_filter('manage_pages_columns', array($me, 'addRedirectColumn'));
49 add_action('manage_posts_custom_column', array($me, 'renderRedirectColumn'), 10, 2);
50 add_action('manage_pages_custom_column', array($me, 'renderRedirectColumn'), 10, 2);
51 add_action('quick_edit_custom_box', array($me, 'renderQuickEditCheckbox'), 10, 2);
52 add_action('admin_enqueue_scripts', array($me, 'enqueueQuickEditScript'));
53
54 // Classic Editor meta box
55 add_action('add_meta_boxes', array($me, 'addMetaBox'));
56 add_action('save_post', array($me, 'saveExclusionMeta'));
57
58 // Gutenberg sidebar panel
59 add_action('init', array($me, 'registerPostMeta'));
60 add_action('enqueue_block_editor_assets', array($me, 'enqueueGutenbergScript'));
61
62 // Term meta for exclusion on category/tag edit screens
63 add_action('init', array($me, 'registerTermMeta'));
64 }
65
66 /**
67 * Get the default value for the redirect checkbox based on global settings
68 */
69 /** @return bool */
70 public function getDefaultRedirectSetting() {
71 $options = abj_service('plugin_logic')->getOptions();
72 return @$options['auto_slugs'] == '1';
73 }
74
75 // ==================== Quick Edit Integration ====================
76
77 /**
78 * Add a hidden column to store redirect default for Quick Edit JavaScript
79 */
80 /**
81 * @param array<string, string> $columns
82 * @param string|null $post_type
83 * @return array<string, string>
84 */
85 public function addRedirectColumn($columns, $post_type = null) {
86 // Add our column (it will be hidden via CSS)
87 $columns['abj404_redirect'] = '';
88 return $columns;
89 }
90
91 /**
92 * Render the hidden column data (used by Quick Edit JavaScript)
93 */
94 /**
95 * @param string $column
96 * @param int $post_id
97 * @return void
98 */
99 public function renderRedirectColumn($column, $post_id) {
100 if ($column === 'abj404_redirect') {
101 $default = $this->getDefaultRedirectSetting() ? '1' : '0';
102 // Output data attribute for JavaScript to read
103 echo '<span class="abj404-redirect-default" data-default="' . esc_attr($default) . '"></span>';
104 }
105 }
106
107 /**
108 * Render the Quick Edit checkbox
109 */
110 /**
111 * @param string $column_name
112 * @param string $post_type
113 * @return void
114 */
115 public function renderQuickEditCheckbox($column_name, $post_type) {
116 if ($column_name !== 'abj404_redirect') {
117 return;
118 }
119
120 // Only show for post types that can have slugs
121 if (!post_type_supports($post_type, 'slug') && !in_array($post_type, array('post', 'page'))) {
122 return;
123 }
124
125 static $nonce_printed = false;
126 if (!$nonce_printed) {
127 wp_nonce_field('abj404_quick_edit', 'abj404_quick_edit_nonce');
128 $nonce_printed = true;
129 }
130 ?>
131 <fieldset class="inline-edit-col-right abj404-quick-edit">
132 <div class="inline-edit-col">
133 <label class="inline-edit-group">
134 <input type="checkbox" name="abj404_create_redirect" value="1">
135 <span class="checkbox-title"><?php echo esc_html__('Create redirect from old URL to new URL', '404-solution'); ?></span>
136 </label>
137 </div>
138 </fieldset>
139 <?php
140 }
141
142 /**
143 * Enqueue Quick Edit JavaScript on post list screens
144 */
145 /**
146 * @param string $hook
147 * @return void
148 */
149 public function enqueueQuickEditScript($hook) {
150 if ($hook !== 'edit.php') {
151 return;
152 }
153
154 wp_enqueue_script(
155 'abj404-quick-edit-redirect',
156 plugin_dir_url(__FILE__) . 'js/quick-edit-redirect.js',
157 array('jquery', 'inline-edit-post'),
158 ABJ404_VERSION,
159 true
160 );
161
162 // Hide the redirect column (we only use it for data storage)
163 wp_add_inline_style('wp-admin', '.column-abj404_redirect { display: none; }');
164 }
165
166 // ==================== Classic Editor Meta Box ====================
167
168 /**
169 * Add meta box to Classic Editor only (not Gutenberg)
170 * Gutenberg has its own integration via gutenberg-redirect.js
171 */
172 /** @return void */
173 public function addMetaBox() {
174 $post_types = get_post_types(array('public' => true), 'names');
175
176 foreach ($post_types as $post_type) {
177 add_meta_box(
178 'abj404_redirect_meta_box',
179 __('404 Solution', '404-solution'),
180 array($this, 'renderMetaBox'),
181 $post_type,
182 'side',
183 'default',
184 array(
185 // Prevent this meta box from appearing in Gutenberg
186 // We have a custom Gutenberg integration that shows the checkbox
187 // only when the slug is modified
188 '__block_editor_compatible_meta_box' => false,
189 '__back_compat_meta_box' => true
190 )
191 );
192 }
193 }
194
195 /**
196 * Render the Classic Editor meta box content
197 */
198 /**
199 * @param \WP_Post $post
200 * @return void
201 */
202 public function renderMetaBox($post) {
203 // Only show for published posts (new posts have no old URL to redirect from)
204 if ($post->post_status !== 'publish') {
205 echo '<p class="description">' . esc_html__('Redirect options are available after the post is published.', '404-solution') . '</p>';
206 return;
207 }
208
209 $default = $this->getDefaultRedirectSetting();
210 $checked = $default ? 'checked' : '';
211
212 $excludeMeta = get_post_meta($post->ID, '_abj404_exclude', true);
213 $excludeChecked = ($excludeMeta === '1') ? 'checked' : '';
214
215 wp_nonce_field('abj404_meta_box', 'abj404_meta_box_nonce');
216 ?>
217 <p>
218 <label>
219 <input type="checkbox" name="abj404_create_redirect" value="1" <?php echo $checked; ?>>
220 <?php echo esc_html__('Create redirect from old URL to new URL', '404-solution'); ?>
221 </label>
222 </p>
223 <p class="description">
224 <?php echo esc_html__('If you change the permalink/slug, a redirect will be created from the old URL to the new one.', '404-solution'); ?>
225 </p>
226 <hr>
227 <p>
228 <label>
229 <input type="checkbox" name="abj404_exclude" value="1" <?php echo $excludeChecked; ?>>
230 <?php echo esc_html__('Exclude from 404 redirect suggestions', '404-solution'); ?>
231 </label>
232 </p>
233 <p class="description">
234 <?php echo esc_html__('When checked, this post will not be suggested as a redirect target for 404 errors.', '404-solution'); ?>
235 </p>
236 <?php
237 }
238
239 // ==================== Gutenberg Integration ====================
240
241 /**
242 * Register post meta for Gutenberg REST API access
243 */
244 /** @return void */
245 public function registerPostMeta() {
246 register_post_meta('', '_abj404_create_redirect', array(
247 'show_in_rest' => true,
248 'single' => true,
249 'type' => 'string',
250 'default' => '',
251 'auth_callback' => function() {
252 return current_user_can('edit_posts');
253 },
254 'sanitize_callback' => function($value) {
255 return $value === '1' ? '1' : ($value === '0' ? '0' : '');
256 }
257 ));
258
259 register_post_meta('', '_abj404_exclude', array(
260 'show_in_rest' => true,
261 'single' => true,
262 'type' => 'string',
263 'default' => '',
264 'auth_callback' => function() {
265 return current_user_can('edit_posts');
266 },
267 'sanitize_callback' => array(__CLASS__, 'sanitizeExclusionMeta'),
268 ));
269 }
270
271 /**
272 * Sanitize the exclusion meta value: only '1' is truthy, everything else becomes ''.
273 *
274 * @param mixed $value
275 * @return string
276 */
277 public static function sanitizeExclusionMeta($value) {
278 return $value === '1' ? '1' : '';
279 }
280
281 /**
282 * Enqueue Gutenberg sidebar script
283 */
284 /** @return void */
285 public function enqueueGutenbergScript() {
286 // Only load on post edit screens.
287 // get_current_screen() lives in wp-admin/includes/screen.php; guard adjacent
288 // because this hook may fire from contexts where wp-admin includes aren't loaded.
289 if (!function_exists('get_current_screen')) {
290 return;
291 }
292 $screen = get_current_screen();
293 if (!$screen || $screen->base !== 'post') {
294 return;
295 }
296
297 wp_enqueue_script(
298 'abj404-gutenberg-redirect',
299 plugin_dir_url(__FILE__) . 'js/gutenberg-redirect.js',
300 array('wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n'),
301 ABJ404_VERSION,
302 true
303 );
304
305 // Pass default setting and translations to JavaScript
306 wp_localize_script('abj404-gutenberg-redirect', 'abj404GutenbergRedirect', array(
307 'defaultEnabled' => $this->getDefaultRedirectSetting(),
308 'i18n' => array(
309 'checkboxLabel' => __('Create redirect from old URL to new URL', '404-solution'),
310 'slugChangedNotice' => __('Slug changed:', '404-solution'),
311 'excludeLabel' => __('Exclude from 404 redirect suggestions', '404-solution'),
312 'excludeHelp' => __('When checked, this post will not be suggested as a redirect target for 404 errors.', '404-solution'),
313 )
314 ));
315 }
316
317 // ==================== Post Exclusion Save ====================
318
319 /**
320 * Save _abj404_exclude post meta on post save.
321 *
322 * @param int $post_id
323 * @return void
324 */
325 public function saveExclusionMeta($post_id) {
326 // Prevent duplicate processing within same request (WordPress fires save_post 2-4 times per save).
327 if (isset(self::$processedExclusionPosts[$post_id])) {
328 return;
329 }
330 if (!isset($_POST['abj404_meta_box_nonce'])) {
331 return;
332 }
333 if (!wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box')) {
334 return;
335 }
336 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
337 return;
338 }
339 if (!current_user_can('edit_post', $post_id)) {
340 return;
341 }
342
343 $value = isset($_POST['abj404_exclude']) && $_POST['abj404_exclude'] === '1' ? '1' : '';
344 update_post_meta($post_id, '_abj404_exclude', $value);
345 self::$processedExclusionPosts[$post_id] = true;
346 }
347
348 // ==================== Term Meta Integration ====================
349
350 /**
351 * Register term meta and hook edit/save for all public taxonomies.
352 *
353 * @return void
354 */
355 public function registerTermMeta() {
356 $taxonomies = get_taxonomies(array('public' => true), 'names');
357 foreach ($taxonomies as $taxonomy) {
358 register_term_meta($taxonomy, '_abj404_exclude', array(
359 'show_in_rest' => true,
360 'single' => true,
361 'type' => 'string',
362 'default' => '',
363 'sanitize_callback' => array(__CLASS__, 'sanitizeExclusionMeta'),
364 ));
365
366 add_action("{$taxonomy}_edit_form_fields", array($this, 'renderTermExclusionField'), 10, 2);
367 add_action("edited_{$taxonomy}", array($this, 'saveTermExclusionMeta'));
368 add_action("created_{$taxonomy}", array($this, 'saveTermExclusionMeta'));
369 }
370 }
371
372 /**
373 * Render the exclusion checkbox on the term edit form.
374 *
375 * @param \WP_Term $term
376 * @param string $taxonomy
377 * @return void
378 */
379 public function renderTermExclusionField($term, $taxonomy = '') {
380 $value = get_term_meta($term->term_id, '_abj404_exclude', true);
381 $checked = ($value === '1') ? 'checked' : '';
382 wp_nonce_field('abj404_term_exclude', 'abj404_term_exclude_nonce');
383 ?>
384 <tr class="form-field">
385 <th scope="row">
386 <label for="abj404_exclude"><?php echo esc_html__('404 Solution', '404-solution'); ?></label>
387 </th>
388 <td>
389 <label>
390 <input type="checkbox" name="abj404_exclude" id="abj404_exclude" value="1" <?php echo $checked; ?>>
391 <?php echo esc_html__('Exclude from 404 redirect suggestions', '404-solution'); ?>
392 </label>
393 <p class="description">
394 <?php echo esc_html__('When checked, this term will not be suggested as a redirect target for 404 errors.', '404-solution'); ?>
395 </p>
396 </td>
397 </tr>
398 <?php
399 }
400
401 /**
402 * Save term exclusion meta on term create/edit.
403 *
404 * @param int $term_id
405 * @return void
406 */
407 public function saveTermExclusionMeta($term_id) {
408 if (!isset($_POST['abj404_term_exclude_nonce'])) {
409 return;
410 }
411 if (!wp_verify_nonce($_POST['abj404_term_exclude_nonce'], 'abj404_term_exclude')) {
412 return;
413 }
414
415 $value = isset($_POST['abj404_exclude']) && $_POST['abj404_exclude'] === '1' ? '1' : '';
416 update_term_meta($term_id, '_abj404_exclude', $value);
417 }
418
419 // ==================== Save Handler Helper ====================
420
421 /**
422 * Check if redirect should be created for this post save
423 * Called by SlugChangeHandler
424 *
425 * @param int $post_id Post ID
426 * @param array<string, mixed> $options Plugin options
427 * @return bool Whether to create redirect
428 */
429 public static function shouldCreateRedirect($post_id, $options) {
430 // Check Quick Edit / Classic Editor POST data
431 if (isset($_POST['abj404_create_redirect'])) {
432 // Verify nonce for Quick Edit
433 if (isset($_POST['abj404_quick_edit_nonce']) &&
434 wp_verify_nonce($_POST['abj404_quick_edit_nonce'], 'abj404_quick_edit')) {
435 return $_POST['abj404_create_redirect'] === '1';
436 }
437 // Verify nonce for Classic Editor
438 if (isset($_POST['abj404_meta_box_nonce']) &&
439 wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box')) {
440 return $_POST['abj404_create_redirect'] === '1';
441 }
442 }
443
444 // Check for unchecked checkbox in Quick Edit (checkbox not in POST = unchecked)
445 if (isset($_POST['abj404_quick_edit_nonce']) &&
446 wp_verify_nonce($_POST['abj404_quick_edit_nonce'], 'abj404_quick_edit') &&
447 !isset($_POST['abj404_create_redirect'])) {
448 return false;
449 }
450
451 // Check for unchecked checkbox in Classic Editor (checkbox not in POST = unchecked)
452 if (isset($_POST['abj404_meta_box_nonce']) &&
453 wp_verify_nonce($_POST['abj404_meta_box_nonce'], 'abj404_meta_box') &&
454 !isset($_POST['abj404_create_redirect'])) {
455 return false;
456 }
457
458 // Check Gutenberg post meta
459 $meta = get_post_meta($post_id, '_abj404_create_redirect', true);
460 if ($meta !== '' && $meta !== null) {
461 // Clear the meta after reading (one-time use per edit session)
462 delete_post_meta($post_id, '_abj404_create_redirect');
463 return $meta === '1';
464 }
465
466 // Fall back to global setting
467 return @$options['auto_slugs'] == '1';
468 }
469 }
470