Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.93% covered (danger)
14.93%
10 / 67
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Notices
14.93% covered (danger)
14.93%
10 / 67
0.00% covered (danger)
0.00%
0 / 4
378.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_error_detail_option_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_last_error
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 admin_notices
17.86% covered (danger)
17.86%
10 / 56
0.00% covered (danger)
0.00%
0 / 1
139.71
1<?php
2/**
3 * Add an admin notice for new errors.
4 *
5 * @package brianhenryie/bh-wp-logger
6 */
7
8namespace BrianHenryIE\WP_Logger\Admin;
9
10use BrianHenryIE\WP_Logger\API\BH_WP_PSR_Logger;
11use BrianHenryIE\WP_Logger\API_Interface;
12use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
13use DateTimeImmutable;
14use Psr\Log\LoggerAwareTrait;
15use Psr\Log\LoggerInterface;
16use WPTRT\AdminNotices\Notices;
17
18/**
19 * @uses API_Interface::api->get_last_log_time()
20 * @uses API_Interface::get_last_logs_view_time()
21 * @uses API_Interface::get_log_url()
22 * @uses Logger_Settings_Interface::get_plugin_slug()
23 * @uses Logger_Settings_Interface::get_plugin_name()
24 *
25 * @see https://github.com/WPTT/admin-notices
26 */
27class Admin_Notices extends Notices {
28
29    use LoggerAwareTrait;
30
31    /**
32     * @param API_Interface             $api The main functions.
33     * @param Logger_Settings_Interface $settings The configured settings.
34     * @param LoggerInterface           $logger PSR logger for recording errors that happen within this class.
35     */
36    public function __construct(
37        protected API_Interface $api,
38        protected Logger_Settings_Interface $settings,
39        LoggerInterface $logger
40    ) {
41        $this->setLogger( $logger );
42    }
43
44    /**
45     * The wp_option name that a/any recent error has been saved to.
46     *
47     * @see BH_WP_PSR_Logger::log()
48     */
49    protected function get_error_detail_option_name(): string {
50        return $this->settings->get_plugin_slug() . '-recent-error-data';
51    }
52
53    /**
54     * The last error is stored in the option `plugin-slug-recent-error-data` as an array with `message` and `timestamp`.
55     *
56     * @see Admin_Notices::get_error_detail_option_name()
57     *
58     * @return ?array{message: string, timestamp: int}
59     */
60    protected function get_last_error(): ?array {
61        $last_error = get_option( $this->get_error_detail_option_name() );
62        if ( is_array( $last_error )
63            && 2 === count( $last_error )
64            && isset( $last_error['message'] )
65            && isset( $last_error['timestamp'] )
66            && is_string( $last_error['message'] )
67            && is_int( $last_error['timestamp'] )
68        ) {
69            return $last_error;
70        }
71        return null;
72    }
73
74    /**
75     * Show a notice for recent errors in the logs.
76     *
77     * TODO: Check file exists before linking to it.
78     *
79     * hooked earlier than 10 because Notices::boot() also hooks a function on admin_init that needs to run after this.
80     *
81     * @hooked admin_init
82     */
83    public function admin_notices(): void {
84
85        // We don't need to register the admin notice except to display it and to handle the dismiss button.
86        if ( ! is_admin() && ! wp_doing_ajax() ) {
87            return;
88        }
89
90        // Don't show during plugin installs.
91        /** @var string $pagenow */
92        global $pagenow;
93        if ( 'updater.php' === $pagenow ) {
94            return;
95        }
96
97        // Check is the ajax request relevant.
98        if ( wp_doing_ajax() ) {
99            $action = "wptrt_dismiss_notice_{$this->settings->get_plugin_slug()}-recent-error";
100            if ( ! isset( $_POST['action'] )
101                || ! is_string( $_POST['action'] )
102                || 'wptrt_dismiss_notice' !== sanitize_key( wp_unslash( $_POST['action'] ) )
103                || false === check_admin_referer( $action, 'nonce' ) // `false === ` doesn't do anything because it `die()`s if it fails.
104            ) {
105
106                return;
107            }
108        }
109
110        $error_detail_option_name = $this->get_error_detail_option_name();
111
112        // If we're on the logs page, don't show the admin notice linking to the logs page.
113        /** @var string $plugin_page */
114        global $plugin_page;
115        if ( 'admin.php' === $pagenow && $this->settings->get_plugin_slug() . '-logs' === $plugin_page ) {
116            delete_option( $error_detail_option_name );
117            return;
118        }
119
120        $last_error = $this->get_last_error();
121
122        $last_log_time       = $this->api->get_last_log_time();
123        $last_logs_view_time = $this->api->get_last_logs_view_time();
124
125        // TODO: This should be comparing $last_error time?
126        if (
127            ! empty( $last_error )
128            &&
129            ( is_null( $last_logs_view_time ) || $last_log_time > $last_logs_view_time )
130        ) {
131
132            /**
133             * E.g. "wptrt_notice_dismissed_bh-wp-logger-development-plugin-recent-error".
134             */
135            $is_dismissed_option_name = "wptrt_notice_dismissed_{$this->settings->get_plugin_slug()}-recent-error";
136
137            $error_text = trim( $last_error['message'] );
138            $error_time = $last_error['timestamp'];
139
140            $title   = '';
141            $content = sprintf(
142                '<strong>%s</strong>. Error: "%s" ',
143                $this->settings->get_plugin_name(),
144                esc_html( $error_text )
145            );
146
147            $content .= ' at ' . gmdate( 'Y-m-d H:i:s', $error_time ) . ' UTC';
148
149            if ( '+00:00' !== wp_timezone()->getName() ) {
150
151                $content .= ' (';
152                $content .= ( new DateTimeImmutable( "@{$error_time}" ) )->setTimezone( wp_timezone() )->format( 'Y-m-d H:i:s' );
153                $content .= ' ' . wp_timezone()->getName() . ')';
154            }
155
156            $content .= ' – ' . human_time_diff( $error_time, time() ) . ' ago';
157
158            $content .= '.';
159
160                // Link to logs.
161            $log_link = $this->api->get_log_url( gmdate( 'Y-m-d', $error_time ) );
162
163            $content .= sprintf(
164                ' <a href="%s">View Logs</a>.',
165                esc_url( $log_link, null, 'href' )
166            );
167
168            // ID must be globally unique because it is the CSS id that will be used.
169            $this->add(
170                $this->settings->get_plugin_slug() . '-recent-error',
171                $title,   // The title for this notice.
172                $content, // The content for this notice.
173                array(
174                    'scope' => 'global',
175                    'type'  => 'error',
176                )
177            );
178
179            /**
180             * When the notice is dismissed, delete the error detail option (to stop the notice being recreated),
181             * and delete the saved dismissed flag (which would prevent it displaying when the next error occurs).
182             *
183             * @see update_option()
184             */
185            $on_dismiss = function ( $value, $old_value, string $option_name ) use ( $error_detail_option_name ) {
186                delete_option( $error_detail_option_name );
187                delete_option( $option_name );
188                return $old_value; // When new and old match, it short-circuits.
189            };
190
191            add_filter( "pre_update_option_{$is_dismissed_option_name}", $on_dismiss, 10, 3 );
192        }
193    }
194}