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 / PluginLogicTrait_PageOrdering.php

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

456 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Page ordering, hierarchy helpers, redirect destination building, and notification helpers.
9 * Used by ABJ_404_Solution_PluginLogic via `use`.
10 *
11 * @phpstan-type PageObject object{id: int, post_parent: int, depth: int, post_type: string, post_title: string}
12 */
13 trait ABJ_404_Solution_PluginLogicTrait_PageOrdering {
14
15 /**
16 * Build the final redirect destination URL.
17 *
18 * This is separated for testability and to avoid mixing HTML escaping with redirect URL construction.
19 *
20 * @param string $location Base redirect destination.
21 * @param string $requestedURL Original requested URL (used for custom 404 ref tracking).
22 * @param bool $isCustom404 Whether we are redirecting to a custom 404 page.
23 * @return string Redirect destination suitable for wp_redirect().
24 */
25 public function buildFinalRedirectDestination($location, $requestedURL = '', $isCustom404 = false) {
26 // Translate redirect destination for multilingual sites (TranslatePress, etc.)
27 $location = $this->maybeTranslateRedirectUrl($location, $requestedURL);
28
29 // Preserve comment pagination and query string from the original request.
30 $commentPartAndQueryPart = (string)$this->getCommentPartAndQueryPartOfRequest();
31 $finalDestination = (string)$location . $commentPartAndQueryPart;
32
33 // Append _ref LAST for custom 404 redirects (prevents user override via query string).
34 // This is a fallback for when cookies don't survive 301 redirects.
35 if ($isCustom404 && is_string($requestedURL) && $requestedURL !== '') {
36 $refUrlResult = preg_replace('/\?.*/', '', $requestedURL); // Strip query string from ref
37 $refUrl = is_string($refUrlResult) ? $refUrlResult : $requestedURL;
38 $refParam = ABJ404_PP . '_ref';
39 if (function_exists('remove_query_arg')) {
40 $finalDestination = remove_query_arg($refParam, $finalDestination);
41 }
42 if (function_exists('add_query_arg')) {
43 $finalDestination = add_query_arg($refParam, rawurlencode($refUrl), $finalDestination);
44 } else {
45 $separator = (strpos($finalDestination, '?') === false) ? '?' : '&';
46 $finalDestination .= $separator . $refParam . '=' . rawurlencode($refUrl);
47 }
48 }
49
50 // Sanitize for redirect header context (NOT HTML context).
51 // Harden against CRLF header injection even when WP helpers are not available.
52 $finalDestCleaned = preg_replace("/[\\r\\n]+/", '', (string)$finalDestination);
53 $finalDestination = is_string($finalDestCleaned) ? $finalDestCleaned : (string)$finalDestination;
54
55 if (function_exists('wp_sanitize_redirect')) {
56 $finalDestination = wp_sanitize_redirect($finalDestination);
57 } elseif (function_exists('esc_url_raw')) {
58 $finalDestination = esc_url_raw($finalDestination);
59 }
60
61 return (string)$finalDestination;
62 }
63
64 /** Order pages and set the page depth for child pages.
65 * Move the children to be underneath the parents.
66 * @param array<int, object> $pages
67 * @param bool $includeMissingParentPages
68 * @return array<int, object>
69 */
70 function orderPageResults(array $pages, bool $includeMissingParentPages = false): array {
71
72 // sort by type then title.
73 usort($pages, function (object $a, object $b): int {
74 return $this->sortByTypeThenTitle($a, $b);
75 });
76 // run this to see if there are any child pages left.
77 $orderedPages = $this->setDepthAndAddChildren($pages);
78
79 // The pages are now sorted. We now apply the depth AND we make sure the child pages
80 // always immediately follow the parent pages.
81
82 // -------------
83 if ($includeMissingParentPages && (count($orderedPages) != count($pages))) {
84 $iterations = 0;
85
86 do {
87 $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages);
88 $pageCountBefore = count($pages);
89 $iterations = $iterations + 1;
90
91 // get the parents of the unused pages.
92 foreach ($idsOfMissingParentPages as $pageID) {
93 $postParent = get_post(is_scalar($pageID) ? (int)$pageID : 0);
94 if ($postParent == null) {
95 continue;
96 }
97 $parentPageSlug = $postParent->post_name;
98 $parentPage = $this->dao->getPublishedPagesAndPostsIDs($parentPageSlug);
99 if (count($parentPage) != 0) {
100 $pages[] = $parentPage[0];
101 }
102 }
103
104 if ($iterations > 30) {
105 break;
106 }
107
108 $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages);
109
110 // loop until we can't find any more parents. This may happen if a sub-page is published
111 // and the parent page is not published.
112 } while ($pageCountBefore != count($pages));
113
114 // sort everything again
115 usort($pages, function (object $a, object $b): int {
116 return $this->sortByTypeThenTitle($a, $b);
117 });
118 $orderedPages = $this->setDepthAndAddChildren($pages);
119 }
120
121 // if there are child pages left over then there's an issue. it means there's a child page that was
122 // returned but the parent for that child was not returned. so we don't have any place to display
123 // the child page. this could be because the parent page is not "published"
124 if (count($orderedPages) != count($pages)) {
125 $unusedPages = array_udiff($pages, $orderedPages, function (object $a, object $b): int {
126 return $this->compareByID($a, $b);
127 });
128 $this->logger->debugMessage("There was an issue finding the parent pages for some child pages. " .
129 "These pages' parents may not have a 'published' status. Pages: " .
130 wp_kses_post(json_encode($unusedPages) ?: ''));
131 }
132
133 return $orderedPages;
134 }
135
136 /** For custom categories we create a Map<String, List> where the key is the name
137 * of the taxonomy and the list holds the rows that have the category info.
138 * @param array<int, object{taxonomy: string, name?: string}> $categoryRows
139 * @return array<string, array<int, object{taxonomy: string, name?: string}>>
140 */
141 function getMapOfCustomCategories(array $categoryRows): array {
142 $customTagsEtc = array();
143
144 foreach ($categoryRows as $cat) {
145 $taxonomy = $cat->taxonomy;
146 if ($taxonomy == 'category') {
147 continue;
148 }
149 // for custom categories we create a Map<String, List> where the key is the name
150 // of the taxonomy and the list holds the rows that have the category info.
151 if (!array_key_exists($taxonomy, $customTagsEtc) || $customTagsEtc[$taxonomy] == null) {
152 $customTagsEtc[$taxonomy] = array($cat);
153 } else {
154 array_push($customTagsEtc[$taxonomy], $cat);
155 }
156
157 }
158 return $customTagsEtc;
159 }
160
161 /** Returns a list of parent IDs that can't be found in the passed in pages.
162 * @param array<int, object> $pages
163 * @return array<int, mixed>
164 */
165 function getMissingParentPageIDs(array $pages): array {
166 $listOfIDs = array();
167 $missingParentPageIDs = array();
168
169 foreach ($pages as $page) {
170 /** @var PageObject $page */
171 $listOfIDs[] = $page->id;
172 }
173
174 foreach ($pages as $page) {
175 /** @var PageObject $page */
176 if ($page->post_parent == 0) {
177 continue;
178 }
179 if (in_array($page->post_parent, $listOfIDs)) {
180 continue;
181 }
182
183 $missingParentPageIDs[] = $page->post_parent;
184 }
185
186 $missingParentPageIDs = array_merge(
187 array_unique($missingParentPageIDs, SORT_REGULAR), array());
188 return $missingParentPageIDs;
189 }
190
191 /**
192 * Compare pages based on their ID.
193 * @param object $a
194 * @param object $b
195 * @return int
196 */
197 function compareByID(object $a, object $b): int {
198 /** @var PageObject $a */
199 /** @var PageObject $b */
200 if ($a->id < $b->id) {
201 return -1;
202 }
203 if ($b->id < $a->id) {
204 return 1;
205 }
206 return 0;
207 }
208
209 /** Set the depth of each page and add pages under their parents by rebuilding the list
210 * every time we iterate through it and adding the child pages at the right moment every time
211 * the list is built.
212 * @param array<int, object> $pages
213 * @return array<int, object>
214 */
215 function setDepthAndAddChildren(array $pages): array {
216 // find all child pages (pages that have parents).
217 $childPages = $this->findChildPages($pages);
218
219 // find all pages with no parents.
220 $mainPages = $this->findAllMainPages($pages);
221
222 $oldChildPageCount = -1;
223
224 // this do{} loop is here because some child pages have children.
225 do {
226 // add every page to a new list, while looking for parents.
227 $orderedPages = array();
228 foreach ($mainPages as $page) {
229 /** @var PageObject $page */
230 // always add the main page.
231 $orderedPages[] = $page;
232
233 // if this page is the parent of any children then add the children.
234 $removeThese = array();
235 foreach ($childPages as $child) {
236 /** @var PageObject $child */
237 if ($child->post_parent == $page->id) {
238 // set the page depth based on the parent's page depth.
239 $parentDepth = $page->depth;
240 /** @var \stdClass $childMut */
241 $childMut = $child;
242 $childMut->depth = $parentDepth + 1;
243
244 $removeThese[] = $child;
245 $orderedPages[] = $child;
246 }
247 }
248
249 // remove any child pages that have been placed already
250 $childPages = $this->removeUsedChildPages($childPages, $removeThese);
251 }
252
253 // the new list becomes the list that we will iterate over next time.
254 // this prepares us for the next iteration and for child pages with a depth greater than 1.
255 // (for child pages that have children).
256 $mainPages = $orderedPages;
257
258 // if the count has not changed then there's no point in looping again.
259 if (count($childPages) == $oldChildPageCount) {
260 break;
261 }
262 $oldChildPageCount = count($childPages);
263 // stop the loop once there are no more children to add.
264 } while (count($childPages) > 0);
265
266 return $orderedPages;
267 }
268
269 /**
270 * @param array<int, object> $pages
271 * @return array<int, object>
272 */
273 function findAllMainPages(array $pages): array {
274 $mainPages = array();
275 foreach ($pages as $page) {
276 /** @var PageObject $page */
277 // if there's no parent then just add the page.
278 if ($page->post_parent == 0) {
279 $mainPages[] = $page;
280 }
281 }
282
283 return $mainPages;
284 }
285
286 /**
287 * @param array<int, object> $childPages
288 * @param array<int, object> $removeThese
289 * @return array<int, object>
290 */
291 function removeUsedChildPages(array $childPages, array $removeThese): array {
292 // if any children were added then remove them from the list.
293 foreach ($removeThese as $removeThis) {
294 $key = array_search($removeThis, $childPages);
295 if ($key !== false) {
296 unset($childPages[$key]);
297 }
298 }
299
300 return $childPages;
301 }
302
303 /** Return pages that have a non-0 parent.
304 * @param array<int, object> $pages
305 * @return array<int, object>
306 */
307 function findChildPages(array $pages): array {
308 $childPages = array();
309 foreach ($pages as $page) {
310 /** @var PageObject $page */
311 if ($page->post_parent != 0) {
312 $childPages[] = $page;
313 }
314 }
315 return $childPages;
316 }
317
318 /**
319 * @param object $a
320 * @param object $b
321 * @return int
322 */
323 function sortByTypeThenTitle(object $a, object $b): int {
324 /** @var PageObject $a */
325 /** @var PageObject $b */
326 // first sort by type
327 $result = strcmp($a->post_type, $b->post_type);
328 if ($result != 0) {
329 return $result;
330 }
331
332 // then by title.
333 return strcmp($a->post_title, $b->post_title);
334 }
335
336 /** Send an email if a notification should be displayed. Return true if an email is sent, or false otherwise.
337 * @return string
338 */
339 function emailCaptured404Notification() {
340
341 $options = $this->getOptions(true);
342
343 $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency'])
344 ? $options['admin_notification_frequency']
345 : 'instant';
346
347 // For non-instant frequencies, the digest handles sending — skip the count-only email.
348 if ($frequency !== 'instant') {
349 $emailDigest = new ABJ_404_Solution_EmailDigest($this->dao, $this->logger);
350 return $emailDigest->sendDigest();
351 }
352
353 $captured404Count = $this->dao->getCapturedCountForNotification();
354 if (!$this->shouldNotifyAboutCaptured404s($captured404Count)) {
355 return "Not enough 404s found to send an admin notification email (" . $captured404Count . ").";
356 }
357
358 $captured404URLSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_captured';
359 $generalSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
360 $to = is_string($options['admin_notification_email']) ? $options['admin_notification_email'] : '';
361 $subject = '404 Solution: Captured 404 Notification';
362 $body = "There are currently " . $captured404Count . " captured 404s to look at. <BR/><BR/>\n\n";
363 $body .= 'Visit <a href="' . $captured404URLSettings . '">' . $captured404URLSettings .
364 '</a> to see them.<BR/><BR/>' . "\n";
365 $body .= 'To stop getting these emails, update the settings at <a href="' . $generalSettings . '">' .
366 $generalSettings . '</a>, or contact the site administrator.' . "<BR/>\n";
367 $body .= "<BR/><BR/>\n\nSent " . date('Y/m/d h:i:s T') . "<BR/>\n" . "PHP version: " . PHP_VERSION .
368 ", <BR/>\nPlugin version: " . ABJ404_VERSION;
369 $headers = array('Content-Type: text/html; charset=UTF-8');
370 $adminEmail = get_option('admin_email');
371 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
372 $headers[] = 'From: ' . $adminEmailStr . '<' . $adminEmailStr . '>';
373
374 // send the email
375 $this->logger->debugMessage("Sending captured 404 notification email to: " . $to);
376 wp_mail($to, $subject, $body, $headers);
377 $this->logger->debugMessage("Captured 404 notification email sent.");
378 return "Captured 404 notification email sent to: " . trim($to);
379 }
380
381 /** Return true if a notification should be displayed, or false otherwise.
382 * @global type $abj404dao
383 * @param number $captured404Count the number of captured 404s
384 * @return boolean
385 */
386 function shouldNotifyAboutCaptured404s($captured404Count) {
387 $options = $this->getOptions(true);
388
389 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
390 if ($captured404Count >= $options['admin_notification']) {
391 return true;
392 }
393 }
394
395 return false;
396 }
397
398 /** 0|0 => "(Default 404 Page)"
399 * 5|5 => "(Home Page)"
400 * 10|1 => "About"
401 * @param string $idAndType
402 * @param string $externalLinkURL
403 * @return string
404 */
405 function getPageTitleFromIDAndType($idAndType, $externalLinkURL) {
406
407 if ($idAndType == '') {
408 return '';
409 }
410
411 $meta = explode("|", $idAndType);
412 $id = $meta[0];
413 // Handle malformed data that doesn't contain a pipe separator
414 $type = isset($meta[1]) ? $meta[1] : '';
415
416 // Use strict comparison to avoid null/false == 0 issues with type coercion
417 // Cast to int for comparison since ABJ404_TYPE_* constants are integers
418 $typeInt = is_numeric($type) ? (int)$type : -1;
419
420 if ($idAndType == ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED) {
421 return __('(Default 404 Page)', '404-solution');
422 } else if ($idAndType == ABJ404_TYPE_HOME . '|' . ABJ404_TYPE_HOME) {
423 return __('(Home Page)', '404-solution');
424 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
425 return $externalLinkURL;
426 } else if ($typeInt === ABJ404_TYPE_HOME) {
427 return __('(Home Page)', '404-solution');
428 }
429
430 $idInt = (int)$id;
431 if ($typeInt === ABJ404_TYPE_POST) {
432 return get_the_title($idInt);
433
434 } else if ($typeInt === ABJ404_TYPE_CAT) {
435 $rows = $this->dao->getPublishedCategories($idInt);
436 if (empty($rows)) {
437 $this->logger->debugMessage('No TERM (category) found with ID: ' . $id);
438 return '';
439 }
440 $firstRow = $rows[0];
441 return property_exists($firstRow, 'name') ? (string)$firstRow->name : '';
442
443 } else if ($typeInt === ABJ404_TYPE_TAG) {
444 $tag = get_tag($idInt);
445 if (is_object($tag) && property_exists($tag, 'name')) {
446 return (string)$tag->name;
447 }
448 return '';
449 }
450
451 $this->logger->errorMessage("Couldn't get page title. No matching type found for type: " . esc_html($type));
452 return '';
453 }
454
455 }
456