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

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

461 lines 19.4 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 * @phpstan-type EditPageContext array{sourcePage: string, backUrl: string, isSimpleMode: bool, isFromCaptured: bool, title: string, backLabel: string, filter: string, orderby: string, order: string, hiddenInputs: string}
11 */
12 class ABJ_404_Solution_View_Redirects extends ABJ_404_Solution_ViewComponent {
13
14 /** @var ABJ_404_Solution_RedirectEditFormPresenter|null */
15 private $editFormPresenter = null;
16
17 /** @var ABJ_404_Solution_RedirectDestinationOptionsPresenter|null */
18 private $destinationOptionsPresenter = null;
19
20 /** @var ABJ_404_Solution_RedirectDestinationSuggestionService|null */
21 private $suggestionService = null;
22
23 /** @var ABJ_404_Solution_RedirectEngineLabeler|null */
24 private $engineLabeler = null;
25
26 /** @var ABJ_404_Solution_RedirectDestinationResolver|null */
27 private $destinationResolver = null;
28
29 /** @var ABJ_404_Solution_RedirectEditRequest|null */
30 private $editRequest = null;
31
32 /** @var ABJ_404_Solution_RedirectEditDeadEndRenderer|null */
33 private $deadEnds = null;
34
35 /**
36 * @return ABJ_404_Solution_RedirectEditFormPresenter
37 */
38 private function editFormPresenter(): ABJ_404_Solution_RedirectEditFormPresenter {
39 if ($this->editFormPresenter === null) {
40 $this->editFormPresenter = new ABJ_404_Solution_RedirectEditFormPresenter(
41 $this->f,
42 $this->engineLabeler()
43 );
44 }
45 return $this->editFormPresenter;
46 }
47
48 /**
49 * @return ABJ_404_Solution_RedirectDestinationOptionsPresenter
50 */
51 private function destinationOptionsPresenter(): ABJ_404_Solution_RedirectDestinationOptionsPresenter {
52 if ($this->destinationOptionsPresenter === null) {
53 $this->destinationOptionsPresenter = new ABJ_404_Solution_RedirectDestinationOptionsPresenter();
54 }
55 return $this->destinationOptionsPresenter;
56 }
57
58 /**
59 * @return ABJ_404_Solution_RedirectDestinationSuggestionService
60 */
61 private function suggestionService(): ABJ_404_Solution_RedirectDestinationSuggestionService {
62 if ($this->suggestionService === null) {
63 $this->suggestionService = new ABJ_404_Solution_RedirectDestinationSuggestionService($this->logger);
64 }
65 return $this->suggestionService;
66 }
67
68 /**
69 * @return ABJ_404_Solution_RedirectEngineLabeler
70 */
71 private function engineLabeler(): ABJ_404_Solution_RedirectEngineLabeler {
72 if ($this->engineLabeler === null) {
73 $this->engineLabeler = new ABJ_404_Solution_RedirectEngineLabeler();
74 }
75 return $this->engineLabeler;
76 }
77
78
79 /**
80 * @return ABJ_404_Solution_RedirectDestinationResolver
81 */
82 private function destinationResolver(): ABJ_404_Solution_RedirectDestinationResolver {
83 if ($this->destinationResolver === null) {
84 $this->destinationResolver = new ABJ_404_Solution_RedirectDestinationResolver();
85 }
86 return $this->destinationResolver;
87 }
88
89 /**
90 * Resolve final destination, pageIDAndType, and redirect code from a redirect row.
91 *
92 * @param array<string, mixed> $redirect
93 * @param array<string, mixed> $options
94 * @return array{final: string, pageIDAndType: string, codeSelected: string}
95 */
96 public function resolveRedirectDestinationInfo(array $redirect, array $options): array {
97 return $this->destinationResolver()->resolveRedirectDestinationInfo($redirect, $options);
98 }
99
100 /**
101 * Build the redirect-to autocomplete dropdown HTML from the template.
102 *
103 * @param string $pageTitle
104 * @param string $pageIDAndType
105 * @return string
106 */
107 public function buildRedirectToDropdownHtml(string $pageTitle, string $pageIDAndType): string {
108 return $this->editFormPresenter()->buildRedirectToDropdownHtml($pageTitle, $pageIDAndType);
109 }
110
111
112 /**
113 * Build hidden input + form-table row HTML for bulk redirect editing.
114 *
115 * @param EditPageContext $context The edit page context, needed so the
116 * missing-redirect notice offers the same way back as the form would have.
117 * @param array<int, int> $recnums_multiple
118 * @return array{redirect: array<string, mixed>, redirects_multiple: array<int, array<string, mixed>>, hiddenInput: string, rowHtml: string}|null Null on error (already echoed).
119 */
120 public function renderBulkRedirectFormFields(array $context, array $recnums_multiple): ?array {
121 $redirects_multiple = $this->redirectsRepository->getRedirectsByIDs($recnums_multiple);
122 if (empty($redirects_multiple)) {
123 return $this->deadEnds()->missingRedirects($context, $recnums_multiple);
124 }
125
126 $rowHtml = $this->editFormPresenter()->buildBulkUrlsRowHtml($redirects_multiple);
127 $hiddenInput = $this->editFormPresenter()->buildIdsMultipleHiddenInput($recnums_multiple);
128
129 // here we set the variable to the first value returned because it's used to set default values
130 // in the form data.
131 $redirect = reset($redirects_multiple);
132
133 return array(
134 'redirect' => $redirect,
135 'redirects_multiple' => $redirects_multiple,
136 'hiddenInput' => $hiddenInput,
137 'rowHtml' => $rowHtml,
138 );
139 }
140
141 /**
142 * Build the suggestion block HTML for a captured URL's best match.
143 *
144 * @param array{title: string, score: int, id_and_type: string, type_label: string} $suggestion
145 * @return string
146 */
147 public function buildSuggestionBlockHtml(array $suggestion): string {
148 return $this->editFormPresenter()->buildSuggestionBlockHtml($suggestion);
149 }
150
151 /**
152 * Render the suggestion block for a captured URL's best match.
153 *
154 * @param array{title: string, score: int, id_and_type: string, type_label: string} $suggestion
155 * @return void
156 */
157 public function renderSuggestionBlock(array $suggestion): void {
158 echo $this->buildSuggestionBlockHtml($suggestion);
159 }
160
161 /** @return void */
162 function echoAdminEditRedirectPage() {
163
164 $options = $this->optionsPresenter->getOptionsWithDefaults();
165 $context = $this->editRedirectPageContext();
166 $actionUrl = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_edit", "abj404editRedirect");
167 $content = $this->editRedirectRecordContent($context, $context['hiddenInputs']);
168 if ($content === null) {
169 return;
170 }
171
172 $destInfo = $this->resolveRedirectDestinationInfo($content['redirect'], $options);
173 $final = $destInfo['final'];
174 $pageIDAndType = $destInfo['pageIDAndType'];
175 $codeSelected = $destInfo['codeSelected'];
176
177 $suggestion = null;
178 if ($context['isFromCaptured'] && !empty($content['redirectUrl'])) {
179 $suggestion = $this->getSuggestedDestination($content['redirectUrl'], $options);
180 }
181 $preTableBlock = '';
182 if ($suggestion !== null) {
183 $preTableBlock .= $this->buildSuggestionBlockHtml($suggestion);
184 }
185
186 // Redirect-to autocomplete row. When creating from captured URLs, clear the
187 // redirect_to field so the placeholder text is visible.
188 $rawFinalDest = $content['redirect']['final_dest'] ?? 0;
189 $redirectFinalDest = is_scalar($rawFinalDest) ? (string)$rawFinalDest : '0';
190 if ($context['isFromCaptured']) {
191 $pageTitle = '';
192 $pageIDAndType = '';
193 } else {
194 $pageTitle = $this->logic->pageOrdering()->getPageTitleFromIDAndType($pageIDAndType, $redirectFinalDest);
195 }
196 $manualPickerHiddenClass = ($suggestion !== null && $context['isSimpleMode']) ? ' abj404-hidden' : '';
197 $redirectToInner = $this->buildRedirectToDropdownHtml($pageTitle, $pageIDAndType);
198 $redirectToBody = $this->editFormPresenter()->buildManualPickerWrapperHtml($manualPickerHiddenClass, $redirectToInner);
199 $formRows = $content['formRows'];
200 $formRows .= $this->editFormPresenter()->buildFieldRowHtml(
201 'redirect_to_user_field',
202 $this->editFormPresenter()->buildRequiredLabel(__('Redirect to', '404-solution')),
203 $redirectToBody
204 );
205
206 // Capture the redirect-type button grid output and place it inside a form-table row.
207 ob_start();
208 $this->redirectTypeUI->echoRedirectTypeButtonGrid((string)$codeSelected);
209 $typeGridHtml = (string)ob_get_clean();
210 $formRows .= $this->editFormPresenter()->buildFieldRowHtml('code', esc_html__('Redirect Type', '404-solution'), $typeGridHtml);
211
212 // Build advanced options (dates + conditions).
213 $advancedOptions = $this->buildAdvancedOptionsHtml($content['startDate'], $content['endDate']);
214
215 // Compose the page using the shell template.
216 $cancelUrl = $this->editFormPresenter()->buildCancelUrl($context['sourcePage'], $context['filter'], $context['orderby'], $context['order']);
217
218 echo $this->editFormPresenter()->buildShellHtml(array(
219 '{title}' => esc_html($context['title']),
220 '{back_url}' => esc_url($context['backUrl']),
221 '{back_label}' => esc_html($context['backLabel']),
222 '{action_url}' => esc_attr($actionUrl),
223 '{hidden_inputs}' => $content['hiddenInputs'],
224 '{pre_table_block}' => $preTableBlock,
225 '{form_rows}' => $formRows,
226 '{advanced_options}' => $advancedOptions,
227 '{submit_label}' => esc_html__('Update Redirect', '404-solution'),
228 '{cancel_url}' => esc_url($cancelUrl),
229 '{cancel_label}' => esc_html__('Cancel', '404-solution'),
230 ));
231 }
232
233 /**
234 * What the edit screen was asked for: which redirect ids, and which list
235 * page the admin came from. Built lazily so a view rendering some other
236 * subpage never reads the edit request at all.
237 *
238 * @return ABJ_404_Solution_RedirectEditRequest
239 */
240 private function editRequest(): ABJ_404_Solution_RedirectEditRequest {
241 if ($this->editRequest === null) {
242 $this->editRequest = new ABJ_404_Solution_RedirectEditRequest($this->f);
243 }
244 return $this->editRequest;
245 }
246
247 /**
248 * @return ABJ_404_Solution_RedirectEditDeadEndRenderer
249 */
250 private function deadEnds(): ABJ_404_Solution_RedirectEditDeadEndRenderer {
251 if ($this->deadEnds === null) {
252 $this->deadEnds = new ABJ_404_Solution_RedirectEditDeadEndRenderer(
253 $this->editFormPresenter(), $this->logger);
254 }
255 return $this->deadEnds;
256 }
257
258 /**
259 * @return EditPageContext
260 */
261 private function editRedirectPageContext(): array {
262 $sourcePage = $this->editRequest()->getSourcePage();
263
264 $isSimpleMode = abj_service('settings_mode_preference')->getMode() === 'simple';
265 $isFromCaptured = ($sourcePage === 'abj404_captured');
266 $filter = $this->shared->viewGetPostOrGetSanitize('filter');
267 $orderby = $this->shared->viewGetPostOrGetSanitize('orderby');
268 $order = $this->shared->viewGetPostOrGetSanitize('order');
269 $paged = $this->shared->viewGetPostOrGetSanitize('paged');
270
271 return array(
272 'sourcePage' => $sourcePage,
273 'backUrl' => '?page=' . ABJ404_PP . '&subpage=' . esc_attr($sourcePage),
274 'isSimpleMode' => $isSimpleMode,
275 'isFromCaptured' => $isFromCaptured,
276 'title' => ($isSimpleMode && $isFromCaptured) ? __('Create Redirect', '404-solution') : __('Edit Redirect', '404-solution'),
277 // Tracks $isFromCaptured alone, exactly like backUrl above, because
278 // the two are one link: keying the label off $isSimpleMode as well
279 // made the advanced-mode Captured 404s edit screen offer a link
280 // reading "Back to Redirects" that navigated to Captured 404s.
281 'backLabel' => $isFromCaptured ? __('Back to Captured 404s', '404-solution') : __('Back to Redirects', '404-solution'),
282 'filter' => $filter,
283 'orderby' => $orderby,
284 'order' => $order,
285 'hiddenInputs' => $this->editFormPresenter()->buildSourceHiddenInputs($sourcePage, $filter, $orderby, $order, $paged),
286 );
287 }
288
289 /**
290 * @param EditPageContext $context
291 * @param string $baseHiddenInputs
292 * @return array{redirect: array<string, mixed>, redirects_multiple: array<int, array<string, mixed>>, redirectUrl: string, startDate: string, endDate: string, hiddenInputs: string, formRows: string}|null
293 */
294 private function editRedirectRecordContent(array $context, string $baseHiddenInputs): ?array {
295 $isSimpleMode = $context['isSimpleMode'];
296 $request = $this->editRequest()->getRequestedIds();
297 if ($request === null) {
298 return $this->deadEnds()->missingRedirects($context, array());
299 }
300 if ($request->wasRefusedAsTooMany()) {
301 return $this->deadEnds()->tooManySelected($context, $request->requestedCount());
302 }
303 // Recorded here rather than inside RedirectEditRequest so that reader
304 // stays a pure getter (scripts/lint/lint-hidden-write-getters).
305 $this->logger->debugMessage('Edit redirect page. Requested via ' . $request->source() . ': ' .
306 wp_kses_post((string)json_encode($request->isSingle()
307 ? $request->singleId() : $request->ids())));
308
309 if ($request->isSingle()) {
310 $singleResult = $this->buildSingleRecordContent(
311 $context, (int)$request->singleId(), $isSimpleMode);
312 if ($singleResult === null) {
313 return null;
314 }
315 $singleResult['hiddenInputs'] = $baseHiddenInputs . $singleResult['hiddenInputs'];
316 return $singleResult;
317 }
318
319 $bulkResult = $this->renderBulkRedirectFormFields($context, $request->ids());
320 if ($bulkResult === null) {
321 return null;
322 }
323
324 return array(
325 'redirect' => $bulkResult['redirect'],
326 'redirects_multiple' => $bulkResult['redirects_multiple'],
327 'redirectUrl' => '',
328 'startDate' => '',
329 'endDate' => '',
330 'hiddenInputs' => $baseHiddenInputs . $bulkResult['hiddenInput'],
331 'formRows' => $bulkResult['rowHtml'],
332 );
333 }
334
335 /**
336 * Build the form-row HTML, hidden id input, and date strings for a single-record edit.
337 *
338 * @param EditPageContext $context
339 * @param int $recnum
340 * @param bool $isSimpleMode
341 * @return array{redirect: array<string, mixed>, redirects_multiple: array<int, array<string, mixed>>, redirectUrl: string, startDate: string, endDate: string, hiddenInputs: string, formRows: string}|null Null on error (already echoed).
342 */
343 private function buildSingleRecordContent(array $context, int $recnum, bool $isSimpleMode): ?array {
344 $redirects_multiple = $this->redirectsRepository->getRedirectsByIDs(array($recnum));
345 if (empty($redirects_multiple)) {
346 return $this->deadEnds()->missingRedirects($context, array($recnum));
347 }
348
349 /** @var array<string, mixed> $redirect */
350 $redirect = reset($redirects_multiple);
351 $row = ABJ_404_Solution_RedirectRow::fromRaw($redirect);
352
353 $redirectId = $row !== null ? (string)$row->getId() : '';
354 $redirectUrl = $row !== null ? $row->getUrl() : '';
355 $redirectEngine = $row !== null ? $row->getEngine() : '';
356
357 $hiddenInputs = $this->editFormPresenter()->buildRedirectIdHiddenInput($redirectId);
358 $formRows = $this->editFormPresenter()->buildUrlRowHtml($redirectUrl, $redirectEngine);
359 $isRegexChecked = ($row !== null && $row->isRegex()) ? ' checked' : '';
360 $formRows .= $this->editFormPresenter()->buildRegexRowHtml($isRegexChecked);
361
362 $startTs = $row !== null ? $row->getStartTs() : 0;
363 $endTs = $row !== null ? $row->getEndTs() : 0;
364
365 return array(
366 'redirect' => $redirect,
367 'redirects_multiple' => $redirects_multiple,
368 'redirectUrl' => $redirectUrl,
369 'startDate' => $startTs > 0 ? ABJ_404_Solution_RedirectScheduleTimezone::toDateString($startTs) : '',
370 'endDate' => $endTs > 0 ? ABJ_404_Solution_RedirectScheduleTimezone::toDateString($endTs) : '',
371 'hiddenInputs' => $hiddenInputs,
372 'formRows' => $formRows,
373 );
374 }
375
376 /**
377 * Build the Advanced Options section (schedule + conditions) HTML using the template.
378 *
379 * @param string $startDate ISO date for "Active From", or empty.
380 * @param string $endDate ISO date for "Active Until", or empty.
381 * @return string
382 */
383 private function buildAdvancedOptionsHtml(string $startDate, string $endDate): string {
384 $redirectId = 0;
385 $rawId = $_GET['id'] ?? ($_POST['id'] ?? null);
386 if (is_scalar($rawId) && $this->f->regexMatch('[0-9]+', (string)$rawId)) {
387 $redirectId = absint((string)$rawId);
388 }
389 $hasExistingConditions = ($redirectId > 0) && !empty($this->redirectsRepository->getRedirectConditions($redirectId));
390 $hasAdvancedValues = ($startDate !== '' || $endDate !== '' || $hasExistingConditions);
391 $openAttr = $hasAdvancedValues ? ' open' : '';
392
393 ob_start();
394 $this->redirectConditions->echoRedirectConditionsSection();
395 $conditionsHtml = (string)ob_get_clean();
396
397 return $this->editFormPresenter()->buildAdvancedOptionsHtml(
398 $startDate,
399 $endDate,
400 $conditionsHtml,
401 $openAttr !== ''
402 );
403 }
404
405 /**
406 * @param string $dest
407 * @param array<int, object> $rows
408 * @return string
409 */
410 function echoRedirectDestinationOptionsOthers($dest, $rows) {
411 return $this->destinationOptionsPresenter()->buildPostOptions(
412 (string)$dest,
413 $rows,
414 function(string $debugInfo): void {
415 abj_service('request_context')->debug_info = $debugInfo;
416 }
417 );
418 }
419
420 /**
421 * @param string $dest
422 * @return string
423 */
424 function echoRedirectDestinationOptionsCatsTags($dest) {
425 $cats = $this->contentRepository->getPublishedCategories();
426 /** @var array<int, object{taxonomy: string, name?: string}> $cats */
427 $customTagsEtc = $this->logic->pageOrdering()->getMapOfCustomCategories($cats);
428 $tags = $this->contentRepository->getPublishedTags();
429 return $this->destinationOptionsPresenter()->buildTaxonomyOptions((string)$dest, $cats, $tags, $customTagsEtc);
430 }
431
432 /**
433 * Convert a raw engine class name to a human-readable label.
434 *
435 * Examples:
436 * TitleMatchingEngine → "Title Matching"
437 * SpellingMatchingEngine → "Spelling Matching"
438 * CategoryTagMatchingEngine → "Category/Tag Matching"
439 * UrlFixEngine → "URL Fix"
440 * ArchiveFallbackEngine → "Archive Fallback"
441 *
442 * @param string $rawName
443 * @return string
444 */
445 public function humanizeEngineName(string $rawName): string {
446 return $this->engineLabeler()->humanize($rawName);
447 }
448
449 /**
450 * Get the best suggested destination for a captured URL using the spell-checker.
451 *
452 * @param string $url The captured 404 URL.
453 * @param array<string, mixed> $options Plugin options.
454 * @return array{title: string, score: int, id_and_type: string, type_label: string}|null The best match, or null if none found.
455 */
456 public function getSuggestedDestination(string $url, array $options): ?array {
457 return $this->suggestionService()->getSuggestedDestination($url, $options);
458 }
459
460 }
461