PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.1.13, at includes/ShortCode.php

829 lines 39.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 /* 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 $abj404dao = abj_service('data_access');
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 // get the slug that caused the 404 from the session.
265 $urlRequest = '';
266 $cookieName = ABJ404_PP . '_REQUEST_URI';
267 $cookieVal = isset($_COOKIE[$cookieName]) && is_string($_COOKIE[$cookieName]) ? $_COOKIE[$cookieName] : '';
268 if ($cookieVal !== '') {
269 // Normalize URL using centralized function for consistency
270 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($cookieVal));
271 // delete the cookie because the request was a one-time thing.
272 // we use javascript to delete the cookie because the headers have already been sent.
273 $content .= "<script> \n" .
274 " var d = new Date(); \n" .
275 " d.setTime(d.getTime() - (60 * 5)); \n" .
276 ' var expires = "expires="+ d.toUTCString(); ' . "\n" .
277 ' document.cookie = "' . $cookieName . '=;" + expires + ";path=/"; ' . "\n" .
278 "</script> \n";
279 }
280
281 // we delete the UPDATE_URL cookie here, where the shortcode is used so that it won't
282 // get deleted too early if multiple redirects happen.
283 $updateURLCookieName = ABJ404_PP . '_REQUEST_URI';
284 $updateURLCookieName .= '_UPDATE_URL';
285 $updateCookieVal = isset($_COOKIE[$updateURLCookieName]) && is_string($_COOKIE[$updateURLCookieName]) ? $_COOKIE[$updateURLCookieName] : '';
286 if ($updateCookieVal !== '') {
287 // Use UPDATE_URL cookie as fallback if primary cookie wasn't set
288 // (fixes: manual redirects to custom 404 pages not showing suggestions)
289 if ($urlRequest == '') {
290 // Normalize URL using centralized function for consistency
291 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($updateCookieVal));
292 }
293 // delete the cookie since we're done with it. it's a one-time use thing.
294 $content .= "<script> \n" .
295 " var d = new Date(); /* delete the cookie */\n" .
296 " d.setTime(d.getTime() - (60 * 5)); \n" .
297 ' var expires = "expires="+ d.toUTCString(); ' . "\n" .
298 ' document.cookie = "' . $updateURLCookieName . '=;" + expires + ";path=/"; ' .
299 "</script> \n";
300 }
301
302 $ctxUrl = abj_service('request_context')->requested_url;
303 if ($ctxUrl !== '') {
304 // Normalize URL using centralized function for consistency
305 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($ctxUrl));
306 }
307
308 // Fallback: check for URL passed via query parameter
309 // (fixes: cookies from 301 redirects aren't stored by browsers)
310 $queryParamName = ABJ404_PP . '_ref';
311 $getParamVal = isset($_GET[$queryParamName]) && is_string($_GET[$queryParamName]) ? $_GET[$queryParamName] : '';
312 if ($urlRequest == '' && $getParamVal !== '') {
313 // Normalize URL using centralized function for consistency
314 $urlRequest = $f->normalizeURLForCacheKey($f->normalizeUrlString($getParamVal));
315 }
316
317 if ($urlRequest == '') {
318 // if no 404 was detected then we don't offer any suggestions
319 return "<!-- " . ABJ404_PP . " - No 404 was detected. No suggestions to offer. -->\n";
320 }
321
322 // Check for cached suggestion computation (transient-based)
323 $urlKey = md5($urlRequest);
324 $transientKey = 'abj404_suggest_' . $urlKey;
325 $cachedData = get_transient($transientKey);
326
327 if ($cachedData !== false && is_array($cachedData)) {
328 if (isset($cachedData['status']) && $cachedData['status'] === 'complete') {
329 // Suggestions ready - use cached data
330 /** @var array<int, mixed> $cachedSuggestions */
331 $cachedSuggestions = isset($cachedData['suggestions']) && is_array($cachedData['suggestions']) ? $cachedData['suggestions'] : array();
332 $content .= self::renderSuggestionsHTML(
333 $cachedSuggestions,
334 $urlRequest
335 );
336 $content .= "\n<!-- " . ABJ404_PP . " - End 404 suggestions (cached) -->\n";
337 return $content;
338
339 } elseif (isset($cachedData['status']) && $cachedData['status'] === 'pending') {
340 // Still computing - show loading placeholder
341 self::enqueueAsyncPollingScript($urlRequest);
342 $content .= self::renderAsyncPlaceholder($urlRequest, $options);
343 $content .= "\n<!-- " . ABJ404_PP . " - Suggestions loading -->\n";
344 return $content;
345 }
346 }
347
348 // No async data - fall back to synchronous computation
349 $urlSlugOnly = $abj404logic->removeHomeDirectory($urlRequest);
350
351 // Try cache first (populated by processRedirect() for existing redirects)
352 $permalinkSuggestionsPacket = $abj404spellChecker->getFromPermalinkCache($urlSlugOnly);
353
354 // If cache miss, compute suggestions
355 if (empty($permalinkSuggestionsPacket) || empty($permalinkSuggestionsPacket[0])) {
356 $suggestCatsOpt = isset($options['suggest_cats']) && is_string($options['suggest_cats']) ? $options['suggest_cats'] : '1';
357 $suggestTagsOpt = isset($options['suggest_tags']) && is_string($options['suggest_tags']) ? $options['suggest_tags'] : '1';
358 $permalinkSuggestionsPacket = $abj404spellChecker->findMatchingPosts($urlSlugOnly,
359 $suggestCatsOpt, $suggestTagsOpt);
360 }
361
362 // Ensure suggestions is an array (cache may return stdClass from json_decode)
363 $permalinkSuggestions = isset($permalinkSuggestionsPacket[0]) ? (array)$permalinkSuggestionsPacket[0] : [];
364 $rowType = isset($permalinkSuggestionsPacket[1]) ? $permalinkSuggestionsPacket[1] : 'pages';
365
366 $showExtraAdminData = (is_user_logged_in() && $abj404logic->userIsPluginAdmin());
367 $extraData = null;
368 $extraDataById = []; // <--- New: Array to hold extra data indexed by ID
369 $adminDebugData = []; // <--- New: Array to collect data for JS
370
371 if ($showExtraAdminData) {
372 // add extra information to the permalinkSuggestionsPacket. for each permalink,
373 // retrieve the post_type, taxonomy, post_author (this is an id not a name),
374 // post_date, post_name (this is the slug),
375 $postIDs = array_keys($permalinkSuggestions);
376 if (!empty($postIDs)) {
377 // for each id remove the part after '|' using substring
378 foreach ($postIDs as $index => $id) {
379 $idStr = is_string($id) ? $id : (string)$id;
380 $pipePos = $f->strpos($idStr, '|');
381 $postIDs[$index] = $f->substr($idStr, 0, $pipePos !== false ? $pipePos : null);
382 }
383
384 $rawExtraData = $abj404dao->getExtraDataToPermalinkSuggestions($postIDs);
385 foreach ($rawExtraData as $dataItem) {
386 if (!is_array($dataItem)) {
387 continue;
388 }
389 $postIdVal = isset($dataItem['post_id']) ? (string)$dataItem['post_id'] : '';
390 $termIdVal = isset($dataItem['term_id']) ? (string)$dataItem['term_id'] : '';
391 $extraDataById['post_id_' . $postIdVal] = $dataItem;
392 $extraDataById['term_id_' . $termIdVal] = $dataItem;
393 }
394 }
395 }
396
397 // allow some HTML.
398 $content .= '<div class="suggest-404s">' . "\n";
399 $suggestTitleStr = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
400 $content .= wp_kses_post(
401 self::replaceSuggestionTemplateToken($suggestTitleStr, 'suggest_title_text',
402 __('Here are some other great pages', '404-solution')
403 )) . "\n";
404
405 $requestUriVal = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
406 $currentSlug = $abj404logic->removeHomeDirectory(
407 $f->regexReplace('\?.*', '', $f->normalizeUrlString($requestUriVal)));
408 $displayed = 0;
409 $commentPartAndQueryPart = $abj404logic->getCommentPartAndQueryPartOfRequest();
410
411 // Check if minimum score filtering is enabled
412 $minScoreEnabled = isset($options['suggest_minscore_enabled']) && $options['suggest_minscore_enabled'] == '1';
413 $suggestMinscoreRaw = isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) ? $options['suggest_minscore'] : 25;
414 $minScore = $minScoreEnabled ? intval($suggestMinscoreRaw) : 0;
415
416 foreach ($permalinkSuggestions as $idAndType => $linkScore) {
417 $idAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
418 $linkScoreFloat = is_scalar($linkScore) ? (float)$linkScore : 0.0;
419 $rowTypeStr = is_string($rowType) ? $rowType : null;
420 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($idAndTypeStr, $linkScoreFloat,
421 $rowTypeStr, $options);
422
423 $permLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
424 // Skip if we're currently on the page we're about to suggest
425 if (basename($permLink) == $currentSlug) {
426 continue;
427 }
428
429 // Skip if minimum score filtering is enabled and score is below threshold
430 if ($minScoreEnabled && $permalink['score'] < $minScore) {
431 continue;
432 }
433
434 $suggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
435 $suggestEntryBefore = isset($options['suggest_entrybefore']) && is_string($options['suggest_entrybefore']) ? $options['suggest_entrybefore'] : '';
436 $permTitle = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
437 $permScore = isset($permalink['score']) && is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0;
438
439 if ($displayed == 0) {
440 // <ol>
441 $content .= wp_kses_post($suggestBefore);
442 }
443
444 // <li>
445 $content .= wp_kses_post($suggestEntryBefore);
446
447 $content .= "<a href=\"" . esc_url($permLink . $commentPartAndQueryPart) .
448 "\" title=\"" . esc_attr($permTitle) . "\">" .
449 esc_attr($permTitle) . "</a>";
450
451 // display the score after the page link
452
453 if ($showExtraAdminData) {
454 $idParts = explode('|', $idAndTypeStr);
455 $currentId = isset($idParts[0]) ? (int)$idParts[0] : null;
456 $typeCode = isset($idParts[1]) ? $idParts[1] : null;
457
458 $currentSuggestionData = [
459 'Title' => $permTitle,
460 'Link' => $permLink,
461 'Score' => number_format($permScore, 2),
462 'ID_Type_Code' => $idAndTypeStr, // e.g., "123|1" or "94|2"
463 ];
464
465 // Extract ID for lookup
466 $idParts = explode('|', $idAndTypeStr);
467 $currentId = isset($idParts[0]) ? $idParts[0] : null;
468
469 // Merge extra data if available (post may have been deleted since suggestions were cached)
470 if ($typeCode == '1') { // It's a Post
471 $extraKey = 'post_id_' . $currentId;
472 if (isset($extraDataById[$extraKey])) {
473 $currentSuggestionData = $currentSuggestionData + $extraDataById[$extraKey];
474 }
475 } else { // It's a Term
476 $extraKey = 'term_id_' . $currentId;
477 if (isset($extraDataById[$extraKey])) {
478 $currentSuggestionData = $currentSuggestionData + $extraDataById[$extraKey];
479 }
480 }
481
482 // Add this suggestion's data to the array for JS
483 $adminDebugData[] = $currentSuggestionData;
484
485 // Make the score clickable
486 $content .= ' (<a href="#" onclick="show404AdminDebugData(); return false;" title="' .
487 esc_attr__('Click to view debug data for all suggestions', '404-solution') .
488 '">' . number_format($permScore, 2) . // Format score
489 '</a>)';
490 }
491
492 // </li>
493 $suggestEntryAfter = isset($options['suggest_entryafter']) && is_string($options['suggest_entryafter']) ? $options['suggest_entryafter'] : '';
494 $content .= wp_kses_post($suggestEntryAfter) . "\n";
495 $displayed++;
496 $suggestMaxOpt = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? (int)$options['suggest_max'] : 5;
497 if ($displayed >= $suggestMaxOpt) {
498 break;
499 }
500 }
501 $suggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
502 $suggestNoresults = isset($options['suggest_noresults']) && is_string($options['suggest_noresults']) ? $options['suggest_noresults'] : '';
503 if ($displayed >= 1) {
504 // </ol>
505 $content .= wp_kses_post($suggestAfter) . "\n";
506
507 } else {
508 $content .= wp_kses_post(
509 self::replaceSuggestionTemplateToken($suggestNoresults, 'suggest_noresults_text',
510 __('No suggestions. :/ ', '404-solution')
511 ));
512 }
513
514 $content .= "\n</div>";
515
516 if ($showExtraAdminData && !empty($adminDebugData)) {
517 // Ensure the JSON is properly encoded and escaped for JavaScript
518 $allSuggestionsJson = wp_json_encode($adminDebugData);
519 if ($allSuggestionsJson === false) {
520 // Handle encoding error
521 $allSuggestionsJson = '[]';
522 }
523
524 $content .= "<script type=\"text/javascript\">\n";
525 $content .= "var abj404_suggestionData = " . $allSuggestionsJson . ";\n";
526 $content .= "function show404AdminDebugData() {\n";
527 $content .= " var debugText = 'Suggestion Debug Data:\\n====================\\n\\n';\n";
528 $content .= " if (typeof abj404_suggestionData !== 'undefined' && abj404_suggestionData.length > 0) {\n";
529 $content .= " for (var i = 0; i < abj404_suggestionData.length; i++) {\n";
530 $content .= " var item = abj404_suggestionData[i];\n";
531 $content .= " debugText += 'Suggestion #' + (i + 1) + ':\\n';\n";
532 $content .= " for (var key in item) {\n";
533 $content .= " if (item.hasOwnProperty(key) && item[key]) {\n";
534 $content .= " // Only include properties that have values\n";
535 $content .= " // Format the key for display (capitalize first letter)\n";
536 $content .= " var displayKey = key;\n";
537 $content .= " // Escape any potentially harmful content using text nodes\n";
538 $content .= " debugText += ' ' + displayKey + ': ' + String(item[key]).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '\\n';\n";
539 $content .= " }\n";
540 $content .= " }\n";
541 $content .= " debugText += '--------------------\\n';\n";
542 $content .= " }\n";
543 $content .= " } else {\n";
544 $content .= " debugText += 'No suggestion data collected.';\n";
545 $content .= " }\n";
546 $content .= " \n";
547 $content .= " // Create a modal dialog with copyable text\n";
548 $content .= " var modalOverlay = document.createElement('div');\n";
549 $content .= " modalOverlay.style.position = 'fixed';\n";
550 $content .= " modalOverlay.style.top = '0';\n";
551 $content .= " modalOverlay.style.left = '0';\n";
552 $content .= " modalOverlay.style.width = '100%';\n";
553 $content .= " modalOverlay.style.height = '100%';\n";
554 $content .= " modalOverlay.style.backgroundColor = 'rgba(0,0,0,0.5)';\n";
555 $content .= " modalOverlay.style.zIndex = '9999';\n";
556 $content .= " \n";
557 $content .= " var modalContent = document.createElement('div');\n";
558 $content .= " modalContent.style.position = 'absolute';\n";
559 $content .= " modalContent.style.top = '50%';\n";
560 $content .= " modalContent.style.left = '50%';\n";
561 $content .= " modalContent.style.transform = 'translate(-50%, -50%)';\n";
562 $content .= " modalContent.style.backgroundColor = 'white';\n";
563 $content .= " modalContent.style.padding = '20px';\n";
564 $content .= " modalContent.style.borderRadius = '5px';\n";
565 $content .= " modalContent.style.maxWidth = '80%';\n";
566 $content .= " modalContent.style.maxHeight = '80%';\n";
567 $content .= " modalContent.style.overflow = 'auto';\n";
568 $content .= " \n";
569 $content .= " var textArea = document.createElement('textarea');\n";
570 $content .= " textArea.style.width = '100%';\n";
571 $content .= " textArea.style.height = '300px';\n";
572 $content .= " textArea.style.marginBottom = '10px';\n";
573 $content .= " // Set value safely using textContent\n";
574 $content .= " textArea.value = debugText;\n";
575 $content .= " textArea.readOnly = true;\n";
576 $content .= " \n";
577 $content .= " var copyButton = document.createElement('button');\n";
578 $content .= " // Using textContent instead of innerHTML\n";
579 $content .= " copyButton.textContent = 'Copy to Clipboard';\n";
580 $content .= " copyButton.style.marginRight = '10px';\n";
581 $content .= " copyButton.onclick = function() {\n";
582 $content .= " textArea.select();\n";
583 $content .= " document.execCommand('copy');\n";
584 $content .= " };\n";
585 $content .= " \n";
586 $content .= " var closeButton = document.createElement('button');\n";
587 $content .= " // Using textContent instead of innerHTML\n";
588 $content .= " closeButton.textContent = 'Close';\n";
589 $content .= " closeButton.onclick = function() {\n";
590 $content .= " document.body.removeChild(modalOverlay);\n";
591 $content .= " };\n";
592 $content .= " \n";
593 $content .= " modalContent.appendChild(textArea);\n";
594 $content .= " modalContent.appendChild(copyButton);\n";
595 $content .= " modalContent.appendChild(closeButton);\n";
596 $content .= " modalOverlay.appendChild(modalContent);\n";
597 $content .= " document.body.appendChild(modalOverlay);\n";
598 $content .= "}\n";
599 $content .= "</script>\n";
600 }
601
602 $content .= "\n<!-- " . ABJ404_PP . " - End 404 suggestions for slug " . esc_html($urlSlugOnly) . " -->\n";
603
604 return $content;
605 } finally {
606 self::maybeRestoreFrontendLocale($didSwitchLocale);
607 }
608 }
609
610 /**
611 * Render suggestions HTML from pre-computed data (for AJAX polling response).
612 * This method is called by Ajax_SuggestionPolling when suggestions are ready.
613 *
614 * @param array<int, mixed> $suggestionsPacket The suggestions data from findMatchingPosts()
615 * @param string $requestedURL The original 404 URL (for debugging)
616 * @return string HTML content for suggestions
617 */
618 public static function renderSuggestionsHTML(array $suggestionsPacket, string $requestedURL = ''): string {
619 $didSwitchLocale = self::maybeSwitchToFrontendLocale();
620 try {
621 $abj404logic = abj_service('plugin_logic');
622 $f = abj_service('functions');
623 // Rendering should be side-effect free (no upgrade/migration work triggered on frontend/AJAX).
624 $options = $abj404logic->getOptions(true);
625
626 // Ensure suggestions is an array (cache may return stdClass from json_decode)
627 $permalinkSuggestions = isset($suggestionsPacket[0]) ? (array)$suggestionsPacket[0] : [];
628 $rowType = isset($suggestionsPacket[1]) ? $suggestionsPacket[1] : 'pages';
629
630 // Check if user is plugin admin to show scores
631 $showExtraAdminData = (is_user_logged_in() && $abj404logic->userIsPluginAdmin());
632
633 // Extract option strings safely
634 $rSuggestTitle = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
635 $rSuggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
636 $rSuggestEntryBefore = isset($options['suggest_entrybefore']) && is_string($options['suggest_entrybefore']) ? $options['suggest_entrybefore'] : '';
637 $rSuggestEntryAfter = isset($options['suggest_entryafter']) && is_string($options['suggest_entryafter']) ? $options['suggest_entryafter'] : '';
638 $rSuggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
639 $rSuggestNoresults = isset($options['suggest_noresults']) && is_string($options['suggest_noresults']) ? $options['suggest_noresults'] : '';
640
641 $content = '<div class="suggest-404s">' . "\n";
642 $content .= wp_kses_post(
643 self::replaceSuggestionTemplateToken($rSuggestTitle, 'suggest_title_text',
644 __('Here are some other great pages', '404-solution')
645 )) . "\n";
646
647 $currentSlug = '';
648 if (isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI'])) {
649 $currentSlug = $abj404logic->removeHomeDirectory(
650 $f->regexReplace('\?.*', '', $f->normalizeUrlString($_SERVER['REQUEST_URI'])));
651 }
652
653 $displayed = 0;
654 $commentPartAndQueryPart = $abj404logic->getCommentPartAndQueryPartOfRequest();
655
656 // Check if minimum score filtering is enabled
657 $minScoreEnabled = isset($options['suggest_minscore_enabled']) && $options['suggest_minscore_enabled'] == '1';
658 $rMinscoreRaw = isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) ? $options['suggest_minscore'] : 25;
659 $minScore = $minScoreEnabled ? intval($rMinscoreRaw) : 0;
660
661 foreach ($permalinkSuggestions as $idAndType => $linkScore) {
662 $rIdAndTypeStr = is_string($idAndType) ? $idAndType : (string)$idAndType;
663 $rLinkScoreFloat = is_scalar($linkScore) ? (float)$linkScore : 0.0;
664 $rRowTypeStr = is_string($rowType) ? $rowType : null;
665
666 // Check per-post/per-term exclusion before rendering.
667 // ABJ404_TYPE_POST=1, ABJ404_TYPE_CAT=2, ABJ404_TYPE_TAG=3.
668 $idTypeParts = explode('|', $rIdAndTypeStr, 2);
669 $idInt = isset($idTypeParts[0]) && is_numeric($idTypeParts[0]) ? (int)$idTypeParts[0] : 0;
670 $typeInt = isset($idTypeParts[1]) && is_numeric($idTypeParts[1]) ? (int)$idTypeParts[1] : 0;
671 if ($idInt > 0) {
672 $typePost = defined('ABJ404_TYPE_POST') ? (int)ABJ404_TYPE_POST : 1;
673 $typeCat = defined('ABJ404_TYPE_CAT') ? (int)ABJ404_TYPE_CAT : 2;
674 $typeTag = defined('ABJ404_TYPE_TAG') ? (int)ABJ404_TYPE_TAG : 3;
675 if ($typeInt === $typePost) {
676 $excludeMeta = get_post_meta($idInt, '_abj404_exclude', true);
677 if ($excludeMeta === '1') {
678 continue;
679 }
680 } elseif ($typeInt === $typeCat || $typeInt === $typeTag) {
681 $excludeMeta = get_term_meta($idInt, '_abj404_exclude', true);
682 if ($excludeMeta === '1') {
683 continue;
684 }
685 }
686 }
687
688 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($rIdAndTypeStr, $rLinkScoreFloat,
689 $rRowTypeStr, $options);
690
691 $rPermLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : '';
692 $rPermTitle = isset($permalink['title']) && is_string($permalink['title']) ? $permalink['title'] : '';
693 $rPermScore = isset($permalink['score']) && is_numeric($permalink['score']) ? (float)$permalink['score'] : 0.0;
694
695 // Skip if we're currently on the page we're about to suggest
696 if ($currentSlug !== '' && basename($rPermLink) == $currentSlug) {
697 continue;
698 }
699
700 // Skip if minimum score filtering is enabled and score is below threshold
701 if ($minScoreEnabled && $rPermScore < $minScore) {
702 continue;
703 }
704
705 if ($displayed == 0) {
706 // <ol>
707 $content .= wp_kses_post($rSuggestBefore);
708 }
709
710 // <li>
711 $content .= wp_kses_post($rSuggestEntryBefore);
712
713 $content .= "<a href=\"" . esc_url($rPermLink . $commentPartAndQueryPart) .
714 "\" title=\"" . esc_attr($rPermTitle) . "\">" .
715 esc_attr($rPermTitle) . "</a>";
716
717 // Display the score after the page link (admin only)
718 if ($showExtraAdminData) {
719 $content .= ' (' . number_format($rPermScore, 4) . ')';
720 }
721
722 // </li>
723 $content .= wp_kses_post($rSuggestEntryAfter) . "\n";
724 $displayed++;
725 $rSuggestMaxOpt = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? (int)$options['suggest_max'] : 5;
726 if ($displayed >= $rSuggestMaxOpt) {
727 break;
728 }
729 }
730
731 if ($displayed >= 1) {
732 // </ol>
733 $content .= wp_kses_post($rSuggestAfter) . "\n";
734 } else {
735 $content .= wp_kses_post(
736 self::replaceSuggestionTemplateToken($rSuggestNoresults, 'suggest_noresults_text',
737 __('No suggestions. :/ ', '404-solution')
738 ));
739 }
740
741 $content .= "\n</div>";
742
743 return $content;
744 } finally {
745 self::maybeRestoreFrontendLocale($didSwitchLocale);
746 }
747 }
748
749 /**
750 * Render a loading placeholder for async suggestions.
751 * Shows skeleton loading animation while suggestions are being computed.
752 *
753 * @param string $requestedURL The 404 URL being looked up
754 * @param array<string, mixed> $options Plugin options
755 * @return string HTML placeholder with loading state
756 */
757 public static function renderAsyncPlaceholder(string $requestedURL, array $options): string {
758 $didSwitchLocale = self::maybeSwitchToFrontendLocale();
759 try {
760 $suggestMaxVal = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
761 $suggestMax = intval($suggestMaxVal);
762
763 // Generate skeleton items based on suggest_max
764 $skeletons = '';
765 for ($i = 0; $i < $suggestMax; $i++) {
766 $skeletons .= '<li class="abj404-skeleton"></li>' . "\n";
767 }
768
769 $pSuggestTitle = isset($options['suggest_title']) && is_string($options['suggest_title']) ? $options['suggest_title'] : '';
770 $pSuggestBefore = isset($options['suggest_before']) && is_string($options['suggest_before']) ? $options['suggest_before'] : '';
771 $pSuggestAfter = isset($options['suggest_after']) && is_string($options['suggest_after']) ? $options['suggest_after'] : '';
772
773 $content = '<div id="abj404-suggestions-placeholder" class="suggest-404s" ' .
774 'data-requested-url="' . esc_attr($requestedURL) . '">' . "\n";
775 $content .= wp_kses_post(
776 self::replaceSuggestionTemplateToken($pSuggestTitle, 'suggest_title_text',
777 __('Here are some other great pages', '404-solution')
778 )) . "\n";
779 $content .= wp_kses_post($pSuggestBefore);
780 $content .= '<div class="abj404-loading">' . "\n";
781 $content .= '<p class="abj404-loading-text">' . esc_html__('Loading page suggestions...', '404-solution') . '</p>' . "\n";
782 $content .= $skeletons;
783 $content .= '</div>' . "\n";
784 $content .= wp_kses_post($pSuggestAfter) . "\n";
785 $content .= '</div>';
786
787 return $content;
788 } finally {
789 self::maybeRestoreFrontendLocale($didSwitchLocale);
790 }
791 }
792
793 /**
794 * Enqueue the async suggestion polling JavaScript.
795 *
796 * @param string $requestedURL The 404 URL for polling
797 * @return void
798 */
799 public static function enqueueAsyncPollingScript(string $requestedURL): void {
800 // Enqueue jQuery dependency
801 wp_enqueue_script('jquery');
802
803 // Enqueue polling script
804 wp_enqueue_script(
805 'abj404-suggestion-polling',
806 plugin_dir_url(__FILE__) . 'ajax/SuggestionPolling.js',
807 array('jquery'),
808 ABJ404_VERSION,
809 true // Load in footer
810 );
811
812 // Pass AJAX URL, nonce, and localized strings to JavaScript
813 wp_localize_script('abj404-suggestion-polling', 'abj404_suggestions', array(
814 'ajax_url' => admin_url('admin-ajax.php'),
815 'nonce' => wp_create_nonce('abj404_poll_suggestions'),
816 'no_suggestions_text' => __('No suggestions. :/ ', '404-solution')
817 ));
818
819 // Enqueue loading CSS
820 wp_enqueue_style(
821 'abj404-suggestions-loading',
822 plugin_dir_url(__FILE__) . 'css/suggestions-loading.css',
823 array(),
824 ABJ404_VERSION
825 );
826 }
827
828 }
829