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

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

439 lines 15.8 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_PluginLogic */
32 private $pluginLogic;
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_PluginLogic $pluginLogic
41 */
42 function __construct($f, $logger, $contentRepo, $statsRepo, $urlNormalization, $pluginLogic) {
43 $this->f = $f;
44 $this->logger = $logger;
45 $this->contentRepo = $contentRepo;
46 $this->statsRepo = $statsRepo;
47 $this->urlNormalization = $urlNormalization;
48 $this->pluginLogic = $pluginLogic;
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 = (string)$this->pluginLogic->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 = $this->pluginLogic->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(abj_service('logs_repository'), abj_service('stats_repository'), $this->logger);
340 return $emailDigest->sendDigest();
341 }
342
343 $captured404Count = $this->statsRepo->getCapturedCountForNotification();
344 if (!$this->shouldNotifyAboutCaptured404s($captured404Count)) {
345 return "Not enough 404s found to send an admin notification email (" . $captured404Count . ").";
346 }
347
348 $captured404URLSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_captured';
349 $generalSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options';
350 $to = is_string($options['admin_notification_email']) ? $options['admin_notification_email'] : '';
351 $subject = '404 Solution: Captured 404 Notification';
352 $body = "There are currently " . $captured404Count . " captured 404s to look at. <BR/><BR/>\n\n";
353 $body .= 'Visit <a href="' . $captured404URLSettings . '">' . $captured404URLSettings .
354 '</a> to see them.<BR/><BR/>' . "\n";
355 $body .= 'To stop getting these emails, update the settings at <a href="' . $generalSettings . '">' .
356 $generalSettings . '</a>, or contact the site administrator.' . "<BR/>\n";
357 $body .= "<BR/><BR/>\n\nSent " . date('Y/m/d h:i:s T') . "<BR/>\n" . "PHP version: " . PHP_VERSION .
358 ", <BR/>\nPlugin version: " . ABJ404_VERSION;
359 $headers = array('Content-Type: text/html; charset=UTF-8');
360 $adminEmail = get_option('admin_email');
361 $adminEmailStr = is_string($adminEmail) ? $adminEmail : '';
362 $headers[] = 'From: ' . $adminEmailStr . '<' . $adminEmailStr . '>';
363
364 $this->logger->debugMessage("Sending captured 404 notification email to: " . $to);
365 wp_mail($to, $subject, $body, $headers);
366 $this->logger->debugMessage("Captured 404 notification email sent.");
367 return "Captured 404 notification email sent to: " . trim($to);
368 }
369
370 /** Return true if a notification should be displayed.
371 * @param number $captured404Count the number of captured 404s
372 * @return boolean
373 */
374 function shouldNotifyAboutCaptured404s($captured404Count) {
375 $options = $this->pluginLogic->getOptions(true);
376
377 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
378 if ($captured404Count >= $options['admin_notification']) {
379 return true;
380 }
381 }
382
383 return false;
384 }
385
386 /** 0|0 => "(Default 404 Page)"
387 * @param string $idAndType
388 * @param string $externalLinkURL
389 * @return string
390 */
391 function getPageTitleFromIDAndType($idAndType, $externalLinkURL) {
392
393 if ($idAndType == '') {
394 return '';
395 }
396
397 $meta = explode("|", $idAndType);
398 $id = $meta[0];
399 $type = isset($meta[1]) ? $meta[1] : '';
400
401 $typeInt = is_numeric($type) ? (int)$type : -1;
402
403 if ($idAndType == ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED) {
404 return __('(Default 404 Page)', '404-solution');
405 } else if ($idAndType == ABJ404_TYPE_HOME . '|' . ABJ404_TYPE_HOME) {
406 return __('(Home Page)', '404-solution');
407 } else if ($typeInt === ABJ404_TYPE_EXTERNAL) {
408 return $externalLinkURL;
409 } else if ($typeInt === ABJ404_TYPE_HOME) {
410 return __('(Home Page)', '404-solution');
411 }
412
413 $idInt = (int)$id;
414 if ($typeInt === ABJ404_TYPE_POST) {
415 return get_the_title($idInt);
416
417 } else if ($typeInt === ABJ404_TYPE_CAT) {
418 $rows = $this->contentRepo->getPublishedCategories($idInt);
419 if (empty($rows)) {
420 $this->logger->debugMessage('No TERM (category) found with ID: ' . $id);
421 return '';
422 }
423 $firstRow = $rows[0];
424 return property_exists($firstRow, 'name') ? (string)$firstRow->name : '';
425
426 } else if ($typeInt === ABJ404_TYPE_TAG) {
427 $tag = get_tag($idInt);
428 if (is_object($tag) && property_exists($tag, 'name')) {
429 return (string)$tag->name;
430 }
431 return '';
432 }
433
434 $this->logger->errorMessage("Couldn't get page title. No matching type found for type: " . esc_html($type));
435 return '';
436 }
437
438 }
439