| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Renders edit-redirect form templates and form-table rows. |
| 9 |
*/ |
| 10 |
class ABJ_404_Solution_RedirectEditFormPresenter { |
| 11 |
|
| 12 |
/** @var ABJ_404_Solution_Functions */ |
| 13 |
private $functions; |
| 14 |
|
| 15 |
/** @var ABJ_404_Solution_RedirectEngineLabeler */ |
| 16 |
private $engineLabeler; |
| 17 |
|
| 18 |
/** |
| 19 |
* @param ABJ_404_Solution_Functions $functions |
| 20 |
* @param ABJ_404_Solution_RedirectEngineLabeler $engineLabeler |
| 21 |
*/ |
| 22 |
public function __construct($functions, ABJ_404_Solution_RedirectEngineLabeler $engineLabeler) { |
| 23 |
$this->functions = $functions; |
| 24 |
$this->engineLabeler = $engineLabeler; |
| 25 |
} |
| 26 |
|
| 27 |
/** |
| 28 |
* Build the redirect-to autocomplete dropdown HTML from the template. |
| 29 |
* |
| 30 |
* @param string $pageTitle |
| 31 |
* @param string $pageIDAndType |
| 32 |
* @return string |
| 33 |
*/ |
| 34 |
public function buildRedirectToDropdownHtml(string $pageTitle, string $pageIDAndType): string { |
| 35 |
$html = $this->readTemplate('addManualRedirectPageSearchDropdown.html'); |
| 36 |
$html = $this->functions->str_replace('{redirect_to_label}', __('Redirect to', '404-solution'), $html); |
| 37 |
$html = $this->functions->str_replace('{TOOLTIP_POPUP_EXPLANATION_EMPTY}', |
| 38 |
__('(Type a page name or an external URL)', '404-solution'), $html); |
| 39 |
$html = $this->functions->str_replace('{TOOLTIP_POPUP_EXPLANATION_PAGE}', |
| 40 |
__('(A page has been selected.)', '404-solution'), $html); |
| 41 |
$html = $this->functions->str_replace('{TOOLTIP_POPUP_EXPLANATION_CUSTOM_STRING}', |
| 42 |
__('(A custom string has been entered.)', '404-solution'), $html); |
| 43 |
$html = $this->functions->str_replace('{TOOLTIP_POPUP_EXPLANATION_URL}', |
| 44 |
__('(An external URL will be used.)', '404-solution'), $html); |
| 45 |
$html = $this->functions->str_replace('{REDIRECT_TO_USER_FIELD_WARNING}', '', $html); |
| 46 |
$html = $this->functions->str_replace('{redirectPageTitle}', esc_attr($pageTitle), $html); |
| 47 |
$html = $this->functions->str_replace('{pageIDAndType}', esc_attr($pageIDAndType), $html); |
| 48 |
$html = $this->functions->str_replace('{data-url}', |
| 49 |
"admin-ajax.php?action=echoRedirectToPages&includeDefault404Page=true&includeSpecial=true&nonce=" . wp_create_nonce('abj404_ajax'), $html); |
| 50 |
return $this->functions->doNormalReplacements($html); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* @param array<int, array<string, mixed>> $redirects |
| 55 |
* @return string |
| 56 |
*/ |
| 57 |
public function buildBulkUrlsRowHtml(array $redirects): string { |
| 58 |
$items = ''; |
| 59 |
$itemTemplate = $this->readTemplate('redirectEditBulkUrlItem.html'); |
| 60 |
foreach ($redirects as $bulkRedirect) { |
| 61 |
$bulkUrl = is_string($bulkRedirect['url'] ?? '') ? (string)($bulkRedirect['url'] ?? '') : ''; |
| 62 |
$items .= $this->functions->str_replace('{url}', esc_html($bulkUrl), $itemTemplate); |
| 63 |
} |
| 64 |
|
| 65 |
$rowHtml = $this->readTemplate('editRedirectBulkUrls.html'); |
| 66 |
$rowHtml = $this->functions->str_replace('{bulk_urls_label}', esc_html__('URLs to redirect', '404-solution'), $rowHtml); |
| 67 |
$rowHtml = $this->functions->str_replace('{bulk_count}', (string)count($redirects), $rowHtml); |
| 68 |
return $this->functions->str_replace('{bulk_url_items}', $items, $rowHtml); |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* @param array<int, int> $ids |
| 73 |
* @return string |
| 74 |
*/ |
| 75 |
public function buildIdsMultipleHiddenInput(array $ids): string { |
| 76 |
$hiddenInput = $this->readTemplate('editRedirectIdsMultipleHiddenInput.html'); |
| 77 |
return $this->functions->str_replace('{ids_multiple}', esc_attr(implode(',', $ids)), $hiddenInput); |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Build the suggestion block HTML for a captured URL's best match. |
| 82 |
* |
| 83 |
* @param array{title: string, score: int, id_and_type: string, type_label: string} $suggestion |
| 84 |
* @return string |
| 85 |
*/ |
| 86 |
public function buildSuggestionBlockHtml(array $suggestion): string { |
| 87 |
$bucket = $suggestion['score'] >= 75 ? 'high' : ($suggestion['score'] >= 50 ? 'medium' : 'low'); |
| 88 |
$typeLabel = ''; |
| 89 |
if (!empty($suggestion['type_label'])) { |
| 90 |
$typeLabel = $this->readTemplate('editRedirectSuggestionTypeLabel.html'); |
| 91 |
$typeLabel = $this->functions->str_replace('{type_label}', esc_html($suggestion['type_label']), $typeLabel); |
| 92 |
} |
| 93 |
|
| 94 |
$html = $this->readTemplate('editRedirectSuggestionBlock.html'); |
| 95 |
$html = $this->functions->str_replace('{suggestion_label}', esc_html__('Suggested destination', '404-solution'), $html); |
| 96 |
$html = $this->functions->str_replace('{suggestion_title}', esc_html($suggestion['title']), $html); |
| 97 |
$html = $this->functions->str_replace('{suggestion_title_attr}', esc_attr($suggestion['title']), $html); |
| 98 |
$html = $this->functions->str_replace('{suggestion_type_label}', $typeLabel, $html); |
| 99 |
$html = $this->functions->str_replace('{suggestion_score_bucket}', $bucket, $html); |
| 100 |
$html = $this->functions->str_replace('{suggestion_score}', esc_html((string)$suggestion['score']), $html); |
| 101 |
$html = $this->functions->str_replace('{suggestion_id_and_type}', esc_attr($suggestion['id_and_type']), $html); |
| 102 |
$html = $this->functions->str_replace('{match_text}', esc_html__('match', '404-solution'), $html); |
| 103 |
$html = $this->functions->str_replace('{accept_label}', esc_html__('Accept Suggestion', '404-solution'), $html); |
| 104 |
return $this->functions->str_replace('{pick_different_label}', esc_html__('Pick a Different Page', '404-solution'), $html); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* @param string $redirectId |
| 109 |
* @return string |
| 110 |
*/ |
| 111 |
public function buildRedirectIdHiddenInput(string $redirectId): string { |
| 112 |
$hiddenInputs = $this->readTemplate('editRedirectIdHiddenInput.html'); |
| 113 |
return $this->functions->str_replace('{redirect_id}', esc_attr($redirectId), $hiddenInputs); |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Build the notice shown when the edit screen was asked for redirect ids |
| 118 |
* that no longer have a row. |
| 119 |
* |
| 120 |
* Rendered as the stock WordPress warning notice (docs/ui-aesthetic: |
| 121 |
* "All four notice states share the same structural shape") because the |
| 122 |
* admin has done nothing wrong: the link they clicked was rendered before |
| 123 |
* the row was removed. The link back is what makes the dead end |
| 124 |
* recoverable. |
| 125 |
* |
| 126 |
* @param array<int, int> $missingIds Ids the request asked for. |
| 127 |
* @param string $backUrl Where the "back to the list" link points. |
| 128 |
* @param string $backLabel Visible text of that link. |
| 129 |
* @return string |
| 130 |
*/ |
| 131 |
public function buildMissingRedirectsNoticeHtml(array $missingIds, string $backUrl, string $backLabel): string { |
| 132 |
$idList = implode(', ', array_map('strval', $missingIds)); |
| 133 |
$message = sprintf( |
| 134 |
/* translators: %s is a comma-separated list of redirect id numbers. */ |
| 135 |
_n( |
| 136 |
'Redirect %s was not found. It may have been deleted since this page was opened.', |
| 137 |
'Redirects %s were not found. They may have been deleted since this page was opened.', |
| 138 |
count($missingIds), |
| 139 |
'404-solution' |
| 140 |
), |
| 141 |
$idList |
| 142 |
); |
| 143 |
|
| 144 |
return $this->buildEditScreenNoticeHtml($message, $backUrl, $backLabel); |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Build the notice shown when the request named more redirects than one |
| 149 |
* edit screen may carry. |
| 150 |
* |
| 151 |
* A dead end on purpose, sharing the shape of the other two. The |
| 152 |
* alternative -- render the first MAX_SELECTED_IDS and say so -- leaves the |
| 153 |
* admin holding a partial selection whose edge they cannot see: nothing on |
| 154 |
* the screen tells them WHICH of their choices was dropped, and the save |
| 155 |
* that follows applies to the survivors. Refusing is recoverable in one |
| 156 |
* step (select fewer), and names both numbers so the step is obvious. |
| 157 |
* |
| 158 |
* @param int $requestedCount How many the request actually named. |
| 159 |
* @param int $maximum The plugin's ceiling. |
| 160 |
* @param string $backUrl Where the "back to the list" link points. |
| 161 |
* @param string $backLabel Visible text of that link. |
| 162 |
* @return string |
| 163 |
*/ |
| 164 |
public function buildTooManySelectedNoticeHtml(int $requestedCount, int $maximum, |
| 165 |
string $backUrl, string $backLabel): string { |
| 166 |
$message = sprintf( |
| 167 |
/* translators: 1: number of redirects the admin selected. 2: the maximum allowed. */ |
| 168 |
__('You selected %1$s redirects. At most %2$s can be edited at once, so nothing was changed. Please select fewer and try again.', '404-solution'), |
| 169 |
number_format_i18n($requestedCount), |
| 170 |
number_format_i18n($maximum) |
| 171 |
); |
| 172 |
|
| 173 |
return $this->buildEditScreenNoticeHtml($message, $backUrl, $backLabel); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Build the notice shown when the edit screen was reached with no usable |
| 178 |
* redirect id at all (no id parameter, or only zero / non-numeric ones). |
| 179 |
* |
| 180 |
* @param string $backUrl Where the "back to the list" link points. |
| 181 |
* @param string $backLabel Visible text of that link. |
| 182 |
* @return string |
| 183 |
*/ |
| 184 |
public function buildNoRedirectIdsNoticeHtml(string $backUrl, string $backLabel): string { |
| 185 |
return $this->buildEditScreenNoticeHtml( |
| 186 |
__('No redirect was selected to edit.', '404-solution'), |
| 187 |
$backUrl, |
| 188 |
$backLabel |
| 189 |
); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Shared renderer for every edit-screen dead end, so a second one cannot |
| 194 |
* drift into a different shape or a different severity than the first. |
| 195 |
* |
| 196 |
* @param string $message Already-translated sentence, not yet escaped. |
| 197 |
* @param string $backUrl Where the "back to the list" link points. |
| 198 |
* @param string $backLabel Visible text of that link. |
| 199 |
* @return string |
| 200 |
*/ |
| 201 |
private function buildEditScreenNoticeHtml(string $message, string $backUrl, string $backLabel): string { |
| 202 |
$html = $this->readTemplate('editRedirectMissingNotice.html'); |
| 203 |
$html = $this->functions->str_replace('{message}', esc_html($message), $html); |
| 204 |
$html = $this->functions->str_replace('{back_url}', esc_url($backUrl), $html); |
| 205 |
return $this->functions->str_replace('{back_label}', esc_html($backLabel), $html); |
| 206 |
} |
| 207 |
|
| 208 |
/** |
| 209 |
* Build the URL form-table row with an optional "Auto-matched by" note. |
| 210 |
* |
| 211 |
* @return string |
| 212 |
*/ |
| 213 |
public function buildUrlRowHtml(string $redirectUrl, string $redirectEngine): string { |
| 214 |
$matchedByNote = ''; |
| 215 |
if ($redirectEngine !== '') { |
| 216 |
$matchedByNote = $this->readTemplate('editRedirectMatchedByNote.html'); |
| 217 |
$matchedByNote = $this->functions->str_replace('{matched_by_label}', esc_html__('Auto-matched by:', '404-solution'), $matchedByNote); |
| 218 |
$matchedByNote = $this->functions->str_replace('{engine_name}', esc_html($this->engineLabeler->humanize($redirectEngine)), $matchedByNote); |
| 219 |
} |
| 220 |
$urlBody = $this->readTemplate('editRedirectUrlRowBody.html'); |
| 221 |
$urlBody = $this->functions->str_replace('{url_value}', esc_attr($redirectUrl), $urlBody); |
| 222 |
$urlBody = $this->functions->str_replace('{matched_by_note}', $matchedByNote, $urlBody); |
| 223 |
return $this->buildFieldRowHtml('url', $this->buildRequiredLabel(__('URL', '404-solution')), $urlBody); |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* Build the "regular expression" form-table row. |
| 228 |
* |
| 229 |
* @param string $isRegexChecked ' checked' or '' |
| 230 |
* @return string |
| 231 |
*/ |
| 232 |
public function buildRegexRowHtml(string $isRegexChecked): string { |
| 233 |
$regexLabel = __('Treat this URL as a regular expression', '404-solution'); |
| 234 |
$body = $this->readTemplate('editRedirectRegexBody.html'); |
| 235 |
$body = $this->functions->str_replace('{regex_label}', esc_html($regexLabel), $body); |
| 236 |
$body = $this->functions->str_replace('{is_regex_checked}', $isRegexChecked, $body); |
| 237 |
$body = $this->functions->str_replace('{regex_explain_link}', esc_html__('(Explain)', '404-solution'), $body); |
| 238 |
$body = $this->functions->str_replace('{regex_explain_text}', 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'), $body); |
| 239 |
$body = $this->functions->str_replace('{regex_example_label}', esc_html__('Example:', '404-solution'), $body); |
| 240 |
$body = $this->functions->str_replace('{regex_example_text}', esc_html__('/events/(.+) will match any URL that begins with /events/. Use $1 in the destination to insert the captured text. Site-relative paths such as /archive/$1 and full HTTP(S) URLs are supported.', '404-solution'), $body); |
| 241 |
return $this->buildFieldRowHtml('is_regex_url', ' ', $body); |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Build hidden `source_*` inputs that preserve the originating list-table view. |
| 246 |
* |
| 247 |
* @return string |
| 248 |
*/ |
| 249 |
public function buildSourceHiddenInputs(string $sourcePage, string $filter, string $orderby, string $order, string $paged): string { |
| 250 |
$pairs = array( |
| 251 |
'source_page' => $sourcePage, |
| 252 |
'source_filter' => $filter, |
| 253 |
'source_orderby' => $orderby, |
| 254 |
'source_order' => $order, |
| 255 |
'source_paged' => $paged, |
| 256 |
); |
| 257 |
$template = $this->readTemplate('editRedirectSourceHiddenInput.html'); |
| 258 |
$html = ''; |
| 259 |
foreach ($pairs as $name => $value) { |
| 260 |
if ($value === '') { |
| 261 |
continue; |
| 262 |
} |
| 263 |
$line = $this->functions->str_replace('{name}', esc_attr($name), $template); |
| 264 |
$line = $this->functions->str_replace('{value}', esc_attr($value), $line); |
| 265 |
$html .= $line; |
| 266 |
} |
| 267 |
return $html; |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Build the back-to-list cancel URL with preserved filter/orderby/order params. |
| 272 |
* |
| 273 |
* @return string |
| 274 |
*/ |
| 275 |
public function buildCancelUrl(string $sourcePage, string $filter, string $orderby, string $order): string { |
| 276 |
$url = '?page=' . ABJ404_PP; |
| 277 |
$pairs = array( |
| 278 |
'subpage' => $sourcePage, |
| 279 |
'filter' => $filter, |
| 280 |
'orderby' => $orderby, |
| 281 |
'order' => $order, |
| 282 |
); |
| 283 |
foreach ($pairs as $name => $value) { |
| 284 |
if ($value === '') { |
| 285 |
continue; |
| 286 |
} |
| 287 |
// rawurlencode, matching AdminPageUrlBuilder, AdminPaginationLinks |
| 288 |
// and AdminTableColumnHeaders. These are URL COMPONENTS: the filter |
| 289 |
// is free text an admin typed into the list search box, and |
| 290 |
// unencoded 'x&subpage=abj404_options' makes Cancel navigate to a |
| 291 |
// screen nobody chose, because PHP takes the last value for a |
| 292 |
// repeated parameter. esc_attr() does not help -- it escapes HTML |
| 293 |
// and leaves '&' and '=' untouched -- and neither does the esc_url() |
| 294 |
// the caller wraps this in, which validates a URL rather than |
| 295 |
// deciding which '&' the author meant as a separator. |
| 296 |
$url .= '&' . $name . '=' . rawurlencode($value); |
| 297 |
} |
| 298 |
return $url; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Build the Advanced Options section HTML using the template. |
| 303 |
* |
| 304 |
* @param string $startDate ISO date for "Active From", or empty. |
| 305 |
* @param string $endDate ISO date for "Active Until", or empty. |
| 306 |
* @param string $conditionsHtml Already-rendered conditions section. |
| 307 |
* @param bool $isOpen Whether the details element starts open. |
| 308 |
* @return string |
| 309 |
*/ |
| 310 |
public function buildAdvancedOptionsHtml(string $startDate, string $endDate, string $conditionsHtml, bool $isOpen): string { |
| 311 |
$html = $this->readTemplate('editRedirectAdvancedOptions.html'); |
| 312 |
$html = $this->functions->str_replace('{advanced_options_label}', esc_html__('Advanced Options', '404-solution'), $html); |
| 313 |
$html = $this->functions->str_replace('{open_attr}', $isOpen ? ' open' : '', $html); |
| 314 |
$html = $this->functions->str_replace('{start_date_label}', esc_html__('Active From (optional)', '404-solution'), $html); |
| 315 |
$html = $this->functions->str_replace('{start_date_value}', esc_attr($startDate), $html); |
| 316 |
$html = $this->functions->str_replace('{start_date_help}', esc_html__('Leave blank to activate immediately', '404-solution'), $html); |
| 317 |
$html = $this->functions->str_replace('{end_date_label}', esc_html__('Active Until (optional)', '404-solution'), $html); |
| 318 |
$html = $this->functions->str_replace('{end_date_value}', esc_attr($endDate), $html); |
| 319 |
$html = $this->functions->str_replace('{end_date_help}', esc_html__('Leave blank to never expire', '404-solution'), $html); |
| 320 |
return $this->functions->str_replace('{conditions_section}', $conditionsHtml, $html); |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* @return string |
| 325 |
*/ |
| 326 |
public function buildRequiredLabel(string $baseLabel): string { |
| 327 |
$html = $this->readTemplate('editRedirectRequiredLabelSuffix.html'); |
| 328 |
$html = $this->functions->str_replace('{base_label}', esc_html($baseLabel), $html); |
| 329 |
return $this->functions->str_replace('{required_label}', esc_html__('(Required)', '404-solution'), $html); |
| 330 |
} |
| 331 |
|
| 332 |
/** |
| 333 |
* Build a single form-table row using the field-row template. |
| 334 |
* |
| 335 |
* @param string $fieldId Form-control id used in the label's `for` attribute. |
| 336 |
* @param string $labelHtml Already-escaped label HTML. |
| 337 |
* @param string $bodyHtml Already-built input/markup for the td cell. |
| 338 |
* @return string |
| 339 |
*/ |
| 340 |
public function buildFieldRowHtml(string $fieldId, string $labelHtml, string $bodyHtml): string { |
| 341 |
$row = $this->readTemplate('editRedirectFieldRow.html'); |
| 342 |
$row = $this->functions->str_replace('{field_id}', esc_attr($fieldId), $row); |
| 343 |
$row = $this->functions->str_replace('{field_label}', $labelHtml, $row); |
| 344 |
return $this->functions->str_replace('{field_body}', $bodyHtml, $row); |
| 345 |
} |
| 346 |
|
| 347 |
/** |
| 348 |
* @return string |
| 349 |
*/ |
| 350 |
public function buildManualPickerWrapperHtml(string $hiddenClass, string $innerHtml): string { |
| 351 |
$redirectToBody = $this->readTemplate('editRedirectManualPickerWrapper.html'); |
| 352 |
$redirectToBody = $this->functions->str_replace('{hidden_class}', $hiddenClass, $redirectToBody); |
| 353 |
return $this->functions->str_replace('{inner_html}', $innerHtml, $redirectToBody); |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* @param array<string, string> $parts |
| 358 |
* @return string |
| 359 |
*/ |
| 360 |
public function buildShellHtml(array $parts): string { |
| 361 |
$shell = $this->readTemplate('editRedirectFormShell.html'); |
| 362 |
foreach ($parts as $placeholder => $value) { |
| 363 |
$shell = $this->functions->str_replace($placeholder, $value, $shell); |
| 364 |
} |
| 365 |
return $shell; |
| 366 |
} |
| 367 |
|
| 368 |
/** |
| 369 |
* @param string $name |
| 370 |
* @return string |
| 371 |
*/ |
| 372 |
private function readTemplate(string $name): string { |
| 373 |
return ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name); |
| 374 |
} |
| 375 |
} |
| 376 |
|