'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 '' . esc_html( self::initials( $user->display_name ) ) . '';
}
/**
* 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 '
';
echo '
';
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 '';
}
}