PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/ViewTrait_UI.php

746 lines 44.2 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->dao->getPostOrGetSanitize('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 = is_object($instance->dao) && method_exists($instance->dao, 'getPostOrGetSanitize')
91 ? (string)$instance->dao->getPostOrGetSanitize('subpage') : '';
92 $triggerForPerm = ($subpageForContext === 'abj404_captured')
93 ? 'captured_404s_page' : 'redirects_page';
94 echo self::renderErrorNoticeWithSupportButton(
95 $permMessage,
96 $triggerForPerm,
97 'Permission denied on plugin admin page (action=' .
98 ($action == '' ? '(none)' : $action) . ')'
99 );
100 echo '</div>';
101 return;
102 }
103
104 $sub = "";
105
106 // --------------------------------------------------------------------
107 // Handle Post Actions
108 $instance->logger->debugMessage("Processing request for action: " .
109 esc_html($action == '' ? '(none)' : $action));
110
111 // this should really not pass things by reference so it can be more object oriented (encapsulation etc).
112 $message = "";
113 $message .= $instance->logic->handlePluginAction($action, $sub);
114 $message .= $instance->logic->hanldeTrashAction();
115 $message .= $instance->logic->handleDeleteAction();
116 $message .= $instance->logic->handleIgnoreAction();
117 $message .= $instance->logic->handleLaterAction();
118 $message .= $instance->logic->handleActionEdit($sub, $action);
119 $message .= $instance->logic->handleActionImportRedirects();
120 $instance->logic->handleActionChangeItemsPerRow();
121 $message .= $instance->logic->handleActionImportFile();
122
123 // --------------------------------------------------------------------
124 // Output the correct page.
125 $abj404view->echoChosenAdminTab($action, $sub, $message);
126
127 } catch (\Throwable $e) {
128 $encodedEx = json_encode($e);
129 $instance->logger->errorMessage("Caught exception: " . stripcslashes(wp_kses_post(is_string($encodedEx) ? $encodedEx : '')));
130 $subpageForContext = is_object($instance->dao) && method_exists($instance->dao, 'getPostOrGetSanitize')
131 ? (string)$instance->dao->getPostOrGetSanitize('subpage') : '';
132 $triggerForRenderError = ($subpageForContext === 'abj404_captured')
133 ? 'captured_404s_page' : 'redirects_page';
134 $renderErrorMessage = '<strong>404 Solution:</strong> An error occurred while rendering this page.</p>'
135 . '<details><summary>Show error details</summary>'
136 . '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">'
137 . esc_html($e->getMessage() . "\n" . $e->getTraceAsString())
138 . '</pre>'
139 . '</details>'
140 . '<p>';
141 echo '<div class="wrap">';
142 echo self::renderErrorNoticeWithSupportButton(
143 $renderErrorMessage,
144 $triggerForRenderError,
145 'Render error: ' . substr($e->getMessage(), 0, 200)
146 );
147 echo '</div>';
148 }
149 }
150
151 /** Display the chosen admin page.
152 * @param string $action
153 * @param string $sub
154 * @param string $message
155 * @return void
156 */
157 function echoChosenAdminTab($action, $sub, $message) {
158 global $abj404view;
159
160 // If globals are not set, use sensible defaults
161 if ($abj404view === null) {
162 $abj404view = $this;
163 }
164
165 // Deal With Page Tabs
166 if ($sub == "") {
167 $sub = $this->f->strtolower($this->dao->getPostOrGetSanitize('subpage'));
168 }
169 if ($sub == "") {
170 $sub = 'abj404_redirects';
171 $this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
172 }
173
174 // Check if we're returning from a successful redirect update
175 $updated = $this->dao->getPostOrGetSanitize('updated');
176 if ($updated == '1') {
177 $message .= __('Redirect Information Updated Successfully!', '404-solution');
178 }
179
180 $this->logger->debugMessage("Displaying sub page: " . esc_html($sub == '' ? '(none)' : $sub));
181
182 $abj404view->outputAdminHeaderTabs($sub, $message);
183
184 $abj404action = $this->dao->getPostOrGetSanitize('abj404action');
185 if (($action == 'editRedirect') || ($abj404action == 'editRedirect') || ($sub == 'abj404_edit')) {
186 $abj404view->echoAdminEditRedirectPage();
187 } else if ($sub == 'abj404_redirects') {
188 $abj404view->echoAdminRedirectsPage();
189 } else if ($sub == 'abj404_captured') {
190 $abj404view->echoAdminCapturedURLsPage();
191 } else if ($sub == "abj404_options") {
192 $abj404view->echoAdminOptionsPage();
193 } else if ($sub == 'abj404_logs') {
194 $abj404view->echoAdminLogsPage();
195 } else if ($sub == 'abj404_stats') {
196 $abj404view->outputAdminStatsPage();
197 } else if ($sub == 'abj404_tools') {
198 $abj404view->echoAdminToolsPage();
199 } else if ($sub == 'abj404_debugfile') {
200 $abj404view->echoAdminDebugFile();
201 } else {
202 $this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
203 $abj404view->echoAdminRedirectsPage();
204 }
205
206 $abj404view->echoAdminFooter();
207 }
208
209 /**
210 * Echo the text that appears at the bottom of each admin page.
211 * @return void
212 */
213 function echoAdminFooter() {
214 // read the html content.
215 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/adminFooter.html");
216 $html = $this->f->str_replace('{JAPANESE_FLASHCARDS_URL}', ABJ404_FC_URL, $html);
217 $html = $this->f->str_replace(
218 '{ABJ404_VIEW_FRESHNESS}',
219 $this->renderViewFreshnessLabel(),
220 $html
221 );
222
223 // constants and translations.
224 $html = $this->f->doNormalReplacements($html);
225 echo $html;
226 }
227
228 /**
229 * Render a short, human-readable "how long ago was the view_done cache
230 * last successfully built" label for the admin footer. Returns one of:
231 * - "n/a" when never built or freshness option cleared (post-invalidate);
232 * - "Xs" when 0 to 59 seconds old;
233 * - "Xm" when 1 to 59 minutes old;
234 * - "Xh" when 1 to 23 hours old;
235 * - "Xd" when a day or more.
236 *
237 * The view_done snapshot is what the Redirects / Captured / Logs tables
238 * read from; if this label drifts upward into hours when the freshness
239 * TTL is 120s, the auto-rebuild path is failing or being blocked.
240 *
241 * @return string
242 */
243 private function renderViewFreshnessLabel(): string {
244 if (!is_object($this->dao) || !method_exists($this->dao, 'getViewDoneBuiltAtTimestamp')) {
245 return 'n/a';
246 }
247 // Defensive: a unit-test DAO may be a Mockery mock that throws
248 // BadMethodCallException when called without an expectation; any
249 // future DAO implementation could also throw on a transient read
250 // failure. The freshness label is a footer cosmetic. Treat any
251 // failure as n/a so it never blocks the page render. Without this
252 // try/catch, dozens of pre-existing unit tests that mock the DAO
253 // without explicitly stubbing this method threw on every footer
254 // render.
255 try {
256 $builtAt = (int)$this->dao->getViewDoneBuiltAtTimestamp();
257 } catch (\Throwable $e) {
258 return 'n/a';
259 }
260 if ($builtAt <= 0) {
261 return 'n/a';
262 }
263 $age = time() - $builtAt;
264 if ($age < 0) {
265 return 'n/a'; // clock skew; do not surface a negative age
266 }
267 if ($age < 60) { return $age . 's'; }
268 if ($age < 3600) { return intval($age / 60) . 'm'; }
269 if ($age < 86400) { return intval($age / 3600) . 'h'; }
270 return intval($age / 86400) . 'd';
271 }
272
273 /** Output the tabs at the top of the plugin page.
274 * @param string $sub
275 * @param string $message
276 * @return void
277 */
278 function outputAdminHeaderTabs($sub = 'list', $message = '') {
279 ABJ_404_Solution_WPNotices::echoAdminNotices();
280
281 echo "<div class=\"wrap\" style='z-index: 1;position: relative;'>";
282
283 // Post-setup welcome banner
284 if (isset($_GET['setup_complete']) && $_GET['setup_complete'] === '1') {
285 $options = $this->logic->getOptions();
286 $auto = !empty($options['auto_redirects']) && $options['auto_redirects'] !== '0';
287 $notify = !empty($options['admin_notification']) && $options['admin_notification'] !== '0';
288
289 echo '<div class="notice notice-success is-dismissible"><p>';
290 echo '<strong>' . esc_html__("You're all set!", '404-solution') . '</strong> ';
291 if ($auto && $notify) {
292 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');
293 } elseif ($auto) {
294 echo esc_html__('404 Solution is now monitoring your site. When visitors hit broken links, they will be automatically redirected.', '404-solution');
295 } elseif ($notify) {
296 echo esc_html__('404 Solution is now monitoring your site. We will email you if captured 404 URLs need attention.', '404-solution');
297 } else {
298 echo esc_html__('404 Solution is now monitoring your site. You can create manual redirects anytime from the Page Redirects tab.', '404-solution');
299 }
300 echo '</p></div>' . "\n";
301 }
302
303 if ($message != "") {
304 $allowed_tags = array(
305 'br' => array(),
306 'em' => array(),
307 'strong' => array(),
308 );
309
310 if (($this->f->strlen($message) >= 6) && ($this->f->substr($this->f->strtolower($message), 0, 6) == 'error:')) {
311 $cssClasses = 'notice notice-error';
312 } else {
313 $cssClasses = 'notice notice-success';
314 }
315
316 echo '<div class="' . $cssClasses . '"><p>' . wp_kses($message, $allowed_tags) . "</p></div>\n";
317 }
318
319 // Regex auto-promote notice. Rendered as a separate info-level
320 // banner because it carries an action (Undo) the user can choose
321 // not to take. Kept distinct from the success message so the
322 // visual hierarchy reads "save succeeded; by the way, we
323 // promoted to regex and you can undo it" rather than mixing the
324 // two.
325 if (class_exists('ABJ_404_Solution_RegexAutoPromote')) {
326 $regexNotice = ABJ_404_Solution_RegexAutoPromote::readNotice();
327 if (is_array($regexNotice) && $regexNotice['redirect_id'] > 0) {
328 $this->renderRegexAutoPromoteNotice($regexNotice);
329 }
330 }
331
332 $isSimpleMode = $this->logic->getSettingsMode() === 'simple';
333
334 echo '<nav class="abj404-tab-navigation" role="tablist">';
335
336 // Page Redirects tab
337 $class = ($sub == 'abj404_redirects') ? "active" : "";
338 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_redirects" title="' . esc_attr__('Page Redirects', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
339 echo '<span class="dashicons dashicons-randomize"></span>';
340 echo '<span class="abj404-tab-text">' . esc_html__('Page Redirects', '404-solution') . '</span>';
341 echo '</a>';
342
343 // Captured 404 URLs tab
344 $class = ($sub == 'abj404_captured') ? "active" : "";
345 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_captured" title="' . esc_attr__('Captured 404 URLs', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
346 echo '<span class="dashicons dashicons-search"></span>';
347 echo '<span class="abj404-tab-text">' . esc_html__('Captured 404s', '404-solution') . '</span>';
348 echo '</a>';
349
350 if (!$isSimpleMode) {
351 // Logs tab (hidden in Simple mode)
352 $class = ($sub == 'abj404_logs') ? "active" : "";
353 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_logs" title="' . esc_attr__('Redirect & Capture Logs', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
354 echo '<span class="dashicons dashicons-list-view"></span>';
355 echo '<span class="abj404-tab-text">' . esc_html__('Logs', '404-solution') . '</span>';
356 echo '</a>';
357
358 // Stats tab (hidden in Simple mode)
359 $class = ($sub == 'abj404_stats') ? "active" : "";
360 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_stats" title="' . esc_attr__('Stats', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
361 echo '<span class="dashicons dashicons-chart-bar"></span>';
362 echo '<span class="abj404-tab-text">' . esc_html__('Stats', '404-solution') . '</span>';
363 echo '</a>';
364 }
365
366 // Tools tab
367 $class = ($sub == 'abj404_tools') ? "active" : "";
368 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_tools" title="' . esc_attr__('Tools', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
369 echo '<span class="dashicons dashicons-admin-tools"></span>';
370 echo '<span class="abj404-tab-text">' . esc_html__('Tools', '404-solution') . '</span>';
371 echo '</a>';
372
373 // Options tab
374 $class = ($sub == "abj404_options") ? "active" : "";
375 echo '<a href="?page=' . ABJ404_PP . '&subpage=abj404_options" title="' . esc_attr__('Options', '404-solution') . '" class="abj404-tab ' . $class . '" role="tab">';
376 echo '<span class="dashicons dashicons-admin-generic"></span>';
377 echo '<span class="abj404-tab-text">' . esc_html__('Options', '404-solution') . '</span>';
378 echo '</a>';
379
380 // Plugin branding - right side of tabs
381 echo '<span class="abj404-tab-branding">' . esc_html(PLUGIN_NAME) . '</span>';
382
383 echo '</nav>';
384 }
385
386 /**
387 * Render the "Detected as a regex pattern" notice with [Edit] and
388 * [Undo] links. The notice is shown once per save that triggered
389 * server-side regex auto-promotion (admin posted a URL with
390 * unambiguous regex metachars but did not check the "Treat as regex"
391 * box). The [Undo] link restores the row to status=MANUAL with the
392 * original from_url; [Edit] jumps straight to the redirect edit
393 * page for further adjustments.
394 *
395 * @param array{redirect_id: int, original_url: string, new_url: string, url_rewritten: bool, created_at: int} $notice
396 * @return void
397 */
398 private function renderRegexAutoPromoteNotice(array $notice) {
399 $redirectId = (int)$notice['redirect_id'];
400 $originalUrl = (string)$notice['original_url'];
401 $newUrl = (string)$notice['new_url'];
402 $urlRewritten = !empty($notice['url_rewritten']);
403
404 $editUrl = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_edit&id=' . $redirectId);
405 $undoBase = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_redirects&action=undoRegexAutoPromote');
406 $undoUrl = wp_nonce_url($undoBase, 'abj404undoRegexAutoPromote');
407
408 if ($urlRewritten) {
409 $message = sprintf(
410 /* translators: %1$s = original URL as typed by the admin; %2$s = the URL after glob-to-regex fixup (e.g. /sales/* becomes /sales/.*) */
411 __('Detected as a regex pattern. Stored "%1$s" as "%2$s".', '404-solution'),
412 $originalUrl,
413 $newUrl
414 );
415 } else {
416 $message = sprintf(
417 /* translators: %s = the URL stored unchanged with Regex status */
418 __('Detected as a regex pattern. Stored "%s" with Regex status.', '404-solution'),
419 $originalUrl
420 );
421 }
422
423 echo '<div class="notice notice-info abj404-regex-autopromote-notice"><p>';
424 echo '<strong>' . esc_html__('404 Solution:', '404-solution') . '</strong> ';
425 echo esc_html($message);
426 echo ' <a href="' . esc_url($editUrl) . '">' . esc_html__('Edit', '404-solution') . '</a>';
427 echo ' | ';
428 echo '<a href="' . esc_url($undoUrl) . '" onclick="return confirm(\'' . esc_js(__('Undo regex auto-promotion and restore Manual status?', '404-solution')) . '\');">';
429 echo esc_html__('Undo', '404-solution') . '</a>';
430 echo '</p></div>' . "\n";
431 }
432
433 /** This outputs a box with a title and some content in it.
434 * It's used on the Stats, Options and Tools page (for example).
435 * @param int|string $id
436 * @param string $title
437 * @param string $content
438 * @return void
439 */
440 function echoPostBox($id, $title, $content) {
441 echo "<div id=\"" . esc_attr((string)$id) . "\" class=\"postbox\">";
442 echo "<h3 class=\"\" ><span>" . esc_html($title) . "</span></h3>";
443 echo "<div class=\"inside\">" . $content /* Can't escape here, as contains forms */ . "</div>";
444 echo "</div>";
445 }
446
447 /**
448 * Echo an accordion section using card-based layout
449 * @param string $sectionId The section identifier
450 * @param string $postboxId The ID for the postbox
451 * @param string $title The title of the section
452 * @param string $content The content to display
453 * @param bool $initiallyVisible Whether the card starts expanded (default: false)
454 * @param string $icon The SVG icon for the card header (optional)
455 * @param string $badge Optional info badge text
456 * @return void
457 */
458 function echoOptionsSection($sectionId, $postboxId, $title, $content, $initiallyVisible = false, $icon = '', $badge = '') {
459 $expandedClass = $initiallyVisible ? ' expanded' : '';
460
461 echo "<div class=\"abj404-card" . esc_attr($expandedClass) . "\" data-card=\"" . esc_attr($sectionId) . "\" id=\"" . esc_attr($postboxId) . "\">";
462 echo "<div class=\"abj404-card-header\" onclick=\"abj404ToggleCard(this)\" role=\"button\" aria-expanded=\"" . ($initiallyVisible ? 'true' : 'false') . "\" tabindex=\"0\">";
463 echo "<h2 class=\"abj404-card-title\">";
464 if ($icon) {
465 echo $icon; // Icon is pre-sanitized SVG
466 }
467 echo esc_html($title);
468 if ($badge) {
469 echo "<span class=\"abj404-info-badge\">" . esc_html($badge) . "</span>";
470 }
471 echo "</h2>";
472 echo "<svg class=\"abj404-collapse-icon\" width=\"20\" height=\"20\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">";
473 echo "<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>";
474 echo "</svg>";
475 echo "</div>";
476 echo "<div class=\"abj404-card-content\">";
477 echo $content; /* Can't escape here, as contains forms */
478 echo "</div>";
479 echo "</div>";
480 }
481
482 /**
483 * Get SVG icon for a section
484 * @param string $iconName The icon name
485 * @return string The SVG icon markup
486 */
487 function getCardIcon($iconName) {
488 $icons = array(
489 '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>',
490 '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>',
491 '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>',
492 '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>',
493 '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>',
494 '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>',
495 // Stats page icons
496 '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>',
497 '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>',
498 '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>',
499 // Tools page icons
500 '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>',
501 '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>',
502 '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>',
503 '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>',
504 '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>'
505 );
506 return isset($icons[$iconName]) ? $icons[$iconName] : '';
507 }
508
509
510 /**
511 * Echo the sticky save bar
512 * @return void
513 */
514 function echoStickySaveBar() {
515 $version = ABJ404_VERSION;
516 $restoreNonce = wp_create_nonce('abj404_restore_defaults');
517 echo '<div class="abj404-sticky-save-bar">';
518 echo '<div class="abj404-save-bar-status">';
519 /* translators: %s: plugin version number */
520 echo esc_html(sprintf(__('Plugin v%s', '404-solution'), $version));
521 echo '</div>';
522 echo '<div class="abj404-save-bar-actions">';
523 echo '<button type="button" id="abj404-restore-defaults" class="button abj404-btn abj404-btn-secondary" data-nonce="' . esc_attr($restoreNonce) . '">';
524 echo esc_html__('Restore Defaults', '404-solution');
525 echo '</button>';
526 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">';
527 echo '</div>';
528 echo '</div>';
529 $this->echoRestoreDefaultsModal();
530 }
531
532 /**
533 * Echo the confirmation modal shown before restoring settings to defaults.
534 * Toggled open by adding `.active`; matches the markup of other plugin modals.
535 * @return void
536 */
537 function echoRestoreDefaultsModal() {
538 ?>
539 <div id="abj404-restore-defaults-modal" class="abj404-modal" role="dialog" aria-modal="true" aria-labelledby="abj404-restore-defaults-modal-title">
540 <div class="abj404-modal-content">
541 <div class="abj404-modal-header">
542 <h2 id="abj404-restore-defaults-modal-title"><?php echo esc_html__('Restore Default Settings?', '404-solution'); ?></h2>
543 <button type="button" id="abj404-restore-defaults-cancel" class="abj404-modal-close" aria-label="<?php echo esc_attr__('Close', '404-solution'); ?>">&times;</button>
544 </div>
545 <div class="abj404-modal-body">
546 <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>
547 </div>
548 <div class="abj404-modal-footer">
549 <button type="button" id="abj404-restore-defaults-cancel-2" class="button"><?php echo esc_html__('Cancel', '404-solution'); ?></button>
550 <button type="button" id="abj404-restore-defaults-confirm" class="button button-primary"><?php echo esc_html__('Restore Defaults', '404-solution'); ?></button>
551 </div>
552 </div>
553 </div>
554 <?php
555 }
556
557 /**
558 * Echo the success toast notification container
559 * @return void
560 */
561 function echoToastNotification() {
562 echo '<div id="abj404-toast" class="abj404-toast">';
563 echo '<span class="abj404-toast-icon">✓</span> ';
564 echo '<span class="abj404-toast-message">' . esc_html__('Settings saved successfully!', '404-solution') . '</span>';
565 echo '</div>';
566 }
567
568
569 /**
570 * Echo the expand/collapse all button and save button
571 * @param bool $showSuggestions Not used (kept for compatibility)
572 * @return void
573 */
574 function echoExpandCollapseButton($showSuggestions = true) {
575 ?>
576 <div class="abj404-accordion-controls">
577 <button type="button" id="abj404-expand-collapse-all" class="button">
578 <?php echo esc_html__('Expand All', '404-solution'); ?>
579 </button>
580 <input type="submit" name="abj404-optionssub" id="abj404-optionssub" value="<?php echo esc_attr__('Save Settings', '404-solution'); ?>" class="button-primary">
581 </div>
582 <?php
583 }
584
585 /**
586 * Echo the Simple/Advanced mode toggle control.
587 * @param string $currentMode 'simple' or 'advanced'
588 * @return void
589 */
590 function echoSettingsModeToggle($currentMode) {
591 $simpleActive = ($currentMode === 'simple') ? 'active' : '';
592 $advancedActive = ($currentMode === 'advanced') ? 'active' : '';
593 $simplePressedState = ($currentMode === 'simple') ? 'true' : 'false';
594 $advancedPressedState = ($currentMode === 'advanced') ? 'true' : 'false';
595
596 if ($currentMode === 'simple') {
597 $modeDescription = __('Simple Mode shows essential options only. Switch to Advanced Mode for full configuration.', '404-solution');
598 } else {
599 $modeDescription = __('Advanced Mode shows all options. Switch to Simple Mode for a streamlined view.', '404-solution');
600 }
601
602 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/settingsModeToggle.html");
603 $html = $this->f->str_replace('{nonce}', wp_create_nonce('abj404_mode_toggle'), $html);
604 $html = $this->f->str_replace('{simpleActive}', $simpleActive, $html);
605 $html = $this->f->str_replace('{advancedActive}', $advancedActive, $html);
606 $html = $this->f->str_replace('{simplePressedState}', $simplePressedState, $html);
607 $html = $this->f->str_replace('{advancedPressedState}', $advancedPressedState, $html);
608 $html = $this->f->str_replace('{Simple Mode}', __('Simple Mode', '404-solution'), $html);
609 $html = $this->f->str_replace('{Advanced Mode}', __('Advanced Mode', '404-solution'), $html);
610 $html = $this->f->str_replace('{mode_description}', $modeDescription, $html);
611
612 echo $html;
613 }
614
615 /**
616 * Echo an inline (compact) mode toggle for the header row.
617 * This is a smaller version without the description text.
618 * @return void
619 */
620 function echoInlineModeToggle() {
621 $currentMode = $this->logic->getSettingsMode();
622 $simpleActive = ($currentMode === 'simple') ? 'active' : '';
623 $advancedActive = ($currentMode === 'advanced') ? 'active' : '';
624 $simplePressedState = ($currentMode === 'simple') ? 'true' : 'false';
625 $advancedPressedState = ($currentMode === 'advanced') ? 'true' : 'false';
626
627 echo '<div class="abj404-mode-toggle abj404-mode-toggle-inline" data-nonce="' . esc_attr(wp_create_nonce('abj404_mode_toggle')) . '">';
628 echo '<div class="abj404-mode-toggle-buttons">';
629 echo '<button type="button" class="abj404-mode-btn ' . esc_attr($simpleActive) . '" data-mode="simple" aria-pressed="' . esc_attr($simplePressedState) . '">';
630 echo '<span class="abj404-mode-btn-text">' . esc_html__('Simple', '404-solution') . '</span>';
631 echo '</button>';
632 echo '<button type="button" class="abj404-mode-btn ' . esc_attr($advancedActive) . '" data-mode="advanced" aria-pressed="' . esc_attr($advancedPressedState) . '">';
633 echo '<span class="abj404-mode-btn-text">' . esc_html__('Advanced', '404-solution') . '</span>';
634 echo '</button>';
635 echo '</div>';
636 echo '</div>';
637 }
638
639 /**
640 * Echo the Simple Mode options page.
641 * @param array<string, mixed> $options The plugin options
642 * @return void
643 */
644 function echoSimpleModeOptions($options) {
645 // Build behavior tiles HTML (replaces old dropdown)
646 $behaviorTilesHtml = $this->getBehaviorTilesHTML($options);
647
648 // Build selected states for checkboxes and dropdowns
649 $selectedAutoRedirects = $this->getCheckedAttr($options, 'auto_redirects');
650 $selectedCapture404 = $this->getCheckedAttr($options, 'capture_404');
651 $selectedDefaultRedirect301 = ($options['default_redirect'] == '301') ? 'selected' : '';
652 $selectedDefaultRedirect302 = ($options['default_redirect'] == '302') ? 'selected' : '';
653 $selectedDefaultRedirect307 = ($options['default_redirect'] == '307') ? 'selected' : '';
654 $selectedDefaultRedirect308 = ($options['default_redirect'] == '308') ? 'selected' : '';
655
656 // Theme selections
657 $selectedThemeDefault = ($options['admin_theme'] == 'default') ? 'selected' : '';
658 $selectedThemeCalm = ($options['admin_theme'] == 'calm') ? 'selected' : '';
659 $selectedThemeMono = ($options['admin_theme'] == 'mono') ? 'selected' : '';
660 $selectedThemeNeon = ($options['admin_theme'] == 'neon') ? 'selected' : '';
661 $selectedThemeObsidian = ($options['admin_theme'] == 'obsidian') ? 'selected' : '';
662
663 // Notification frequency selections
664 $notifyFrequency = isset($options['admin_notification_frequency']) ? (string)(is_scalar($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant') : 'instant';
665 $selectedNotifyInstant = ($notifyFrequency === 'instant') ? 'selected' : '';
666 $selectedNotifyDaily = ($notifyFrequency === 'daily') ? 'selected' : '';
667 $selectedNotifyWeekly = ($notifyFrequency === 'weekly') ? 'selected' : '';
668
669 // Read and build the simple options template
670 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/adminOptionsSimple.html");
671
672 // Replace dest404page tiles
673 $html = $this->f->str_replace('{behaviorTiles}', $behaviorTilesHtml, $html);
674
675 // Replace checkbox states
676 $html = $this->f->str_replace('{selectedAutoRedirects}', $selectedAutoRedirects, $html);
677 $html = $this->f->str_replace('{selectedCapture404}', $selectedCapture404, $html);
678 $html = $this->f->str_replace('{selectedDefaultRedirect301}', $selectedDefaultRedirect301, $html);
679 $html = $this->f->str_replace('{selectedDefaultRedirect302}', $selectedDefaultRedirect302, $html);
680 $html = $this->f->str_replace('{selectedDefaultRedirect307}', $selectedDefaultRedirect307, $html);
681 $html = $this->f->str_replace('{selectedDefaultRedirect308}', $selectedDefaultRedirect308, $html);
682
683 // Replace values
684 $html = $this->f->str_replace('{capture_deletion}', esc_attr($this->optStr($options, 'capture_deletion')), $html);
685 $html = $this->f->str_replace('{admin_notification}', esc_attr($this->optStr($options, 'admin_notification')), $html);
686 $html = $this->f->str_replace('{maximum_log_disk_usage}', esc_attr($this->optStr($options, 'maximum_log_disk_usage')), $html);
687
688 // Theme selections
689 $html = $this->f->str_replace('{selectedThemeDefault}', $selectedThemeDefault, $html);
690 $html = $this->f->str_replace('{selectedThemeCalm}', $selectedThemeCalm, $html);
691 $html = $this->f->str_replace('{selectedThemeMono}', $selectedThemeMono, $html);
692 $html = $this->f->str_replace('{selectedThemeNeon}', $selectedThemeNeon, $html);
693 $html = $this->f->str_replace('{selectedThemeObsidian}', $selectedThemeObsidian, $html);
694 $html = $this->f->str_replace('{theme_default}', __('Default (Follows WordPress)', '404-solution'), $html);
695
696 // Translate labels
697 $html = $this->f->str_replace('{Core Settings}', __('Core Settings', '404-solution'), $html);
698 $html = $this->f->str_replace('{404 Capture}', __('404 Capture', '404-solution'), $html);
699 $html = $this->f->str_replace('{Maintenance}', __('Maintenance', '404-solution'), $html);
700 $html = $this->f->str_replace('{Default 404 destination}', __('Default 404 destination', '404-solution'), $html);
701 $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);
702 $html = $this->f->str_replace('{Create automatic redirects}', __('Create automatic redirects', '404-solution'), $html);
703 $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);
704 $html = $this->f->str_replace('{Redirect type}', __('Redirect type', '404-solution'), $html);
705 $html = $this->f->str_replace('{Permanent 301}', __('Permanent 301', '404-solution'), $html);
706 $html = $this->f->str_replace('{Temporary 302}', __('Temporary 302', '404-solution'), $html);
707 $html = $this->f->str_replace('{301 for SEO, 302 for temporary changes}', __('301 for SEO, 302 for temporary changes', '404-solution'), $html);
708 $html = $this->f->str_replace('{Collect incoming 404 URLs}', __('Collect incoming 404 URLs', '404-solution'), $html);
709 $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);
710 $html = $this->f->str_replace('{Delete captured URLs after}', __('Delete captured URLs after', '404-solution'), $html);
711 $html = $this->f->str_replace('{days}', __('days', '404-solution'), $html);
712 $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);
713 $html = $this->f->str_replace('{Notify me when captured URLs exceed}', __('Notify me when captured URLs exceed', '404-solution'), $html);
714 $html = $this->f->str_replace('{URLs}', __('URLs', '404-solution'), $html);
715 $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);
716 $html = $this->f->str_replace('{Maximum log storage}', __('Maximum log storage', '404-solution'), $html);
717 $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);
718 $html = $this->f->str_replace('{Admin theme}', __('Admin theme', '404-solution'), $html);
719 $html = $this->f->str_replace('{Calm Ops (Light)}', __('Calm Ops (Light)', '404-solution'), $html);
720 $html = $this->f->str_replace('{Monochrome Minimal (Light)}', __('Monochrome Minimal (Light)', '404-solution'), $html);
721 $html = $this->f->str_replace('{Neon Slate (Dark)}', __('Neon Slate (Dark)', '404-solution'), $html);
722 $html = $this->f->str_replace('{Obsidian Blue (Dark)}', __('Obsidian Blue (Dark)', '404-solution'), $html);
723 $html = $this->f->str_replace('{Save Settings}', __('Save Settings', '404-solution'), $html);
724 $html = $this->f->str_replace('{Need more control?}', __('Need more control?', '404-solution'), $html);
725 $html = $this->f->str_replace('{Switch to Advanced Mode}', __('Switch to Advanced Mode', '404-solution'), $html);
726 $html = $this->f->str_replace('{for 30+ additional options.}', __('for 30+ additional options.', '404-solution'), $html);
727
728 // Frequency dropdown selections and labels
729 $html = $this->f->str_replace('{selectedNotifyInstant}', $selectedNotifyInstant, $html);
730 $html = $this->f->str_replace('{selectedNotifyDaily}', $selectedNotifyDaily, $html);
731 $html = $this->f->str_replace('{selectedNotifyWeekly}', $selectedNotifyWeekly, $html);
732 $html = $this->f->str_replace('{Email notification frequency}', __('Email notification frequency', '404-solution'), $html);
733 $html = $this->f->str_replace('{Instant (when threshold exceeded)}', __('Instant (when threshold exceeded)', '404-solution'), $html);
734 $html = $this->f->str_replace('{Daily digest}', __('Daily digest', '404-solution'), $html);
735 $html = $this->f->str_replace('{Weekly digest}', __('Weekly digest', '404-solution'), $html);
736 $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);
737 // Also handle the Temporary 307/308 options (present in simple mode too)
738 $html = $this->f->str_replace('{Temporary 307 (preserve method)}', __('Temporary 307 (preserve method)', '404-solution'), $html);
739 $html = $this->f->str_replace('{Permanent 308 (preserve method)}', __('Permanent 308 (preserve method)', '404-solution'), $html);
740
741 echo $html;
742 }
743
744
745 }
746