Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.61% covered (danger)
30.61%
30 / 98
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Logs_List_Table
30.61% covered (danger)
30.61%
30 / 98
0.00% covered (danger)
0.00%
0 / 11
270.54
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
 set_date
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_data
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 get_columns
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 prepare_items
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 single_row
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 column_default
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
132
 replace_wp_user_id_with_link
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 replace_post_type_id_with_link
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
3.01
 no_items
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_print_r
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * A standard WordPress table to show the time, severity, message and context of each log entry.
4 *
5 * The dream would someday to have complex filtering on this table. e.g. filter all logs to one request, to one user...
6 *
7 * Time should show (UTC, local and "five hours ago")
8 *
9 * @package  brianhenryie/bh-wp-logger
10 */
11
12namespace BrianHenryIE\WP_Logger\Admin;
13
14use BrianHenryIE\WP_Logger\API\BH_WP_PSR_Logger;
15use BrianHenryIE\WP_Logger\API_Interface;
16use BrianHenryIE\WP_Logger\Logger_Settings_Interface;
17use DateTime;
18use Exception;
19use Psr\Log\LoggerInterface;
20use WP_List_Table;
21use WP_Post_Type;
22use WP_User;
23
24/**
25 * WordPress list table for displaying the logs.
26 */
27class Logs_List_Table extends WP_List_Table {
28    /**
29     * The logs are displayed one day at a time. The most recent day's log file, or the optionally specified date.
30     *
31     * @used-by Logs_List_Table::get_data()
32     *
33     * @var string|null
34     */
35    protected ?string $selected_date = null;
36
37    /**
38     * Logs_Table constructor.
39     *
40     * @see WP_List_Table::__construct()
41     *
42     * @param API_Interface                                                       $api The logger API.
43     * @param Logger_Settings_Interface                                           $settings The logger settings.
44     * @param BH_WP_PSR_Logger|LoggerInterface                                    $logger The logger itself, to use for actual logging. Also passed into a filter.
45     * @param array{plural?:string, singular?:string, ajax?:bool, screen?:string} $args Arguments array from parent class.
46     */
47    public function __construct(
48        protected API_Interface $api,
49        protected Logger_Settings_Interface $settings,
50        protected BH_WP_PSR_Logger|LoggerInterface $logger,
51        array $args = array()
52    ) {
53        parent::__construct( $args );
54    }
55
56    /**
57     * Called before prepare_items() to set for which date logs should be displayed.
58     *
59     * @used-by Logs_Page::display_page()
60     *
61     * @param ?string $ymd_date Date in format 2022-09-28.
62     */
63    public function set_date( ?string $ymd_date ): void {
64        $this->selected_date = $ymd_date;
65    }
66
67    /**
68     * Read the log file and parse the data.
69     *
70     * TODO: Move out of here. This should be a generic PSR-Log-Data class.
71     *
72     * @return array<array{time:string,datetime:?DateTime,level:string,message:string,context:?\stdClass}>
73     */
74    public function get_data(): array {
75
76        $log_files = $this->api->get_log_files();
77
78        if ( empty( $log_files ) ) {
79            // TODO: "No logs yet." message. Maybe with "current log level is:".
80            return array();
81        } elseif ( ! is_null( $this->selected_date ) && isset( $log_files[ $this->selected_date ] ) ) {
82            $filepath = $log_files[ $this->selected_date ];
83        } else {
84            $filepath = array_pop( $log_files );
85        }
86
87        return $this->api->parse_log( $filepath );
88    }
89
90    /**
91     * Get the list of columns in this table.
92     *
93     * TODO: Add a filter/instructions on how to add a new column.
94     *
95     * @overrides WP_List_Table::get_columns()
96     * @see WP_List_Table::get_columns()
97     *
98     * @return array{level:string, time:string, message:string, context:string} array<column identifier, column title>
99     */
100    public function get_columns() {
101        return array(
102            'level'   => '',
103            'time'    => 'Time',
104            'message' => 'Message',
105            'context' => 'Context',
106        );
107    }
108
109    /**
110     * @override parent::prepare_items()
111     * @see WP_List_Table::prepare_items()
112     * @return void
113     */
114    public function prepare_items() {
115
116        $columns               = $this->get_columns();
117        $hidden                = array();
118        $sortable              = array();
119        $this->_column_headers = array( $columns, $hidden, $sortable );
120        $this->items           = $this->get_data();
121    }
122
123    /**
124     * Generates content for a single row of the table.
125     *
126     * @see WP_List_Table::single_row()
127     *
128     * @used-by WP_List_Table::display_rows()
129     *
130     * @param array{time:string, level:string, message:string, context:array<mixed>} $item The current item.
131     * @return void
132     */
133    public function single_row( $item ) {
134        echo '<tr class="level-' . esc_attr( strtolower( $item['level'] ) ) . '">';
135        $this->single_row_columns( $item );
136        echo '</tr>';
137    }
138
139    /**
140     * Get the HTML for a column.
141     *
142     * @param array{time:string, level:string, message:string, context:array<string, mixed>} $item ...whatever type get_data returns.
143     * @param string                                                                         $column_name The specified column.
144     *
145     * @see WP_List_Table::column_default()
146     * @see Logs_List_Table::get_data()
147     *
148     * @return string
149     */
150    public function column_default( $item, $column_name ) {
151
152        $column_output = '';
153        switch ( $column_name ) {
154            case 'time':
155                $time = $item['time'];
156
157                try {
158                    $datetime = new DateTime( $time );
159                    // TODO: Is there a way to know if the site's timezone has never been set properly?
160                    // TODO: Is it better to use the user's timezone rather than the server timezone?
161                    $datetime->setTimezone( wp_timezone() );
162
163                    // Output in format: 20:02, Saturday, 14 November 2020 (PST).
164                    $date_formatted = $datetime->format( 'H:i, l, d F Y (T)' );
165                    $column_output .= $date_formatted;
166                    $column_output .= '<br/>';
167                } catch ( Exception ) {
168                    $column_output .= 'Could not parse date: ';
169                }
170                $column_output .= $time;
171                $column_output  = '<span class="logs-time">' . $column_output . '</span>';
172                break;
173            case 'context':
174                if ( ! empty( $item['context'] ) ) {
175                    $pretty_context = wp_json_encode( $item['context'], JSON_PRETTY_PRINT );
176                    // phpcs:disable WordPress.PHP.DisallowShortTernary.Found
177                    $un_pretty_context = wp_json_encode( $item['context'] ) ?: '';
178                    $column_output     = $pretty_context
179                        ? sprintf(
180                            '<pre data-json="%s" class="log-context-pre">%s</pre>',
181                            esc_html( $un_pretty_context ),
182                            esc_html( trim( $pretty_context, "'\"" ) )
183                        )
184                        : esc_html( $this->get_print_r( $item['context'] ) );
185                }
186                break;
187            case 'message':
188                // The "message" is just text.
189                $column_output = $item['message'];
190                $column_output = esc_html( $column_output );
191                $column_output = $this->replace_wp_user_id_with_link( $column_output );
192                $column_output = $this->replace_post_type_id_with_link( $column_output );
193                break;
194            case 'level':
195                // The "level" column is just a color bar.
196            default:
197                // TODO: Log unexpected column name.
198                break;
199        }
200
201        $logger_settings  = $this->settings;
202        $bh_wp_psr_logger = $this->logger;
203
204        $plugin_slug = $this->settings->get_plugin_slug();
205        /**
206         * Filter to modify what is printed for the column.
207         * e.g. find and replace wc_order:123 with a link to the order.
208         *
209         * @param string $column_output
210         * @param array{time:string, level:string, message:string, context:array<string,mixed>} $item The log entry row.
211         * @param string $column_name
212         * @param Logger_Settings_Interface $logger_settings
213         * @param BH_WP_PSR_Logger|LoggerInterface $bh_wp_psr_logger
214         */
215        $filtered_column_output = apply_filters( "{$plugin_slug}_bh_wp_logger_column", $column_output, $item, $column_name, $logger_settings, $bh_wp_psr_logger );
216
217        return is_string( $filtered_column_output ) ? $filtered_column_output : $column_output; /** @phpstan-ignore function.alreadyNarrowedType */
218    }
219
220    /**
221     * Update `wp_user:123` with links to the user profile.
222     *
223     * Public for now. Maybe should be in another class.
224     *
225     * @param string $message The log text to search and replace in.
226     *
227     * @return string
228     */
229    public function replace_wp_user_id_with_link( string $message ): string {
230
231        $callback = function ( array $matches ): string {
232            /** @var array{0:string,1:numeric-string} $matches */
233
234            $user = get_user_by( 'ID', $matches[1] );
235
236            if ( $user instanceof WP_User ) {
237                return sprintf(
238                    '<a href="%s">%s</a>',
239                    admin_url( "user-edit.php?user_id={$matches[1]}" ),
240                    esc_html( $user->user_nicename )
241                );
242            }
243
244            return $matches[0];
245        };
246
247        return preg_replace_callback( '/wp_user:(\d+)/', $callback, $message ) ?? $message;
248    }
249
250    /**
251     * Replace references to posts with links to the post edit screen.
252     * E.g. update `shop_order:123` with link to the order called "Order 123".
253     * E.g. update `attachment:456` with link to the attachment called "Media 456".
254     *
255     * The backticks are required.
256     *
257     * @param string $column_output The column output so far.
258     *
259     * @return string
260     */
261    public function replace_post_type_id_with_link( string $column_output ): string {
262
263        /**
264         * Get the list of valid post types registered with WordPress.
265         *
266         * @var array<string, WP_Post_Type> $post_types
267         */
268        $post_types = get_post_types( array(), 'objects' );
269
270        /**
271         * Filter the list of registered post types to only those with a UI we can link to.
272         *
273         * @var array<string, WP_Post_Type> $post_types_with_ui
274         */
275        $post_types_with_ui = array_filter(
276            $post_types,
277            fn( WP_Post_Type $post_type ) => $post_type->show_ui
278        );
279
280        /** @param string[] $matches */
281        $callback = function ( array $matches ) use ( $post_types_with_ui ): string {
282            /** @var array{0:string,1:string,2:numeric-string} $matches} */
283
284            if ( ! isset( $post_types_with_ui[ $matches[1] ] ) ) {
285                return $matches[0];
286            }
287
288            $post_id   = (int) $matches[2];
289            $post_type = $post_types_with_ui[ $matches[1] ];
290            /** @var string $post_type_name */
291            $post_type_name = $post_type->labels->singular_name;
292
293            $url = get_edit_post_link( $post_id );
294            if ( is_null( $url ) ) {
295                return $matches[0];
296            }
297
298            return sprintf(
299                '<a href="%s">%s %s</a>',
300                esc_url( $url, null, 'href' ),
301                $post_type_name,
302                $post_id,
303            );
304        };
305
306        return preg_replace_callback( '/`(\w+):(\d+)`/', $callback, $column_output ) ?? $column_output;
307    }
308
309    /**
310     * Print message to when there are no items to display.
311     *
312     * We add a span here to add padding via CSS.
313     *
314     * @see WP_List_Table::no_items()
315     * @used-by WP_List_Table::display_rows_or_placeholder()
316     */
317    public function no_items(): void {
318        echo '<span class="no-items-message">';
319        echo esc_html( __( 'No items found.', 'bh-wp-logger' ) );
320        echo '</span>';
321    }
322
323    /**
324     * Print an array to a string.
325     *
326     * This is probably an array whose source was reading it from a text file.
327     * This function is used only when {@see wp_json_encode()} returns false.
328     *
329     * Is there a better option to print an array?
330     *
331     * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r
332     *
333     * @param array<string, mixed> $context The log context array.
334     */
335    protected function get_print_r( array $context ): string {
336
337        return print_r( $context, true );
338    }
339}