PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / frontend / PostEditorIntegration.php

PostEditorIntegration.php in 404 Solution trunk, at includes/frontend/PostEditorIntegration.php

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