PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/core/PluginLogicPageOrdering.php

467 lines 16.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 /**
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(array('slug' => $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 === 'never') {
339 return 'Email notifications disabled: frequency is never.';
340 }
341
342 if ($frequency !== 'instant') {
343 $emailDigest = new ABJ_404_Solution_EmailDigest(
344 abj_service('logs_repository'),
345 ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__),
346 $this->logger
347 );
348 return $emailDigest->sendDigest();
349 }
350
351 $captured404Count = $this->statsRepo->getCapturedCountForNotification();
352 if (!$this->shouldNotifyAboutCaptured404s($captured404Count)) {
353 return "Not enough 404s found to send an admin notification email (" . $captured404Count . ").";
354 }
355
356 $captured404URLSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_captured';
357 $generalSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
358 $to = is_string($options['admin_notification_email']) ? $options['admin_notification_email'] : '';
359 $subject = '404 Solution: Captured 404 Notification';
360 $bodyTemplate = ABJ_404_Solution_FileSystemService::readFileContents(
361 dirname(__DIR__) . '/html/emailCapturedNotificationBody.html',
362 false
363 );
364 $body = str_replace(
365 array(
366 '{captured_count}',
367 '{captured_url}',
368 '{settings_url}',
369 '{sent_at}',
370 '{php_version}',
371 '{plugin_version}',
372 ),
373 array(
374 esc_html((string)$captured404Count),
375 esc_url($captured404URLSettings),
376 esc_url($generalSettings),
377 esc_html(date('Y/m/d h:i:s T', abj_clock()->now())),
378 esc_html(PHP_VERSION),
379 esc_html(ABJ404_VERSION),
380 ),
381 $bodyTemplate
382 );
383 $headers = array('Content-Type: text/html; charset=UTF-8');
384 $adminEmail = get_option('admin_email');
385 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
386 $headers[] = 'From: ' . $adminEmailStr . '<' . $adminEmailStr . '>';
387
388 $this->logger->debugMessage("Sending captured 404 notification email to: " . $to);
389 wp_mail($to, $subject, $body, $headers);
390 $this->logger->debugMessage("Captured 404 notification email sent.");
391 return "Captured 404 notification email sent to: " . trim($to);
392 }
393
394 /** Return true if a notification should be displayed.
395 * @param number $captured404Count the number of captured 404s
396 * @return boolean
397 */
398 function shouldNotifyAboutCaptured404s($captured404Count) {
399 $options = abj_service('options_repository')->getOptions(true);
400
401 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
402 if ($captured404Count >= $options['admin_notification']) {
403 return true;
404 }
405 }
406
407 return false;
408 }
409
410 /** 0|0 => "(Default 404 Page)"
411 * @param string $idAndType
412 * @param string $externalLinkURL
413 * @return string
414 */
415 function getPageTitleFromIDAndType($idAndType, $externalLinkURL) {
416
417 if ($idAndType == '') {
418 return '';
419 }
420
421 $meta = explode("|", $idAndType);
422 $id = $meta[0];
423 $type = isset($meta[1]) ? $meta[1] : '';
424
425 $typeInt = is_numeric($type) ? (int)$type : -1;
426
427 if ($idAndType == ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED) {
428 return __('(Default 404 Page)', '404-solution');
429 } else if ($idAndType == ABJ404_TYPE_HOME . '|' . ABJ404_TYPE_HOME) {
430 return __('(Home Page)', '404-solution');
431 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
432 return $externalLinkURL;
433 } else if ($typeInt === ABJ404_TYPE_HOME) {
434 return __('(Home Page)', '404-solution');
435 }
436
437 $idInt = (int)$id;
438 if ($typeInt === ABJ404_TYPE_POST) {
439 return get_the_title($idInt);
440
441 } else if ($typeInt === ABJ404_TYPE_CAT) {
442 $rows = $this->contentRepo->getPublishedCategories($idInt);
443 if (empty($rows)) {
444 $this->logger->debugMessage('No TERM (category) found with ID: ' . $id);
445 return '';
446 }
447 $firstRow = $rows[0];
448 return property_exists($firstRow, 'name') ? (string)$firstRow->name : '';
449
450 } else if ($typeInt === ABJ404_TYPE_TAG) {
451 $tag = get_tag($idInt);
452 if (is_object($tag) && property_exists($tag, 'name')) {
453 return (string)$tag->name;
454 }
455 return '';
456 }
457
458 // Unrecognized/garbage type on a corrupt redirect row is tolerable
459 // bad data: we just can't title it, so we return ''. Per Defensive
460 // Coding Philosophy #8 this degrades at WARNING level rather than
461 // errorMessage() (which fires a production telemetry report).
462 $this->logger->warn("Couldn't get page title. No matching type found for type: " . esc_html($type));
463 return '';
464 }
465
466 }
467