PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / SlugChangeHandler.php

SlugChangeHandler.php in 404 Solution 4.1.19, at includes/SlugChangeHandler.php

274 lines 10.1 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 class ABJ_404_Solution_SlugChangeHandler {
9
10 /** @var self|null */
11 private static $instance = null;
12
13 /**
14 * Track post IDs already processed within the current request.
15 * WordPress fires save_post multiple times per save; this prevents duplicate redirects.
16 * @var array<int, bool>
17 */
18 private static $processedPosts = [];
19
20 /**
21 * Get singleton instance
22 * @return ABJ_404_Solution_SlugChangeHandler
23 */
24 public static function getInstance() {
25 if (self::$instance == null) {
26 self::$instance = new ABJ_404_Solution_SlugChangeHandler();
27 }
28 return self::$instance;
29 }
30
31 /**
32 * Initialize the handler and register WordPress hooks
33 * @return void
34 */
35 static function init() {
36 $me = abj_service('slug_change_handler');
37 add_action('save_post', array($me, 'save_postHandler'), 10, 3);
38 add_action('transition_post_status', array($me, 'postStatusTransitionHandler'), 10, 3);
39 add_action('before_delete_post', array($me, 'beforeDeletePostHandler'), 10, 2);
40 }
41
42 /** We'll just make sure the permalink gets updated in case it's changed.
43 * @param int $post_id The post ID.
44 * @param \WP_Post $post The post object.
45 * @param bool $update Whether this is an existing post being updated or not.
46 * @return void
47 */
48 function save_postHandler($post_id, $post, $update) {
49 $abj404logging = abj_service('logging');
50
51 // Prevent duplicate processing within same request
52 // WordPress fires save_post multiple times per save operation
53 if (isset(self::$processedPosts[$post_id])) {
54 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
55 ": Already processed post ID " . $post_id . " in this request (skipped).");
56 return;
57 }
58
59 // Defensive: WordPress hook may pass unexpected types at runtime.
60 if (!is_object($post) || !property_exists($post, 'post_name')) {
61 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
62 ": Invalid post object or missing post_name property for post ID " . $post_id . ".");
63 return;
64 }
65
66 if (!$update) {
67 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
68 ": Non-update skipped for post ID " . $post_id . ".");
69 return;
70 }
71
72 // Check if we should create a redirect (respects per-post override from editor)
73 $abj404logic = abj_service('plugin_logic');
74 $options = $abj404logic->getOptions();
75
76 // Check for per-post override from Quick Edit, Classic Editor, or Gutenberg
77 if (class_exists('ABJ_404_Solution_PostEditorIntegration')) {
78 $shouldCreate = ABJ_404_Solution_PostEditorIntegration::shouldCreateRedirect($post_id, $options);
79 } else {
80 // Fallback to global setting if PostEditorIntegration not loaded
81 $shouldCreate = @$options['auto_slugs'] == '1';
82 }
83
84 if (!$shouldCreate) {
85 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ . ": Auto slug redirects off " .
86 "or disabled for this post (skipped) (post ID " . $post_id . ").");
87 return;
88 }
89
90 // Use post_status from $post object instead of database query
91 /** @var string|false $postStatus */
92 $postStatus = property_exists($post, 'post_status') ? $post->post_status : get_post_status($post_id);
93 if (!in_array($postStatus, array('publish', 'published'))) {
94 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ . ": Post status: " .
95 $postStatus . " (skipped) (post ID " . $post_id . ").");
96 return;
97 }
98
99 // get the old slug
100 $abj404dao = abj_service('data_access');
101
102 $oldURL = $abj404dao->getPermalinkFromCache($post_id);
103
104 if ($oldURL == null || $oldURL == "") {
105 $abj404logging->debugMessage("Couldn't find old slug for updated page. ID " .
106 $post_id . ", old URL: " . $oldURL . ", post name: " . $post->post_name .
107 ", update: " . $update);
108 return;
109 }
110
111 $newURL = get_permalink($post);
112
113 // Defensive: get_permalink may return WP_Error via filters in some environments.
114 if (is_wp_error($newURL)) {
115 $abj404logging->debugMessage("Could not get permalink for post (WP_Error). ID: " .
116 $post_id . ", error: " . $newURL->get_error_message());
117 return;
118 }
119
120 if ($newURL === false || $newURL === '') {
121 $abj404logging->debugMessage("Could not get permalink for post (invalid return). ID: " .
122 $post_id);
123 return;
124 }
125
126 // Safely parse the old URL
127 $oldURLParsed = parse_url($oldURL);
128 if ($oldURLParsed === false) {
129 $abj404logging->debugMessage("Could not parse old URL (malformed). ID: " .
130 $post_id . ", URL: " . $oldURL);
131 return;
132 }
133
134 if (!isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
135 $abj404logging->debugMessage("Old URL has no path component. ID: " .
136 $post_id . ", URL: " . $oldURL);
137 return;
138 }
139
140 $oldSlug = $oldURLParsed['path'];
141
142 if ($oldURL == $newURL) {
143 $abj404logging->debugMessage("Save post listener: Old and new URL are the same. (Ignored) " .
144 "ID: " . $post_id . ", old URL: " . $oldURL . ", old slug: " . $oldSlug .
145 ", new slug: " . $post->post_name . ", update: " . $update);
146
147 return;
148 }
149
150 // Mark as processed before creating redirect to prevent duplicates
151 self::$processedPosts[$post_id] = true;
152
153 // create a redirect from the old to the new.
154 $abj404dao->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_POST,
155 (string)$post_id, (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301', 0, 'slug change');
156 $abj404logging->infoMessage("Added automatic redirect after slug change from " .
157 $oldURL . ' to ' . $newURL . " for post ID " . $post_id);
158 }
159
160 /**
161 * Fires when a published post is moved to trash.
162 * Creates a redirect from the old permalink to the homepage.
163 *
164 * @param string $new_status New post status.
165 * @param string $old_status Old post status.
166 * @param \WP_Post $post Post object.
167 * @return void
168 */
169 function postStatusTransitionHandler($new_status, $old_status, $post) {
170 // Only care about published posts being trashed.
171 if ($old_status !== 'publish' || $new_status !== 'trash') {
172 return;
173 }
174
175 if (!is_object($post) || !property_exists($post, 'ID')) {
176 return;
177 }
178
179 $post_id = (int)$post->ID;
180
181 // Check option
182 $abj404logic = abj_service('plugin_logic');
183 $options = $abj404logic->getOptions();
184 if (!isset($options['auto_trash_redirect']) || $options['auto_trash_redirect'] != '1') {
185 return;
186 }
187
188 // Prevent duplicate processing within same request
189 if (isset(self::$processedPosts[$post_id])) {
190 return;
191 }
192
193 $abj404dao = abj_service('data_access');
194 $oldURL = $abj404dao->getPermalinkFromCache($post_id);
195
196 if ($oldURL == null || $oldURL == '') {
197 return;
198 }
199
200 $oldURLParsed = parse_url($oldURL);
201 if ($oldURLParsed === false || !isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
202 return;
203 }
204
205 $oldSlug = $oldURLParsed['path'];
206 $redirectCode = (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301';
207
208 self::$processedPosts[$post_id] = true;
209
210 $abj404dao->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_HOME,
211 '0', $redirectCode, 0, 'post trashed');
212
213 abj_service('logging')->infoMessage(
214 "Added automatic redirect to homepage after post trashed. ID: " . $post_id . ", old URL: " . $oldURL);
215 }
216
217 /**
218 * Fires just before a published post is permanently deleted.
219 * Creates a redirect from the old permalink to the homepage.
220 *
221 * @param int $post_id Post ID.
222 * @param \WP_Post $post Post object.
223 * @return void
224 */
225 function beforeDeletePostHandler($post_id, $post) {
226 if (!is_object($post) || !property_exists($post, 'post_status')) {
227 return;
228 }
229
230 // Only published posts (not already-trashed posts being force-deleted).
231 $postStatus = (string)$post->post_status;
232 if (!in_array($postStatus, array('publish', 'published'), true)) {
233 return;
234 }
235
236 // Check option
237 $abj404logic = abj_service('plugin_logic');
238 $options = $abj404logic->getOptions();
239 if (!isset($options['auto_trash_redirect']) || $options['auto_trash_redirect'] != '1') {
240 return;
241 }
242
243 $post_id = (int)$post_id;
244
245 // Prevent duplicate processing within same request
246 if (isset(self::$processedPosts[$post_id])) {
247 return;
248 }
249
250 $abj404dao = abj_service('data_access');
251 $oldURL = $abj404dao->getPermalinkFromCache($post_id);
252
253 if ($oldURL == null || $oldURL == '') {
254 return;
255 }
256
257 $oldURLParsed = parse_url($oldURL);
258 if ($oldURLParsed === false || !isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
259 return;
260 }
261
262 $oldSlug = $oldURLParsed['path'];
263 $redirectCode = (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301';
264
265 self::$processedPosts[$post_id] = true;
266
267 $abj404dao->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_HOME,
268 '0', $redirectCode, 0, 'post deleted');
269
270 abj_service('logging')->infoMessage(
271 "Added automatic redirect to homepage after post deleted. ID: " . $post_id . ", old URL: " . $oldURL);
272 }
273 }
274