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

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

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