pattern across the plugin so every
* user-facing error notice on a plugin screen ships a one-click way to
* report the failure.
*
* The button mount div renders after the message paragraph so the JS
* component bootstraps it on DOMContentLoaded. The trigger source is
* allowlisted server-side by Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES;
* passing a slug not in that list will render a button that 400s on
* click. The integration test pins the matching set.
*
* Only call this from screens that live under the plugin's own pages
* (CLAUDE.md Self-Healing §4 bans support buttons on generic wp-admin
* notices).
*
* @param string $messageHtml Pre-escaped/-allowed inner HTML
* for the
inside the notice.
* Callers escape per their own
* needs (esc_html / wp_kses_post).
* @param string $triggeredFrom One of
* ABJ_404_Solution_Ajax_SupportRequest::ALLOWED_TRIGGER_SOURCES.
* @param string|null $contextSummary Optional one-line summary shown
* in the support-request modal.
* @return string Notice HTML, ready to echo.
*/
public static function renderErrorNoticeWithSupportButton(string $messageHtml,
string $triggeredFrom, ?string $contextSummary = null): string {
$buttonHtml = class_exists('ABJ_404_Solution_SupportRequestButton')
? ABJ_404_Solution_SupportRequestButton::render($triggeredFrom, $contextSummary)
: '';
return '
' . $messageHtml . '
'
. $buttonHtml
. '
';
}
/** Get the text to notify the user when some URLs have been captured and need attention.
* @param int $captured the number of captured URLs
* @return string html
*/
function getDashboardNotificationCaptured($captured) {
/* Translators: %s is the number of captured 404 URLs. */
$capturedMessage = sprintf( _n( 'There is %s captured 404 URL that needs to be processed.',
'There are %s captured 404 URLs to be processed.',
$captured, '404-solution'), $captured);
$capturedMessage = $this->f->str_replace("",
"",
$capturedMessage);
$capturedMessage = $this->f->str_replace("", "", $capturedMessage);
return '
' . PLUGIN_NAME .
": " . $capturedMessage . "
";
}
/** Do an action like trash/delete/ignore/edit and display a page like stats/logs/redirects/options.
* @return void
*/
static function handleMainAdminPageActionAndDisplay() {
global $abj404view;
$instance = self::getInstance();
try {
$action = $instance->viewGetPostOrGetSanitize('action');
if (!is_admin() || !$instance->logic->userIsPluginAdmin()) {
$instance->logger->logUserCapabilities("handleMainAdminPageActionAndDisplay (" .
esc_html($action == '' ? '(none)' : $action) . ")");
echo '
';
echo '
' . esc_html(PLUGIN_NAME) . '
';
$permMessage = '' . esc_html__('Permission denied.', '404-solution') . ' '
. esc_html__('Your user account does not have permission to access this page.', '404-solution')
. '
'
. esc_html__('Please verify that your WordPress role has the', '404-solution') . ' '
. 'manage_options ' . esc_html__('capability.', '404-solution') . ' '
. esc_html__('If you have a security plugin installed, it may be restricting access to this page.', '404-solution');
$subpageForContext = (string)$instance->viewGetPostOrGetSanitize('subpage');
$triggerForPerm = ($subpageForContext === 'abj404_captured')
? 'captured_404s_page' : 'redirects_page';
echo self::renderErrorNoticeWithSupportButton(
$permMessage,
$triggerForPerm,
'Permission denied on plugin admin page (action=' .
($action == '' ? '(none)' : $action) . ')'
);
echo '
';
}
}
/** Display the chosen admin page.
* @param string $action
* @param string $sub
* @param string $message
* @return void
*/
function echoChosenAdminTab($action, $sub, $message) {
global $abj404view;
// If globals are not set, use sensible defaults
if ($abj404view === null) {
$abj404view = $this;
}
// Deal With Page Tabs
if ($sub == "") {
$sub = $this->f->strtolower($this->viewGetPostOrGetSanitize('subpage'));
}
if ($sub == "") {
$sub = 'abj404_redirects';
$this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
}
// Check if we're returning from a successful redirect update
$updated = $this->viewGetPostOrGetSanitize('updated');
if ($updated == '1') {
$message .= __('Redirect Information Updated Successfully!', '404-solution');
}
$this->logger->debugMessage("Displaying sub page: " . esc_html($sub == '' ? '(none)' : $sub));
$abj404view->outputAdminHeaderTabs($sub, $message);
$abj404action = $this->viewGetPostOrGetSanitize('abj404action');
if (($action == 'editRedirect') || ($abj404action == 'editRedirect') || ($sub == 'abj404_edit')) {
$abj404view->echoAdminEditRedirectPage();
} else if ($sub == 'abj404_redirects') {
$abj404view->echoAdminRedirectsPage();
} else if ($sub == 'abj404_captured') {
$abj404view->echoAdminCapturedURLsPage();
} else if ($sub == "abj404_options") {
$abj404view->echoAdminOptionsPage();
} else if ($sub == 'abj404_logs') {
$abj404view->echoAdminLogsPage();
} else if ($sub == 'abj404_stats') {
$abj404view->outputAdminStatsPage();
} else if ($sub == 'abj404_tools') {
$abj404view->echoAdminToolsPage();
} else if ($sub == 'abj404_debugfile') {
$abj404view->echoAdminDebugFile();
} else {
$this->logger->debugMessage('No tab selected. Displaying the "redirects" tab.');
$abj404view->echoAdminRedirectsPage();
}
$abj404view->echoAdminFooter();
}
/**
* Echo the text that appears at the bottom of each admin page.
* @return void
*/
function echoAdminFooter() {
// read the html content.
$html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/adminFooter.html");
$html = $this->f->str_replace('{JAPANESE_FLASHCARDS_URL}', ABJ404_FC_URL, $html);
$html = $this->f->str_replace(
'{ABJ404_VIEW_FRESHNESS}',
$this->renderViewFreshnessLabel(),
$html
);
// constants and translations.
$html = $this->f->doNormalReplacements($html);
echo $html;
}
/**
* Render a short, human-readable "how long ago was the view_done cache
* last successfully built" label for the admin footer. Returns one of:
* - "n/a" when never built or freshness option cleared (post-invalidate);
* - "Xs" when 0 to 59 seconds old;
* - "Xm" when 1 to 59 minutes old;
* - "Xh" when 1 to 23 hours old;
* - "Xd" when a day or more.
*
* The view_done snapshot is what the Redirects / Captured / Logs tables
* read from; if this label drifts upward into hours when the freshness
* TTL is 120s, the auto-rebuild path is failing or being blocked.
*
* @return string
*/
private function renderViewFreshnessLabel(): string {
if (!is_object($this->viewBuildOrchestrator) || !method_exists($this->viewBuildOrchestrator, 'getViewDoneBuiltAtTimestamp')) {
return 'n/a';
}
// Defensive: a unit-test mock may throw BadMethodCallException
// when called without an expectation; any future implementation
// could also throw on a transient read failure. The freshness
// label is a footer cosmetic. Treat any failure as n/a so it
// never blocks the page render.
try {
$builtAt = (int)$this->viewBuildOrchestrator->getViewDoneBuiltAtTimestamp();
} catch (\Throwable $e) { // allow-silent-catch: freshness label is a footer cosmetic; DAO stub failures must never block page render
return 'n/a';
}
if ($builtAt <= 0) {
return 'n/a';
}
$age = time() - $builtAt;
if ($age < 0) {
return 'n/a'; // clock skew; do not surface a negative age
}
if ($age < 60) { return $age . 's'; }
if ($age < 3600) { return intval($age / 60) . 'm'; }
if ($age < 86400) { return intval($age / 3600) . 'h'; }
return intval($age / 86400) . 'd';
}
/** Output the tabs at the top of the plugin page.
* @param string $sub
* @param string $message
* @return void
*/
function outputAdminHeaderTabs($sub = 'list', $message = '') {
ABJ_404_Solution_WPNotices::echoAdminNotices();
echo "
';
echo '' . esc_html__("You're all set!", '404-solution') . ' ';
if ($auto && $notify) {
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');
} elseif ($auto) {
echo esc_html__('404 Solution is now monitoring your site. When visitors hit broken links, they will be automatically redirected.', '404-solution');
} elseif ($notify) {
echo esc_html__('404 Solution is now monitoring your site. We will email you if captured 404 URLs need attention.', '404-solution');
} else {
echo esc_html__('404 Solution is now monitoring your site. You can create manual redirects anytime from the Page Redirects tab.', '404-solution');
}
echo '
\n";
}
// Regex auto-promote notice. Rendered as a separate info-level
// banner because it carries an action (Undo) the user can choose
// not to take. Kept distinct from the success message so the
// visual hierarchy reads "save succeeded; by the way, we
// promoted to regex and you can undo it" rather than mixing the
// two.
if (class_exists('ABJ_404_Solution_RegexAutoPromote')) {
$regexNotice = ABJ_404_Solution_RegexAutoPromote::readNotice();
if (is_array($regexNotice) && $regexNotice['redirect_id'] > 0) {
$this->renderRegexAutoPromoteNotice($regexNotice);
}
}
$isSimpleMode = $this->logic->getSettingsMode() === 'simple';
echo '';
}
/**
* Render the "Detected as a regex pattern" notice with [Edit] and
* [Undo] links. The notice is shown once per save that triggered
* server-side regex auto-promotion (admin posted a URL with
* unambiguous regex metachars but did not check the "Treat as regex"
* box). The [Undo] link restores the row to status=MANUAL with the
* original from_url; [Edit] jumps straight to the redirect edit
* page for further adjustments.
*
* @param array{redirect_id: int, original_url: string, new_url: string, url_rewritten: bool, created_at: int} $notice
* @return void
*/
private function renderRegexAutoPromoteNotice(array $notice) {
$redirectId = (int)$notice['redirect_id'];
$originalUrl = (string)$notice['original_url'];
$newUrl = (string)$notice['new_url'];
$urlRewritten = !empty($notice['url_rewritten']);
$editUrl = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_edit&id=' . $redirectId);
$undoBase = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_redirects&action=undoRegexAutoPromote');
$undoUrl = wp_nonce_url($undoBase, 'abj404undoRegexAutoPromote');
if ($urlRewritten) {
$message = sprintf(
/* translators: %1$s = original URL as typed by the admin; %2$s = the URL after glob-to-regex fixup (e.g. /sales/* becomes /sales/.*) */
__('Detected as a regex pattern. Stored "%1$s" as "%2$s".', '404-solution'),
$originalUrl,
$newUrl
);
} else {
$message = sprintf(
/* translators: %s = the URL stored unchanged with Regex status */
__('Detected as a regex pattern. Stored "%s" with Regex status.', '404-solution'),
$originalUrl
);
}
echo '
' . "\n";
}
/** This outputs a box with a title and some content in it.
* It's used on the Stats, Options and Tools page (for example).
* @param int|string $id
* @param string $title
* @param string $content
* @return void
*/
function echoPostBox($id, $title, $content) {
echo "
";
echo "
" . esc_html($title) . "
";
echo "
" . $content /* Can't escape here, as contains forms */ . "
";
echo "
";
}
/**
* Echo an accordion section using card-based layout
* @param string $sectionId The section identifier
* @param string $postboxId The ID for the postbox
* @param string $title The title of the section
* @param string $content The content to display
* @param bool $initiallyVisible Whether the card starts expanded (default: false)
* @param string $icon The SVG icon for the card header (optional)
* @param string $badge Optional info badge text
* @return void
*/
function echoOptionsSection($sectionId, $postboxId, $title, $content, $initiallyVisible = false, $icon = '', $badge = '') {
$expandedClass = $initiallyVisible ? ' expanded' : '';
echo "
";
echo "
";
echo "
";
if ($icon) {
echo $icon; // Icon is pre-sanitized SVG
}
echo esc_html($title);
if ($badge) {
echo "" . esc_html($badge) . "";
}
echo "
";
echo "";
echo "
";
echo "
";
echo $content; /* Can't escape here, as contains forms */
echo "
';
/* translators: %s: plugin version number */
echo esc_html(sprintf(__('Plugin v%s', '404-solution'), $version));
echo '
';
echo '
';
echo '';
echo '';
echo '
';
echo '
';
$this->echoRestoreDefaultsModal();
}
/**
* Echo the confirmation modal shown before restoring settings to defaults.
* Toggled open by adding `.active`; matches the markup of other plugin modals.
* @return void
*/
function echoRestoreDefaultsModal() {
?>
';
}
/**
* Echo the expand/collapse all button and save button
* @param bool $showSuggestions Not used (kept for compatibility)
* @return void
*/
function echoExpandCollapseButton($showSuggestions = true) {
?>