PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / view / View.php

View.php in 404 Solution 4.3.0, at includes/view/View.php

481 lines 20.0 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 /* Turns data into an html display and vice versa.
9 * Houses all displayed pages. Logs, options page, captured 404s, stats, etc. */
10
11 require_once __DIR__ . '/ViewComponent.php';
12 require_once __DIR__ . '/View_Shared.php';
13 require_once __DIR__ . '/View_UI.php';
14 require_once __DIR__ . '/View_AdminChrome.php';
15 require_once __DIR__ . '/View_OptionsPresenter.php';
16 require_once __DIR__ . '/../diagnostics/ToolsDiagnostics.php';
17 require_once __DIR__ . '/View_Stats.php';
18 require_once __DIR__ . '/View_Tools.php';
19 require_once __DIR__ . '/View_Settings.php';
20 require_once __DIR__ . '/View_SettingsSections.php';
21 require_once __DIR__ . '/View_SimpleSettings.php';
22 require_once __DIR__ . '/View_Redirects.php';
23 require_once __DIR__ . '/View_RedirectsTable.php';
24 require_once __DIR__ . '/View_CapturedURLsTable.php';
25 require_once __DIR__ . '/View_RedirectForms.php';
26 require_once __DIR__ . '/View_ListTableChrome.php';
27 require_once __DIR__ . '/View_RedirectTypeUI.php';
28 require_once __DIR__ . '/View_RedirectConditions.php';
29 require_once __DIR__ . '/View_Logs.php';
30
31 /**
32 * Magic-method dispatch surface for the View facade. Each entry below points
33 * at a method that lives on one of the ABJ_404_Solution_View_* components
34 * (Shared, UI, AdminChrome, OptionsPresenter, Stats, Tools, Settings, SettingsSections,
35 * SimpleSettings, Redirects, RedirectsTable,
36 * RedirectTypeUI, RedirectConditions, Logs). __call routes the call to whichever component
37 * owns the method. PHPStan needs these annotations because it cannot trace
38 * __call dispatch through the component array.
39 *
40 * @method mixed buildRedirectRowHTML(array<mixed> $row, string $sub, array<mixed> $tableOptions, array<mixed> $deadDestIds, int $y)
41 * @method mixed buildRedirectToDropdownHtml(string $pageTitle, string $pageIDAndType)
42 * @method mixed buildRedirectsColumnDefs(array<mixed> $tableOptions)
43 * @method mixed buildScoreCell($rawScore, string $rowEngine)
44 * @method mixed buildTableActionLinks($row, $sub, $tableOptions, $isCapturedPage = false)
45 * @method mixed echoAddManualRedirect($tableOptions)
46 * @method mixed echoAddRedirectModal($tableOptions)
47 * @method mixed echoAdminCapturedURLsPage()
48 * @method mixed echoAdminDebugFile()
49 * @method mixed echoAdminEditRedirectPage()
50 * @method mixed echoAdminFooter()
51 * @method mixed echoAdminLogsPage()
52 * @method mixed echoAdminOptionsPage()
53 * @method mixed echoAdminRedirectsPage()
54 * @method mixed echoAdminToolsPage()
55 * @method mixed echoBrokenInternalLinksSection()
56 * @method mixed echoChosenAdminTab($action, $sub, $message)
57 * @method mixed echoConditionRow($index, array<mixed> $cond)
58 * @method mixed echoConditionsJavaScript()
59 * @method mixed echoConfidenceDistributionSection()
60 * @method string buildSubsubsubFilters(string $sub, array<int, array{0: int|string, 1: string}> $items, array<string, mixed> $tableOptions)
61 * @method mixed echoEditRedirect(ABJ_404_Solution_EditRedirectFormContext $ctx)
62 * @method mixed echoExpandCollapseButton($showSuggestions = true)
63 * @method mixed echoFileContents($fileName)
64 * @method mixed echoInlineModeToggle()
65 * @method mixed echoOptionsSection(ABJ_404_Solution_OptionsSectionView $section)
66 * @method mixed echoPostBox($id, $title, $content)
67 * @method mixed echoRedirectConditionsSection()
68 * @method mixed echoRedirectDestinationOptionsCatsTags($dest)
69 * @method mixed echoRedirectDestinationOptionsDefaults($currentlySelected)
70 * @method mixed echoRedirectDestinationOptionsOthers($dest, $rows)
71 * @method mixed echoRedirectTypeButtonGrid(string $selectedCode)
72 * @method mixed echoRestoreDefaultsModal()
73 * @method mixed echoSettingsModeToggle($currentMode)
74 * @method mixed echoSimpleModeOptions($options)
75 * @method mixed echoStickySaveBar()
76 * @method mixed echoToastNotification()
77 * @method mixed echoTrendsSection()
78 * @method mixed fillRedirectRowTemplate(array<mixed> $replacements)
79 * @method mixed getAdminLogsPageTable($sub)
80 * @method mixed getAdminOptionsPageAdvancedContent($options)
81 * @method mixed getAdminOptionsPageAdvancedLogging($options)
82 * @method mixed getAdminOptionsPageAdvancedSystem($options)
83 * @method mixed getAdminOptionsPageAutoRedirects($options)
84 * @method mixed getAdminOptionsPageGeneralSettings($options)
85 * @method mixed getAdminRedirectsPageTable($sub)
86 * @method mixed getBehaviorTilesHTML($options)
87 * @method mixed getBulkOperationsFormURL($sub, $tableOptions)
88 * @method mixed getCapturedURLSPageTable($sub)
89 * @method string getCardIcon(string $iconName)
90 * @method mixed getCheckedAttr($options, $key)
91 * @method string computeTableDataSignature($sub)
92 * @method string getCurrentTableDataSignature($sub)
93 * @method mixed getDashboardNotificationCaptured($captured)
94 * @method mixed getFallbackOptionDefaults()
95 * @method mixed getHeaderSortState($tableOptions, $orderby, $preferDescOnFirstClick = false)
96 * @method mixed getMigrateFromPluginMarkup()
97 * @method mixed getOptionsWithDefaults()
98 * @method mixed getPaginationLinks($sub, $showSearchFilter = true)
99 * @method mixed getSignatureFieldsForSubpage($sub, $row)
100 * @method mixed getSubSubSub($sub)
101 * @method mixed getSuggestedDestination(string $url, array<mixed> $options)
102 * @method mixed getTabFilters($sub, $tableOptions)
103 * @method mixed getTableColumns($sub, $columns)
104 * @method mixed getToolsDiagnosticsMarkup()
105 * @method mixed getToolsDiagnosticsRows()
106 * @method mixed humanizeEngineName(string $rawName)
107 * @method mixed normalizeOptionsForView($options)
108 * @method mixed normalizeSignatureValue($value)
109 * @method mixed optStr($options, $key, $default = '')
110 * @method mixed outputAdminHeaderTabs($sub = 'list', $message = '')
111 * @method mixed outputAdminStatsPage()
112 * @method mixed rememberTableDataSignature($sub, $rows)
113 * @method mixed renderBulkRedirectFormFields(array<mixed> $recnums_multiple)
114 * @method mixed renderRegexAutoPromoteNotice(array<mixed> $notice)
115 * @method mixed renderSuggestionBlock(array<mixed> $suggestion)
116 * @method mixed resolveDestinationWarnings(ABJ_404_Solution_RedirectDestinationWarningContext $ctx)
117 * @method mixed resolveRedirectDestLink($rowType, string $rowFinalDest)
118 * @method mixed resolveRedirectDestinationInfo(array<mixed> $redirect, array<mixed> $options)
119 * @method mixed traceOutcomeClass(string $outcome)
120 * @method mixed translateTraceLabel(string $text)
121 * @method mixed viewGetPostOrGetSanitize($name, $defaultValue = null)
122 */
123 class ABJ_404_Solution_View {
124
125 /** @var self|null */
126 private static $instance = null;
127 /**
128 * Test seam: install or clear the cached singleton instance without
129 * private-field reflection. Pass null to reset between tests; pass a
130 * configured instance (or double) to install it. Mirrors the setInstance()
131 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
132 *
133 * @param self|null $instance
134 * @return void
135 */
136 public static function setInstance($instance) {
137 self::$instance = $instance;
138 }
139
140
141 /** @var ABJ_404_Solution_Functions */
142 private $f;
143
144 /** @var ABJ_404_Solution_PluginLogic */
145 private $logic;
146
147 /** @var ABJ_404_Solution_Logging */
148 private $logger;
149
150 /** @var ABJ_404_Solution_ViewReadServiceInterface */
151 private $viewReadService;
152
153 /** @var ABJ_404_Solution_LogsRepositoryInterface */
154 private $logsRepository;
155
156 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
157 private $redirectsRepository;
158
159 /** @var ABJ_404_Solution_ContentRepositoryInterface */
160 private $contentRepository;
161
162 /** @var ABJ_404_Solution_StatsRepositoryInterface */
163 private $statsRepository;
164
165 /** @var array<int, ABJ_404_Solution_ViewComponent> */
166 private $components = array();
167
168 /**
169 * Constructor with dependency injection.
170 *
171 * @param ABJ_404_Solution_Functions|null $functions String manipulation utilities
172 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
173 * @param mixed $dataAccessOrViewReadService Data access (legacy) or ViewReadService
174 * @param ABJ_404_Solution_Logging|null $logging Logging service
175 * @param ABJ_404_Solution_StatsRepositoryInterface|null $statsRepository Stats repository
176 */
177 public function __construct($functions = null, $pluginLogic = null, $dataAccessOrViewReadService = null, $logging = null, $statsRepository = null) {
178 $this->f = $functions !== null ? $functions : abj_service('functions');
179 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
180 $this->logger = $logging !== null ? $logging : abj_service('logging');
181
182 // When a DataAccess facade is injected (legacy callers / tests),
183 // use it directly for all module interfaces -- it delegates to the
184 // real modules internally.
185 $dao = $dataAccessOrViewReadService;
186 $isLegacyDao = ($dao !== null && is_object($dao)
187 && !($dao instanceof ABJ_404_Solution_ViewReadServiceInterface)
188 && (method_exists($dao, 'getRedirectsForView') || $dao instanceof ABJ_404_Solution_DataAccess));
189
190 if ($isLegacyDao) {
191 /** @var ABJ_404_Solution_ViewReadServiceInterface&ABJ_404_Solution_RedirectsRepositoryInterface&ABJ_404_Solution_StatsRepositoryInterface $dao */
192 $this->viewReadService = $dao;
193 // Resolve typed repository surfaces off the DataAccess facade so the
194 // View talks to the real LogsRepository / ContentRepository objects.
195 // Pass-throughs for these repos have been removed from DataAccess,
196 // so resolve the typed repositories from the facade when available.
197 $this->logsRepository = (is_object($dao) && method_exists($dao, 'getLogsRepo'))
198 ? $dao->getLogsRepo()
199 : $dao;
200 $this->redirectsRepository = $dao;
201 $this->contentRepository = (is_object($dao) && method_exists($dao, 'getContentRepo'))
202 ? $dao->getContentRepo()
203 : $dao;
204 $this->statsRepository = $this->resolveStatsRepository($statsRepository, $dao);
205 } else {
206 /** @var ABJ_404_Solution_ViewReadServiceInterface $vrs */
207 $vrs = abj_service('view_read_service');
208 $this->viewReadService = $vrs;
209 /** @var ABJ_404_Solution_LogsRepositoryInterface $lr */
210 $lr = abj_service('logs_repository');
211 $this->logsRepository = $lr;
212 /** @var ABJ_404_Solution_RedirectsRepositoryInterface $rr */
213 $rr = abj_service('redirects_repository');
214 $this->redirectsRepository = $rr;
215 /** @var ABJ_404_Solution_ContentRepositoryInterface $cr */
216 $cr = abj_service('content_repository');
217 $this->contentRepository = $cr;
218 /** @var ABJ_404_Solution_StatsRepositoryInterface $sr */
219 $sr = $statsRepository !== null ? $statsRepository : $this->resolveStatsRepository(null, null);
220 $this->statsRepository = $sr;
221 }
222
223 $this->components = $this->buildComponents();
224 }
225
226 /** @return self */
227 public static function getInstance() {
228 if (self::$instance !== null) {
229 return self::$instance;
230 }
231
232 // If the DI container is initialized, prefer it.
233 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
234 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('view');
235 if ($resolved instanceof self) {
236 self::$instance = $resolved;
237 return self::$instance;
238 }
239 }
240
241 self::$instance = new ABJ_404_Solution_View();
242
243 return self::$instance;
244 }
245
246 /**
247 * @param ABJ_404_Solution_StatsRepositoryInterface|null $provided
248 * @param mixed $legacyDao
249 * @return ABJ_404_Solution_StatsRepositoryInterface
250 */
251 private function resolveStatsRepository($provided, $legacyDao): ABJ_404_Solution_StatsRepositoryInterface {
252 if ($provided instanceof ABJ_404_Solution_StatsRepositoryInterface) {
253 return $provided;
254 }
255
256 $service = class_exists('ABJ_404_Solution_ServiceContainer')
257 ? ABJ_404_Solution_ServiceContainer::safeGet('stats_repository')
258 : null;
259 if ($service instanceof ABJ_404_Solution_StatsRepositoryInterface) {
260 return $service;
261 }
262
263 return ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__);
264 }
265
266 /**
267 * @return array<int, ABJ_404_Solution_ViewComponent>
268 */
269 private function buildComponents() {
270 $args = array(
271 $this,
272 $this->f,
273 $this->logic,
274 $this->logger,
275 $this->viewReadService,
276 $this->logsRepository,
277 $this->redirectsRepository,
278 $this->contentRepository,
279 $this->statsRepository,
280 );
281
282 $shared = new ABJ_404_Solution_View_Shared(...$args);
283 $ui = new ABJ_404_Solution_View_UI(...$args);
284 $adminChrome = new ABJ_404_Solution_View_AdminChrome(...$args);
285 $optionsPresenter = new ABJ_404_Solution_View_OptionsPresenter(...$args);
286 $stats = new ABJ_404_Solution_View_Stats(...$args);
287 $tools = new ABJ_404_Solution_View_Tools(...$args);
288 $settings = new ABJ_404_Solution_View_Settings(...$args);
289 $settingsSections = new ABJ_404_Solution_View_SettingsSections(...$args);
290 $simpleSettings = new ABJ_404_Solution_View_SimpleSettings(...$args);
291 $redirects = new ABJ_404_Solution_View_Redirects(...$args);
292 $redirectsTable = new ABJ_404_Solution_View_RedirectsTable(...$args);
293 $capturedURLsTable = new ABJ_404_Solution_View_CapturedURLsTable(...$args);
294 $redirectForms = new ABJ_404_Solution_View_RedirectForms(...$args);
295 $listTableChrome = new ABJ_404_Solution_View_ListTableChrome(...$args);
296 $redirectTypeUI = new ABJ_404_Solution_View_RedirectTypeUI(...$args);
297 $redirectConditions = new ABJ_404_Solution_View_RedirectConditions(...$args);
298 $logs = new ABJ_404_Solution_View_Logs(...$args);
299
300 $components = array(
301 $shared, $ui, $adminChrome, $optionsPresenter, $stats, $tools,
302 $settings, $settingsSections, $simpleSettings, $redirects, $redirectsTable,
303 $capturedURLsTable, $redirectForms, $listTableChrome,
304 $redirectTypeUI, $redirectConditions, $logs,
305 );
306 foreach ($components as $component) {
307 $component->setSiblingComponents(
308 $shared, $ui, $adminChrome, $optionsPresenter, $stats, $tools,
309 $settings, $settingsSections, $simpleSettings, $redirects,
310 $redirectsTable, $capturedURLsTable, $redirectForms, $listTableChrome,
311 $redirectTypeUI, $redirectConditions, $logs
312 );
313 }
314
315 return $components;
316 }
317
318 /**
319 * Magic method dispatch. Routes the call to whichever component owns the
320 * named method. Replaces the trait-based composition with explicit
321 * delegation while preserving the existing call surface ($view->methodX()).
322 *
323 * @param string $name
324 * @param array<int,mixed> $arguments
325 * @return mixed
326 */
327 public function __call($name, $arguments) {
328 foreach ($this->components as $component) {
329 if (method_exists($component, $name)) {
330 return $component->{$name}(...$arguments);
331 }
332 }
333
334 throw new BadMethodCallException(
335 'ABJ_404_Solution_View has no method ' . $name
336 );
337 }
338
339 /**
340 * Static forwarder for the renderer of error-notice + support-button HTML.
341 * Used by callers that don't have the View instance handy.
342 *
343 * @param string $messageHtml
344 * @param string $triggeredFrom
345 * @param string|null $contextSummary
346 * @return string
347 */
348 public static function renderErrorNoticeWithSupportButton(string $messageHtml,
349 string $triggeredFrom, ?string $contextSummary = null): string {
350 return ABJ_404_Solution_View_UI::renderErrorNoticeWithSupportButton(
351 $messageHtml,
352 $triggeredFrom,
353 $contextSummary
354 );
355 }
356
357 /**
358 * Record the logs_hits rollup staleness diagnostic on plugin admin page
359 * renders. This deliberately does not schedule a rollup rebuild; rebuild
360 * arming has other callers, while the diagnostic needs a page-load seam now
361 * that the old Hits/Last Used tooltip path no longer exists.
362 *
363 * @return void
364 */
365 private function recordLogsHitsRollupStalenessDiagnosticOnAdminPageLoad(): void {
366 try {
367 $this->logsRepository->recordLogsHitsRollupStalenessSignal();
368 } catch (\Throwable $e) {
369 $this->logger->warn(
370 'logs_hits rollup staleness diagnostic failed during admin page render: '
371 . get_class($e) . ' code=' . (string)$e->getCode() . ' message=' . $e->getMessage()
372 );
373 }
374 }
375
376 /**
377 * Static entry point for WP admin page rendering. Routes admin POST actions
378 * (trash / delete / ignore / edit / import) to PluginLogic and then renders
379 * the selected subpage. Needs to live on the View facade itself because it
380 * touches the facade's private $logic / $logger / $components fields, which
381 * View_UI (the rest of the UI surface lives there) cannot reach from
382 * outside the class scope.
383 *
384 * @return void
385 */
386 public static function handleMainAdminPageActionAndDisplay() {
387 global $abj404view;
388 $instance = self::getInstance();
389
390 try {
391 $action = (string)$instance->viewGetPostOrGetSanitize('action');
392
393 if (!is_admin() || !abj_service('admin_access_policy')->isPluginAdmin()) {
394 $instance->logger->logUserCapabilities("handleMainAdminPageActionAndDisplay (" .
395 esc_html($action == '' ? '(none)' : $action) . ")");
396
397 $permMessageTpl = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/adminPagePermissionDeniedMessage.html');
398 $permMessage = str_replace(
399 ['{permission_denied_label}', '{access_explanation}', '{verify_role_prefix}', '{capability_word}', '{security_plugin_note}'],
400 [
401 esc_html__('Permission denied.', '404-solution'),
402 esc_html__('Your user account does not have permission to access this page.', '404-solution'),
403 esc_html__('Please verify that your WordPress role has the', '404-solution'),
404 esc_html__('capability.', '404-solution'),
405 esc_html__('If you have a security plugin installed, it may be restricting access to this page.', '404-solution'),
406 ],
407 $permMessageTpl
408 );
409 $subpageForContext = (string)$instance->viewGetPostOrGetSanitize('subpage');
410 $triggerForPerm = ($subpageForContext === 'abj404_captured')
411 ? 'captured_404s_page' : 'redirects_page';
412 $permNotice = self::renderErrorNoticeWithSupportButton(
413 $permMessage,
414 $triggerForPerm,
415 'Permission denied on plugin admin page (action=' .
416 ($action == '' ? '(none)' : $action) . ')'
417 );
418 $permWrap = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/adminPagePermissionDeniedWrap.html');
419 echo str_replace(
420 ['{plugin_name}', '{notice}'],
421 [esc_html(PLUGIN_NAME), $permNotice],
422 $permWrap
423 );
424 return;
425 }
426
427 $instance->recordLogsHitsRollupStalenessDiagnosticOnAdminPageLoad();
428
429 $sub = "";
430
431 // Handle post actions.
432 $instance->logger->debugMessage("Processing request for action: " .
433 esc_html($action == '' ? '(none)' : $action));
434
435 $message = "";
436 $message .= $instance->logic->adminActions()->handlePluginAction($action, $sub);
437 $message .= $instance->logic->adminActions()->hanldeTrashAction();
438 $message .= $instance->logic->adminActions()->handleDeleteAction();
439 $message .= $instance->logic->adminActions()->handleIgnoreAction();
440 $message .= $instance->logic->adminActions()->handleLaterAction();
441 $message .= $instance->logic->adminActions()->handleActionEdit($sub, $action);
442 $message .= $instance->logic->adminActions()->handleActionImportRedirects();
443 $instance->logic->adminActions()->handleActionChangeItemsPerRow();
444 $message .= $instance->logic->adminActions()->handleActionImportFile();
445
446 if ($action !== '' && $message !== '') {
447 $instance->logger->debugMessage("Admin action completed: " .
448 esc_html($action) . " => " . esc_html(substr($message, 0, 200)));
449 }
450
451 // Output the correct subpage.
452 $abj404view->echoChosenAdminTab($action, $sub, $message);
453
454 } catch (\Throwable $e) {
455 $encodedEx = json_encode($e);
456 $encodedContext = is_string($encodedEx) ? stripcslashes(wp_kses_post($encodedEx)) : '';
457 $instance->logger->errorMessage(
458 "Caught exception (" . get_class($e) . "): " . $e->getMessage()
459 . ($encodedContext !== '' ? " | context=" . $encodedContext : '')
460 );
461 $subpageForContext = (string)$instance->viewGetPostOrGetSanitize('subpage');
462 $triggerForRenderError = ($subpageForContext === 'abj404_captured')
463 ? 'captured_404s_page' : 'redirects_page';
464 $renderErrorMessageTpl = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/adminPageRenderErrorMessage.html');
465 $renderErrorMessage = str_replace(
466 '{escaped_details}',
467 esc_html($e->getMessage() . "\n" . $e->getTraceAsString()),
468 $renderErrorMessageTpl
469 );
470 $renderErrorNotice = self::renderErrorNoticeWithSupportButton(
471 $renderErrorMessage,
472 $triggerForRenderError,
473 'Render error: ' . substr($e->getMessage(), 0, 200)
474 );
475 $renderErrorWrap = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/adminPageRenderErrorWrap.html');
476 echo str_replace('{notice}', $renderErrorNotice, $renderErrorWrap);
477 }
478 }
479
480 }
481