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

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

657 lines 31.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Edit redirect page and destination option helpers.
9 */
10 trait ViewTrait_Redirects {
11
12
13 /**
14 * Resolve final destination, pageIDAndType, and redirect code from a redirect row.
15 *
16 * @param array<string, mixed> $redirect
17 * @param array<string, mixed> $options
18 * @return array{final: string, pageIDAndType: string, codeSelected: string}
19 */
20 private function resolveRedirectDestinationInfo(array $redirect, array $options): array {
21 $final = "";
22 $pageIDAndType = "";
23 $redirectType = $redirect['type'] ?? null;
24 $redirectFinalDestRaw = $redirect['final_dest'] ?? 0;
25 $redirectFinalDest = is_scalar($redirectFinalDestRaw) ? (string)$redirectFinalDestRaw : '0';
26 if ($redirectType == ABJ404_TYPE_EXTERNAL) {
27 $final = $redirectFinalDest;
28 $pageIDAndType = ABJ404_TYPE_EXTERNAL . "|" . ABJ404_TYPE_EXTERNAL;
29
30 } else if ($redirectFinalDest != 0) {
31 // if a destination has been specified then let's fill it in.
32 $pageIDAndType = $redirectFinalDest . "|" . $redirectType;
33
34 } else if ($redirectType == ABJ404_TYPE_404_DISPLAYED) {
35 $pageIDAndType = ABJ404_TYPE_404_DISPLAYED . "|" . ABJ404_TYPE_404_DISPLAYED;
36 }
37
38 $rawCode = $redirect['code'] ?? '';
39 if ($rawCode == "") {
40 $rawDefault = $options['default_redirect'] ?? '301';
41 $codeSelected = is_string($rawDefault) ? $rawDefault : '301';
42 } else {
43 $codeSelected = is_string($rawCode) ? $rawCode : '301';
44 }
45
46 return array('final' => $final, 'pageIDAndType' => $pageIDAndType, 'codeSelected' => $codeSelected);
47 }
48
49 /**
50 * Build the redirect-to autocomplete dropdown HTML from the template.
51 *
52 * @param string $pageTitle
53 * @param string $pageIDAndType
54 * @return string
55 */
56 private function buildRedirectToDropdownHtml(string $pageTitle, string $pageIDAndType): string {
57 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
58 "/html/addManualRedirectPageSearchDropdown.html");
59 $html = $this->f->str_replace('{redirect_to_label}', __('Redirect to', '404-solution'), $html);
60 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_EMPTY}',
61 __('(Type a page name or an external URL)', '404-solution'), $html);
62 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_PAGE}',
63 __('(A page has been selected.)', '404-solution'), $html);
64 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_CUSTOM_STRING}',
65 __('(A custom string has been entered.)', '404-solution'), $html);
66 $html = $this->f->str_replace('{TOOLTIP_POPUP_EXPLANATION_URL}',
67 __('(An external URL will be used.)', '404-solution'), $html);
68 $html = $this->f->str_replace('{REDIRECT_TO_USER_FIELD_WARNING}', '', $html);
69 $html = $this->f->str_replace('{redirectPageTitle}', esc_attr($pageTitle), $html);
70 $html = $this->f->str_replace('{pageIDAndType}', esc_attr($pageIDAndType), $html);
71 $html = $this->f->str_replace('{data-url}',
72 "admin-ajax.php?action=echoRedirectToPages&includeDefault404Page=true&includeSpecial=true&nonce=" . wp_create_nonce('abj404_ajax'), $html);
73 $html = $this->f->doNormalReplacements($html);
74 return $html;
75 }
76
77
78 /**
79 * Render hidden inputs and URL list for bulk redirect editing.
80 *
81 * @param array<int, int> $recnums_multiple
82 * @return array{redirect: array<string, mixed>, redirects_multiple: array<int, array<string, mixed>>}|null Null on error (already echoed).
83 */
84 private function renderBulkRedirectFormFields(array $recnums_multiple): ?array {
85 $redirects_multiple = $this->redirectsRepository->getRedirectsByIDs($recnums_multiple);
86 if ($redirects_multiple == null) {
87 echo "Error: Invalid ID Numbers! (ids: " . esc_html(implode(',', $recnums_multiple)) . ")";
88 $this->logger->debugMessage("Error: Invalid ID Numbers! (ids: " .
89 esc_html(implode(',', $recnums_multiple)) . ")");
90 return null;
91 }
92
93 echo '<input type="hidden" name="ids_multiple" value="' . esc_attr(implode(',', $recnums_multiple)) . '">';
94
95 // Bulk URL list
96 echo '<div class="abj404-form-group">';
97 echo '<label class="abj404-form-label">' . esc_html__('URLs to redirect', '404-solution') . ' (' . count($redirects_multiple) . ')</label>';
98 echo '<div class="abj404-url-list">';
99 echo '<ul>';
100 foreach ($redirects_multiple as $bulkRedirect) {
101 /** @var array<string, mixed> $bulkRedirect */
102 $bulkUrl = is_string($bulkRedirect['url'] ?? '') ? (string)($bulkRedirect['url'] ?? '') : '';
103 echo '<li><code>' . esc_html($bulkUrl) . '</code></li>';
104 }
105 echo '</ul>';
106 echo '</div>';
107 echo '</div>';
108
109 // here we set the variable to the first value returned because it's used to set default values
110 // in the form data.
111 $redirect = reset($redirects_multiple);
112
113 return array(
114 'redirect' => $redirect,
115 'redirects_multiple' => $redirects_multiple,
116 );
117 }
118
119 /**
120 * Render the suggestion block for a captured URL's best match.
121 *
122 * @param array{title: string, score: int, id_and_type: string, type_label: string} $suggestion
123 * @return void
124 */
125 private function renderSuggestionBlock(array $suggestion): void {
126 echo '<div class="abj404-suggestion-block" id="abj404-suggestion-block">';
127 echo '<div class="abj404-suggestion-label">' . esc_html__('Suggested destination', '404-solution') . '</div>';
128 echo '<div class="abj404-suggestion-content">';
129 echo '<strong>' . esc_html($suggestion['title']) . '</strong>';
130 if (!empty($suggestion['type_label'])) {
131 echo '<span class="abj404-suggestion-type">' . esc_html($suggestion['type_label']) . '</span>';
132 }
133 echo '<span class="abj404-score-badge abj404-score-' . ($suggestion['score'] >= 75 ? 'high' : ($suggestion['score'] >= 50 ? 'medium' : 'low')) . '">'
134 . esc_html($suggestion['score'] . '%') . ' ' . esc_html__('match', '404-solution') . '</span>';
135 echo '</div>';
136 echo '<div class="abj404-suggestion-actions">';
137 echo '<button type="button" class="button button-primary" onclick="abj404AcceptSuggestion(this)"'
138 . ' data-page-title="' . esc_attr($suggestion['title']) . '"'
139 . ' data-page-id-type="' . esc_attr($suggestion['id_and_type']) . '">'
140 . esc_html__('Accept Suggestion', '404-solution') . '</button>';
141 echo '<button type="button" class="button" onclick="abj404ShowManualPicker()">'
142 . esc_html__('Pick a Different Page', '404-solution') . '</button>';
143 echo '</div>';
144 echo '</div>';
145 }
146
147 /**
148 * Render hidden inputs, URL field, regex checkbox and date fields for a single redirect.
149 *
150 * @param int $recnum
151 * @param bool $isSimpleMode
152 * @return array{redirect: array<string, mixed>, redirects_multiple: array<int, array<string, mixed>>, redirectUrl: string, startDate: string, endDate: string}|null Null on error (already echoed).
153 */
154 private function renderSingleRedirectFormFields(int $recnum, bool $isSimpleMode): ?array {
155 $recnumAsArray = array();
156 $recnumAsArray[] = $recnum;
157 $redirects_multiple = $this->redirectsRepository->getRedirectsByIDs($recnumAsArray);
158
159 if (empty($redirects_multiple)) {
160 echo "Error: Invalid ID Number! (id: " . esc_html((string)$recnum) . ")";
161 $this->logger->errorMessage("Error: Invalid ID Number! (id: " . esc_html((string)$recnum) . ")");
162 return null;
163 }
164
165 /** @var array<string, mixed> $redirect */
166 $redirect = reset($redirects_multiple);
167 $row = ABJ_404_Solution_RedirectRow::fromRaw($redirect);
168 $isRegexChecked = '';
169 if ($row !== null && $row->isRegex()) {
170 $isRegexChecked = ' checked ';
171 }
172
173 $redirectId = $row !== null ? (string)$row->getId() : '';
174 $redirectUrl = $row !== null ? $row->getUrl() : '';
175 echo '<input type="hidden" name="id" value="' . esc_attr($redirectId) . '">';
176
177 // URL field (with optional "Matched by" note for auto-created redirects)
178 echo '<div class="abj404-form-group">';
179 echo '<label class="abj404-form-label" for="url">' . esc_html__('URL', '404-solution') . ' *</label>';
180 echo '<input type="text" id="url" name="url" class="abj404-form-input" value="' . esc_attr($redirectUrl) . '" required>';
181 $redirectEngine = $row !== null ? $row->getEngine() : '';
182 if ($redirectEngine !== '') {
183 $humanEngine = $this->humanizeEngineName($redirectEngine);
184 echo '<p class="abj404-form-help abj404-matched-by">' . esc_html__('Auto-matched by:', '404-solution') . ' ' . esc_html($humanEngine) . '</p>';
185 }
186 echo '</div>';
187
188 // Regex checkbox (hidden in Simple mode)
189 if (!$isSimpleMode) {
190 echo '<div class="abj404-form-group">';
191 echo '<div class="abj404-checkbox-group">';
192 echo '<input type="checkbox" name="is_regex_url" id="is_regex_url" class="abj404-checkbox-input" value="1" ' . $isRegexChecked . '>';
193 echo '<label for="is_regex_url" class="abj404-checkbox-label">' . esc_html__('Treat this URL as a regular expression', '404-solution') . '</label>';
194 echo ' <a href="#" class="abj404-regex-toggle" onclick="abj404ToggleRegexInfo(event)">' . esc_html__('(Explain)', '404-solution') . '</a>';
195 echo '</div>';
196 echo '<div class="abj404-regex-info" style="display: none;">';
197 echo '<p>' . esc_html__('When checked, the text is treated as a regular expression. Note that including a bad regular expression or one that takes too long will break your website. So please use caution and test them elsewhere before trying them here. If you don\'t know what you\'re doing please don\'t use this option (as it\'s not necessary for the functioning of the plugin).', '404-solution') . '</p>';
198 echo '<p><strong>' . esc_html__('Example:', '404-solution') . '</strong> <code>/events/(.+)</code></p>';
199 echo '<p>' . esc_html__('/events/(.+) will match any URL that begins with /events/ and redirect to the specified page. Since a capture group is used, you can use a $1 replacement in the destination string of an external URL.', '404-solution') . '</p>';
200 echo '</div>';
201 echo '</div>';
202 }
203
204 // Scheduled redirect dates (rendered inside Advanced Options in echoEditRedirect)
205 $startTs = $row !== null ? $row->getStartTs() : 0;
206 $endTs = $row !== null ? $row->getEndTs() : 0;
207 $startDate = $startTs > 0 ? date('Y-m-d', $startTs) : '';
208 $endDate = $endTs > 0 ? date('Y-m-d', $endTs) : '';
209
210 return array(
211 'redirect' => $redirect,
212 'redirects_multiple' => $redirects_multiple,
213 'redirectUrl' => $redirectUrl,
214 'startDate' => $startDate,
215 'endDate' => $endDate,
216 );
217 }
218
219 /** @return void */
220 function echoAdminEditRedirectPage() {
221
222 $options = $this->getOptionsWithDefaults();
223
224 // Compute source page early so we can use it in the back link
225 $source_page = $this->viewGetPostOrGetSanitize('source_page');
226 if ($source_page === '') {
227 $source_page = $this->viewGetPostOrGetSanitize('subpage');
228 }
229 if ($source_page === '' || $source_page == 'abj404_edit') {
230 $source_page = 'abj404_redirects';
231 }
232 $backUrl = '?page=' . ABJ404_PP . '&subpage=' . esc_attr($source_page);
233
234 // Modern page container
235 echo '<div class="abj404-edit-page">';
236 echo '<div class="abj404-edit-container">';
237
238 // Header row: title + back link
239 $isSimpleMode = $this->logic->getSettingsMode() === 'simple';
240 $isFromCaptured = ($source_page === 'abj404_captured');
241 echo '<div class="abj404-edit-page-header">';
242 if ($isSimpleMode && $isFromCaptured) {
243 echo '<h2>' . esc_html__('Create Redirect', '404-solution') . '</h2>';
244 echo '<a href="' . esc_url($backUrl) . '" class="abj404-back-link">&#8592; ' . esc_html__('Back to Captured 404s', '404-solution') . '</a>';
245 } else {
246 echo '<h2>' . esc_html__('Edit Redirect', '404-solution') . '</h2>';
247 echo '<a href="' . esc_url($backUrl) . '" class="abj404-back-link">&#8592; ' . esc_html__('Back to Redirects', '404-solution') . '</a>';
248 }
249 echo '</div>';
250
251 $link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_edit", "abj404editRedirect");
252
253 echo '<form method="POST" name="admin-edit-redirect" action="' . esc_attr($link) . '" onsubmit="return validateAddManualRedirectForm(event);">';
254 echo "<input type=\"hidden\" name=\"action\" value=\"editRedirect\">";
255
256 // Preserve source page for return navigation
257 echo "<input type=\"hidden\" name=\"source_page\" value=\"" . esc_attr($source_page) . "\">";
258
259 // Preserve table options so we can return to the exact same view
260 $filter = $this->viewGetPostOrGetSanitize('filter');
261 if ($filter !== '') {
262 echo "<input type=\"hidden\" name=\"source_filter\" value=\"" . esc_attr($filter) . "\">";
263 }
264 $orderby = $this->viewGetPostOrGetSanitize('orderby');
265 if ($orderby !== '') {
266 echo "<input type=\"hidden\" name=\"source_orderby\" value=\"" . esc_attr($orderby) . "\">";
267 }
268 $order = $this->viewGetPostOrGetSanitize('order');
269 if ($order !== '') {
270 echo "<input type=\"hidden\" name=\"source_order\" value=\"" . esc_attr($order) . "\">";
271 }
272 $paged = $this->viewGetPostOrGetSanitize('paged');
273 if ($paged !== '') {
274 echo "<input type=\"hidden\" name=\"source_paged\" value=\"" . esc_attr($paged) . "\">";
275 }
276
277 $recnum = null;
278 $recnums_multiple = null;
279 $startDate = '';
280 $endDate = '';
281 if (isset($_GET['id']) && $this->f->regexMatch('[0-9]+', $_GET['id'])) {
282 $this->logger->debugMessage("Edit redirect page. GET ID: " .
283 wp_kses_post((string)json_encode($_GET['id'])));
284 $recnum = absint($_GET['id']);
285
286 } else if (isset($_POST['id']) && $this->f->regexMatch('[0-9]+', $_POST['id'])) {
287 $this->logger->debugMessage("Edit redirect page. POST ID: " .
288 wp_kses_post((string)json_encode($_POST['id'])));
289 $recnum = absint($_POST['id']);
290
291 } else if ($this->viewGetPostOrGetSanitize('idnum') !== '' || isset($_GET['idnum']) || isset($_POST['idnum'])) {
292 $rawIdnum = isset($_GET['idnum']) ? $_GET['idnum'] : (isset($_POST['idnum']) ? $_POST['idnum'] : $this->viewGetPostOrGetSanitize('idnum'));
293 $recnums_multiple = array_values(array_filter(array_map(function($v) { return absint($v); }, (array)$rawIdnum), function($v) { return $v > 0; }));
294 $this->logger->debugMessage("Edit redirect page. ids_multiple: " .
295 wp_kses_post((string)json_encode($recnums_multiple)));
296
297 } else {
298 echo __('Error: No ID(s) found for edit request.', '404-solution');
299 $this->logger->debugMessage("No ID(s) found in GET or POST data for edit request.");
300 return;
301 }
302
303 // Decide whether we're editing one or multiple redirects.
304 // If we're editing only one then set the ID to that one value.
305 if ($recnum != null) {
306 $recnumAsArray = array();
307 $recnumAsArray[] = $recnum;
308 $redirects_multiple = $this->redirectsRepository->getRedirectsByIDs($recnumAsArray);
309
310 if (empty($redirects_multiple)) {
311 echo "Error: Invalid ID Number! (id: " . esc_html((string)$recnum) . ")";
312 $this->logger->errorMessage("Error: Invalid ID Number! (id: " . esc_html((string)$recnum) . ")");
313 return;
314 }
315
316 /** @var array<string, mixed> $redirect */
317 $redirect = reset($redirects_multiple);
318 $row = ABJ_404_Solution_RedirectRow::fromRaw($redirect);
319 $isRegexChecked = '';
320 if ($row !== null && $row->isRegex()) {
321 $isRegexChecked = ' checked ';
322 }
323
324 $redirectId = $row !== null ? (string)$row->getId() : '';
325 $redirectUrl = $row !== null ? $row->getUrl() : '';
326 echo '<input type="hidden" name="id" value="' . esc_attr($redirectId) . '">';
327
328 // URL field (with optional "Matched by" note for auto-created redirects)
329 echo '<div class="abj404-form-group">';
330 echo '<label class="abj404-form-label" for="url">' . esc_html__('URL', '404-solution') . ' *</label>';
331 echo '<input type="text" id="url" name="url" class="abj404-form-input" value="' . esc_attr($redirectUrl) . '" required>';
332 $redirectEngine = $row !== null ? $row->getEngine() : '';
333 if ($redirectEngine !== '') {
334 $humanEngine = $this->humanizeEngineName($redirectEngine);
335 echo '<p class="abj404-form-help abj404-matched-by">' . esc_html__('Auto-matched by:', '404-solution') . ' ' . esc_html($humanEngine) . '</p>';
336 }
337 echo '</div>';
338
339 // Regex checkbox (hidden in Simple mode)
340 if (!$isSimpleMode) {
341 echo '<div class="abj404-form-group">';
342 echo '<div class="abj404-checkbox-group">';
343 echo '<input type="checkbox" name="is_regex_url" id="is_regex_url" class="abj404-checkbox-input" value="1" ' . $isRegexChecked . '>';
344 echo '<label for="is_regex_url" class="abj404-checkbox-label">' . esc_html__('Treat this URL as a regular expression', '404-solution') . '</label>';
345 echo ' <a href="#" class="abj404-regex-toggle" onclick="abj404ToggleRegexInfo(event)">' . esc_html__('(Explain)', '404-solution') . '</a>';
346 echo '</div>';
347 echo '<div class="abj404-regex-info" style="display: none;">';
348 echo '<p>' . esc_html__('When checked, the text is treated as a regular expression. Note that including a bad regular expression or one that takes too long will break your website. So please use caution and test them elsewhere before trying them here. If you don\'t know what you\'re doing please don\'t use this option (as it\'s not necessary for the functioning of the plugin).', '404-solution') . '</p>';
349 echo '<p><strong>' . esc_html__('Example:', '404-solution') . '</strong> <code>/events/(.+)</code></p>';
350 echo '<p>' . esc_html__('/events/(.+) will match any URL that begins with /events/ and redirect to the specified page. Since a capture group is used, you can use a $1 replacement in the destination string of an external URL.', '404-solution') . '</p>';
351 echo '</div>';
352 echo '</div>';
353 }
354
355 // Scheduled redirect dates (rendered inside Advanced Options in echoEditRedirect)
356 $startTs = $row !== null ? $row->getStartTs() : 0;
357 $endTs = $row !== null ? $row->getEndTs() : 0;
358 $startDate = $startTs > 0 ? date('Y-m-d', $startTs) : '';
359 $endDate = $endTs > 0 ? date('Y-m-d', $endTs) : '';
360
361 } else if ($recnums_multiple != null) {
362 $bulkResult = $this->renderBulkRedirectFormFields($recnums_multiple);
363 if ($bulkResult === null) {
364 return;
365 }
366 $redirect = $bulkResult['redirect'];
367 $redirects_multiple = $bulkResult['redirects_multiple'];
368 $redirectUrl = '';
369
370 } else {
371 $idsText = isset($rawIdnum) && is_array($rawIdnum) ? implode(',', array_map(function($v) { return is_scalar($v) ? (string)$v : ''; }, $rawIdnum)) : '';
372 echo $errorText = ($recnum === 0 || $idsText !== '') ? "Error: Invalid ID Number(s) specified! (id: " . esc_html((string)$recnum) . ", ids: " . esc_html($idsText) . ")" : __('Error: No ID(s) found for edit request.', '404-solution');
373 $this->logger->debugMessage($errorText . " (id: " . esc_html((string)$recnum) .
374 ", ids: " . esc_html($idsText) . ")");
375 return;
376 }
377
378 $destInfo = $this->resolveRedirectDestinationInfo($redirect, $options);
379 $final = $destInfo['final'];
380 $pageIDAndType = $destInfo['pageIDAndType'];
381 $codeSelected = $destInfo['codeSelected'];
382
383 // Try to find a suggested destination for captured URLs.
384 // Any captured URL should get a suggestion lookup — the plugin may have auto-assigned
385 // a destination via the spell-checker, but the user hasn't chosen one yet.
386 $suggestion = null;
387 $isSimpleMode = $this->logic->getSettingsMode() === 'simple';
388 if ($isFromCaptured && !empty($redirectUrl)) {
389 $suggestion = $this->getSuggestedDestination($redirectUrl, $options);
390 }
391
392 // Render suggested destination block (if available)
393 if ($suggestion !== null) {
394 $this->renderSuggestionBlock($suggestion);
395 }
396
397 // When creating from captured URLs, clear the redirect_to field so the
398 // placeholder text is visible. The suggestion block (if shown) handles
399 // presenting the best match separately.
400 $redirectFinalDest = is_scalar($redirect['final_dest'] ?? 0) ? (string)($redirect['final_dest'] ?? '0') : '0';
401 if ($isFromCaptured) {
402 $pageTitle = '';
403 $pageIDAndType = '';
404 } else {
405 $pageTitle = $this->logic->getPageTitleFromIDAndType($pageIDAndType, $redirectFinalDest);
406 }
407 $html = $this->buildRedirectToDropdownHtml($pageTitle, $pageIDAndType);
408
409 // In Simple mode with a suggestion, hide the manual picker initially
410 $manualPickerHiddenClass = ($suggestion !== null && $isSimpleMode) ? ' abj404-hidden' : '';
411 echo '<div class="abj404-form-group abj404-autocomplete-wrapper' . $manualPickerHiddenClass . '" id="abj404-manual-picker">';
412 echo $html;
413 echo '</div>';
414
415 $this->echoEditRedirect($final, $codeSelected, __('Update Redirect', '404-solution'), $source_page, $filter, $orderby, $order, $startDate, $endDate);
416
417 echo '</form>';
418 echo '</div>'; // end abj404-edit-container
419 echo '</div>'; // end abj404-edit-page
420 }
421
422 /**
423 * @param string $dest
424 * @param array<int, object> $rows
425 * @return string
426 */
427 function echoRedirectDestinationOptionsOthers($dest, $rows) {
428 $content = array();
429
430 $rowCounter = 0;
431 $currentPostType = '';
432
433 foreach ($rows as $row) {
434 $rowCounter++;
435 /** @var object{id: int, post_type: string, depth?: int} $row */
436 $id = $row->id;
437 $theTitle = get_the_title($id);
438 $thisval = $id . "|" . ABJ404_TYPE_POST;
439
440 $selected = "";
441 if ($thisval == $dest) {
442 $selected = " selected";
443 }
444
445 abj_service('request_context')->debug_info = 'Before row: ' . $rowCounter . ', Title: ' . $theTitle .
446 ', Post type: ' . $row->post_type;
447
448 if ($row->post_type != $currentPostType) {
449 if ($currentPostType != '') {
450 $content[] = "\n" . '</optgroup>' . "\n";
451 }
452
453 $content[] = "\n" . '<optgroup label="' . __(ucwords($row->post_type), '404-solution') . '">' . "\n";
454 $currentPostType = $row->post_type;
455 }
456
457 // this is split in this ridiculous way to help me figure out how to resolve a memory issue.
458 // (https://wordpress.org/support/topic/options-tab-is-not-loading/)
459 $content[] = "\n <option value=\"";
460 $content[] = esc_attr($thisval);
461 $content[] = "\"";
462 $content[] = $selected;
463 $content[] = ">";
464
465 // insert some spaces for child pages.
466 $depth = property_exists($row, 'depth') ? intval($row->depth) : 0;
467 for ($i = 0; $i < $depth; $i++) {
468 $content[] = "&nbsp;&nbsp;&nbsp;";
469 }
470
471 $content[] = __(ucwords($row->post_type), '404-solution');
472 $content[] = ": ";
473 $content[] = esc_html($theTitle);
474 $content[] = "</option>";
475
476 abj_service('request_context')->debug_info = 'After row: ' . $rowCounter . ', Title: ' . $theTitle .
477 ', Post type: ' . $row->post_type;
478 }
479
480 $content[] = "\n" . '</optgroup>' . "\n";
481
482
483 abj_service('request_context')->debug_info = 'Cleared after building redirect destination page list.';
484
485 return implode('', $content);
486 }
487
488 /**
489 * @param string $dest
490 * @return string
491 */
492 function echoRedirectDestinationOptionsCatsTags($dest) {
493 $content = "";
494 $content .= "\n" . '<optgroup label="Categories">' . "\n";
495
496 $customTagsEtc = array();
497
498 // categories ---------------------------------------------
499 $cats = $this->contentRepository->getPublishedCategories();
500 foreach ($cats as $cat) {
501 /** @var \WP_Term $cat */
502 $taxonomy = $cat->taxonomy;
503 if ($taxonomy != 'category') {
504 continue;
505 }
506
507 $id = $cat->term_id;
508 $theTitle = $cat->name;
509 $thisval = $id . "|" . ABJ404_TYPE_CAT;
510
511 $selected = "";
512 if ($thisval == $dest) {
513 $selected = " selected";
514 }
515 $content .= "\n<option value=\"" . esc_attr($thisval) . "\"" . $selected . ">" . __('Category', '404-solution') . ": " . $theTitle . "</option>";
516 }
517 $content .= "\n" . '</optgroup>' . "\n";
518 /** @var array<int, object{taxonomy: string, name?: string}> $cats */
519 $customTagsEtc = $this->logic->getMapOfCustomCategories($cats);
520
521 // tags ---------------------------------------------
522 $content .= "\n" . '<optgroup label="Tags">' . "\n";
523 $tags = $this->contentRepository->getPublishedTags();
524 foreach ($tags as $tag) {
525 /** @var \WP_Term $tag */
526 $id = $tag->term_id;
527 $theTitle = $tag->name;
528 $thisval = $id . "|" . ABJ404_TYPE_TAG;
529
530 $selected = "";
531 if ($thisval == $dest) {
532 $selected = " selected";
533 }
534 $content .= "\n<option value=\"" . esc_attr($thisval) . "\"" . $selected . ">" . __('Tag', '404-solution') . ": " . $theTitle . "</option>";
535 }
536 $content .= "\n" . '</optgroup>' . "\n";
537
538 // custom ---------------------------------------------
539 foreach ($customTagsEtc as $taxonomy => $catRow) {
540 $content .= "\n" . '<optgroup label="' . esc_html($taxonomy) . '">' . "\n";
541
542 foreach ($catRow as $cat) {
543 /** @var \WP_Term $cat */
544 $id = $cat->term_id;
545 $theTitle = $cat->name;
546 $thisval = $id . "|" . ABJ404_TYPE_CAT;
547
548 $selected = "";
549 if ($thisval == $dest) {
550 $selected = " selected";
551 }
552 $content .= "\n<option value=\"" . esc_attr($thisval) . "\"" . $selected . ">" . __('Custom', '404-solution') . ": " . $theTitle . "</option>";
553 }
554
555 $content .= "\n" . '</optgroup>' . "\n";
556 }
557
558 return $content;
559 }
560
561 /**
562 * Convert a raw engine class name to a human-readable label.
563 *
564 * Examples:
565 * TitleMatchingEngine → "Title Matching"
566 * SpellingMatchingEngine → "Spelling Matching"
567 * CategoryTagMatchingEngine → "Category/Tag Matching"
568 * UrlFixEngine → "URL Fix"
569 * ArchiveFallbackEngine → "Archive Fallback"
570 *
571 * @param string $rawName
572 * @return string
573 */
574 private function humanizeEngineName(string $rawName): string {
575 // Strip full namespace prefix if stored with it.
576 $name = preg_replace('/^ABJ_404_Solution_/', '', $rawName);
577 if (!is_string($name)) {
578 $name = $rawName;
579 }
580 // Strip "MatchingEngine" or bare "Engine" suffix.
581 $name = (string)preg_replace('/MatchingEngine$/', ' Matching', $name);
582 $name = (string)preg_replace('/Engine$/', '', $name);
583 // Insert a space before each upper-case letter that follows a lower-case letter
584 // (e.g. CategoryTag → Category Tag).
585 $name = (string)preg_replace('/(?<=[a-z])([A-Z])/', ' $1', $name);
586 $name = trim($name);
587 // Fix known abbreviations.
588 $name = str_replace(array('Url ', 'Url'), array('URL ', 'URL'), $name);
589 // Fix Category/Tag — appears as "Category Tag Matching", make the separator a slash.
590 $name = str_replace('Category Tag', 'Category/Tag', $name);
591 return $name !== '' ? $name : $rawName;
592 }
593
594 /**
595 * Get the best suggested destination for a captured URL using the spell-checker.
596 *
597 * @param string $url The captured 404 URL.
598 * @param array<string, mixed> $options Plugin options.
599 * @return array{title: string, score: int, id_and_type: string, type_label: string}|null The best match, or null if none found.
600 */
601 private function getSuggestedDestination(string $url, array $options): ?array {
602 try {
603 $spellChecker = abj_service('spell_checker');
604 $permalinksPacket = $spellChecker->findMatchingPosts($url, '1', '1');
605 $permalinks = is_array($permalinksPacket[0] ?? null) ? $permalinksPacket[0] : array();
606 $rowType = is_string($permalinksPacket[1] ?? '') ? (string)($permalinksPacket[1] ?? '') : '';
607
608 if (empty($permalinks)) {
609 return null;
610 }
611
612 // Take the top match
613 $topIdAndType = array_key_first($permalinks);
614 $topScore = intval($permalinks[$topIdAndType]);
615
616 // Only suggest if score is at least 25%
617 if ($topScore < 25) {
618 return null;
619 }
620
621 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray(
622 $topIdAndType, $topScore, $rowType, $options
623 );
624
625 $title = is_string($permalink['title'] ?? '') ? (string)($permalink['title'] ?? '') : '';
626 if ($title === '' || ($permalink['status'] ?? '') === 'trash') {
627 return null;
628 }
629
630 // Determine a human-readable type label
631 $typeParts = explode('|', is_string($topIdAndType) ? $topIdAndType : '');
632 $typeInt = isset($typeParts[1]) && is_numeric($typeParts[1]) ? (int)$typeParts[1] : -1;
633 $typeLabel = '';
634 if ($typeInt === ABJ404_TYPE_POST) {
635 $postType = get_post_type((int)$typeParts[0]);
636 $typeLabel = ($postType === 'page') ? __('Page', '404-solution') : __('Post', '404-solution');
637 } elseif ($typeInt === ABJ404_TYPE_CAT) {
638 $typeLabel = __('Category', '404-solution');
639 } elseif ($typeInt === ABJ404_TYPE_TAG) {
640 $typeLabel = __('Tag', '404-solution');
641 } elseif ($typeInt === ABJ404_TYPE_HOME) {
642 $typeLabel = __('Home', '404-solution');
643 }
644
645 return array(
646 'title' => $title,
647 'score' => $topScore,
648 'id_and_type' => is_string($topIdAndType) ? $topIdAndType : '',
649 'type_label' => $typeLabel,
650 );
651 } catch (\Throwable $e) { // allow-silent-catch: spell-checker may fail on some URLs (encoding, length); null signals "no suggestion" which the caller already handles
652 return null;
653 }
654 }
655
656 }
657