PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.19, at includes/ShortCode.php

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