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

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

767 lines 33.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 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_ShortCode {
11
12 /** @var self|null */
13 private static $instance = null;
14
15 /** @return self */
16 public static function getInstance(): self {
17 if (self::$instance == null) {
18 self::$instance = new ABJ_404_Solution_ShortCode();
19 }
20
21 return self::$instance;
22 }
23
24 /**
25 * Resolve frontend locale with visitor-locale-first behavior.
26 *
27 * @return string
28 */
29 private static function resolveFrontendSuggestionLocale(): string {
30 // Polylang: explicit locale for current request (visitor context).
31 if (function_exists('pll_current_language')) {
32 $pllLocale = pll_current_language('locale');
33 if (is_string($pllLocale) && $pllLocale !== '') {
34 return $pllLocale;
35 }
36 }
37
38 // WPML: map current language code to locale if available.
39 if (function_exists('apply_filters') && has_filter('wpml_current_language')) {
40 $langCode = apply_filters('wpml_current_language', null);
41 if (is_string($langCode) && $langCode !== '' && has_filter('wpml_active_languages')) {
42 $active = apply_filters('wpml_active_languages', null, 'skip_missing=0');
43 if (is_array($active) && isset($active[$langCode]) && is_array($active[$langCode])) {
44 $entry = $active[$langCode];
45 if (!empty($entry['default_locale']) && is_string($entry['default_locale'])) {
46 return $entry['default_locale'];
47 }
48 if (!empty($entry['locale']) && is_string($entry['locale'])) {
49 return $entry['locale'];
50 }
51 }
52 }
53 }
54
55 if (function_exists('determine_locale')) {
56 $locale = determine_locale();
57 if (is_string($locale) && $locale !== '') {
58 return $locale;
59 }
60 }
61
62 if (function_exists('get_locale')) {
63 $locale = get_locale();
64 if (is_string($locale) && $locale !== '') {
65 return $locale;
66 }
67 }
68
69 return '';
70 }
71
72 /**
73 * @return bool True when locale was switched.
74 */
75 private static function maybeSwitchToFrontendLocale(): bool {
76 if (!function_exists('switch_to_locale') || !function_exists('restore_previous_locale')) {
77 return false;
78 }
79
80 $targetLocale = self::resolveFrontendSuggestionLocale();
81 if ($targetLocale === '') {
82 return false;
83 }
84
85 return switch_to_locale($targetLocale);
86 }
87
88 /**
89 * @param bool $didSwitch
90 * @return void
91 */
92 private static function maybeRestoreFrontendLocale(bool $didSwitch): void {
93 if ($didSwitch && function_exists('restore_previous_locale')) {
94 restore_previous_locale();
95 }
96 }
97
98 /**
99 * Replace both placeholder and legacy bare token forms.
100 *
101 * @param string $template
102 * @param string $tokenNameWithoutBraces
103 * @param string $replacement
104 * @return string
105 */
106 private static function replaceSuggestionTemplateToken(string $template, string $tokenNameWithoutBraces, string $replacement): string {
107 return str_replace(
108 array('{' . $tokenNameWithoutBraces . '}', $tokenNameWithoutBraces),
109 $replacement,
110 $template
111 );
112 }
113
114 /** If we're currently redirecting to a custom 404 page and we are about to show page
115 * suggestions then update the URL displayed to the user.
116 * @return void
117 */
118 static function updateURLbarIfNecessary(): void {
119 $abj404logic = abj_service('plugin_logic');
120 $f = abj_service('functions');
121 $abj404logging = abj_service('logging');
122 $debugMessage = '';
123 $options = $abj404logic->getOptions();
124
125 $shouldUpdateURL = true;
126 // if we're not supposed to update the URL then don't.
127 if (!array_key_exists('update_suggest_url', $options) ||
128 !isset($options['update_suggest_url']) ||
129 $options['update_suggest_url'] != 1) {
130 $shouldUpdateURL = false;
131 $debugMessage .= "do not update (update_suggest_url is off), ";
132 }
133
134 // if the cookie we need isn't set then give up.
135 $updateURLCookieName = ABJ404_PP . '_REQUEST_URI';
136 $updateURLCookieName .= '_UPDATE_URL';
137 $legacyRequestKey = ABJ404_PP . '_REQUEST_URI';
138 $requestedURLForRestore = '';
139 if (isset($_REQUEST[$updateURLCookieName]) && is_string($_REQUEST[$updateURLCookieName]) &&
140 $_REQUEST[$updateURLCookieName] !== '') {
141 $requestedURLForRestore = $_REQUEST[$updateURLCookieName];
142 } else if (isset($_REQUEST[$legacyRequestKey]) && is_string($_REQUEST[$legacyRequestKey]) &&
143 $_REQUEST[$legacyRequestKey] !== '') {
144 // Backward compatibility: older code paths used REQUEST_URI key directly.
145 $requestedURLForRestore = $_REQUEST[$legacyRequestKey];
146 }
147 if ($requestedURLForRestore === '') {
148 $shouldUpdateURL = false;
149 $debugMessage .= "do not update (no cookie found), ";
150 }
151
152 $dest404pageRaw = (isset($options['dest404page']) ?
153 $options['dest404page'] :
154 ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED);
155 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : (ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED);
156
157 // Check if this is a manual redirect (has query param) - these bypass global 404 page check
158 $queryParamName = ABJ404_PP . '_ref';
159 $isManualRedirect = isset($_GET[$queryParamName]) && !empty($_GET[$queryParamName]);
160
161 // if we're not currently loading the custom 404 page then don't change the URL.
162 // Exception: manual redirects to custom 404 pages should always allow URL restoration
163 if ($isManualRedirect) {
164 // Manual redirect - we know we're on a custom 404 page, allow URL restoration
165 $debugMessage .= "ok to update (manual redirect to custom 404 page), ";
166 } else if ($abj404logic->thereIsAUserSpecified404Page($dest404page)) {
167
168 // get the user specified 404 page.
169 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($dest404page, 0,
170 null, $options);
171
172 // if the last part of the URL does not match the custom 404 page then
173 // don't update the URL.
174 // Strip query string from REQUEST_URI for comparison (query params like abj404_solution_ref)
175 $requestUriRaw = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
176 $requestUriPath = parse_url($requestUriRaw, PHP_URL_PATH);
177 $permLinkStr = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
178 $requestUriPathStr = is_string($requestUriPath) ? $requestUriPath : '';
179 if (!$f->endsWithCaseSensitive($permLinkStr, $requestUriPathStr) &&
180 $permalink['status'] != 'trash') {
181
182 $shouldUpdateURL = false;
183 $debugMessage .= "do not update (not on custom 404 page (" .
184 $permLinkStr . ")), ";
185
186 } else {
187 $debugMessage .= "ok to update (displaying custom 404 page (" .
188 $permLinkStr . ")), ";
189 }
190 } else {
191 // the 404 page is the default 404 page. so we shouldn't change the URL.
192 $shouldUpdateURL = false;
193 $debugMessage .= "do not update (no custom 404 page specified), ";
194 }
195
196 $content = '';
197
198 if ($shouldUpdateURL) {
199 // replace the current URL with the user's actual requested URL.
200 $requestedURL = $requestedURLForRestore;
201 $userFriendlyURL = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ?
202 "https" : "http") . "://" . $_SERVER['HTTP_HOST'] . esc_url($requestedURL);
203
204 // Use wp_json_encode to safely encode the URL for JavaScript to prevent XSS
205 $content .= "window.history.replaceState({}, null, " .
206 wp_json_encode($userFriendlyURL) . ");\n";
207
208 $currentReqUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
209 $debugMessage .= "Updating the URL from " . $currentReqUri .
210 " to " . esc_url($userFriendlyURL) . ", ";
211 }
212
213 if ($content != '') {
214 $content = '<script language="JavaScript">' . "\n" .
215 $content .
216 "\n</script>\n\n";
217 echo $content;
218 }
219
220 $scAutoRedirects = isset($options['auto_redirects']) && is_scalar($options['auto_redirects']) ? (string)$options['auto_redirects'] : '';
221 $scAutoScore = isset($options['auto_score']) && is_scalar($options['auto_score']) ? (string)$options['auto_score'] : '';
222 $scTemplatePriority = isset($options['template_redirect_priority']) && is_scalar($options['template_redirect_priority']) ? (string)$options['template_redirect_priority'] : '';
223 $scAutoCats = isset($options['auto_cats']) && is_scalar($options['auto_cats']) ? (string)$options['auto_cats'] : '';
224 $scAutoTags = isset($options['auto_tags']) && is_scalar($options['auto_tags']) ? (string)$options['auto_tags'] : '';
225 $scDest404 = isset($options['dest404page']) && is_scalar($options['dest404page']) ? (string)$options['dest404page'] : '';
226 $debugMessage .= "is404: " . is_404() . ", " .
227 esc_html('auto_redirects: ' . $scAutoRedirects .
228 ', auto_score: ' . $scAutoScore .
229 ', template_redirect_priority: ' . $scTemplatePriority .
230 ', auto_cats: ' . $scAutoCats .
231 ', auto_tags: ' . $scAutoTags .
232 ', dest404page: ' . $scDest404) . ", ";
233
234 $debugMessage .= "is_single(): " . is_single() . " | " . "is_page(): " . is_page() .
235 " | is_feed(): " . is_feed() . " | is_trackback(): " . is_trackback() . " | is_preview(): " .
236 is_preview();
237
238 $abj404logging->debugMessage("updateURLbarIfNecessary: " . $debugMessage);
239 }
240
241 /**
242 * @param array<string, mixed> $atts
243 * @return string
244 */
245 static function shortcodePageSuggestions( array $atts ): string {
246 $didSwitchLocale = self::maybeSwitchToFrontendLocale();
247 try {
248 $abj404logic = abj_service('plugin_logic');
249 $abj404spellChecker = abj_service('spell_checker');
250 $f = abj_service('functions');
251 $viewReadService = abj_service('view_read_service');
252
253 // Attributes
254 $atts = shortcode_atts(
255 array(
256 ),
257 $atts
258 );
259
260 $options = $abj404logic->getOptions();
261
262 $content = "\n<!-- " . ABJ404_PP . " - Begin 404 suggestions. -->\n";
263
264 $urlResult = self::resolveRequestedUrl($f);
265 $content .= $urlResult['cookieScripts'];
266 $urlRequest = $urlResult['url'];
267
268 if ($urlRequest == '') {
269 // if no 404 was detected then we don't offer any suggestions
270 return "<!-- " . ABJ404_PP . " - No 404 was detected. No suggestions to offer. -->\n";
271 }
272
273 // Check for cached suggestion computation (transient-based).
274 // Normalize at the boundary: see ABJ_404_Solution_SuggestionTransient.
275 $urlForCacheKey = $f->normalizeURLForCacheKey($urlRequest);
276 $urlKey = md5($urlForCacheKey);
277 $transientKey = 'abj404_suggest_' . $urlKey;
278 $cached = ABJ_404_Solution_SuggestionTransient::fromRaw(get_transient($transientKey));
279
280 if ($cached !== null) {
281 if ($cached->isComplete()) {
282 // Suggestions ready, use cached data
283 $content .= self::renderSuggestionsHTML(
284 $cached->getSuggestionsPacket(),
285 $urlRequest
286 );
287 $content .= "\n<!-- " . ABJ404_PP . " - End 404 suggestions (cached) -->\n";
288 return $content;
289
290 } elseif ($cached->isPending()) {
291 // Still computing, show loading placeholder
292 self::enqueueAsyncPollingScript($urlRequest);
293 $content .= self::renderAsyncPlaceholder($urlRequest, $options);
294 $content .= "\n<!-- " . ABJ404_PP . " - Suggestions loading -->\n";
295 return $content;
296 }
297 }
298
299 // No async data - fall back to synchronous computation
300 $urlSlugOnly = $abj404logic->removeHomeDirectory($urlRequest);
301
302 // Try cache first (populated by processRedirect() for existing redirects)
303 $permalinkSuggestionsPacket = $abj404spellChecker->getFromPermalinkCache($urlSlugOnly);
304
305 // If cache miss, compute suggestions
306 if (empty($permalinkSuggestionsPacket) || empty($permalinkSuggestionsPacket[0])) {
307 $suggestCatsOpt = isset($options['suggest_cats']) && is_string($options['suggest_cats']) ? $options['suggest_cats'] : '1';
308 $suggestTagsOpt = isset($options['suggest_tags']) && is_string($options['suggest_tags']) ? $options['suggest_tags'] : '1';
309 $permalinkSuggestionsPacket = $abj404spellChecker->findMatchingPosts($urlSlugOnly,
310 $suggestCatsOpt, $suggestTagsOpt);
311 }
312
313 // Ensure suggestions is an array (cache may return stdClass from json_decode)
314 $permalinkSuggestions = isset($permalinkSuggestionsPacket[0]) ? (array)$permalinkSuggestionsPacket[0] : [];
315 $rowType = isset($permalinkSuggestionsPacket[1]) ? $permalinkSuggestionsPacket[1] : 'pages';
316
317 $showExtraAdminData = (is_user_logged_in() && $abj404logic->userIsPluginAdmin());
318 $extraDataById = $showExtraAdminData
319 ? self::collectAdminDebugExtraData($permalinkSuggestions, $viewReadService, $f)
320 : [];
321 $adminDebugData = [];
322
323 // allow some HTML.
324 $content .= '<div class="suggest-404s">' . "\n";
325 $suggestTitleStr = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
326 $content .= wp_kses_post(
327 self::replaceSuggestionTemplateToken($suggestTitleStr, 'suggest_title_text',
328 __('Here are some other great pages', '404-solution')
329 )) . "\n";
330
331 $requestUriVal = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
332 $currentSlug = $abj404logic->removeHomeDirectory(
333 $f->regexReplace('\?.*', '', $f->normalizeUrlString($requestUriVal)));
334 $displayed = 0;
335 $commentPartAndQueryPart = $abj404logic->getCommentPartAndQueryPartOfRequest();
336
337 // Check if minimum score filtering is enabled
338 $minScoreEnabled = isset($options['suggest_minscore_enabled']) && $options['suggest_minscore_enabled'] == '1';
339 $suggestMinscoreRaw = isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) ? $options['suggest_minscore'] : 25;
340 $minScore = $minScoreEnabled ? intval($suggestMinscoreRaw) : 0;
341
342 foreach ($permalinkSuggestions as $idAndType => $linkScore) {
343 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
344 $linkScoreFloat = is_scalar($linkScore) ? (float)$linkScore : 0.0;
345 $rowTypeStr = is_string($rowType) ? $rowType : null;
346 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndTypeStr, $linkScoreFloat,
347 $rowTypeStr, $options);
348
349 $permLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
350 // Skip if we're currently on the page we're about to suggest
351 if (basename($permLink) == $currentSlug) {
352 continue;
353 }
354
355 // Skip if minimum score filtering is enabled and score is below threshold
356 if ($minScoreEnabled && $permalink['score'] < $minScore) {
357 continue;
358 }
359
360 $suggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
361 $suggestEntryBefore = isset($options['suggest_entrybefore']) && is_string($options['suggest_entrybefore']) ? $options['suggest_entrybefore'] : '';
362 $permTitle = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
363 $permScore = isset($permalink['score']) && is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0;
364
365 if ($displayed == 0) {
366 // <ol>
367 $content .= wp_kses_post($suggestBefore);
368 }
369
370 // <li>
371 $content .= wp_kses_post($suggestEntryBefore);
372
373 $content .= "<a href=\"" . esc_url($permLink . $commentPartAndQueryPart) .
374 "\" title=\"" . esc_attr($permTitle) . "\">" .
375 esc_attr($permTitle) . "</a>";
376
377 // display the score after the page link
378
379 if ($showExtraAdminData) {
380 $adminDebugData[] = self::buildAdminDebugItemData(
381 $idAndTypeStr, $permTitle, $permScore, $permLink, $extraDataById
382 );
383 $content .= ' (<a href="#" onclick="show404AdminDebugData(); return false;" title="' .
384 esc_attr__('Click to view debug data for all suggestions', '404-solution') .
385 '">' . number_format($permScore, 2) .
386 '</a>)';
387 }
388
389 // </li>
390 $suggestEntryAfter = isset($options['suggest_entryafter']) && is_string($options['suggest_entryafter']) ? $options['suggest_entryafter'] : '';
391 $content .= wp_kses_post($suggestEntryAfter) . "\n";
392 $displayed++;
393 $suggestMaxOpt = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? (int)$options['suggest_max'] : 5;
394 if ($displayed >= $suggestMaxOpt) {
395 break;
396 }
397 }
398 $suggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
399 $suggestNoresults = isset($options['suggest_noresults']) && is_string($options['suggest_noresults']) ? $options['suggest_noresults'] : '';
400 if ($displayed >= 1) {
401 // </ol>
402 $content .= wp_kses_post($suggestAfter) . "\n";
403
404 } else {
405 $content .= wp_kses_post(
406 self::replaceSuggestionTemplateToken($suggestNoresults, 'suggest_noresults_text',
407 __('No suggestions. :/ ', '404-solution')
408 ));
409 }
410
411 $content .= "\n</div>";
412
413 if ($showExtraAdminData && !empty($adminDebugData)) {
414 $allSuggestionsJson = wp_json_encode($adminDebugData);
415 if ($allSuggestionsJson === false) {
416 $allSuggestionsJson = '[]';
417 }
418 $jsContent = ABJ_404_Solution_Functions::readFileContents(__DIR__ . '/js/suggestion-debug-modal.js');
419 $content .= "<script type=\"text/javascript\">\n";
420 $content .= "var abj404_suggestionData = " . $allSuggestionsJson . ";\n";
421 $content .= $jsContent . "\n";
422 $content .= "</script>\n";
423 }
424
425 $content .= "\n<!-- " . ABJ404_PP . " - End 404 suggestions for slug " . esc_html($urlSlugOnly) . " -->\n";
426
427 return $content;
428 } finally {
429 self::maybeRestoreFrontendLocale($didSwitchLocale);
430 }
431 }
432
433 /**
434 * @param ABJ_404_Solution_Functions $f
435 * @return array{url: string, cookieScripts: string}
436 */
437 private static function resolveRequestedUrl($f): array {
438 $urlRequest = '';
439 $cookieScripts = '';
440
441 $cookieName = ABJ404_PP . '_REQUEST_URI';
442 $cookieVal = isset($_COOKIE[$cookieName]) && is_string($_COOKIE[$cookieName]) ? $_COOKIE[$cookieName] : '';
443 if ($cookieVal !== '') {
444 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($cookieVal));
445 $cookieScripts .= "<script> \n" .
446 " var d = new Date(); \n" .
447 " d.setTime(d.getTime() - (60 * 5)); \n" .
448 ' var expires = "expires="+ d.toUTCString(); ' . "\n" .
449 ' document.cookie = "' . $cookieName . '=;" + expires + ";path=/"; ' . "\n" .
450 "</script> \n";
451 }
452
453 $updateURLCookieName = ABJ404_PP . '_REQUEST_URI';
454 $updateURLCookieName .= '_UPDATE_URL';
455 $updateCookieVal = isset($_COOKIE[$updateURLCookieName]) && is_string($_COOKIE[$updateURLCookieName]) ? $_COOKIE[$updateURLCookieName] : '';
456 if ($updateCookieVal !== '') {
457 if ($urlRequest == '') {
458 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($updateCookieVal));
459 }
460 $cookieScripts .= "<script> \n" .
461 " var d = new Date(); /* delete the cookie */\n" .
462 " d.setTime(d.getTime() - (60 * 5)); \n" .
463 ' var expires = "expires="+ d.toUTCString(); ' . "\n" .
464 ' document.cookie = "' . $updateURLCookieName . '=;" + expires + ";path=/"; ' .
465 "</script> \n";
466 }
467
468 $ctxUrl = abj_service('request_context')->requested_url;
469 if ($ctxUrl !== '') {
470 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($ctxUrl));
471 }
472
473 $queryParamName = ABJ404_PP . '_ref';
474 $getParamVal = isset($_GET[$queryParamName]) && is_string($_GET[$queryParamName]) ? $_GET[$queryParamName] : '';
475 if ($urlRequest == '' && $getParamVal !== '') {
476 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($getParamVal));
477 }
478
479 return array('url' => $urlRequest, 'cookieScripts' => $cookieScripts);
480 }
481
482 /**
483 * @param array<int|string, mixed> $permalinkSuggestions
484 * @param ABJ_404_Solution_ViewReadServiceInterface $viewReadService
485 * @param ABJ_404_Solution_Functions $f
486 * @return array<string, array<string, mixed>>
487 */
488 private static function collectAdminDebugExtraData(array $permalinkSuggestions, $viewReadService, $f): array {
489 $extraDataById = [];
490 $postIDs = array_keys($permalinkSuggestions);
491 if (empty($postIDs)) {
492 return $extraDataById;
493 }
494 foreach ($postIDs as $index => $id) {
495 $idStr = is_string($id) ? $id : (string)$id;
496 $pipePos = $f->strpos($idStr, '|');
497 $postIDs[$index] = $f->substr($idStr, 0, $pipePos !== false ? $pipePos : null);
498 }
499
500 $rawExtraData = $viewReadService->getExtraDataToPermalinkSuggestions($postIDs);
501 foreach ($rawExtraData as $dataItem) {
502 if (!is_array($dataItem)) {
503 continue;
504 }
505 $postIdVal = isset($dataItem['post_id']) ? (string)$dataItem['post_id'] : '';
506 $termIdVal = isset($dataItem['term_id']) ? (string)$dataItem['term_id'] : '';
507 $extraDataById['post_id_' . $postIdVal] = $dataItem;
508 $extraDataById['term_id_' . $termIdVal] = $dataItem;
509 }
510 return $extraDataById;
511 }
512
513 /**
514 * @param string $idAndTypeStr
515 * @param string $permTitle
516 * @param float $permScore
517 * @param string $permLink
518 * @param array<string, array<string, mixed>> $extraDataById
519 * @return array<string, mixed>
520 */
521 private static function buildAdminDebugItemData(string $idAndTypeStr, string $permTitle, float $permScore, string $permLink, array $extraDataById): array {
522 $currentSuggestionData = [
523 'Title' => $permTitle,
524 'Link' => $permLink,
525 'Score' => number_format($permScore, 2),
526 'ID_Type_Code' => $idAndTypeStr,
527 ];
528
529 $idParts = explode('|', $idAndTypeStr);
530 $currentId = isset($idParts[0]) ? $idParts[0] : null;
531 $typeCode = isset($idParts[1]) ? $idParts[1] : null;
532
533 if ($typeCode == '1') {
534 $extraKey = 'post_id_' . $currentId;
535 if (isset($extraDataById[$extraKey])) {
536 $currentSuggestionData = $currentSuggestionData + $extraDataById[$extraKey];
537 }
538 } else {
539 $extraKey = 'term_id_' . $currentId;
540 if (isset($extraDataById[$extraKey])) {
541 $currentSuggestionData = $currentSuggestionData + $extraDataById[$extraKey];
542 }
543 }
544
545 return $currentSuggestionData;
546 }
547
548 /**
549 * Render suggestions HTML from pre-computed data (for AJAX polling response).
550 * This method is called by Ajax_SuggestionPolling when suggestions are ready.
551 *
552 * @param array<int, mixed> $suggestionsPacket The suggestions data from findMatchingPosts()
553 * @param string $requestedURL The original 404 URL (for debugging)
554 * @return string HTML content for suggestions
555 */
556 public static function renderSuggestionsHTML(array $suggestionsPacket, string $requestedURL = ''): string {
557 $didSwitchLocale = self::maybeSwitchToFrontendLocale();
558 try {
559 $abj404logic = abj_service('plugin_logic');
560 $f = abj_service('functions');
561 // Rendering should be side-effect free (no upgrade/migration work triggered on frontend/AJAX).
562 $options = $abj404logic->getOptions(true);
563
564 // Ensure suggestions is an array (cache may return stdClass from json_decode)
565 $permalinkSuggestions = isset($suggestionsPacket[0]) ? (array)$suggestionsPacket[0] : [];
566 $rowType = isset($suggestionsPacket[1]) ? $suggestionsPacket[1] : 'pages';
567
568 // Check if user is plugin admin to show scores
569 $showExtraAdminData = (is_user_logged_in() && $abj404logic->userIsPluginAdmin());
570
571 // Extract option strings safely
572 $rSuggestTitle = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
573 $rSuggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
574 $rSuggestEntryBefore = isset($options['suggest_entrybefore']) && is_string($options['suggest_entrybefore']) ? $options['suggest_entrybefore'] : '';
575 $rSuggestEntryAfter = isset($options['suggest_entryafter']) && is_string($options['suggest_entryafter']) ? $options['suggest_entryafter'] : '';
576 $rSuggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
577 $rSuggestNoresults = isset($options['suggest_noresults']) && is_string($options['suggest_noresults']) ? $options['suggest_noresults'] : '';
578
579 $content = '<div class="suggest-404s">' . "\n";
580 $content .= wp_kses_post(
581 self::replaceSuggestionTemplateToken($rSuggestTitle, 'suggest_title_text',
582 __('Here are some other great pages', '404-solution')
583 )) . "\n";
584
585 $currentSlug = '';
586 if (isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI'])) {
587 $currentSlug = $abj404logic->removeHomeDirectory(
588 $f->regexReplace('\?.*', '', $f->normalizeUrlString($_SERVER['REQUEST_URI'])));
589 }
590
591 $displayed = 0;
592 $commentPartAndQueryPart = $abj404logic->getCommentPartAndQueryPartOfRequest();
593
594 // Check if minimum score filtering is enabled
595 $minScoreEnabled = isset($options['suggest_minscore_enabled']) && $options['suggest_minscore_enabled'] == '1';
596 $rMinscoreRaw = isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) ? $options['suggest_minscore'] : 25;
597 $minScore = $minScoreEnabled ? intval($rMinscoreRaw) : 0;
598
599 foreach ($permalinkSuggestions as $idAndType => $linkScore) {
600 $rIdAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
601 $rLinkScoreFloat = is_scalar($linkScore) ? (float)$linkScore : 0.0;
602 $rRowTypeStr = is_string($rowType) ? $rowType : null;
603
604 // Check per-post/per-term exclusion before rendering.
605 // ABJ404_TYPE_POST=1, ABJ404_TYPE_CAT=2, ABJ404_TYPE_TAG=3.
606 $idTypeParts = explode('|', $rIdAndTypeStr, 2);
607 $idInt = isset($idTypeParts[0]) && is_numeric($idTypeParts[0]) ? (int)$idTypeParts[0] : 0;
608 $typeInt = isset($idTypeParts[1]) && is_numeric($idTypeParts[1]) ? (int)$idTypeParts[1] : 0;
609 if ($idInt > 0) {
610 $typePost = defined('ABJ404_TYPE_POST') ? (int)ABJ404_TYPE_POST : 1;
611 $typeCat = defined('ABJ404_TYPE_CAT') ? (int)ABJ404_TYPE_CAT : 2;
612 $typeTag = defined('ABJ404_TYPE_TAG') ? (int)ABJ404_TYPE_TAG : 3;
613 if ($typeInt === $typePost) {
614 $excludeMeta = get_post_meta($idInt, '_abj404_exclude', true);
615 if ($excludeMeta === '1') {
616 continue;
617 }
618 } elseif ($typeInt === $typeCat || $typeInt === $typeTag) {
619 $excludeMeta = get_term_meta($idInt, '_abj404_exclude', true);
620 if ($excludeMeta === '1') {
621 continue;
622 }
623 }
624 }
625
626 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rIdAndTypeStr, $rLinkScoreFloat,
627 $rRowTypeStr, $options);
628
629 $rPermLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
630 $rPermTitle = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
631 $rPermScore = isset($permalink['score']) && is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0;
632
633 // Skip if we're currently on the page we're about to suggest
634 if ($currentSlug !== '' && basename($rPermLink) == $currentSlug) {
635 continue;
636 }
637
638 // Skip if minimum score filtering is enabled and score is below threshold
639 if ($minScoreEnabled && $rPermScore < $minScore) {
640 continue;
641 }
642
643 if ($displayed == 0) {
644 // <ol>
645 $content .= wp_kses_post($rSuggestBefore);
646 }
647
648 // <li>
649 $content .= wp_kses_post($rSuggestEntryBefore);
650
651 $content .= "<a href=\"" . esc_url($rPermLink . $commentPartAndQueryPart) .
652 "\" title=\"" . esc_attr($rPermTitle) . "\">" .
653 esc_attr($rPermTitle) . "</a>";
654
655 // Display the score after the page link (admin only)
656 if ($showExtraAdminData) {
657 $content .= ' (' . number_format($rPermScore, 4) . ')';
658 }
659
660 // </li>
661 $content .= wp_kses_post($rSuggestEntryAfter) . "\n";
662 $displayed++;
663 $rSuggestMaxOpt = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? (int)$options['suggest_max'] : 5;
664 if ($displayed >= $rSuggestMaxOpt) {
665 break;
666 }
667 }
668
669 if ($displayed >= 1) {
670 // </ol>
671 $content .= wp_kses_post($rSuggestAfter) . "\n";
672 } else {
673 $content .= wp_kses_post(
674 self::replaceSuggestionTemplateToken($rSuggestNoresults, 'suggest_noresults_text',
675 __('No suggestions. :/ ', '404-solution')
676 ));
677 }
678
679 $content .= "\n</div>";
680
681 return $content;
682 } finally {
683 self::maybeRestoreFrontendLocale($didSwitchLocale);
684 }
685 }
686
687 /**
688 * Render a loading placeholder for async suggestions.
689 * Shows skeleton loading animation while suggestions are being computed.
690 *
691 * @param string $requestedURL The 404 URL being looked up
692 * @param array<string, mixed> $options Plugin options
693 * @return string HTML placeholder with loading state
694 */
695 public static function renderAsyncPlaceholder(string $requestedURL, array $options): string {
696 $didSwitchLocale = self::maybeSwitchToFrontendLocale();
697 try {
698 $suggestMaxVal = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
699 $suggestMax = intval($suggestMaxVal);
700
701 // Generate skeleton items based on suggest_max
702 $skeletons = '';
703 for ($i = 0; $i < $suggestMax; $i++) {
704 $skeletons .= '<li class="abj404-skeleton"></li>' . "\n";
705 }
706
707 $pSuggestTitle = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
708 $pSuggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
709 $pSuggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
710
711 $content = '<div id="abj404-suggestions-placeholder" class="suggest-404s" ' .
712 'data-requested-url="' . esc_attr($requestedURL) . '">' . "\n";
713 $content .= wp_kses_post(
714 self::replaceSuggestionTemplateToken($pSuggestTitle, 'suggest_title_text',
715 __('Here are some other great pages', '404-solution')
716 )) . "\n";
717 $content .= wp_kses_post($pSuggestBefore);
718 $content .= '<div class="abj404-loading">' . "\n";
719 $content .= '<p class="abj404-loading-text">' . esc_html__('Loading page suggestions...', '404-solution') . '</p>' . "\n";
720 $content .= $skeletons;
721 $content .= '</div>' . "\n";
722 $content .= wp_kses_post($pSuggestAfter) . "\n";
723 $content .= '</div>';
724
725 return $content;
726 } finally {
727 self::maybeRestoreFrontendLocale($didSwitchLocale);
728 }
729 }
730
731 /**
732 * Enqueue the async suggestion polling JavaScript.
733 *
734 * @param string $requestedURL The 404 URL for polling
735 * @return void
736 */
737 public static function enqueueAsyncPollingScript(string $requestedURL): void {
738 // Enqueue jQuery dependency
739 wp_enqueue_script('jquery');
740
741 // Enqueue polling script
742 wp_enqueue_script(
743 'abj404-suggestion-polling',
744 plugin_dir_url(__FILE__) . 'ajax/SuggestionPolling.js',
745 array('jquery'),
746 ABJ404_VERSION,
747 true // Load in footer
748 );
749
750 // Pass AJAX URL, nonce, and localized strings to JavaScript
751 wp_localize_script('abj404-suggestion-polling', 'abj404_suggestions', array(
752 'ajax_url' => admin_url('admin-ajax.php'),
753 'nonce' => wp_create_nonce('abj404_poll_suggestions'),
754 'no_suggestions_text' => __('No suggestions. :/ ', '404-solution')
755 ));
756
757 // Enqueue loading CSS
758 wp_enqueue_style(
759 'abj404-suggestions-loading',
760 plugin_dir_url(__FILE__) . 'css/suggestions-loading.css',
761 array(),
762 ABJ404_VERSION
763 );
764 }
765
766 }
767