PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / view / RedirectEditFormPresenter.php

RedirectEditFormPresenter.php in 404 Solution trunk, at includes/view/RedirectEditFormPresenter.php

376 lines 18.6 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 * 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', '&nbsp;', $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