'evlfl_event', 'plural' => 'evlfl_events', 'ajax' => false, ) ); $this->logger = $logger; } /** * Validate and sanitize request filters against allowlists. * * @param array $request Usually $_GET (unslashed here). * @return array */ public static function parse_filters( array $request ) { $get = static function ( $key ) use ( $request ) { return isset( $request[ $key ] ) && is_scalar( $request[ $key ] ) ? sanitize_text_field( wp_unslash( (string) $request[ $key ] ) ) : ''; }; $action = sanitize_key( $get( 'evlfl_action' ) ); $status = sanitize_key( $get( 'evlfl_status' ) ); $source = sanitize_key( $get( 'evlfl_source' ) ); $order = strtoupper( $get( 'order' ) ); $by = sanitize_key( $get( 'orderby' ) ); return array( 'search' => self::truncate( $get( 's' ), 100 ), 'action' => isset( EVLFL_Event::get_actions()[ $action ] ) ? $action : '', 'status' => isset( EVLFL_Event::get_statuses()[ $status ] ) ? $status : '', 'source' => isset( EVLFL_Event::get_sources()[ $source ] ) ? $source : '', 'date_from' => self::date_to_gmt( $get( 'evlfl_from' ), '00:00:00' ), 'date_to' => self::date_to_gmt( $get( 'evlfl_to' ), '23:59:59' ), 'orderby' => in_array( $by, EVLFL_Logger::sortable_columns(), true ) ? $by : 'event_time', 'order' => 'ASC' === $order ? 'ASC' : 'DESC', 'raw_from' => self::valid_date( $get( 'evlfl_from' ) ) ? $get( 'evlfl_from' ) : '', 'raw_to' => self::valid_date( $get( 'evlfl_to' ) ) ? $get( 'evlfl_to' ) : '', ); } /** * First $max characters (not bytes), so a multibyte character is never cut in half - * half a character is invalid UTF-8, which $wpdb refuses to query with. * * @param string $text Text. * @param int $max Maximum characters. * @return string */ private static function truncate( $text, $max ) { if ( function_exists( 'mb_substr' ) ) { return mb_substr( $text, 0, $max, 'UTF-8' ); } return preg_match( '/^.{0,' . (int) $max . '}/us', $text, $m ) ? $m[0] : substr( $text, 0, $max ); } /** * Whether any (valid) filter is active. * * @param array $f Output of parse_filters(). * @return bool */ public static function is_filtering( array $f ) { foreach ( array( 'search', 'action', 'status', 'source', 'date_from', 'date_to' ) as $key ) { if ( '' !== $f[ $key ] ) { return true; } } return false; } /** * Query args that reproduce the active filters (validated values only, so nothing * from the request is reflected back unchecked). * * @param array $f Output of parse_filters(). * @return array */ public static function url_args( array $f ) { $map = array( 's' => 'search', 'evlfl_action' => 'action', 'evlfl_status' => 'status', 'evlfl_source' => 'source', 'evlfl_from' => 'raw_from', 'evlfl_to' => 'raw_to', ); $args = array(); foreach ( $map as $param => $key ) { if ( '' !== $f[ $key ] ) { $args[ $param ] = $f[ $key ]; } } return $args; } /** * The sort the user explicitly chose (orderby/order present and valid), else nothing. * * @param array $request Usually $_GET. * @return array */ public static function sort_args( array $request ) { if ( ! isset( $request['orderby'] ) || ! is_scalar( $request['orderby'] ) ) { return array(); } $f = self::parse_filters( $request ); if ( sanitize_key( wp_unslash( (string) $request['orderby'] ) ) !== $f['orderby'] ) { return array(); } return array( 'orderby' => $f['orderby'], 'order' => strtolower( $f['order'] ), ); } /** * URL of this screen with the given query args. * * @param array $args Query args. * @return string Raw URL (escape on output). */ public static function list_url( array $args = array() ) { // add_query_arg() does not encode values: without this a search for "R&D" or "C++" would // come back as "R" or "C " on the next page, after Refresh, or when a chip is removed. return add_query_arg( rawurlencode_deep( array_merge( array( 'page' => EVLFL_Admin::PAGE_SLUG ), $args ) ), admin_url( 'admin.php' ) ); } /** * Supply the unfiltered row count used in the "N of M events" header. * * @param int $total Rows in the whole log. * @return void */ public function set_overall_total( $total ) { $this->overall_total = max( 0, (int) $total ); } /** * Validate Y-m-d. * * @param string $date Date. * @return bool */ private static function valid_date( $date ) { if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m ) ) { return false; } return checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ); } /** * Site-local date to GMT datetime ('' when invalid). * * @param string $date Y-m-d. * @param string $time H:i:s. * @return string */ private static function date_to_gmt( $date, $time ) { return self::valid_date( $date ) ? get_gmt_from_date( $date . ' ' . $time ) : ''; } /** * Columns. * * @return array */ public function get_columns() { return array( 'event_time' => __( 'Date', 'accesstrace-for-learndash' ), 'user' => __( 'User', 'accesstrace-for-learndash' ), 'course' => __( 'Course', 'accesstrace-for-learndash' ), 'action' => __( 'Action', 'accesstrace-for-learndash' ), 'source' => __( 'Source', 'accesstrace-for-learndash' ), 'event_trigger' => __( 'Trigger', 'accesstrace-for-learndash' ), 'status' => __( 'Status', 'accesstrace-for-learndash' ), ); } /** * Sortable columns. * * @return array */ protected function get_sortable_columns() { return array( 'event_time' => array( 'event_time', true, '', '', 'desc' ), // 5th item: shown as sorted by default (WP 6.4+), so the first click flips the order. 'action' => array( 'action', false ), 'source' => array( 'source', false ), 'status' => array( 'status', false ), ); } /** * Table classes: WordPress's defaults minus zebra striping (rows are separated by rules). * * @return string[] */ protected function get_table_classes() { return array( 'widefat', 'fixed', 'evl-table', $this->_args['plural'] ); } /** * Load items. * * @return void */ public function prepare_items() { $per_page = (int) apply_filters( 'evlfl_logs_per_page', 20 ); $per_page = min( EVLFL_Logger::MAX_PER_PAGE, max( 5, $per_page ) ); $filters = self::parse_filters( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filters, validated. $paged = $this->get_pagenum(); $filters['search_email'] = self::can_search_email(); $result = $this->logger->query( array_merge( $filters, array( 'per_page' => $per_page, 'paged' => $paged, ) ) ); $last = max( 1, (int) ceil( $result['total'] / $per_page ) ); if ( $paged > $last && $result['total'] > 0 ) { // A page number past the end (bookmark, rows deleted meanwhile): show the last page // instead of an empty table that contradicts the count above it. $paged = $last; $result = $this->logger->query( array_merge( $filters, array( 'per_page' => $per_page, 'paged' => $last, ) ) ); } $this->items = $result['items']; $this->current_page = $paged; // Prime user cache for the page to avoid N+1 queries. $ids = array_unique( array_merge( wp_list_pluck( $this->items, 'user_id' ), array_filter( wp_list_pluck( $this->items, 'actor_id' ) ) ) ); if ( $ids && function_exists( 'cache_users' ) ) { cache_users( $ids ); } $this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'event_time' ); $this->set_pagination_args( array( 'total_items' => $result['total'], 'per_page' => $per_page, 'total_pages' => (int) ceil( $result['total'] / $per_page ), ) ); } /** * Search matches e-mail addresses only for users who may see them (the e-mail is not shown in * the log). Otherwise someone given access through the evlfl_capability filter could test * whether an address exists by searching for it. * * @return bool */ public static function can_search_email() { return current_user_can( 'list_users' ); } /** * Date column with link to details. * * @param array $item Row. * @return string */ protected function column_event_time( $item ) { return '' . esc_html( EVLFL_Log_Details::format_time( $item['event_time'] ) ) . ''; } /** * User column: initials avatar + name/meta. * * @param array $item Row. * @return string */ protected function column_user( $item ) { return '
' . self::avatar_html( $item['user_id'] ) . '
' . EVLFL_Log_Details::user_html( $item['user_id'] ) . '
'; } /** * Initials avatar (decorative; the name sits next to it). No external requests, unlike Gravatar. * * @param int $user_id User ID. * @return string Escaped HTML. */ public static function avatar_html( $user_id ) { $user_id = absint( $user_id ); $user = get_userdata( $user_id ); if ( ! $user ) { return ''; } return ''; } /** * Up to two initials: first letters of the first two words, or of the first two parts of * a single word such as "evlfl_sim_student" -> "ES". Multibyte-safe without mbstring. * * @param string $name Display name. * @return string */ public static function initials( $name ) { $name = trim( (string) $name ); $words = preg_split( '/\s+/u', $name, -1, PREG_SPLIT_NO_EMPTY ); $words = is_array( $words ) ? $words : array(); if ( 1 === count( $words ) ) { $parts = preg_split( '/[-_.]+/u', $words[0], -1, PREG_SPLIT_NO_EMPTY ); $words = is_array( $parts ) && $parts ? $parts : $words; } $out = ''; foreach ( array_slice( $words, 0, 2 ) as $word ) { if ( preg_match( '/^./u', $word, $m ) ) { $out .= $m[0]; } } $out = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $out, 'UTF-8' ) : strtoupper( $out ); return '' !== $out ? $out : '?'; } /** * Course column. * * @param array $item Row. * @return string */ protected function column_course( $item ) { return EVLFL_Log_Details::course_html( $item['course_id'] ); } /** * Status column. * * @param array $item Row. * @return string */ protected function column_status( $item ) { return EVLFL_Log_Details::status_html( $item['status'] ); } /** * Default column. * * @param array $item Row. * @param string $column_name Column. * @return string */ protected function column_default( $item, $column_name ) { $types = array( 'action' => 'action', 'source' => 'source', 'event_trigger' => 'trigger', ); if ( isset( $types[ $column_name ] ) ) { return esc_html( EVLFL_Event::label( $types[ $column_name ], $item[ $column_name ] ) ); } return ''; } /** * Empty state. * * @return void */ public function no_items() { if ( self::is_filtering( self::parse_filters( $_GET ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended esc_html_e( 'No enrollment events match the current filters.', 'accesstrace-for-learndash' ); return; } esc_html_e( 'No enrollment events have been logged yet.', 'accesstrace-for-learndash' ); } /** * The filter bar (rendered by the view inside its own card and GET form). Keeps the * chosen sort when filters are applied. * * @return void */ public function filter_bar() { foreach ( self::sort_args( $_GET ) as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo ''; } $this->extra_tablenav( 'top' ); } /** * Search, filters and the active-filter chips. Only for "top"; the view places it in the * filter card (display_tablenav() below does not call it, so it is never printed twice). * * @param string $which top|bottom. * @return void */ protected function extra_tablenav( $which ) { if ( 'top' !== $which ) { return; } $f = self::parse_filters( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '
'; echo '
'; echo ''; $this->select( 'evlfl_action', __( 'All actions', 'accesstrace-for-learndash' ), EVLFL_Event::get_actions(), $f['action'], __( 'Filter by action', 'accesstrace-for-learndash' ) ); $this->select( 'evlfl_status', __( 'All statuses', 'accesstrace-for-learndash' ), EVLFL_Event::get_statuses(), $f['status'], __( 'Filter by status', 'accesstrace-for-learndash' ) ); $this->select( 'evlfl_source', __( 'All sources', 'accesstrace-for-learndash' ), EVLFL_Event::get_sources(), $f['source'], __( 'Filter by source', 'accesstrace-for-learndash' ) ); $dates_active = '' !== $f['raw_from'] || '' !== $f['raw_to']; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; $this->render_active_chips( $f ); echo '
'; } /** * "Active: ..." chip row. Each chip links to the same view with that one filter removed. * * @param array $f Output of parse_filters(). * @return void */ private function render_active_chips( array $f ) { $chips = array(); if ( '' !== $f['search'] ) { /* translators: %s: search term */ $chips[] = array( array( 's' ), sprintf( __( 'Search: %s', 'accesstrace-for-learndash' ), $f['search'] ) ); } if ( '' !== $f['action'] ) { /* translators: %s: action label */ $chips[] = array( array( 'evlfl_action' ), sprintf( __( 'Action: %s', 'accesstrace-for-learndash' ), EVLFL_Event::get_actions()[ $f['action'] ] ) ); } if ( '' !== $f['status'] ) { /* translators: %s: status label */ $chips[] = array( array( 'evlfl_status' ), sprintf( __( 'Status: %s', 'accesstrace-for-learndash' ), EVLFL_Event::get_statuses()[ $f['status'] ] ) ); } if ( '' !== $f['source'] ) { /* translators: %s: source label */ $chips[] = array( array( 'evlfl_source' ), sprintf( __( 'Source: %s', 'accesstrace-for-learndash' ), EVLFL_Event::get_sources()[ $f['source'] ] ) ); } if ( '' !== $f['raw_from'] || '' !== $f['raw_to'] ) { if ( '' !== $f['raw_from'] && '' !== $f['raw_to'] ) { /* translators: 1: start date, 2: end date */ $range = sprintf( __( '%1$s – %2$s', 'accesstrace-for-learndash' ), $f['raw_from'], $f['raw_to'] ); } elseif ( '' !== $f['raw_from'] ) { /* translators: %s: date */ $range = sprintf( __( 'from %s', 'accesstrace-for-learndash' ), $f['raw_from'] ); } else { /* translators: %s: date */ $range = sprintf( __( 'until %s', 'accesstrace-for-learndash' ), $f['raw_to'] ); } /* translators: %s: date range */ $chips[] = array( array( 'evlfl_from', 'evlfl_to' ), sprintf( __( 'Date: %s', 'accesstrace-for-learndash' ), $range ) ); } if ( ! $chips ) { return; } $current = self::url_args( $f ); $sort = self::sort_args( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '
' . esc_html__( 'Active:', 'accesstrace-for-learndash' ) . ''; foreach ( $chips as $chip ) { list( $keys, $text ) = $chip; $url = self::list_url( array_merge( array_diff_key( $current, array_flip( $keys ) ), $sort ) ); /* translators: %s: the filter being removed, e.g. "Status: Valid" */ $label = sprintf( __( 'Remove filter %s', 'accesstrace-for-learndash' ), $text ); echo '' . esc_html( $text ) . ''; } echo '' . esc_html__( 'Clear all', 'accesstrace-for-learndash' ) . ''; echo '
'; } /** * Render a filter select. * * @param string $name Field name. * @param string $all "All" label. * @param array $options value => label. * @param string $selected Selected value. * @param string $label Screen reader label. * @return void */ private function select( $name, $all, array $options, $selected, $label ) { echo ''; echo ''; } /** * Replaces WordPress's tablenav: "N of M events" above the table, numbered pager below. * (No bulk actions here, so no bulk nonce either: it would only bloat the URL.) * * @param string $which top|bottom. * @return void */ protected function display_tablenav( $which ) { if ( 'top' === $which ) { echo '
' . esc_html( $this->count_text() ) . '
'; return; } $this->render_pager(); } /** * "6 of 1,284 events" while filtering, else "1,284 events". * * @return string Plain text. */ public function count_text() { $found = (int) $this->get_pagination_arg( 'total_items' ); if ( null !== $this->overall_total && self::is_filtering( self::parse_filters( $_GET ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended /* translators: 1: number of matching events, 2: number of events in the whole log */ return sprintf( _n( '%1$s of %2$s event', '%1$s of %2$s events', $this->overall_total, 'accesstrace-for-learndash' ), number_format_i18n( $found ), number_format_i18n( $this->overall_total ) ); } /* translators: %s: number of events */ return sprintf( _n( '%s event', '%s events', $found, 'accesstrace-for-learndash' ), number_format_i18n( $found ) ); } /** * Pages to show: all of them up to 7, else first, last and the current page's neighbours, * with null marking a gap ("…"). * * @param int $current Current page. * @param int $total Total pages. * @return array */ public static function page_window( $current, $total ) { $total = max( 1, (int) $total ); $current = min( max( 1, (int) $current ), $total ); if ( $total <= 7 ) { return range( 1, $total ); } $pages = array_unique( array( 1, 2, $current - 1, $current, $current + 1, $total - 1, $total ) ); $pages = array_filter( $pages, static function ( $p ) use ( $total ) { return $p >= 1 && $p <= $total; } ); sort( $pages ); $out = array(); $prev = 0; foreach ( $pages as $p ) { if ( 2 === $p - $prev ) { $out[] = $p - 1; // A one-page gap: show the page, "…" would not save any space. } elseif ( $p - $prev > 2 ) { $out[] = null; } $out[] = $p; $prev = $p; } return $out; } /** * Numbered pager: Prev 1 2 3 … Next. Nothing when everything fits on one page. * * @return void */ private function render_pager() { $total = (int) $this->get_pagination_arg( 'total_pages' ); if ( $total < 2 ) { return; } $current = min( max( 1, $this->current_page ), $total ); $base = array_merge( self::url_args( self::parse_filters( $_GET ) ), self::sort_args( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $url = static function ( $n ) use ( $base ) { return self::list_url( $n > 1 ? array_merge( $base, array( 'paged' => $n ) ) : $base ); }; echo ''; } }