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 / core / PluginLogicPageOrdering.php

PluginLogicPageOrdering.php in 404 Solution 4.3.0, at includes/core/PluginLogicPageOrdering.php

463 lines 16.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 * Page ordering, hierarchy helpers, redirect destination building, and notification helpers.
10 * Standalone class extracted from PluginLogicTrait_PageOrdering.
11 *
12 * @phpstan-type PageObject object{id: int, post_parent: int, depth: int, post_type: string, post_title: string}
13 */
14 class ABJ_404_Solution_PluginLogicPageOrdering {
15
16 /** @var ABJ_404_Solution_Functions */
17 private $f;
18
19 /** @var ABJ_404_Solution_Logging */
20 private $logger;
21
22 /** @var ABJ_404_Solution_ContentRepositoryInterface */
23 private $contentRepo;
24
25 /** @var ABJ_404_Solution_StatsRepositoryInterface */
26 private $statsRepo;
27
28 /** @var ABJ_404_Solution_PluginLogicUrlNormalization */
29 private $urlNormalization;
30
31 /** @var ABJ_404_Solution_NotFoundResponseService */
32 private $notFoundResponse;
33
34 /**
35 * @param ABJ_404_Solution_Functions $f
36 * @param ABJ_404_Solution_Logging $logger
37 * @param ABJ_404_Solution_ContentRepositoryInterface $contentRepo
38 * @param ABJ_404_Solution_StatsRepositoryInterface $statsRepo
39 * @param ABJ_404_Solution_PluginLogicUrlNormalization $urlNormalization
40 * @param ABJ_404_Solution_NotFoundResponseService $notFoundResponse
41 */
42 function __construct($f, $logger, $contentRepo, $statsRepo, $urlNormalization, $notFoundResponse) {
43 $this->f = $f;
44 $this->logger = $logger;
45 $this->contentRepo = $contentRepo;
46 $this->statsRepo = $statsRepo;
47 $this->urlNormalization = $urlNormalization;
48 $this->notFoundResponse = $notFoundResponse;
49 }
50
51 /**
52 * Build the final redirect destination URL.
53 *
54 * @param string $location Base redirect destination.
55 * @param string $requestedURL Original requested URL (used for custom 404 ref tracking).
56 * @param bool $isCustom404 Whether we are redirecting to a custom 404 page.
57 * @return string Redirect destination suitable for wp_redirect().
58 */
59 public function buildFinalRedirectDestination($location, $requestedURL = '', $isCustom404 = false) {
60 $location = $this->urlNormalization->maybeTranslateRedirectUrl($location, $requestedURL);
61
62 $commentPartAndQueryPart = $this->notFoundResponse->getCommentPartAndQueryPartOfRequest();
63 $finalDestination = (string)$location . $commentPartAndQueryPart;
64
65 if ($isCustom404 && is_string($requestedURL) && $requestedURL !== '') {
66 $refUrlResult = preg_replace('/\?.*/', '', $requestedURL);
67 $refUrl = is_string($refUrlResult) ? $refUrlResult : $requestedURL;
68 $refParam = ABJ404_PP . '_ref';
69 if (function_exists('remove_query_arg')) {
70 $finalDestination = remove_query_arg($refParam, $finalDestination);
71 }
72 if (function_exists('add_query_arg')) {
73 $finalDestination = add_query_arg($refParam, rawurlencode($refUrl), $finalDestination);
74 } else {
75 $separator = (strpos($finalDestination, '?') === false) ? '?' : '&';
76 $finalDestination .= $separator . $refParam . '=' . rawurlencode($refUrl);
77 }
78 }
79
80 $finalDestCleaned = preg_replace("/[\\r\\n]+/", '', (string)$finalDestination);
81 $finalDestination = is_string($finalDestCleaned) ? $finalDestCleaned : (string)$finalDestination;
82
83 if (function_exists('wp_sanitize_redirect')) {
84 $finalDestination = wp_sanitize_redirect($finalDestination);
85 } elseif (function_exists('esc_url_raw')) {
86 $finalDestination = esc_url_raw($finalDestination);
87 }
88
89 return (string)$finalDestination;
90 }
91
92 /** Order pages and set the page depth for child pages.
93 * @param array<int, object> $pages
94 * @param bool $includeMissingParentPages
95 * @return array<int, object>
96 */
97 function orderPageResults(array $pages, bool $includeMissingParentPages = false): array {
98
99 usort($pages, function (object $a, object $b): int {
100 return $this->sortByTypeThenTitle($a, $b);
101 });
102 $orderedPages = $this->setDepthAndAddChildren($pages);
103
104 if ($includeMissingParentPages && (count($orderedPages) != count($pages))) {
105 $iterations = 0;
106
107 do {
108 $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages);
109 $pageCountBefore = count($pages);
110 $iterations = $iterations + 1;
111
112 foreach ($idsOfMissingParentPages as $pageID) {
113 $postParent = get_post(is_scalar($pageID) ? (int)$pageID : 0);
114 if ($postParent == null) {
115 continue;
116 }
117 $parentPageSlug = $postParent->post_name;
118 $parentPage = $this->contentRepo->getPublishedPagesAndPostsIDs($parentPageSlug);
119 if (count($parentPage) != 0) {
120 $pages[] = $parentPage[0];
121 }
122 }
123
124 if ($iterations > 30) {
125 break;
126 }
127
128 $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages);
129
130 } while ($pageCountBefore != count($pages));
131
132 usort($pages, function (object $a, object $b): int {
133 return $this->sortByTypeThenTitle($a, $b);
134 });
135 $orderedPages = $this->setDepthAndAddChildren($pages);
136 }
137
138 if (count($orderedPages) != count($pages)) {
139 $unusedPages = array_udiff($pages, $orderedPages, function (object $a, object $b): int {
140 return $this->compareByID($a, $b);
141 });
142 $this->logger->debugMessage("There was an issue finding the parent pages for some child pages. " .
143 "These pages' parents may not have a 'published' status. Pages: " .
144 wp_kses_post(json_encode($unusedPages) ?: ''));
145 }
146
147 return $orderedPages;
148 }
149
150 /** For custom categories we create a Map<String, List> where the key is the name
151 * of the taxonomy and the list holds the rows that have the category info.
152 * @param array<int, object{taxonomy: string, name?: string}> $categoryRows
153 * @return array<string, array<int, object{taxonomy: string, name?: string}>>
154 */
155 function getMapOfCustomCategories(array $categoryRows): array {
156 $customTagsEtc = array();
157
158 foreach ($categoryRows as $cat) {
159 $taxonomy = $cat->taxonomy;
160 if ($taxonomy == 'category') {
161 continue;
162 }
163 if (!array_key_exists($taxonomy, $customTagsEtc) || $customTagsEtc[$taxonomy] == null) {
164 $customTagsEtc[$taxonomy] = array($cat);
165 } else {
166 array_push($customTagsEtc[$taxonomy], $cat);
167 }
168
169 }
170 return $customTagsEtc;
171 }
172
173 /** Returns a list of parent IDs that can't be found in the passed in pages.
174 * @param array<int, object> $pages
175 * @return array<int, mixed>
176 */
177 function getMissingParentPageIDs(array $pages): array {
178 $listOfIDs = array();
179 $missingParentPageIDs = array();
180
181 foreach ($pages as $page) {
182 /** @var PageObject $page */
183 $listOfIDs[] = $page->id;
184 }
185
186 foreach ($pages as $page) {
187 /** @var PageObject $page */
188 if ($page->post_parent == 0) {
189 continue;
190 }
191 if (in_array($page->post_parent, $listOfIDs)) {
192 continue;
193 }
194
195 $missingParentPageIDs[] = $page->post_parent;
196 }
197
198 $missingParentPageIDs = array_merge(
199 array_unique($missingParentPageIDs, SORT_REGULAR), array());
200 return $missingParentPageIDs;
201 }
202
203 /**
204 * @param object $a
205 * @param object $b
206 * @return int
207 */
208 function compareByID(object $a, object $b): int {
209 /** @var PageObject $a */
210 /** @var PageObject $b */
211 if ($a->id < $b->id) {
212 return -1;
213 }
214 if ($b->id < $a->id) {
215 return 1;
216 }
217 return 0;
218 }
219
220 /** Set the depth of each page and add pages under their parents.
221 * @param array<int, object> $pages
222 * @return array<int, object>
223 */
224 function setDepthAndAddChildren(array $pages): array {
225 $childPages = $this->findChildPages($pages);
226 $mainPages = $this->findAllMainPages($pages);
227
228 $oldChildPageCount = -1;
229
230 do {
231 $orderedPages = array();
232 foreach ($mainPages as $page) {
233 /** @var PageObject $page */
234 $orderedPages[] = $page;
235
236 $removeThese = array();
237 foreach ($childPages as $child) {
238 /** @var PageObject $child */
239 if ($child->post_parent == $page->id) {
240 $parentDepth = $page->depth;
241 /** @var \stdClass $childMut */
242 $childMut = $child;
243 $childMut->depth = $parentDepth + 1;
244
245 $removeThese[] = $child;
246 $orderedPages[] = $child;
247 }
248 }
249
250 $childPages = $this->removeUsedChildPages($childPages, $removeThese);
251 }
252
253 $mainPages = $orderedPages;
254
255 if (count($childPages) == $oldChildPageCount) {
256 break;
257 }
258 $oldChildPageCount = count($childPages);
259 } while (count($childPages) > 0);
260
261 return $orderedPages;
262 }
263
264 /**
265 * @param array<int, object> $pages
266 * @return array<int, object>
267 */
268 function findAllMainPages(array $pages): array {
269 $mainPages = array();
270 foreach ($pages as $page) {
271 /** @var PageObject $page */
272 if ($page->post_parent == 0) {
273 $mainPages[] = $page;
274 }
275 }
276
277 return $mainPages;
278 }
279
280 /**
281 * @param array<int, object> $childPages
282 * @param array<int, object> $removeThese
283 * @return array<int, object>
284 */
285 function removeUsedChildPages(array $childPages, array $removeThese): array {
286 foreach ($removeThese as $removeThis) {
287 $key = array_search($removeThis, $childPages);
288 if ($key !== false) {
289 unset($childPages[$key]);
290 }
291 }
292
293 return $childPages;
294 }
295
296 /** Return pages that have a non-0 parent.
297 * @param array<int, object> $pages
298 * @return array<int, object>
299 */
300 function findChildPages(array $pages): array {
301 $childPages = array();
302 foreach ($pages as $page) {
303 /** @var PageObject $page */
304 if ($page->post_parent != 0) {
305 $childPages[] = $page;
306 }
307 }
308 return $childPages;
309 }
310
311 /**
312 * @param object $a
313 * @param object $b
314 * @return int
315 */
316 function sortByTypeThenTitle(object $a, object $b): int {
317 /** @var PageObject $a */
318 /** @var PageObject $b */
319 $result = strcmp($a->post_type, $b->post_type);
320 if ($result != 0) {
321 return $result;
322 }
323
324 return strcmp($a->post_title, $b->post_title);
325 }
326
327 /** Send an email if a notification should be displayed.
328 * @return string
329 */
330 function emailCaptured404Notification() {
331
332 $options = abj_service('options_repository')->getOptions(true);
333
334 $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency'])
335 ? $options['admin_notification_frequency']
336 : 'instant';
337
338 if ($frequency !== 'instant') {
339 $emailDigest = new ABJ_404_Solution_EmailDigest(
340 abj_service('logs_repository'),
341 ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__),
342 $this->logger
343 );
344 return $emailDigest->sendDigest();
345 }
346
347 $captured404Count = $this->statsRepo->getCapturedCountForNotification();
348 if (!$this->shouldNotifyAboutCaptured404s($captured404Count)) {
349 return "Not enough 404s found to send an admin notification email (" . $captured404Count . ").";
350 }
351
352 $captured404URLSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_captured';
353 $generalSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
354 $to = is_string($options['admin_notification_email']) ? $options['admin_notification_email'] : '';
355 $subject = '404 Solution: Captured 404 Notification';
356 $bodyTemplate = ABJ_404_Solution_FileSystemService::readFileContents(
357 dirname(__DIR__) . '/html/emailCapturedNotificationBody.html',
358 false
359 );
360 $body = str_replace(
361 array(
362 '{captured_count}',
363 '{captured_url}',
364 '{settings_url}',
365 '{sent_at}',
366 '{php_version}',
367 '{plugin_version}',
368 ),
369 array(
370 esc_html((string)$captured404Count),
371 esc_url($captured404URLSettings),
372 esc_url($generalSettings),
373 esc_html(date('Y/m/d h:i:s T', abj_clock()->now())),
374 esc_html(PHP_VERSION),
375 esc_html(ABJ404_VERSION),
376 ),
377 $bodyTemplate
378 );
379 $headers = array('Content-Type: text/html; charset=UTF-8');
380 $adminEmail = get_option('admin_email');
381 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
382 $headers[] = 'From: ' . $adminEmailStr . '<' . $adminEmailStr . '>';
383
384 $this->logger->debugMessage("Sending captured 404 notification email to: " . $to);
385 wp_mail($to, $subject, $body, $headers);
386 $this->logger->debugMessage("Captured 404 notification email sent.");
387 return "Captured 404 notification email sent to: " . trim($to);
388 }
389
390 /** Return true if a notification should be displayed.
391 * @param number $captured404Count the number of captured 404s
392 * @return boolean
393 */
394 function shouldNotifyAboutCaptured404s($captured404Count) {
395 $options = abj_service('options_repository')->getOptions(true);
396
397 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
398 if ($captured404Count >= $options['admin_notification']) {
399 return true;
400 }
401 }
402
403 return false;
404 }
405
406 /** 0|0 => "(Default 404 Page)"
407 * @param string $idAndType
408 * @param string $externalLinkURL
409 * @return string
410 */
411 function getPageTitleFromIDAndType($idAndType, $externalLinkURL) {
412
413 if ($idAndType == '') {
414 return '';
415 }
416
417 $meta = explode("|", $idAndType);
418 $id = $meta[0];
419 $type = isset($meta[1]) ? $meta[1] : '';
420
421 $typeInt = is_numeric($type) ? (int)$type : -1;
422
423 if ($idAndType == ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED) {
424 return __('(Default 404 Page)', '404-solution');
425 } else if ($idAndType == ABJ404_TYPE_HOME . '|' . ABJ404_TYPE_HOME) {
426 return __('(Home Page)', '404-solution');
427 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
428 return $externalLinkURL;
429 } else if ($typeInt === ABJ404_TYPE_HOME) {
430 return __('(Home Page)', '404-solution');
431 }
432
433 $idInt = (int)$id;
434 if ($typeInt === ABJ404_TYPE_POST) {
435 return get_the_title($idInt);
436
437 } else if ($typeInt === ABJ404_TYPE_CAT) {
438 $rows = $this->contentRepo->getPublishedCategories($idInt);
439 if (empty($rows)) {
440 $this->logger->debugMessage('No TERM (category) found with ID: ' . $id);
441 return '';
442 }
443 $firstRow = $rows[0];
444 return property_exists($firstRow, 'name') ? (string)$firstRow->name : '';
445
446 } else if ($typeInt === ABJ404_TYPE_TAG) {
447 $tag = get_tag($idInt);
448 if (is_object($tag) && property_exists($tag, 'name')) {
449 return (string)$tag->name;
450 }
451 return '';
452 }
453
454 // Unrecognized/garbage type on a corrupt redirect row is tolerable
455 // bad data: we just can't title it, so we return ''. Per Defensive
456 // Coding Philosophy #8 this degrades at WARNING level rather than
457 // errorMessage() (which fires a production telemetry report).
458 $this->logger->warn("Couldn't get page title. No matching type found for type: " . esc_html($type));
459 return '';
460 }
461
462 }
463