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_UI.php

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

750 lines 44.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 * ViewTrait_UI methods.
9 */
10 trait ViewTrait_UI {
11
12 /**
13 * Render an error notice (notice notice-error) on a plugin admin page
14 * with the "Send debug log to developer" support button appended inside
15 * the same notice block. This is the wrapper for the existing
16 * <div class="notice notice-error"> pattern across the plugin so every
17 * user-facing error notice on a plugin screen ships a one-click way to
18 * report the failure.
19 *
20 * The button mount div renders after the message paragraph so the JS
21 * component bootstraps it on DOMContentLoaded. The trigger source is
22 * allowlisted server-side by Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES;
23 * passing a slug not in that list will render a button that 400s on
24 * click. The integration test pins the matching set.
25 *
26 * Only call this from screens that live under the plugin's own pages
27 * (CLAUDE.md Self-Healing §4 bans support buttons on generic wp-admin
28 * notices).
29 *
30 * @param string $messageHtml Pre-escaped/-allowed inner HTML
31 * for the <p> inside the notice.
32 * Callers escape per their own
33 * needs (esc_html / wp_kses_post).
34 * @param string $triggeredFrom One of
35 * ABJ_404_Solution_Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES.
36 * @param string|null $contextSummary Optional one-line summary shown
37 * in the support-request modal.
38 * @return string Notice HTML, ready to echo.
39 */
40 public static function renderErrorNoticeWithSupportButton(string $messageHtml,
41 string $triggeredFrom, ?string $contextSummary = null): string {
42 $buttonHtml = class_exists('ABJ_404_Solution_SupportRequestButton')
43 ? ABJ_404_Solution_SupportRequestButton::render($triggeredFrom, $contextSummary)
44 : '';
45 return '<div class="notice notice-error"><p>' . $messageHtml . '</p>'
46 . $buttonHtml
47 . '</div>';
48 }
49
50 /** Get the text to notify the user when some URLs have been captured and need attention.
51 * @param int $captured the number of captured URLs
52 * @return string html
53 */
54 function getDashboardNotificationCaptured($captured) {
55 /* Translators: %s is the number of captured 404 URLs. */
56 $capturedMessage = sprintf( _n( 'There is <a>%s captured 404 URL</a> that needs to be processed.',
57 'There are <a>%s captured 404 URLs</a> to be processed.',
58 $captured, '404-solution'), $captured);
59 $capturedMessage = $this->f->str_replace("<a>",
60 "<a href=\"options-general.php?page=" . ABJ404_PP . "&subpage=abj404_captured\" >",
61 $capturedMessage);
62 $capturedMessage = $this->f->str_replace("</a>", "</a>", $capturedMessage);
63
64 return '<div class="notice notice-info"><p><strong>' . PLUGIN_NAME .
65 ":</strong> " . $capturedMessage . "</p></div>";
66 }
67
68 /** Do an action like trash/delete/ignore/edit and display a page like stats/logs/redirects/options.
69 * @return void
70 */
71 static function handleMainAdminPageActionAndDisplay() {
72 global $abj404view;
73 $instance = self::getInstance();
74
75 try {
76 $action = $instance->viewGetPostOrGetSanitize('action');
77
78 if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
79 $instance->logger->logUserCapabilities("handleMainAdminPageActionAndDisplay (" .
80 esc_html($action == '' ? '(none)' : $action) . ")");
81
82 echo '<div class="wrap">';
83 echo '<h1>' . esc_html(PLUGIN_NAME) . '</h1>';
84 $permMessage = '<strong>' . esc_html__('Permission denied.', '404-solution') . '</strong> '
85 . esc_html__('Your user account does not have permission to access this page.', '404-solution')
86 . '</p><p>'
87 . esc_html__('Please verify that your WordPress role has the', '404-solution') . ' '
88 . '<code>manage_options</code> ' . esc_html__('capability.', '404-solution') . ' '
89 . esc_html__('If you have a security plugin installed, it may be restricting access to this page.', '404-solution');
90 $subpageForContext = (string)$instance->viewGetPostOrGetSanitize('subpage');
91 $triggerForPerm = ($subpageForContext === 'abj404_captured')
92 ? 'captured_404s_page' : 'redirects_page';
93 echo self::renderErrorNoticeWithSupportButton(
94 $permMessage,
95 $triggerForPerm,
96 'Permission denied on plugin admin page (action=' .
97 ($action == '' ? '(none)' : $action) . ')'
98 );
99 echo '</div>';
100 return;
101 }
102
103 $sub = "";
104
105 // --------------------------------------------------------------------
106 // Handle Post Actions
107 $instance->logger->debugMessage("Processing request for action: " .
108 esc_html($action == '' ? '(none)' : $action));
109
110 // this should really not pass things by reference so it can be more object oriented (encapsulation etc).
111 $message = "";
112 $message .= $instance->logic->handlePluginAction($action, $sub);
113 $message .= $instance->logic->hanldeTrashAction();
114 $message .= $instance->logic->handleDeleteAction();
115 $message .= $instance->logic->handleIgnoreAction();
116 $message .= $instance->logic->handleLaterAction();
117 $message .= $instance->logic->handleActionEdit($sub, $action);
118 $message .= $instance->logic->handleActionImportRedirects();
119 $instance->logic->handleActionChangeItemsPerRow();
120 $message .= $instance->logic->handleActionImportFile();
121
122 if ($action !== '' && $message !== '') {
123 $instance->logger->debugMessage("Admin action completed: " .
124 esc_html($action) . " => " . esc_html(substr($message, 0, 200)));
125 }
126
127 // --------------------------------------------------------------------
128 // Output the correct page.
129 $abj404view->echoChosenAdminTab($action, $sub, $message);
130
131 } catch (\Throwable $e) {
132 $encodedEx = json_encode($e);
133 $encodedContext = is_string($encodedEx) ? stripcslashes(wp_kses_post($encodedEx)) : '';
134 $instance->logger->errorMessage(
135 "Caught exception (" . get_class($e) . "): " . $e->getMessage()
136 . ($encodedContext !== '' ? " | context=" . $encodedContext : '')
137 );
138 $subpageForContext = (string)$instance->viewGetPostOrGetSanitize('subpage');
139 $triggerForRenderError = ($subpageForContext === 'abj404_captured')
140 ? 'captured_404s_page' : 'redirects_page';
141 $renderErrorMessage = '<strong>404 Solution:</strong> An error occurred while rendering this page.</p>'
142 . '<details><summary>Show error details</summary>'
143 . '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">'
144 . esc_html($e->getMessage() . "\n" . $e->getTraceAsString())
145 . '</pre>'
146 . '</details>'
147 . '<p>';
148 echo '<div class="wrap">';
149 echo self::renderErrorNoticeWithSupportButton(
150 $renderErrorMessage,
151 $triggerForRenderError,
152 'Render error: ' . substr($e->getMessage(), 0, 200)
153 );
154 echo '</div>';
155 }
156 }
157
158 /** Display the chosen admin page.
159 * @param string $action
160 * @param string $sub
161 * @param string $message
162 * @return void
163 */
164 function echoChosenAdminTab($action, $sub, $message) {
165 global $abj404view;
166
167 // If globals are not set, use sensible defaults
168 if ($abj404view === null) {
169 $abj404view = $this;
170 }
171
172 // Deal With Page Tabs
173 if ($sub == "") {
174 $sub = $this->f->strtolower($this->viewGetPostOrGetSanitize('subpage'));
175 }
176 if ($sub == "") {
177 $sub = 'abj404_redirects';
178 $this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
179 }
180
181 // Check if we're returning from a successful redirect update
182 $updated = $this->viewGetPostOrGetSanitize('updated');
183 if ($updated == '1') {
184 $message .= __('Redirect Information Updated Successfully!', '404-solution');
185 }
186
187 $this->logger->debugMessage("Displaying sub page: " . esc_html($sub == '' ? '(none)' : $sub));
188
189 $abj404view->outputAdminHeaderTabs($sub, $message);
190
191 $abj404action = $this->viewGetPostOrGetSanitize('abj404action');
192 if (($action == 'editRedirect') || ($abj404action == 'editRedirect') || ($sub == 'abj404_edit')) {
193 $abj404view->echoAdminEditRedirectPage();
194 } else if ($sub == 'abj404_redirects') {
195 $abj404view->echoAdminRedirectsPage();
196 } else if ($sub == 'abj404_captured') {
197 $abj404view->echoAdminCapturedURLsPage();
198 } else if ($sub == "abj404_options") {
199 $abj404view->echoAdminOptionsPage();
200 } else if ($sub == 'abj404_logs') {
201 $abj404view->echoAdminLogsPage();
202 } else if ($sub == 'abj404_stats') {
203 $abj404view->outputAdminStatsPage();
204 } else if ($sub == 'abj404_tools') {
205 $abj404view->echoAdminToolsPage();
206 } else if ($sub == 'abj404_debugfile') {
207 $abj404view->echoAdminDebugFile();
208 } else {
209 $this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
210 $abj404view->echoAdminRedirectsPage();
211 }
212
213 $abj404view->echoAdminFooter();
214 }
215
216 /**
217 * Echo the text that appears at the bottom of each admin page.
218 * @return void
219 */
220 function echoAdminFooter() {
221 // read the html content.
222 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/adminFooter.html");
223 $html = $this->f->str_replace('{JAPANESE_FLASHCARDS_URL}', ABJ404_FC_URL, $html);
224 $html = $this->f->str_replace(
225 '{ABJ404_VIEW_FRESHNESS}',
226 $this->renderViewFreshnessLabel(),
227 $html
228 );
229
230 // constants and translations.
231 $html = $this->f->doNormalReplacements($html);
232 echo $html;
233 }
234
235 /**
236 * Render a short, human-readable "how long ago was the view_done cache
237 * last successfully built" label for the admin footer. Returns one of:
238 * - "n/a" when never built or freshness option cleared (post-invalidate);
239 * - "Xs" when 0 to 59 seconds old;
240 * - "Xm" when 1 to 59 minutes old;
241 * - "Xh" when 1 to 23 hours old;
242 * - "Xd" when a day or more.
243 *
244 * The view_done snapshot is what the Redirects / Captured / Logs tables
245 * read from; if this label drifts upward into hours when the freshness
246 * TTL is 120s, the auto-rebuild path is failing or being blocked.
247 *
248 * @return string
249 */
250 private function renderViewFreshnessLabel(): string {
251 if (!is_object($this->viewBuildOrchestrator) || !method_exists($this->viewBuildOrchestrator, 'getViewDoneBuiltAtTimestamp')) {
252 return 'n/a';
253 }
254 // Defensive: a unit-test mock may throw BadMethodCallException
255 // when called without an expectation; any future implementation
256 // could also throw on a transient read failure. The freshness
257 // label is a footer cosmetic. Treat any failure as n/a so it
258 // never blocks the page render.
259 try {
260 $builtAt = (int)$this->viewBuildOrchestrator->getViewDoneBuiltAtTimestamp();
261 } catch (\Throwable $e) { // allow-silent-catch: freshness label is a footer cosmetic; DAO stub failures must never block page render
262 return 'n/a';
263 }
264 if ($builtAt <= 0) {
265 return 'n/a';
266 }
267 $age = time() - $builtAt;
268 if ($age < 0) {
269 return 'n/a'; // clock skew; do not surface a negative age
270 }
271 if ($age < 60) { return $age . 's'; }
272 if ($age < 3600) { return intval($age / 60) . 'm'; }
273 if ($age < 86400) { return intval($age / 3600) . 'h'; }
274 return intval($age / 86400) . 'd';
275 }
276
277 /** Output the tabs at the top of the plugin page.
278 * @param string $sub
279 * @param string $message
280 * @return void
281 */
282 function outputAdminHeaderTabs($sub = 'list', $message = '') {
283 ABJ_404_Solution_WPNotices::echoAdminNotices();
284
285 echo "<div class=\"wrap\" style='z-index: 1;position: relative;'>";
286
287 // Post-setup welcome banner
288 if (isset($_GET['setup_complete']) && $_GET['setup_complete'] === '1') {
289 $options = $this->logic->getOptions();
290 $auto = !empty($options['auto_redirects']) && $options['auto_redirects'] !== '0';
291 $notify = !empty($options['admin_notification']) && $options['admin_notification'] !== '0';
292
293 echo '<div class="notice notice-success is-dismissible"><p>';
294 echo '<strong>' . esc_html__("You're all set!", '404-solution') . '</strong> ';
295 if ($auto && $notify) {
296 echo esc_html__('404 Solution is now monitoring your site. When visitors hit broken links, they will be automatically redirected. We will email you if something needs attention.', '404-solution');
297 } elseif ($auto) {
298 echo esc_html__('404 Solution is now monitoring your site. When visitors hit broken links, they will be automatically redirected.', '404-solution');
299 } elseif ($notify) {
300 echo esc_html__('404 Solution is now monitoring your site. We will email you if captured 404 URLs need attention.', '404-solution');
301 } else {
302 echo esc_html__('404 Solution is now monitoring your site. You can create manual redirects anytime from the Page Redirects tab.', '404-solution');
303 }
304 echo '</p></div>' . "\n";
305 }
306
307 if ($message != "") {
308 $allowed_tags = array(
309 'br' => array(),
310 'em' => array(),
311 'strong' => array(),
312 );
313
314 if (($this->f->strlen($message) >= 6) && ($this->f->substr($this->f->strtolower($message), 0, 6) == 'error:')) {
315 $cssClasses = 'notice notice-error';
316 } else {
317 $cssClasses = 'notice notice-success';
318 }
319
320 echo '<div class="' . $cssClasses . '"><p>' . wp_kses($message, $allowed_tags) . "</p></div>\n";
321 }
322
323 // Regex auto-promote notice. Rendered as a separate info-level
324 // banner because it carries an action (Undo) the user can choose
325 // not to take. Kept distinct from the success message so the
326 // visual hierarchy reads "save succeeded; by the way, we
327 // promoted to regex and you can undo it" rather than mixing the
328 // two.
329 if (class_exists('ABJ_404_Solution_RegexAutoPromote')) {
330 $regexNotice = ABJ_404_Solution_RegexAutoPromote::readNotice();
331 if (is_array($regexNotice) && $regexNotice['redirect_id'] > 0) {
332 $this->renderRegexAutoPromoteNotice($regexNotice);
333 }
334 }
335
336 $isSimpleMode = $this->logic->getSettingsMode() === 'simple';
337
338 echo '<nav class="abj404-tab-navigation" role="tablist">';
339
340 // Page Redirects tab
341 $class = ($sub == 'abj404_redirects') ? "active" : "";
342 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_redirects" title="' . esc_attr__('Page Redirects', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
343 echo '<span class="dashicons dashicons-randomize"></span>';
344 echo '<span class="abj404-tab-text">' . esc_html__('Page Redirects', '404-solution') . '</span>';
345 echo '</a>';
346
347 // Captured 404 URLs tab
348 $class = ($sub == 'abj404_captured') ? "active" : "";
349 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_captured" title="' . esc_attr__('Captured 404 URLs', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
350 echo '<span class="dashicons dashicons-search"></span>';
351 echo '<span class="abj404-tab-text">' . esc_html__('Captured 404s', '404-solution') . '</span>';
352 echo '</a>';
353
354 if (!$isSimpleMode) {
355 // Logs tab (hidden in Simple mode)
356 $class = ($sub == 'abj404_logs') ? "active" : "";
357 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_logs" title="' . esc_attr__('Redirect & Capture Logs', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
358 echo '<span class="dashicons dashicons-list-view"></span>';
359 echo '<span class="abj404-tab-text">' . esc_html__('Logs', '404-solution') . '</span>';
360 echo '</a>';
361
362 // Stats tab (hidden in Simple mode)
363 $class = ($sub == 'abj404_stats') ? "active" : "";
364 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_stats" title="' . esc_attr__('Stats', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
365 echo '<span class="dashicons dashicons-chart-bar"></span>';
366 echo '<span class="abj404-tab-text">' . esc_html__('Stats', '404-solution') . '</span>';
367 echo '</a>';
368 }
369
370 // Tools tab
371 $class = ($sub == 'abj404_tools') ? "active" : "";
372 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_tools" title="' . esc_attr__('Tools', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
373 echo '<span class="dashicons dashicons-admin-tools"></span>';
374 echo '<span class="abj404-tab-text">' . esc_html__('Tools', '404-solution') . '</span>';
375 echo '</a>';
376
377 // Options tab
378 $class = ($sub == "abj404_options") ? "active" : "";
379 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_options" title="' . esc_attr__('Options', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
380 echo '<span class="dashicons dashicons-admin-generic"></span>';
381 echo '<span class="abj404-tab-text">' . esc_html__('Options', '404-solution') . '</span>';
382 echo '</a>';
383
384 // Plugin branding - right side of tabs
385 echo '<span class="abj404-tab-branding">' . esc_html(PLUGIN_NAME) . '</span>';
386
387 echo '</nav>';
388 }
389
390 /**
391 * Render the "Detected as a regex pattern" notice with [Edit] and
392 * [Undo] links. The notice is shown once per save that triggered
393 * server-side regex auto-promotion (admin posted a URL with
394 * unambiguous regex metachars but did not check the "Treat as regex"
395 * box). The [Undo] link restores the row to status=MANUAL with the
396 * original from_url; [Edit] jumps straight to the redirect edit
397 * page for further adjustments.
398 *
399 * @param array{redirect_id: int, original_url: string, new_url: string, url_rewritten: bool, created_at: int} $notice
400 * @return void
401 */
402 private function renderRegexAutoPromoteNotice(array $notice) {
403 $redirectId = (int)$notice['redirect_id'];
404 $originalUrl = (string)$notice['original_url'];
405 $newUrl = (string)$notice['new_url'];
406 $urlRewritten = !empty($notice['url_rewritten']);
407
408 $editUrl = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_edit&id=' . $redirectId);
409 $undoBase = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_redirects&action=undoRegexAutoPromote');
410 $undoUrl = wp_nonce_url($undoBase, 'abj404undoRegexAutoPromote');
411
412 if ($urlRewritten) {
413 $message = sprintf(
414 /* translators: %1$s = original URL as typed by the admin; %2$s = the URL after glob-to-regex fixup (e.g. /sales/* becomes /sales/.*) */
415 __('Detected as a regex pattern. Stored "%1$s" as "%2$s".', '404-solution'),
416 $originalUrl,
417 $newUrl
418 );
419 } else {
420 $message = sprintf(
421 /* translators: %s = the URL stored unchanged with Regex status */
422 __('Detected as a regex pattern. Stored "%s" with Regex status.', '404-solution'),
423 $originalUrl
424 );
425 }
426
427 echo '<div class="notice notice-info abj404-regex-autopromote-notice"><p>';
428 echo '<strong>' . esc_html__('404 Solution:', '404-solution') . '</strong> ';
429 echo esc_html($message);
430 echo ' <a href="' . esc_url($editUrl) . '">' . esc_html__('Edit', '404-solution') . '</a>';
431 echo ' | ';
432 echo '<a href="' . esc_url($undoUrl) . '" onclick="return confirm(\'' . esc_js(__('Undo regex auto-promotion and restore Manual status?', '404-solution')) . '\');">';
433 echo esc_html__('Undo', '404-solution') . '</a>';
434 echo '</p></div>' . "\n";
435 }
436
437 /** This outputs a box with a title and some content in it.
438 * It's used on the Stats, Options and Tools page (for example).
439 * @param int|string $id
440 * @param string $title
441 * @param string $content
442 * @return void
443 */
444 function echoPostBox($id, $title, $content) {
445 echo "<div id=\"" . esc_attr((string)$id) . "\" class=\"postbox\">";
446 echo "<h3 class=\"\" ><span>" . esc_html($title) . "</span></h3>";
447 echo "<div class=\"inside\">" . $content /* Can't escape here, as contains forms */ . "</div>";
448 echo "</div>";
449 }
450
451 /**
452 * Echo an accordion section using card-based layout
453 * @param string $sectionId The section identifier
454 * @param string $postboxId The ID for the postbox
455 * @param string $title The title of the section
456 * @param string $content The content to display
457 * @param bool $initiallyVisible Whether the card starts expanded (default: false)
458 * @param string $icon The SVG icon for the card header (optional)
459 * @param string $badge Optional info badge text
460 * @return void
461 */
462 function echoOptionsSection($sectionId, $postboxId, $title, $content, $initiallyVisible = false, $icon = '', $badge = '') {
463 $expandedClass = $initiallyVisible ? ' expanded' : '';
464
465 echo "<div class=\"abj404-card" . esc_attr($expandedClass) . "\" data-card=\"" . esc_attr($sectionId) . "\" id=\"" . esc_attr($postboxId) . "\">";
466 echo "<div class=\"abj404-card-header\" onclick=\"abj404ToggleCard(this)\" role=\"button\" aria-expanded=\"" . ($initiallyVisible ? 'true' : 'false') . "\" tabindex=\"0\">";
467 echo "<h2 class=\"abj404-card-title\">";
468 if ($icon) {
469 echo $icon; // Icon is pre-sanitized SVG
470 }
471 echo esc_html($title);
472 if ($badge) {
473 echo "<span class=\"abj404-info-badge\">" . esc_html($badge) . "</span>";
474 }
475 echo "</h2>";
476 echo "<svg class=\"abj404-collapse-icon\" width=\"20\" height=\"20\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">";
477 echo "<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>";
478 echo "</svg>";
479 echo "</div>";
480 echo "<div class=\"abj404-card-content\">";
481 echo $content; /* Can't escape here, as contains forms */
482 echo "</div>";
483 echo "</div>";
484 }
485
486 /**
487 * Get SVG icon for a section
488 * @param string $iconName The icon name
489 * @return string The SVG icon markup
490 */
491 function getCardIcon($iconName) {
492 $icons = array(
493 'lightning' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>',
494 'gear' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>',
495 'filter' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path></svg>',
496 'document' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>',
497 'sliders' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path></svg>',
498 'lightbulb' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path></svg>',
499 // Stats page icons
500 'chart' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>',
501 'warning' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>',
502 'clock' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
503 // Tools page icons
504 'download' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>',
505 'upload' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>',
506 'trash' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>',
507 'database' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>',
508 'cog' => '<svg class="abj404-card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>'
509 );
510 return isset($icons[$iconName]) ? $icons[$iconName] : '';
511 }
512
513
514 /**
515 * Echo the sticky save bar
516 * @return void
517 */
518 function echoStickySaveBar() {
519 $version = ABJ404_VERSION;
520 $restoreNonce = wp_create_nonce('abj404_restore_defaults');
521 echo '<div class="abj404-sticky-save-bar">';
522 echo '<div class="abj404-save-bar-status">';
523 /* translators: %s: plugin version number */
524 echo esc_html(sprintf(__('Plugin v%s', '404-solution'), $version));
525 echo '</div>';
526 echo '<div class="abj404-save-bar-actions">';
527 echo '<button type="button" id="abj404-restore-defaults" class="button abj404-btn abj404-btn-secondary" data-nonce="' . esc_attr($restoreNonce) . '">';
528 echo esc_html__('Restore Defaults', '404-solution');
529 echo '</button>';
530 echo '<input type="submit" form="admin-options-page" name="abj404-optionssub" id="abj404-optionssub" value="' . esc_attr__('Save Settings', '404-solution') . '" class="button button-primary abj404-btn abj404-btn-primary">';
531 echo '</div>';
532 echo '</div>';
533 $this->echoRestoreDefaultsModal();
534 }
535
536 /**
537 * Echo the confirmation modal shown before restoring settings to defaults.
538 * Toggled open by adding `.active`; matches the markup of other plugin modals.
539 * @return void
540 */
541 function echoRestoreDefaultsModal() {
542 ?>
543 <div id="abj404-restore-defaults-modal" class="abj404-modal" role="dialog" aria-modal="true" aria-labelledby="abj404-restore-defaults-modal-title">
544 <div class="abj404-modal-content">
545 <div class="abj404-modal-header">
546 <h2 id="abj404-restore-defaults-modal-title"><?php echo esc_html__('Restore Default Settings?', '404-solution'); ?></h2>
547 <button type="button" id="abj404-restore-defaults-cancel" class="abj404-modal-close" aria-label="<?php echo esc_attr__('Close', '404-solution'); ?>">&times;</button>
548 </div>
549 <div class="abj404-modal-body">
550 <p><?php echo esc_html__('This will reset all plugin settings to their default values. This cannot be undone. Your redirect rules and 404 logs will not be affected.', '404-solution'); ?></p>
551 </div>
552 <div class="abj404-modal-footer">
553 <button type="button" id="abj404-restore-defaults-cancel-2" class="button"><?php echo esc_html__('Cancel', '404-solution'); ?></button>
554 <button type="button" id="abj404-restore-defaults-confirm" class="button button-primary"><?php echo esc_html__('Restore Defaults', '404-solution'); ?></button>
555 </div>
556 </div>
557 </div>
558 <?php
559 }
560
561 /**
562 * Echo the success toast notification container
563 * @return void
564 */
565 function echoToastNotification() {
566 echo '<div id="abj404-toast" class="abj404-toast">';
567 echo '<span class="abj404-toast-icon">✓</span> ';
568 echo '<span class="abj404-toast-message">' . esc_html__('Settings saved successfully!', '404-solution') . '</span>';
569 echo '</div>';
570 }
571
572
573 /**
574 * Echo the expand/collapse all button and save button
575 * @param bool $showSuggestions Not used (kept for compatibility)
576 * @return void
577 */
578 function echoExpandCollapseButton($showSuggestions = true) {
579 ?>
580 <div class="abj404-accordion-controls">
581 <button type="button" id="abj404-expand-collapse-all" class="button">
582 <?php echo esc_html__('Expand All', '404-solution'); ?>
583 </button>
584 <input type="submit" name="abj404-optionssub" id="abj404-optionssub" value="<?php echo esc_attr__('Save Settings', '404-solution'); ?>" class="button-primary">
585 </div>
586 <?php
587 }
588
589 /**
590 * Echo the Simple/Advanced mode toggle control.
591 * @param string $currentMode 'simple' or 'advanced'
592 * @return void
593 */
594 function echoSettingsModeToggle($currentMode) {
595 $simpleActive = ($currentMode === 'simple') ? 'active' : '';
596 $advancedActive = ($currentMode === 'advanced') ? 'active' : '';
597 $simplePressedState = ($currentMode === 'simple') ? 'true' : 'false';
598 $advancedPressedState = ($currentMode === 'advanced') ? 'true' : 'false';
599
600 if ($currentMode === 'simple') {
601 $modeDescription = __('Simple Mode shows essential options only. Switch to Advanced Mode for full configuration.', '404-solution');
602 } else {
603 $modeDescription = __('Advanced Mode shows all options. Switch to Simple Mode for a streamlined view.', '404-solution');
604 }
605
606 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/settingsModeToggle.html");
607 $html = $this->f->str_replace('{nonce}', wp_create_nonce('abj404_mode_toggle'), $html);
608 $html = $this->f->str_replace('{simpleActive}', $simpleActive, $html);
609 $html = $this->f->str_replace('{advancedActive}', $advancedActive, $html);
610 $html = $this->f->str_replace('{simplePressedState}', $simplePressedState, $html);
611 $html = $this->f->str_replace('{advancedPressedState}', $advancedPressedState, $html);
612 $html = $this->f->str_replace('{Simple Mode}', __('Simple Mode', '404-solution'), $html);
613 $html = $this->f->str_replace('{Advanced Mode}', __('Advanced Mode', '404-solution'), $html);
614 $html = $this->f->str_replace('{mode_description}', $modeDescription, $html);
615
616 echo $html;
617 }
618
619 /**
620 * Echo an inline (compact) mode toggle for the header row.
621 * This is a smaller version without the description text.
622 * @return void
623 */
624 function echoInlineModeToggle() {
625 $currentMode = $this->logic->getSettingsMode();
626 $simpleActive = ($currentMode === 'simple') ? 'active' : '';
627 $advancedActive = ($currentMode === 'advanced') ? 'active' : '';
628 $simplePressedState = ($currentMode === 'simple') ? 'true' : 'false';
629 $advancedPressedState = ($currentMode === 'advanced') ? 'true' : 'false';
630
631 echo '<div class="abj404-mode-toggle abj404-mode-toggle-inline" data-nonce="' . esc_attr(wp_create_nonce('abj404_mode_toggle')) . '">';
632 echo '<div class="abj404-mode-toggle-buttons">';
633 echo '<button type="button" class="abj404-mode-btn ' . esc_attr($simpleActive) . '" data-mode="simple" aria-pressed="' . esc_attr($simplePressedState) . '">';
634 echo '<span class="abj404-mode-btn-text">' . esc_html__('Simple', '404-solution') . '</span>';
635 echo '</button>';
636 echo '<button type="button" class="abj404-mode-btn ' . esc_attr($advancedActive) . '" data-mode="advanced" aria-pressed="' . esc_attr($advancedPressedState) . '">';
637 echo '<span class="abj404-mode-btn-text">' . esc_html__('Advanced', '404-solution') . '</span>';
638 echo '</button>';
639 echo '</div>';
640 echo '</div>';
641 }
642
643 /**
644 * Echo the Simple Mode options page.
645 * @param array<string, mixed> $options The plugin options
646 * @return void
647 */
648 function echoSimpleModeOptions($options) {
649 // Build behavior tiles HTML (replaces old dropdown)
650 $behaviorTilesHtml = $this->getBehaviorTilesHTML($options);
651
652 // Build selected states for checkboxes and dropdowns
653 $selectedAutoRedirects = $this->getCheckedAttr($options, 'auto_redirects');
654 $selectedCapture404 = $this->getCheckedAttr($options, 'capture_404');
655 $selectedDefaultRedirect301 = ($options['default_redirect'] == '301') ? 'selected' : '';
656 $selectedDefaultRedirect302 = ($options['default_redirect'] == '302') ? 'selected' : '';
657 $selectedDefaultRedirect307 = ($options['default_redirect'] == '307') ? 'selected' : '';
658 $selectedDefaultRedirect308 = ($options['default_redirect'] == '308') ? 'selected' : '';
659
660 // Theme selections
661 $selectedThemeDefault = ($options['admin_theme'] == 'default') ? 'selected' : '';
662 $selectedThemeCalm = ($options['admin_theme'] == 'calm') ? 'selected' : '';
663 $selectedThemeMono = ($options['admin_theme'] == 'mono') ? 'selected' : '';
664 $selectedThemeNeon = ($options['admin_theme'] == 'neon') ? 'selected' : '';
665 $selectedThemeObsidian = ($options['admin_theme'] == 'obsidian') ? 'selected' : '';
666
667 // Notification frequency selections
668 $notifyFrequency = isset($options['admin_notification_frequency']) ? (string)(is_scalar($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant') : 'instant';
669 $selectedNotifyInstant = ($notifyFrequency === 'instant') ? 'selected' : '';
670 $selectedNotifyDaily = ($notifyFrequency === 'daily') ? 'selected' : '';
671 $selectedNotifyWeekly = ($notifyFrequency === 'weekly') ? 'selected' : '';
672
673 // Read and build the simple options template
674 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/adminOptionsSimple.html");
675
676 // Replace dest404page tiles
677 $html = $this->f->str_replace('{behaviorTiles}', $behaviorTilesHtml, $html);
678
679 // Replace checkbox states
680 $html = $this->f->str_replace('{selectedAutoRedirects}', $selectedAutoRedirects, $html);
681 $html = $this->f->str_replace('{selectedCapture404}', $selectedCapture404, $html);
682 $html = $this->f->str_replace('{selectedDefaultRedirect301}', $selectedDefaultRedirect301, $html);
683 $html = $this->f->str_replace('{selectedDefaultRedirect302}', $selectedDefaultRedirect302, $html);
684 $html = $this->f->str_replace('{selectedDefaultRedirect307}', $selectedDefaultRedirect307, $html);
685 $html = $this->f->str_replace('{selectedDefaultRedirect308}', $selectedDefaultRedirect308, $html);
686
687 // Replace values
688 $html = $this->f->str_replace('{capture_deletion}', esc_attr($this->optStr($options, 'capture_deletion')), $html);
689 $html = $this->f->str_replace('{admin_notification}', esc_attr($this->optStr($options, 'admin_notification')), $html);
690 $html = $this->f->str_replace('{maximum_log_disk_usage}', esc_attr($this->optStr($options, 'maximum_log_disk_usage')), $html);
691
692 // Theme selections
693 $html = $this->f->str_replace('{selectedThemeDefault}', $selectedThemeDefault, $html);
694 $html = $this->f->str_replace('{selectedThemeCalm}', $selectedThemeCalm, $html);
695 $html = $this->f->str_replace('{selectedThemeMono}', $selectedThemeMono, $html);
696 $html = $this->f->str_replace('{selectedThemeNeon}', $selectedThemeNeon, $html);
697 $html = $this->f->str_replace('{selectedThemeObsidian}', $selectedThemeObsidian, $html);
698 $html = $this->f->str_replace('{theme_default}', __('Default (Follows WordPress)', '404-solution'), $html);
699
700 // Translate labels
701 $html = $this->f->str_replace('{Core Settings}', __('Core Settings', '404-solution'), $html);
702 $html = $this->f->str_replace('{404 Capture}', __('404 Capture', '404-solution'), $html);
703 $html = $this->f->str_replace('{Maintenance}', __('Maintenance', '404-solution'), $html);
704 $html = $this->f->str_replace('{Default 404 destination}', __('Default 404 destination', '404-solution'), $html);
705 $html = $this->f->str_replace('{Where to send visitors when a 404 error occurs}', __('Where to send visitors when a 404 error occurs', '404-solution'), $html);
706 $html = $this->f->str_replace('{Create automatic redirects}', __('Create automatic redirects', '404-solution'), $html);
707 $html = $this->f->str_replace('{Automatically redirect 404s to similar pages when a good match is found}', __('Automatically redirect 404s to similar pages when a good match is found', '404-solution'), $html);
708 $html = $this->f->str_replace('{Redirect type}', __('Redirect type', '404-solution'), $html);
709 $html = $this->f->str_replace('{Permanent 301}', __('Permanent 301', '404-solution'), $html);
710 $html = $this->f->str_replace('{Temporary 302}', __('Temporary 302', '404-solution'), $html);
711 $html = $this->f->str_replace('{301 for SEO, 302 for temporary changes}', __('301 for SEO, 302 for temporary changes', '404-solution'), $html);
712 $html = $this->f->str_replace('{Collect incoming 404 URLs}', __('Collect incoming 404 URLs', '404-solution'), $html);
713 $html = $this->f->str_replace('{Log 404 errors so you can review and fix them}', __('Log 404 errors so you can review and fix them', '404-solution'), $html);
714 $html = $this->f->str_replace('{Delete captured URLs after}', __('Delete captured URLs after', '404-solution'), $html);
715 $html = $this->f->str_replace('{days}', __('days', '404-solution'), $html);
716 $html = $this->f->str_replace('{Auto-remove old 404 records (0 to keep forever)}', __('Auto-remove old 404 records (0 to keep forever)', '404-solution'), $html);
717 $html = $this->f->str_replace('{Notify me when captured URLs exceed}', __('Notify me when captured URLs exceed', '404-solution'), $html);
718 $html = $this->f->str_replace('{URLs}', __('URLs', '404-solution'), $html);
719 $html = $this->f->str_replace('{Show admin notice when 404 count gets high (0 to disable)}', __('Show admin notice when 404 count gets high (0 to disable)', '404-solution'), $html);
720 $html = $this->f->str_replace('{Maximum log storage}', __('Maximum log storage', '404-solution'), $html);
721 $html = $this->f->str_replace('{Oldest logs are deleted when this limit is reached}', __('Oldest logs are deleted when this limit is reached', '404-solution'), $html);
722 $html = $this->f->str_replace('{Admin theme}', __('Admin theme', '404-solution'), $html);
723 $html = $this->f->str_replace('{Calm Ops (Light)}', __('Calm Ops (Light)', '404-solution'), $html);
724 $html = $this->f->str_replace('{Monochrome Minimal (Light)}', __('Monochrome Minimal (Light)', '404-solution'), $html);
725 $html = $this->f->str_replace('{Neon Slate (Dark)}', __('Neon Slate (Dark)', '404-solution'), $html);
726 $html = $this->f->str_replace('{Obsidian Blue (Dark)}', __('Obsidian Blue (Dark)', '404-solution'), $html);
727 $html = $this->f->str_replace('{Save Settings}', __('Save Settings', '404-solution'), $html);
728 $html = $this->f->str_replace('{Need more control?}', __('Need more control?', '404-solution'), $html);
729 $html = $this->f->str_replace('{Switch to Advanced Mode}', __('Switch to Advanced Mode', '404-solution'), $html);
730 $html = $this->f->str_replace('{for 30+ additional options.}', __('for 30+ additional options.', '404-solution'), $html);
731
732 // Frequency dropdown selections and labels
733 $html = $this->f->str_replace('{selectedNotifyInstant}', $selectedNotifyInstant, $html);
734 $html = $this->f->str_replace('{selectedNotifyDaily}', $selectedNotifyDaily, $html);
735 $html = $this->f->str_replace('{selectedNotifyWeekly}', $selectedNotifyWeekly, $html);
736 $html = $this->f->str_replace('{Email notification frequency}', __('Email notification frequency', '404-solution'), $html);
737 $html = $this->f->str_replace('{Instant (when threshold exceeded)}', __('Instant (when threshold exceeded)', '404-solution'), $html);
738 $html = $this->f->str_replace('{Daily digest}', __('Daily digest', '404-solution'), $html);
739 $html = $this->f->str_replace('{Weekly digest}', __('Weekly digest', '404-solution'), $html);
740 $html = $this->f->str_replace('{Choose how often to receive email notifications about captured 404s.}', __('Choose how often to receive email notifications about captured 404s.', '404-solution'), $html);
741 // Also handle the Temporary 307/308 options (present in simple mode too)
742 $html = $this->f->str_replace('{Temporary 307 (preserve method)}', __('Temporary 307 (preserve method)', '404-solution'), $html);
743 $html = $this->f->str_replace('{Permanent 308 (preserve method)}', __('Permanent 308 (preserve method)', '404-solution'), $html);
744
745 echo $html;
746 }
747
748
749 }
750