store = $store;
}
/**
* Renders the screen.
*
* The capability check is repeated here because a page callback is reachable
* by URL independently of the menu that registered it: the menu controls who
* is shown the link, not who may request the screen. Without it, the
* callback would run for any logged-in user who typed the URL.
*
* @since 1.1.0
*
* @return void
*/
public function render() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__( 'You do not have permission to view the SummitCite crawler log.', 'summitcite' ),
'',
array( 'response' => 403 )
);
}
$bot = $this->requested_bot();
$orderby = $this->requested_orderby();
$order = $this->requested_order();
?>
render_collection_notice();
$this->render_visits_by_bot( $orderby, $order, $bot );
$this->render_recent_visits( $bot, $orderby, $order );
?>
%1$s %3$s
',
esc_html__( 'Recording new crawler visits is switched off. Visits already recorded are shown below and still expire on the retention schedule.', 'summitcite' ),
esc_url( admin_url( 'admin.php?page=' . Settings::PAGE_SLUG ) ),
esc_html__( 'Open settings', 'summitcite' )
);
}
/**
* Renders the Visits by Bot table.
*
* @since 1.1.0
*
* @param string $orderby Active sort key.
* @param string $order Active sort direction.
* @param string|null $bot Active bot filter, carried into the sort links.
* @return void
*/
private function render_visits_by_bot( $orderby, $order, $bot ) {
$rows = $this->store->visits_by_bot( $orderby, $order );
echo '' . esc_html__( 'Visits by Bot', 'summitcite' ) . '
';
if ( null === $rows ) {
$this->render_query_error();
return;
}
if ( array() === $rows ) {
printf(
'%s
',
esc_html__( 'No AI crawler visits have been recorded yet.', 'summitcite' )
);
return;
}
?>
render_sortable_header( 'bot_name', __( 'Bot name', 'summitcite' ), $orderby, $order, $bot );
$this->render_sortable_header( 'bot_type', __( 'Type', 'summitcite' ), $orderby, $order, $bot );
$this->render_sortable_header( 'visit_count', __( 'Visits', 'summitcite' ), $orderby, $order, $bot );
$this->render_sortable_header( 'last_visit', __( 'Last visit', 'summitcite' ), $orderby, $order, $bot );
?>
|
|
|
|
format_timestamp( $row['last_visit'] ) ); ?> |
store->recent_visits( $bot, CrawlerLogStore::RECENT_LIMIT );
echo '' . esc_html__( 'Recent Visits', 'summitcite' ) . '
';
printf(
'%s
',
esc_html(
sprintf(
/* translators: %d: maximum number of visits listed. */
__( 'The %d newest recorded visits. The address shown is the one that connected to this site, which for a site behind a CDN or reverse proxy is the proxy rather than the crawler.', 'summitcite' ),
CrawlerLogStore::RECENT_LIMIT
)
)
);
if ( null !== $bot ) {
printf(
'%1$s %3$s
',
esc_html(
sprintf(
/* translators: %s: bot name the list is filtered to. */
__( 'Filtered to %s.', 'summitcite' ),
$bot
)
),
esc_url(
$this->page_url(
array(
'orderby' => $orderby,
'order' => $order,
)
)
),
esc_html__( 'Show all bots', 'summitcite' )
);
}
if ( null === $visits ) {
$this->render_query_error();
return;
}
if ( array() === $visits ) {
printf(
'%s
',
esc_html(
null === $bot
? __( 'No AI crawler visits have been recorded yet.', 'summitcite' )
: __( 'No recorded visits match the current filter.', 'summitcite' )
)
);
return;
}
?>
|
|
|
|
|
|
| format_timestamp( $visit['visited_at_utc'] ) ); ?> |
|
|
|
|
content_cell( $visit['post_id'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
|
%2$s',
esc_url( $edit_link ),
esc_html( $title )
);
}
/**
* Renders a sortable column header.
*
* Clicking the active column reverses it; clicking another column starts it
* descending, which is the more useful first look at a count or a date.
*
* @since 1.1.0
*
* @param string $key Sort key for this column.
* @param string $label Translated column label.
* @param string $orderby Active sort key.
* @param string $order Active sort direction.
* @param string|null $bot Active bot filter, carried into the link.
* @return void
*/
private function render_sortable_header( $key, $label, $orderby, $order, $bot ) {
$is_active = $key === $orderby;
$next = $is_active && 'desc' === $order ? 'asc' : 'desc';
$args = array(
'orderby' => $key,
'order' => $next,
);
if ( null !== $bot ) {
$args['bot'] = $bot;
}
printf(
'%3$s | ',
esc_attr( $is_active ? ( 'asc' === $order ? 'ascending' : 'descending' ) : 'none' ),
esc_url( $this->page_url( $args ) ),
esc_html( $label )
);
}
/**
* Renders the generic failure notice used when a query does not run.
*
* Deliberately carries no query text, no submitted input and no row data,
* and never claims the table below is complete.
*
* @since 1.1.0
*
* @return void
*/
private function render_query_error() {
printf(
'',
esc_html__( 'The crawler log could not be read, so no results are shown. This is a database error rather than an empty log.', 'summitcite' )
);
}
/**
* Builds a URL to this screen with the given query arguments.
*
* @since 1.1.0
*
* @param array $args Query arguments to add.
* @return string Admin URL.
*/
private function page_url( array $args ) {
return add_query_arg(
array_merge( array( 'page' => self::PAGE_SLUG ), $args ),
admin_url( 'admin.php' )
);
}
/**
* Formats a canonical UTC instant for display in the site timezone.
*
* The store returns UTC, so the string is parsed as UTC and handed to
* `wp_date()`, which renders it in whatever the site timezone currently is.
* Conversion happens here, at render time, and never in storage: changing
* the site timezone therefore changes what this screen shows and nothing
* about which rows a retention or reporting window contains. Nothing here is
* derived from the browser.
*
* `mysql2date()` is deliberately not used - it parses its argument in
* `wp_timezone()`, which would read a UTC instant as a local one and shift
* every timestamp on the screen by the site's offset.
*
* Two visits an hour apart inside a daylight-saving fall-back hour do render
* identically, because the site's wall clock genuinely reads the same twice.
* They remain distinct stored instants and still sort correctly.
*
* @since 1.1.0
*
* @param string $value Stored UTC `Y-m-d H:i:s` value.
* @return string Formatted timestamp, or an em dash when unparsable.
*/
private function format_timestamp( $value ) {
$instant = date_create_immutable( (string) $value, new DateTimeZone( 'UTC' ) );
if ( false === $instant ) {
return '—';
}
$format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
$formatted = wp_date( $format, $instant->getTimestamp() );
return is_string( $formatted ) && '' !== $formatted ? $formatted : '—';
}
/**
* Returns the requested bot filter, or null when there is none.
*
* Sanitised as plain text and bounded to the column width. No check is made
* against the registry or the stored names: a filter that matches nothing is
* an empty result, not an error, and comparing against the registry would
* also hide rows for a crawler that has since been removed from it.
*
* @since 1.1.0
*
* @return string|null Bot name to filter by, or null.
*/
private function requested_bot() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list filter from a link; it changes no state. Bound as a SQL value by the store.
if ( ! isset( $_GET['bot'] ) || ! is_scalar( $_GET['bot'] ) ) {
return null;
}
$bot = sanitize_text_field( wp_unslash( $_GET['bot'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See above.
$bot = mb_substr( $bot, 0, self::MAX_BOT_FILTER, 'UTF-8' );
return '' === $bot ? null : $bot;
}
/**
* Returns the requested sort key, falling back to the default.
*
* An unrecognised value is replaced rather than rejected, so a stale or
* hand-edited link still renders the table.
*
* @since 1.1.0
*
* @return string Sort key accepted by the store.
*/
private function requested_orderby() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list sort from a link; it changes no state. Matched against an allow-list below.
if ( ! isset( $_GET['orderby'] ) || ! is_scalar( $_GET['orderby'] ) ) {
return 'visit_count';
}
$orderby = sanitize_key( wp_unslash( $_GET['orderby'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See above.
return in_array( $orderby, CrawlerLogStore::sort_keys(), true ) ? $orderby : 'visit_count';
}
/**
* Returns the requested sort direction, falling back to descending.
*
* @since 1.1.0
*
* @return string Either `asc` or `desc`.
*/
private function requested_order() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list sort from a link; it changes no state. Reduced to one of two literals below.
if ( ! isset( $_GET['order'] ) || ! is_scalar( $_GET['order'] ) ) {
return 'desc';
}
$order = sanitize_key( wp_unslash( $_GET['order'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See above.
return 'asc' === $order ? 'asc' : 'desc';
}
}