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