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