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( '

%s

', 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'; } }