*/ private array $notice_hooks = [ 'admin_notices', 'network_admin_notices', 'all_admin_notices', 'user_admin_notices' ]; /** * Counter for generating unique notice IDs. * * @since 1.0.0 * @var array */ private array $notice_counter = []; /** * Captured notice markup, grouped by hook name. * * @since 1.0.29 * @var array> */ private array $captured_notices = []; private OptionsRepository $options_repository; private CapabilitiesService $capabilities_service; /** * Constructor. * * @since 1.0.29 * @param OptionsRepository $options_repository Options repository instance. * @param CapabilitiesService $capabilities_service Capabilities service instance. */ public function __construct( OptionsRepository $options_repository, CapabilitiesService $capabilities_service ) { $this->options_repository = $options_repository; $this->capabilities_service = $capabilities_service; } /** * Initialize the notice ID manager. * * @since 1.0.0 */ public function init(): void { if (!$this->should_manage_notices()) { return; } // Capture notices late so that every plugin has registered its callbacks. add_action('admin_print_scripts', [$this, 'capture_notices'], 999); } /** * Decide whether notice interception is needed at all. * * @since 1.0.29 * @return bool True when notices should be reprinted with IDs. */ private function should_manage_notices(): bool { /** * Filters whether admin notices are intercepted to receive unique IDs. * * @since 1.0.29 * @param bool $enabled Whether interception is enabled. */ if (!apply_filters('adsd_enable_notice_id_manager', true)) { return false; } if (!$this->capabilities_service->can_use_selector()) { return false; } return !empty($this->options_repository->get_active_rules()); } /** * Capture and reprint notices from WordPress hooks. * * @since 1.0.0 */ public function capture_notices(): void { $this->captured_notices = $this->collect_notices_by_hook(); foreach ($this->notice_hooks as $hook_name) { add_action($hook_name, function () use ($hook_name) { $this->print_notices_for_hook($hook_name); }, 9999); } } /** * Collect notices from all hooks. * * @since 1.0.0 * @return array> */ private function collect_notices_by_hook(): array { $notices_by_hook = array_fill_keys($this->notice_hooks, []); foreach ($this->notice_hooks as $hook_name) { $notices_by_hook[$hook_name] = $this->process_hook_callbacks($hook_name); } return $notices_by_hook; } /** * Render and detach the callbacks of a single notice hook. * * Only the callbacks whose output was actually captured are removed, so * notices registered after this point still reach the page. * * @since 1.0.0 * @param string $hook_name Hook name. * @return array Captured notice markup. */ private function process_hook_callbacks(string $hook_name): array { global $wp_filter; if (!isset($wp_filter[$hook_name])) { return []; } $callbacks_map = $wp_filter[$hook_name]->callbacks ?? []; if (empty($callbacks_map)) { return []; } ksort($callbacks_map); $notices = []; $captured_callbacks = []; foreach ($callbacks_map as $priority => $callbacks) { foreach ((array) $callbacks as $callback_data) { $callback = $callback_data['function'] ?? null; if ($this->is_own_callback($callback)) { continue; } $output = $this->call_notice_callback_safely($callback); $captured_callbacks[] = ['callback' => $callback, 'priority' => (int) $priority]; if ($output === '') { continue; } $notices[] = $this->add_id_to_notice_container($output); } } foreach ($captured_callbacks as $entry) { remove_action($hook_name, $entry['callback'], $entry['priority']); } return $notices; } /** * Check if callback is our own. * * @since 1.0.0 * @param mixed $callback Callback to check. * @return bool */ private function is_own_callback($callback): bool { if (is_array($callback) && isset($callback[0]) && $callback[0] instanceof self) { return true; } if (is_string($callback) && strpos($callback, 'NoticeIdManager') !== false) { return true; } return false; } /** * Render a third-party notice callback into a string. * * Foreign code runs here, so every error type is swallowed: a broken notice * must not take the whole admin screen down. * * @since 1.0.0 * @param mixed $callback Hook callback. * @return string Rendered markup. */ private function call_notice_callback_safely($callback): string { if (!is_callable($callback)) { return ''; } $buffer_level = ob_get_level(); try { ob_start(); call_user_func($callback); return (string) ob_get_clean(); } catch (\Throwable $e) { while (ob_get_level() > $buffer_level) { ob_end_clean(); } return ''; } } /** * Print notices for a specific hook. * * @since 1.0.0 * @param string $hook_name Hook name. */ private function print_notices_for_hook(string $hook_name): void { foreach ($this->captured_notices[$hook_name] ?? [] as $notice) { // MODERATOR NOTE: Do not escape $notice['content'] here. // This content is already escaped by other plugins/authors. // Escaping it again would break notifications and interfere with other plugins. // This plugin is a wrapper that only adds unique IDs to notices without modifying their output. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content is pre-escaped by other plugins echo $notice; } } /** * Add unique ID to a single notice container. * * @since 1.0.0 * @param string $content Notice content. * @return string Processed notice content with ID. */ private function add_id_to_notice_container(string $content): string { // Generate unique ID for this notice $notice_id = $this->generate_notice_id($content); // Inject the ID using multiple strategies return $this->inject_notice_id($content, $notice_id); } /** * Inject unique ID into notice HTML. * * @since 1.0.0 * @param string $content Notice content. * @param string $notice_id Notice ID. * @return string Processed notice content. */ private function inject_notice_id(string $content, string $notice_id): string { // Try multiple strategies to find the notice container // Strategy 1: Look for div with notice classes if (preg_match('/]*class="[^"]*notice[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 2: Look for div with updated classes (WooCommerce style) if (preg_match('/]*class="[^"]*updated[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 3: Look for div with woocommerce-message classes if (preg_match('/]*class="[^"]*woocommerce-message[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 4: Look for div with error classes if (preg_match('/]*class="[^"]*error[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 5: Look for div with settings-error classes if (preg_match('/]*class="[^"]*settings-error[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 6: Look for any div with an ID (TGMPA style) if (preg_match('/]*id="[^"]*"[^>]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 7: Look for any first div if (preg_match('/]*)>/i', $content, $matches)) { return $this->add_id_to_tag($content, $matches[0], $notice_id); } // Strategy 8: If no div found, wrap in a div with ID return '
' . $content . '
'; } /** * Add ID to a specific HTML tag. * * @since 1.0.0 * @param string $content Notice content. * @param string $tag HTML tag. * @param string $notice_id Notice ID. * @return string Processed notice content. */ private function add_id_to_tag(string $content, string $tag, string $notice_id): string { // Check if tag already has an ID if (preg_match('/\bid\s*=\s*["\'][^"\']+["\']/', $tag)) { return $content; // Already has ID, don't modify } // Add ID attribute $new_tag = str_replace('>', ' id="' . esc_attr($notice_id) . '">', $tag); return str_replace($tag, $new_tag, $content); } /** * Generate a unique ID for a notice based on its content. * * @since 1.0.0 * @param string $content Notice content. * @return string Unique notice ID. */ private function generate_notice_id(string $content): string { // Normalize content to make ID more stable $normalized = $this->normalize_notice_content($content); // Create hash from normalized content $content_hash = substr(md5($normalized), 0, 8); // Add counter to ensure uniqueness even for identical content $counter = $this->notice_counter[$content_hash] ?? 0; $this->notice_counter[$content_hash] = $counter + 1; return 'adsd-notice-' . $content_hash . '-' . $counter; } /** * Normalize notice content to make ID generation more stable. * * @since 1.0.0 * @param string $content Notice content. * @return string Normalized content. */ private function normalize_notice_content(string $content): string { // Remove HTML tags but preserve structure $text = wp_strip_all_tags($content); // Remove common variable elements that change frequently $patterns = [ // Remove dates in various formats '/\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}/', '/\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}/', '/\w+\s+\d{1,2},?\s+\d{4}/', // Remove times '/\d{1,2}:\d{2}(:\d{2})?(\s*[AP]M)?/i', // Remove version numbers '/\b\d+\.\d+\.\d+\b/', // Remove URLs '/https?:\/\/[^\s<>"]+/i', // Remove file paths '/[a-zA-Z]:[\\\\\\/][^\s<>"]+/', '/\/[^\s<>"]+/', // Remove session IDs, tokens, nonces '/\b[a-f0-9]{8,}\b/i', ]; foreach ($patterns as $pattern) { $text = (string) preg_replace($pattern, '[VARIABLE]', $text); } // Normalize whitespace return trim((string) preg_replace('/\s+/', ' ', $text)); } }