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

SlugChangeHandler.php in 404 Solution 4.3.0, at includes/frontend/SlugChangeHandler.php

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