query( $wpdb->prepare( "INSERT IGNORE INTO {$relevanssi_variables['tracking_table']} " . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared '(`post_id`, `query`, `rank`, `page`, `timestamp`) VALUES (%d, %s, %d, %d, %s)', $post->ID, $rt['query'], $rt['rank'], $rt['page'], gmdate( 'c', $rt['time'] ) ) ); } /** * Extracts the values from the _rt URL parameter. * * @param string $rt The URL parameter. * * @return array|WP_Error An array of values: 'rank', 'page', 'query', and * 'time'. Returns a WP_Error if the value doesn't explode into right number of * parts. */ function relevanssi_extract_rt( string $rt ) { $rt_values = explode( '|', $rt ); if ( count( $rt_values ) !== 4 ) { return new WP_Error( 'invalid-rt', __( 'Invalid click tracking value format.', 'relevanssi' ) ); } $rank = intval( $rt_values[0] ); $page = intval( $rt_values[1] ); $time = intval( $rt_values[3] ); if ( 0 === $rank || 0 === $page || 0 === $time ) { return new WP_Error( 'invalid-rt', __( 'Invalid click tracking value format.', 'relevanssi' ) ); } return array( 'rank' => $rank, 'page' => $page, 'query' => $rt_values[2], 'time' => $time, ); } /** * Adds tracking information to a permalink. * * Called from the `relevanssi_permalink` filter function to add the tracking * data to the link. * * @param string $permalink The permalink to modify. * @param object $link_post A post object, default null in which case the global * $post is used. * * @global $relevanssi_tracking_positions An array of post ID => rank pairs used * to get the post rankings. If a post does not appear in this array, the * tracking data is not added to the permalink. * @global $relevanssi_tracking_permalink A cache of permalinks to avoid doing * work that is already done. * * @return string The modified permalink. */ function relevanssi_add_tracking( string $permalink, $link_post = null ): string { if ( 'on' !== get_option( 'relevanssi_click_tracking', 'off' ) ) { return $permalink; } if ( empty( get_search_query() ) ) { return $permalink; } if ( ! relevanssi_is_ok_to_log() ) { return $permalink; } if ( ! $link_post ) { global $post; $link_post = $post; } $id = relevanssi_get_post_identifier( $link_post ); if ( ! isset( $link_post->blog_id ) || get_current_blog_id() === $link_post->blog_id ) { if ( relevanssi_is_front_page_id( $link_post->ID ) ) { return $permalink; } } global $relevanssi_tracking_positions, $relevanssi_tracking_permalink; $position = $relevanssi_tracking_positions[ $id ] ?? null; if ( ! $position ) { return $permalink; } if ( isset( $relevanssi_tracking_permalink[ $id ] ) ) { return $relevanssi_tracking_permalink[ $id ]; } $page = get_query_var( 'paged' ) > 0 ? get_query_var( 'paged' ) : 1; $nonce = wp_create_nonce( 'relevanssi_click_tracking_' . $id ); $query = relevanssi_strtolower( str_replace( '|', ' ', get_search_query() ) ); $time = time(); $value = "$position|$page|$query|$time"; $permalink = esc_attr( add_query_arg( array( '_rt' => relevanssi_base64url_encode( $value ), '_rt_nonce' => $nonce, ), $permalink ) ); $relevanssi_tracking_permalink[ $id ] = $permalink; return $permalink; } /** * URL-friendly base64 encode. * * @param string $data String to encode. * @return string Encoded string. */ function relevanssi_base64url_encode( string $data ): string { return rtrim( strtr( base64_encode( $data ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions '+/', '-_' ), '=' ); } /** * URL-friendly base64 decode. * * @param string $data String to decode. * @return string Decoded string. */ function relevanssi_base64url_decode( string $data ): string { return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions strtr( $data, '-_', '+/' ) ); } /** * Records the ranking positions for the posts found. * * Runs as the last thing (at PHP_INT_MAX) on the `relevanssi_hits_filter` hook * to record the ranking positions of each post. * * @global array $relevanssi_tracking_positions An array of post ID => rank * pairs. * * @param array $hits The hits found. * * @return array The hits found, unmodified. */ function relevanssi_record_positions( array $hits ): array { global $relevanssi_tracking_positions; $position = 0; foreach ( $hits[0] as $hit ) { ++$position; $hit = relevanssi_get_an_object( $hit )['object']; if ( ! $hit ) { continue; } if ( $hit->ID > 0 ) { $id = relevanssi_get_post_identifier( $hit ); $relevanssi_tracking_positions[ $id ] = $position; } elseif ( isset( $hit->term_id ) ) { $relevanssi_tracking_positions[ $hit->post_type . '_' . $hit->term_id ] = $position; } elseif ( isset( $hit->user_id ) ) { $relevanssi_tracking_positions[ 'user_' . $hit->user_id ] = $position; } } return $hits; } /** * Removes the undisplayed posts from the $relevanssi_tracking_positions array. * * Goes through the $relevanssi_tracking_positions array and only keeps the * posts that appear on the current page of results. * * @global array $relevanssi_tracking_positions An array of post ID => rank * pairs. * * @param array $hits The hits displayed. * * @return array The hits displayed, unmodified. */ function relevanssi_current_page_hits( array $hits ): array { global $relevanssi_tracking_positions; $all_positions = $relevanssi_tracking_positions; $relevanssi_tracking_positions = array(); foreach ( $hits as $hit ) { $hit = relevanssi_get_an_object( $hit )['object']; $id = relevanssi_get_post_identifier( $hit ); if ( $hit->ID > 0 ) { $relevanssi_tracking_positions[ $id ] = $all_positions[ $id ]; } elseif ( isset( $hit->term_id ) ) { $id = $hit->post_type . '_' . $hit->term_id; $relevanssi_tracking_positions[ $id ] = $all_positions[ $id ]; } elseif ( isset( $hit->user_id ) ) { $id = 'user_' . $hit->user_id; $relevanssi_tracking_positions[ $id ] = $all_positions[ $id ]; } } return $hits; } /** * Creates the tracking table. * * @param string $charset_collate Character set collation. * * @return void */ function relevanssi_create_tracking_table( string $charset_collate ) { global $wpdb; $sql = 'CREATE TABLE ' . $wpdb->prefix . 'relevanssi_tracking ' . "(`id` int(11) NOT NULL AUTO_INCREMENT, `post_id` int(11) NOT NULL DEFAULT '0', `query` varchar(200) NOT NULL, `rank` int(11) NOT NULL DEFAULT '0', `page` int(11) NOT NULL DEFAULT '0', `timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY id (id), UNIQUE INDEX post_id_timestamp (post_id, timestamp)) $charset_collate"; dbDelta( $sql ); } /** * Generates an array with date indices and 0 values for each date. * * Uses the `relevanssi_trim_click_logs` option to determine the length of the * date range. * * @param string $type The type of date count: 'clicks', 'log' or 'both'. * * @return array An array of 'Y-m-d' date indices. */ function relevanssi_default_date_count( string $type ): array { global $wpdb, $relevanssi_variables; if ( 'clicks' === $type ) { $amount_of_days = get_option( 'relevanssi_trim_click_logs', 90 ); } if ( 'log' === $type ) { $amount_of_days = get_option( 'relevanssi_trim_logs', 30 ); if ( 0 === $amount_of_days ) { $amount_of_days = abs( $wpdb->get_var( "SELECT TIMESTAMPDIFF(DAY, NOW(), time) FROM {$relevanssi_variables['log_table']} ORDER BY time ASC LIMIT 1" ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared. } } if ( 'both' === $type ) { $click_days = get_option( 'relevanssi_trim_click_logs', 90 ); $log_days = get_option( 'relevanssi_trim_logs', 30 ); if ( '0' === $log_days ) { $log_days = abs( $wpdb->get_var( "SELECT TIMESTAMPDIFF(DAY, NOW(), time) FROM {$relevanssi_variables['log_table']} ORDER BY time ASC LIMIT 1" ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared. } $amount_of_days = max( $click_days, $log_days ); } $date_counts = array(); $start_date = gmdate( 'Y-m-d', strtotime( intval( $amount_of_days ) . ' days ago' ) ); $end_date = gmdate( 'Y-m-d' ); while ( strtotime( $start_date ) <= strtotime( $end_date ) ) { $date_counts[ $start_date ] = 0; $start_date = gmdate( 'Y-m-d', strtotime( '+1 days', strtotime( $start_date ) ) ); } return $date_counts; } /** * Determines what happens when a request for a post insights screen is made. * * @param array $request The $_REQUEST array to dig for parameters. * * @return bool True, if a screen was displayed and false if not. */ function relevanssi_handle_insights_screens( array $request ): bool { if ( isset( $request['insights'] ) ) { if ( isset( $request['action'] ) && isset( $request['query'] ) && 'delete_query' === $request['action'] ) { check_admin_referer( 'relevanssi_delete_query' ); relevanssi_delete_query( $request['query'] ); } if ( isset( $request['action'] ) && isset( $request['query'] ) && 'delete_query_from_log' === $request['action'] ) { check_admin_referer( 'relevanssi_delete_query' ); relevanssi_delete_query_from_log( $request['query'] ); } relevanssi_show_insights( stripslashes( $request['insights'] ) ); return true; } if ( isset( $request['post_insights'] ) ) { relevanssi_show_post_insights( $request['post_insights'] ); return true; } return false; } /** * Deletes a query from the click tracking database. * * @param string $query The query to delete. */ function relevanssi_delete_query( string $query ) { global $wpdb, $relevanssi_variables; $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$relevanssi_variables['tracking_table']} WHERE query = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared stripslashes( $query ) ) ); if ( $deleted ) { printf( "

%s

", sprintf( // Translators: %s is the stopword. esc_html__( "The query '%s' deleted from the click tracking log.", 'relevanssi' ), esc_html( stripslashes( $query ) ) ) ); } else { printf( "

%s

", sprintf( // Translators: %s is the stopword. esc_html__( "Couldn't remove the query '%s' from the click tracking log.", 'relevanssi' ), esc_html( stripslashes( $query ) ) ) ); } } /** * Displays the search query insights screen. * * Prints out the display for a single search query insights screen. * * @param string $query The search query. */ function relevanssi_show_insights( string $query ) { global $wpdb, $relevanssi_variables; ?> ' . // Translators: %s is the search query string. esc_html__( 'Search insights for %s', 'relevanssi' ) . '', esc_html( '"' . $query . '"' ) ); $results = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM ' . $relevanssi_variables['tracking_table'] // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared . ' WHERE query = %s', $query ) ); $posts = array(); $post_average_rank = array(); $post_average_page = array(); $date_counts = relevanssi_default_date_count( 'both' ); $oldest_date = array_keys( $date_counts )[0]; foreach ( $results as $row ) { if ( $row->timestamp < $oldest_date ) { continue; } relevanssi_increase_value( $posts[ $row->post_id ] ); relevanssi_increase_value( $post_average_rank[ $row->post_id ], $row->rank ); relevanssi_increase_value( $post_average_page[ $row->post_id ], $row->page ); relevanssi_increase_value( $date_counts[ gmdate( 'Y-m-d', strtotime( $row->timestamp ) ) ] ); } relevanssi_average_array( $post_average_rank, $posts ); relevanssi_average_array( $post_average_page, $posts ); $results = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM ' . $relevanssi_variables['log_table'] // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared . ' WHERE query = %s', $query ) ); $date_numbers = relevanssi_default_date_count( 'both' ); foreach ( $results as $row ) { relevanssi_increase_value( $date_numbers[ gmdate( 'Y-m-d', strtotime( $row->time ) ) ] ); } ksort( $date_numbers ); arsort( $posts ); $dates_array = $date_counts + $date_numbers; ksort( $dates_array ); $dates = array_map( function ( $v ) { return gmdate( 'M j', strtotime( $v ) ); }, array_keys( $dates_array ) ); relevanssi_create_line_chart( $dates, array( __( '# of Searches', 'relevanssi' ) => array_values( $date_numbers ), __( '# of Clicks', 'relevanssi' ) => array_values( $date_counts ), ) ); if ( count( $posts ) > 0 ) { ?>

$count ) { $insights_url = relevanssi_get_insights_url( intval( $post_id ) ); $insights = sprintf( "%s", esc_url( $insights_url ), get_the_title( $post_id ) ); $link = get_permalink( $post_id ); $edit = get_edit_post_link( $post_id ); ?>
( | )

' . // Translators: %s is the search query string. esc_html__( 'Search insights for %s', 'relevanssi' ) . '', esc_html( '"' . $title . '"' ) ); $link = get_permalink( $post_id ); $edit = get_edit_post_link( $post_id ); ?>

|

get_results( $wpdb->prepare( 'SELECT * FROM ' . $relevanssi_variables['tracking_table'] // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared . ' WHERE post_id = %d', $post_id ) ); $queries = array(); $query_average_rank = array(); $query_average_page = array(); $date_counts = relevanssi_default_date_count( 'clicks' ); foreach ( $results as $row ) { relevanssi_increase_value( $queries[ $row->query ] ); relevanssi_increase_value( $query_average_rank[ $row->query ], $row->rank ); relevanssi_increase_value( $query_average_page[ $row->query ], $row->page ); relevanssi_increase_value( $date_counts[ gmdate( 'Y-m-d', strtotime( $row->timestamp ) ) ] ); } relevanssi_average_array( $query_average_rank, $queries ); relevanssi_average_array( $query_average_page, $queries ); arsort( $queries ); $dates = array_map( function ( $v ) { return gmdate( 'M j', strtotime( $v ) ); }, array_keys( $date_counts ) ); relevanssi_create_line_chart( $dates, array( __( '# of Clicks', 'relevanssi' ) => array_values( $date_counts ), ) ); ?>

$count ) { $insights_url = relevanssi_get_insights_url( $query ); $insights = sprintf( "%s", esc_url( $insights_url ), $query ); ?>
get_results( 'SELECT LOWER(query) AS query, COUNT(*) AS count ' . "FROM {$relevanssi_variables['tracking_table']} " // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared . 'GROUP BY query' ); $relevanssi_variables['query_clicks'] = array_combine( wp_list_pluck( $data, 'query' ), wp_list_pluck( $data, 'count' ) ); return $relevanssi_variables['query_clicks'][ $query ] ?? 0; } /** * Prints out the user interface for setting the click tracking options. */ function relevanssi_click_tracking_interface() { $click_tracking = relevanssi_check( get_option( 'relevanssi_click_tracking' ) ); $trim_click_logs = get_option( 'relevanssi_trim_click_logs' ); ?>

get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM ' . $click_table . ' ' . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared 'WHERE timestamp >= %s AND timestamp <= %s', $from . ' 00:00:00', $to . ' 23:59:59' ) ); $click_ratio = 0; if ( $total > 0 ) { $click_ratio = round( 100 * $total_clicks / $total, 1 ); } ?>
()

get_results( $wpdb->prepare( "SELECT post_id, COUNT(*) AS hits, AVG(`rank`) AS average FROM $click_table " . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared 'WHERE timestamp >= %s AND timestamp <= %s GROUP BY post_id ORDER BY hits DESC', $from . ' 00:00:00', $to . ' 23:59:59' ) ); $top_ten = array_slice( $results, 0, 10 ); $list = array(); foreach ( $top_ten as $result ) { $title = get_the_title( $result->post_id ); $insights_url = relevanssi_get_insights_url( intval( $result->post_id ) ); $list[] = '
  • ' . wp_kses_post( $title ) . ' (' . intval( $result->hits ) . ')
  • '; } if ( count( $list ) > 0 ) { ?>

    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $something_printed = true; ?>
    average - $a->average; } ); $top_ten = array_slice( $results, 0, 10 ); $list = array(); foreach ( $top_ten as $result ) { $title = get_the_title( $result->post_id ); $insights_url = relevanssi_get_insights_url( intval( $result->post_id ) ); $list[] = '
  • ' . wp_kses_post( $title ) . ' (' . round( $result->average, 0 ) . ')
  • '; } if ( count( $list ) > 0 ) { ?>

    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $something_printed = true; ?>
    get_results( $wpdb->prepare( "SELECT query, COUNT(DISTINCT(post_id)) AS posts FROM $click_table " . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared 'WHERE timestamp >= %s AND timestamp <= %s GROUP BY query ORDER BY posts DESC LIMIT 10', $from . ' 00:00:00', $to . ' 23:59:59' ) ); $list = array(); foreach ( $results as $result ) { if ( $result->posts < 3 ) { continue; } $insights_url = relevanssi_get_insights_url( $result->query ); $list[] = '
  • ' . esc_html( $result->query ) . ' (' // Translators: %1$s is the number of posts. . sprintf( __( '%1$s posts', 'relevanssi' ), intval( $result->posts ) ) . ')
  • '; } if ( count( $list ) > 0 ) { ?>

    ' . implode( "\n", $list ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $something_printed = true; } if ( ! $something_printed ) { ?>

    query. * * @return string The HTML link tag to link to the insights page. */ function relevanssi_insights_link( $query ): string { global $relevanssi_variables; $insights_url = admin_url( 'admin.php?page=' . rawurlencode( $relevanssi_variables['plugin_basename'] ) ) . '&insights=' . rawurlencode( $query->query ); $insights = sprintf( "%s", esc_url( $insights_url ), esc_html( relevanssi_hyphenate( $query->query ) ) ); return $insights; } /** * Trims Relevanssi click tracking table. * * Trims Relevanssi click tracking table, using the day interval setting from * 'relevanssi_trim_click_logs'. * * @global object $wpdb The WordPress database interface. * @global array $relevanssi_variables The global Relevanssi variables, used * for database table names. * * @return int|bool Number of rows deleted, or false on error. */ function relevanssi_trim_click_logs() { global $wpdb, $relevanssi_variables; $interval = intval( get_option( 'relevanssi_trim_click_logs' ) ); return $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $relevanssi_variables['tracking_table'] . // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared ' WHERE timestamp < TIMESTAMP(DATE_SUB(NOW(), INTERVAL %d DAY))', $interval ) ); } /** * Sets up the Relevanssi click tracking log trimming action. */ function relevanssi_schedule_click_tracking_trim() { if ( get_option( 'relevanssi_trim_click_logs' ) > 0 ) { if ( ! wp_next_scheduled( 'relevanssi_trim_click_logs' ) ) { wp_schedule_event( time(), 'daily', 'relevanssi_trim_click_logs' ); } } elseif ( wp_next_scheduled( 'relevanssi_trim_click_logs' ) ) { wp_clear_scheduled_hook( 'relevanssi_trim_click_logs' ); } } /** * Prints out the Relevanssi click tracking log as a CSV file. * * Exports the whole Relevanssi click tracking log as a CSV file. * * @uses relevanssi_output_exported_log */ function relevanssi_export_click_log() { global $wpdb, $relevanssi_variables; $data = $wpdb->get_results( 'SELECT * FROM ' . $relevanssi_variables['tracking_table'], ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared relevanssi_output_exported_log( 'relevanssi_click_log.csv', $data, __( 'No search clicks logged.', 'relevanssi' ) ); } /** * Returns the post ID prefixed with the blog ID. * * @param object $post_object The post object. * * @return string Post ID or "blog ID-post ID". */ function relevanssi_get_post_identifier( $post_object ) { if ( is_multisite() ) { if ( isset( $post_object->blog_id ) ) { return $post_object->blog_id . '-' . $post_object->ID; } else { return get_current_blog_id() . '-' . $post_object->ID; } } else { return $post_object->ID; } }