$links * @return array */ function add_plugin_action_links(array $links): array { $settings_link = sprintf( '%s', esc_url(admin_url(SETTINGS_PAGE)), esc_html__('Settings', 'admin-views-column-for-matomo') ); $links['admivico_settings'] = $settings_link; return $links; } /** * @param array $links * @return array */ function open_author_link_in_new_tab(array $links, string $file): array { if ($file !== plugin_basename(__FILE__)) { return $links; } foreach ($links as $key => $link) { if (strpos($link, 'https://www.pushaune.com/') === false) { continue; } $links[$key] = str_replace( ', use_old_slugs: bool} */ function get_plugin_options(): array { $defaults = [ 'post_types' => ['post', 'page'], 'period' => 'last30', 'metric' => 'nb_hits', 'cache_ttl' => 3600, 'source' => 'local', 'remote_url' => '', 'remote_site_id' => 1, 'remote_token' => '', 'redirect_rules' => [], 'use_old_slugs' => true, ]; $options = get_option(OPTION_NAME, []); if (!is_array($options)) { return $defaults; } $options = array_merge($defaults, $options); $options['post_types'] = array_values(array_filter(array_map('sanitize_key', (array) $options['post_types']))); $options['period'] = sanitize_key((string) $options['period']); $options['metric'] = sanitize_key((string) $options['metric']); $options['cache_ttl'] = max(300, absint($options['cache_ttl'])); $options['source'] = $options['source'] === 'remote' ? 'remote' : 'local'; $options['remote_url'] = esc_url_raw(trim((string) $options['remote_url'])); $options['remote_site_id'] = absint($options['remote_site_id']); $options['remote_token'] = trim((string) $options['remote_token']); $options['redirect_rules'] = sanitize_redirect_rules($options['redirect_rules']); $options['use_old_slugs'] = !empty($options['use_old_slugs']); return $options; } /** * @param mixed $rules * @return array */ function sanitize_redirect_rules($rules): array { if (!is_array($rules)) { return []; } $clean = []; $seen = []; foreach ($rules as $rule) { if (!is_array($rule)) { continue; } $from = isset($rule['from']) && is_scalar($rule['from']) ? trim(sanitize_text_field((string) $rule['from'])) : ''; $to = isset($rule['to']) && is_scalar($rule['to']) ? trim(sanitize_text_field((string) $rule['to'])) : ''; if ($from === '' || $to === '') { continue; } $key = $from . "\n" . $to; if (isset($seen[$key])) { continue; } $seen[$key] = true; $clean[] = ['from' => $from, 'to' => $to]; } return $clean; } /** * Cache key component identifying where the stats come from, so switching * source settings never serves stale data from another Matomo instance. * * @param array{source: string, remote_url: string, remote_site_id: int} $options * @return array{source: string, remote_url: string, remote_site_id: int} */ function get_source_fingerprint(array $options): array { return [ 'source' => $options['source'], 'remote_url' => $options['source'] === 'remote' ? $options['remote_url'] : '', 'remote_site_id' => $options['source'] === 'remote' ? $options['remote_site_id'] : 0, ]; } function register_admin_columns(): void { if (!is_admin()) { return; } add_filter('manage_posts_columns', __NAMESPACE__ . '\\add_views_column'); add_filter('manage_pages_columns', __NAMESPACE__ . '\\add_views_column'); add_filter('manage_edit-post_sortable_columns', __NAMESPACE__ . '\\add_sortable_views_column'); add_filter('manage_edit-page_sortable_columns', __NAMESPACE__ . '\\add_sortable_views_column'); add_action('manage_posts_custom_column', __NAMESPACE__ . '\\render_views_column', 10, 2); add_action('manage_pages_custom_column', __NAMESPACE__ . '\\render_views_column', 10, 2); foreach (get_column_post_types() as $post_type) { add_filter("manage_{$post_type}_posts_columns", __NAMESPACE__ . '\\add_views_column'); add_filter("manage_edit-{$post_type}_sortable_columns", __NAMESPACE__ . '\\add_sortable_views_column'); add_action("manage_{$post_type}_posts_custom_column", __NAMESPACE__ . '\\render_views_column', 10, 2); } } function register_current_screen_columns(\WP_Screen $screen): void { if ($screen->base !== 'edit' || empty($screen->post_type)) { return; } $options = get_plugin_options(); if (!in_array($screen->post_type, $options['post_types'], true)) { return; } add_filter("manage_{$screen->post_type}_posts_columns", __NAMESPACE__ . '\\add_views_column'); add_filter("manage_edit-{$screen->post_type}_sortable_columns", __NAMESPACE__ . '\\add_sortable_views_column'); add_action("manage_{$screen->post_type}_posts_custom_column", __NAMESPACE__ . '\\render_views_column', 10, 2); } /** * Period selector added to the Screen Options panel of the admin lists, so the * period can be changed per list and per user without touching the site-wide * setting. */ function render_screen_period_field(string $settings, \WP_Screen $screen): string { if ($screen->base !== 'edit' || empty($screen->post_type)) { return $settings; } $options = get_plugin_options(); if (!in_array($screen->post_type, $options['post_types'], true)) { return $settings; } // The Apply button is what submits this field. The posts list already asks // for it through its per-page option, but don't depend on that. add_filter('screen_options_show_submit', '__return_true'); $selected = get_list_period((string) $screen->post_type); $html = '
'; $html .= '' . esc_html__('Matomo Views', 'admin-views-column-for-matomo') . ''; $html .= ' '; $html .= ''; $html .= ''; $html .= '
'; return $settings . $html; } /** * Saves the period picked in the Screen Options panel. * * Hooked on `init` on purpose: wp-admin/admin.php runs set_screen_options() * before firing `admin_init`, and the core handler redirects as soon as it has * saved the other screen options, which would drop this field on the way. Late * in `init` so that custom post types registered there are already known, and * still well before set_screen_options(), which only runs once `init` is over. */ function save_screen_period(): void { if (!is_admin() || wp_doing_ajax()) { return; } if (!isset($_POST['admivico_period'], $_POST['admivico_post_type'])) { return; } // Same nonce as the core screen options form this field is rendered in. check_admin_referer('screen-options-nonce', 'screenoptionnonce'); $user_id = get_current_user_id(); if ($user_id > 0 && current_user_can('edit_posts')) { $period = is_scalar($_POST['admivico_period']) ? sanitize_key((string) wp_unslash($_POST['admivico_period'])) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by sanitize_key(). : ''; $post_type = is_scalar($_POST['admivico_post_type']) ? sanitize_key((string) wp_unslash($_POST['admivico_post_type'])) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by sanitize_key(). : ''; if (array_key_exists($period, get_period_choices()) && in_array($post_type, get_supported_post_types(), true)) { $stored = get_user_meta($user_id, LIST_PERIODS_META, true); $stored = is_array($stored) ? $stored : []; $stored[$post_type] = $period; update_user_meta($user_id, LIST_PERIODS_META, $stored); } } // The core handler redirects once it has saved the other screen options; // it only runs when they were submitted too, so cover the case where the // list offers nothing else to apply. if (!isset($_POST['wp_screen_options'])) { wp_safe_redirect(wp_get_referer() ?: admin_url('edit.php')); exit; } } /** * @return string[] */ function get_supported_post_types(): array { $objects = get_post_types([], 'objects'); $post_types = []; foreach ($objects as $post_type => $object) { if ($post_type === 'attachment') { continue; } if (!empty($object->show_ui)) { $post_types[] = $post_type; } } return array_values($post_types); } /** * @return string[] */ function get_column_post_types(): array { $options = get_plugin_options(); return array_values(array_unique(array_merge(get_supported_post_types(), $options['post_types']))); } /** * @param array $columns * @return array */ function add_views_column(array $columns): array { $options = get_plugin_options(); $screen = get_current_screen(); if (!$screen || !in_array($screen->post_type, $options['post_types'], true)) { return $columns; } if (array_key_exists(COLUMN_KEY, $columns)) { return $columns; } $title = column_label((string) $screen->post_type); $new_columns = []; foreach ($columns as $key => $label) { if ($key === 'date') { $new_columns[COLUMN_KEY] = $title; } $new_columns[$key] = $label; } if (!array_key_exists(COLUMN_KEY, $new_columns)) { $new_columns[COLUMN_KEY] = $title; } return $new_columns; } /** * Column header, naming the period it reports on: the period is a per-user * screen option, so the same list can show different numbers to two people. */ function column_label(string $post_type): string { $choices = get_period_short_choices(); $period = get_list_period($post_type); if (!isset($choices[$period])) { return __('Matomo Views', 'admin-views-column-for-matomo'); } return sprintf( /* translators: %s: short reporting period, for example "30d". */ __('Matomo Views (%s)', 'admin-views-column-for-matomo'), $choices[$period] ); } /** * @param array $columns * @return array */ function add_sortable_views_column(array $columns): array { $options = get_plugin_options(); $screen = get_current_screen(); if (!$screen || !in_array($screen->post_type, $options['post_types'], true)) { return $columns; } $columns[COLUMN_KEY] = COLUMN_KEY; return $columns; } function prepare_matomo_views_sorting(\WP_Query $query): void { if (!is_admin() || !$query->is_main_query()) { return; } $orderby = $query->get('orderby'); if ($orderby !== COLUMN_KEY) { return; } $post_type = get_query_post_type($query); $options = get_plugin_options(); if ($post_type === '' || !in_array($post_type, $options['post_types'], true)) { return; } sync_post_type_views_meta($post_type, get_list_period($post_type)); $query->set('meta_key', META_KEY); $query->set('orderby', 'meta_value_num'); $query->set('order', strtoupper((string) $query->get('order')) === 'ASC' ? 'ASC' : 'DESC'); } function get_query_post_type(\WP_Query $query): string { $post_type = $query->get('post_type'); if (is_string($post_type) && $post_type !== '') { return $post_type; } if (is_array($post_type) && count($post_type) === 1 && is_string(reset($post_type))) { return (string) reset($post_type); } return 'post'; } function sync_post_type_views_meta(string $post_type, string $period): void { $options = get_plugin_options(); // Keyed by post type only, and holding the fingerprint of what was written: // the meta is shared by everyone, so when another user has just rebuilt it // for their own period the fingerprints differ and it is rebuilt again, // instead of being sorted on someone else's numbers. $sync_key = CACHE_PREFIX . 'synced_' . md5($post_type); $fingerprint = md5((string) wp_json_encode([ 'post_type' => $post_type, 'period' => $period, 'metric' => $options['metric'], 'home' => home_url('/'), 'src' => get_source_fingerprint($options), 'history' => [$options['redirect_rules'], $options['use_old_slugs']], ])); if (get_transient($sync_key) === $fingerprint) { return; } $stats = get_matomo_url_stats($period); if ($stats === null) { return; } $post_ids = get_posts([ 'post_type' => $post_type, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ]); foreach ($post_ids as $post_id) { $value = get_post_views_from_stats((int) $post_id, $stats, $period); update_post_meta((int) $post_id, META_KEY, (int) $value); } set_plugin_transient($sync_key, $fingerprint, $options['cache_ttl']); } function render_views_column(string $column, int $post_id): void { if ($column !== COLUMN_KEY) { return; } static $rendered = []; $render_key = $post_id . ':' . $column; if (isset($rendered[$render_key])) { return; } $rendered[$render_key] = true; $period = get_list_period((string) get_post_type($post_id)); $value = get_post_views($post_id, $period, false); if ($value === null) { printf( '…', (int) $post_id, esc_attr__('Loading Matomo statistics…', 'admin-views-column-for-matomo') ); return; } echo views_column_html($post_id, $value, $period); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped in views_column_html(). } /** * Escaped HTML for a views value in the admin column. */ function views_column_html(int $post_id, ?int $value, string $period): string { if ($value === null) { return '—'; } $number = esc_html(number_format_i18n($value)); $report_url = get_page_report_url($post_id, $period); if ($report_url === '') { return $number; } return sprintf( '
%s', esc_url($report_url), esc_attr__('Open the Matomo report for this page', 'admin-views-column-for-matomo'), $number ); } /** * URL of the Matomo Pages report narrowed down to a single post, or an empty * string when no report URL can be built. */ function get_page_report_url(int $post_id, string $period): string { return get_matomo_report_url($period, page_segment($post_id), $post_id); } /** * Matomo segment isolating one post: the exact page URLs whose statistics the * displayed count adds up, so the report and the column agree. */ function page_segment(int $post_id): string { $url = get_permalink($post_id); if (!$url) { return ''; } $urls = get_url_api_candidates($url); foreach (get_historical_urls($post_id, $url) as $old_url) { $urls = array_merge($urls, get_url_api_candidates($old_url)); } $conditions = []; foreach ($urls as $candidate) { // Matomo indexes page URLs as absolute URLs: a path alone never matches. if (strpos($candidate, 'http://') !== 0 && strpos($candidate, 'https://') !== 0) { continue; } // Encoded here, then once more when the segment is added to the report // URL: Matomo expects the value of a condition to be double-encoded. $conditions['pageUrl==' . rawurlencode($candidate)] = true; if (count($conditions) >= SEGMENT_URL_LIMIT) { break; } } // A comma is the OR operator of the Matomo segment syntax. return implode(',', array_keys($conditions)); } /** * URL of the Actions > Pages report in Matomo for the given period and source, * optionally narrowed down by a segment, or an empty string when it cannot be * built. */ function get_matomo_report_url(string $period = '', string $segment = '', int $post_id = 0): string { $options = get_plugin_options(); $range = matomo_period_params($period !== '' ? $period : $options['period']); if ($options['source'] === 'remote') { if ($options['remote_url'] === '' || $options['remote_site_id'] < 1) { return ''; } $site_id = $options['remote_site_id']; $query = [ 'module' => 'CoreHome', 'action' => 'index', 'idSite' => $site_id, 'period' => $range['period'], 'date' => $range['date'], ]; // Matomo carries the segment in the query string as well as in the // hash, the way its own interface builds the URL. if ($segment !== '') { $query['segment'] = $segment; } $base = add_query_arg($query, trailingslashit($options['remote_url']) . 'index.php'); } else { if (!class_exists('\WpMatomo') && !defined('MATOMO_PLUGIN_FILE')) { return ''; } // Matomo for WordPress always reports the site as idSite 1, and reads // everything else from the hash. $site_id = 1; $base = add_query_arg('page', 'matomo-reporting', admin_url('admin.php')); } $hash_args = [ 'idSite' => $site_id, 'period' => $range['period'], 'date' => $range['date'], 'category' => 'General_Actions', 'subcategory' => 'General_Pages', ]; if ($segment !== '') { $hash_args['segment'] = $segment; // Flat view: the page shows up as one row bearing its full URL, // instead of being buried in the folder hierarchy. $hash_args['flat'] = '1'; } $hash = http_build_query($hash_args, '', '&'); /** * Filters the URL of the Matomo report opened from the views column and dashboard widget. * * @param string $url * @param array $options * @param int $post_id Post the report is about, 0 for the general report. * @param string $segment Matomo segment applied to the report, empty when none. */ return (string) apply_filters('admivico_report_url', $base . '#?' . $hash, $options, $post_id, $segment); } /** * @param bool $allow_fetch Whether the Matomo API may be queried on a cache miss. * @return int|null Null when the stats are unavailable, or not cached yet and $allow_fetch is false. */ function get_post_views(int $post_id, string $period, bool $allow_fetch = true): ?int { $stats = get_matomo_url_stats($period, $allow_fetch); if ($stats !== null) { return get_post_views_from_stats($post_id, $stats, $period, $allow_fetch); } return null; } /** * @param array $stats * @param bool $allow_fetch Whether the Matomo API may be queried for URLs missing from $stats. * @return int|null Null when a value is not cached yet and $allow_fetch is false. */ function get_post_views_from_stats(int $post_id, array $stats, string $period, bool $allow_fetch = true): ?int { $url = get_permalink($post_id); if (!$url) { return 0; } $current = null; $counted_keys = []; foreach (get_url_lookup_keys($url) as $key) { $counted_keys[$key] = true; if ($current === null && isset($stats[$key])) { $current = $stats[$key]; } } if ($current === null) { $single = get_single_url_views($url, $period, $allow_fetch); if ($single === false) { return null; } $current = $single; } $historical = 0; foreach (get_historical_urls($post_id, $url) as $old_url) { $old_keys = []; $overlaps = false; foreach (get_url_lookup_keys($old_url) as $key) { if (isset($counted_keys[$key])) { $overlaps = true; } $old_keys[] = $key; } if ($overlaps) { continue; } foreach ($old_keys as $key) { $counted_keys[$key] = true; if (isset($stats[$key])) { $historical += $stats[$key]; break; } } } return (int) $current + $historical; } /** * URLs that previously pointed to this post (old slugs, 301 redirect rules) * and whose Matomo statistics should be added to the post count. * * @return string[] */ function get_historical_urls(int $post_id, string $current_url): array { $options = get_plugin_options(); $urls = []; if ($options['use_old_slugs']) { $post = get_post($post_id); if ($post && $post->post_name !== '') { foreach (get_post_meta($post_id, '_wp_old_slug', false) as $old_slug) { if (!is_string($old_slug) || $old_slug === '' || $old_slug === $post->post_name) { continue; } $urls[] = str_replace( '/' . $post->post_name . '/', '/' . $old_slug . '/', trailingslashit($current_url) ); } } } foreach ($options['redirect_rules'] as $rule) { $old_url = reverse_redirect_rule($current_url, $rule['from'], $rule['to']); if ($old_url !== null) { $urls[] = $old_url; } } /** * Filters the historical URLs whose Matomo statistics are added to a post's count. * * @param string[] $urls * @param int $post_id * @param string $current_url */ $urls = apply_filters('admivico_url_candidates', $urls, $post_id, $current_url); return array_values(array_unique(array_filter(array_map('strval', (array) $urls)))); } /** * Rebuild the old URL of a post from an "old structure => new structure" * redirect rule, by matching the rule backwards against the current * permalink (full URL or path). "*" captures the unchanged part of the path. */ function reverse_redirect_rule(string $current_url, string $from, string $to): ?string { $targets = [$current_url]; $path = wp_parse_url($current_url, PHP_URL_PATH); if (is_string($path) && $path !== '' && $path !== $current_url) { $targets[] = $path; } $star_pos = strpos($to, '*'); foreach ($targets as $target) { if ($star_pos === false) { if (untrailingslashit($target) === untrailingslashit($to)) { return $from; } continue; } $prefix = substr($to, 0, $star_pos); $suffix = substr($to, $star_pos + 1); if (strlen($target) < strlen($prefix) + strlen($suffix)) { continue; } if ($prefix !== '' && strpos($target, $prefix) !== 0) { continue; } if ($suffix !== '' && substr($target, -strlen($suffix)) !== $suffix) { continue; } $capture = substr($target, strlen($prefix), strlen($target) - strlen($prefix) - strlen($suffix)); return str_replace('*', (string) $capture, $from); } return null; } /** * @param bool $allow_fetch Whether the Matomo API may be queried on a cache miss. * @return array|null Null when unavailable, or not cached yet and $allow_fetch is false. */ function get_matomo_url_stats(string $period, bool $allow_fetch = true): ?array { // Indexed by period: a single request can now render a list and a widget // reporting on two different ones. static $stats = []; static $loaded = []; if (isset($loaded[$period])) { return $stats[$period]; } $result = get_url_stats_for_period($period, $allow_fetch); if ($result !== null) { $stats[$period] = $result; $loaded[$period] = true; } elseif ($allow_fetch) { // The fetch was attempted and failed: don't retry within this request. $stats[$period] = null; $loaded[$period] = true; } return $result; } /** * @param bool $allow_fetch Whether the Matomo API may be queried on a cache miss. * @return array|null Null when unavailable, or not cached yet and $allow_fetch is false. */ function get_url_stats_for_period(string $period, bool $allow_fetch = true): ?array { $options = get_plugin_options(); $cache_key = CACHE_PREFIX . md5(wp_json_encode([ 'period' => $period, 'metric' => $options['metric'], 'home' => home_url('/'), 'src' => get_source_fingerprint($options), ])); $cached = get_transient($cache_key); if (is_array($cached)) { return $cached; } if (!$allow_fetch) { return null; } $rows = request_matomo_rows($period); if ($rows === null) { return null; } $map = build_url_stats_map($rows, $options['metric']); set_plugin_transient($cache_key, $map, $options['cache_ttl']); return $map; } /** * @param bool $allow_fetch Whether the Matomo API may be queried on a cache miss. * @return int|null|false Views count, null for a known miss, false when not cached yet and $allow_fetch is false. */ function get_single_url_views(string $url, string $period, bool $allow_fetch = true) { $options = get_plugin_options(); $cache_key = CACHE_PREFIX . 'single_' . md5((string) wp_json_encode([ 'period' => $period, 'metric' => $options['metric'], 'url' => $url, 'src' => get_source_fingerprint($options), ])); $cached = get_transient($cache_key); if ($cached !== false) { return is_numeric($cached) ? absint($cached) : null; } if (!$allow_fetch) { return false; } $best_value = null; foreach (get_url_api_candidates($url) as $candidate_url) { $rows = request_matomo_rows($period, 'getPageUrl', ['pageUrl' => $candidate_url]); if ($rows === null) { continue; } $value = sum_metric_from_rows($rows, $options['metric']); $best_value = max($best_value ?? 0, $value); } if ($best_value === null) { set_plugin_transient($cache_key, 'miss', min($options['cache_ttl'], 900)); return null; } set_plugin_transient($cache_key, $best_value, $options['cache_ttl']); return $best_value; } /** * @param array $rows * @return array */ function build_url_stats_map(array $rows, string $metric): array { $map = []; foreach (flatten_matomo_rows($rows) as $row) { $row_urls = get_row_url_candidates($row); if (!$row_urls) { continue; } $value = isset($row[$metric]) ? absint($row[$metric]) : 0; foreach ($row_urls as $row_url) { foreach (get_url_lookup_keys($row_url) as $key) { $map[$key] = ($map[$key] ?? 0) + $value; } } } return $map; } /** * @param array $rows */ function sum_metric_from_rows(array $rows, string $metric): int { $total = 0; foreach (flatten_matomo_rows($rows) as $row) { $total += isset($row[$metric]) ? absint($row[$metric]) : 0; } return $total; } /** * @param array $rows * @return array> */ function flatten_matomo_rows(array $rows): array { $flat = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $flat[] = $row; foreach (['subtable', 'idsubdatatable', 'children'] as $child_key) { if (isset($row[$child_key]) && is_array($row[$child_key])) { $flat = array_merge($flat, flatten_matomo_rows($row[$child_key])); } } } return $flat; } /** * @param array $row * @return string[] */ function get_row_url_candidates(array $row): array { $candidates = []; foreach (['url', 'label', 'segment', 'idsubdatatable_in_db'] as $key) { if (isset($row[$key]) && is_scalar($row[$key])) { $value = trim((string) $row[$key]); if ($value !== '' && looks_like_page_url($value)) { $candidates[] = $value; } } } return array_values(array_unique($candidates)); } function looks_like_page_url(string $value): bool { return strpos($value, 'http://') === 0 || strpos($value, 'https://') === 0 || strpos($value, '/') === 0; } /** * @param array $extra_params * @return array|null */ function request_matomo_rows(string $period, string $api_action = 'getPageUrls', array $extra_params = []): ?array { $options = get_plugin_options(); if ($options['source'] === 'remote') { return request_remote_matomo_rows($period, $api_action, $extra_params, $options); } if (!class_exists('\WP_REST_Request')) { return null; } $request = new \WP_REST_Request('GET', '/matomo/v1/api/processed_report'); $range = matomo_period_params($period); $request->set_query_params(array_merge([ 'period' => $range['period'], 'date' => $range['date'], 'apiModule' => 'Actions', 'apiAction' => $api_action, 'flat' => '1', 'expanded' => '1', 'filter_limit' => '-1', ], $extra_params)); $response = rest_do_request($request); if ($response->is_error()) { return null; } $data = $response->get_data(); if (isset($data['reportData']) && is_array($data['reportData'])) { return $data['reportData']; } if (isset($data['data']) && is_array($data['data'])) { return $data['data']; } if (array_is_list_compat($data)) { return $data; } return null; } /** * Query the Reporting API of a remote Matomo instance over HTTP. * * The token is sent in the POST body so it never appears in server logs. * * @param array $extra_params * @param array{period: string, remote_url: string, remote_site_id: int, remote_token: string} $options * @return array|null */ function request_remote_matomo_rows(string $period, string $api_action, array $extra_params, array $options): ?array { if ($options['remote_url'] === '' || $options['remote_site_id'] < 1 || $options['remote_token'] === '') { return null; } $range = matomo_period_params($period); $response = wp_remote_post( trailingslashit($options['remote_url']) . 'index.php', [ 'timeout' => 15, 'body' => array_merge([ 'module' => 'API', 'method' => 'Actions.' . $api_action, 'idSite' => (string) $options['remote_site_id'], 'period' => $range['period'], 'date' => $range['date'], 'format' => 'JSON', 'flat' => '1', 'expanded' => '1', 'filter_limit' => '-1', 'token_auth' => $options['remote_token'], ], $extra_params), ] ); if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($data)) { return null; } if (isset($data['result']) && $data['result'] === 'error') { return null; } return array_is_list_compat($data) ? $data : null; } /** * @return string[] */ function get_url_api_candidates(string $url): array { $candidates = [$url, rtrim($url, '/')]; $parsed = wp_parse_url($url); $path = isset($parsed['path']) ? '/' . ltrim((string) $parsed['path'], '/') : ''; if ($path !== '') { $candidates[] = $path; $candidates[] = rtrim($path, '/'); $candidates[] = home_url($path); $candidates[] = rtrim(home_url($path), '/'); } return array_values(array_unique(array_filter($candidates))); } /** * @return array{period: string, date: string} */ function matomo_period_params(string $period): array { switch ($period) { case 'today': return ['period' => 'day', 'date' => 'today']; case 'yesterday': return ['period' => 'day', 'date' => 'yesterday']; case 'last7': return ['period' => 'range', 'date' => 'last7']; case 'last90': return ['period' => 'range', 'date' => 'last90']; case 'year': return ['period' => 'year', 'date' => 'today']; case 'last365': return ['period' => 'range', 'date' => 'last365']; case 'all': return ['period' => 'range', 'date' => '2000-01-01,today']; case 'last30': default: return ['period' => 'range', 'date' => 'last30']; } } /** * @return string[] */ function get_url_lookup_keys(string $url): array { $url = html_entity_decode($url, ENT_QUOTES); $parsed = wp_parse_url($url); $path = isset($parsed['path']) ? (string) $parsed['path'] : ''; if ($path === '' && strpos($url, '/') === 0) { $path = $url; } $host = isset($parsed['host']) ? strtolower((string) $parsed['host']) : ''; $scheme = isset($parsed['scheme']) ? strtolower((string) $parsed['scheme']) : ''; $home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $path = '/' . ltrim($path, '/'); $path_no_slash = rtrim($path, '/') ?: '/'; $keys = [ normalize_lookup_key($path), normalize_lookup_key($path_no_slash), ]; if ($host !== '') { $authority = $host; if (isset($parsed['port'])) { $authority .= ':' . (string) $parsed['port']; } $full = ($scheme ?: 'https') . '://' . $authority . $path; $keys[] = normalize_lookup_key($full); $keys[] = normalize_lookup_key(rtrim($full, '/')); } if ($home_host !== '' && $host === '') { $full = home_url($path); $keys[] = normalize_lookup_key($full); $keys[] = normalize_lookup_key(rtrim($full, '/')); } return array_values(array_unique(array_filter($keys))); } function normalize_lookup_key(string $value): string { $value = strtolower(rawurldecode($value)); $value = strtok($value, '?') ?: $value; $value = strtok($value, '#') ?: $value; return rtrim($value, '/') ?: '/'; } /** * @param mixed $value */ function array_is_list_compat($value): bool { if (!is_array($value)) { return false; } $index = 0; foreach (array_keys($value) as $key) { if ($key !== $index++) { return false; } } return true; } function register_settings_page(): void { add_options_page( __('Matomo Columns', 'admin-views-column-for-matomo'), __('Matomo Columns', 'admin-views-column-for-matomo'), 'manage_options', 'admivico', __NAMESPACE__ . '\\render_settings_page' ); } function enqueue_settings_assets(string $hook_suffix): void { if ($hook_suffix !== 'settings_page_admivico') { return; } wp_enqueue_style( 'admivico-settings', plugins_url('css/admin.css', __FILE__), [], VERSION ); wp_enqueue_script( 'admivico-settings', plugins_url('js/admin.js', __FILE__), [], VERSION, true ); wp_localize_script( 'admivico-settings', 'admivicoSettings', [ 'optionName' => OPTION_NAME, 'removeLabel' => __('Remove rule', 'admin-views-column-for-matomo'), /* translators: %d: number of redirect rules imported from the CSV file. */ 'csvAdded' => __('%d rules added from the CSV file. Remember to save your changes.', 'admin-views-column-for-matomo'), 'csvEmpty' => __('No valid rules found in the file. Expected two columns: old URL, new URL.', 'admin-views-column-for-matomo'), 'ajaxUrl' => admin_url('admin-ajax.php'), 'testNonce' => wp_create_nonce('admivico_test_connection'), 'testing' => __('Testing connection…', 'admin-views-column-for-matomo'), 'testFailed' => __('The connection test failed. Please try again.', 'admin-views-column-for-matomo'), ] ); } function enqueue_list_assets(string $hook_suffix): void { if ($hook_suffix !== 'edit.php') { return; } $screen = get_current_screen(); $options = get_plugin_options(); if (!$screen || !in_array($screen->post_type, $options['post_types'], true)) { return; } wp_enqueue_style( 'admivico-settings', plugins_url('css/admin.css', __FILE__), [], VERSION ); wp_enqueue_script( 'admivico-column', plugins_url('js/column.js', __FILE__), [], VERSION, true ); wp_localize_script( 'admivico-column', 'admivicoColumn', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('admivico_column_views'), ] ); } function enqueue_dashboard_assets(string $hook_suffix): void { if ($hook_suffix !== 'index.php' || !current_user_can('edit_posts')) { return; } wp_enqueue_style( 'admivico-settings', plugins_url('css/admin.css', __FILE__), [], VERSION ); wp_enqueue_script( 'admivico-dashboard', plugins_url('js/dashboard.js', __FILE__), [], VERSION, true ); wp_localize_script( 'admivico-dashboard', 'admivicoDashboard', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('admivico_dashboard'), 'error' => __('Unable to load the Matomo statistics.', 'admin-views-column-for-matomo'), 'loading' => __('Loading Matomo statistics…', 'admin-views-column-for-matomo'), ] ); } function register_settings(): void { register_setting( 'admivico_settings', OPTION_NAME, [ 'type' => 'array', 'sanitize_callback' => __NAMESPACE__ . '\\sanitize_options', 'default' => [ 'post_types' => ['post', 'page'], 'period' => 'last30', 'metric' => 'nb_hits', 'cache_ttl' => 3600, 'source' => 'local', 'remote_url' => '', 'remote_site_id' => 1, 'remote_token' => '', 'redirect_rules' => [], 'use_old_slugs' => true, ], 'show_in_rest' => false, ] ); } /** * @param mixed $input * @return array{post_types: string[], period: string, metric: string, cache_ttl: int, source: string, remote_url: string, remote_site_id: int, remote_token: string, redirect_rules: array, use_old_slugs: bool} */ function sanitize_options($input): array { $input = is_array($input) ? $input : []; $defaults = [ 'post_types' => ['post', 'page'], 'period' => 'last30', 'metric' => 'nb_hits', 'cache_ttl' => 3600, 'source' => 'local', 'remote_url' => '', 'remote_site_id' => 1, 'remote_token' => '', 'redirect_rules' => [], 'use_old_slugs' => true, ]; $supported_post_types = get_supported_post_types(); $requested_post_types = []; if (isset($input['post_types']) && is_array($input['post_types'])) { foreach ($input['post_types'] as $post_type) { if (!is_scalar($post_type)) { continue; } $requested_post_types[] = sanitize_key((string) $post_type); } } $post_types = array_values(array_intersect($requested_post_types, $supported_post_types)); $periods = array_keys(get_period_choices()); $metrics = array_keys(get_metric_choices()); $period = isset($input['period']) && is_scalar($input['period']) ? sanitize_key((string) $input['period']) : $defaults['period']; $metric = isset($input['metric']) && is_scalar($input['metric']) ? sanitize_key((string) $input['metric']) : $defaults['metric']; $cache_ttl = isset($input['cache_ttl']) && is_scalar($input['cache_ttl']) ? absint($input['cache_ttl']) : $defaults['cache_ttl']; $source = isset($input['source']) && $input['source'] === 'remote' ? 'remote' : 'local'; $remote_url = isset($input['remote_url']) && is_scalar($input['remote_url']) ? esc_url_raw(trim((string) $input['remote_url'])) : ''; $remote_site_id = isset($input['remote_site_id']) && is_scalar($input['remote_site_id']) ? absint($input['remote_site_id']) : $defaults['remote_site_id']; $remote_token = isset($input['remote_token']) && is_scalar($input['remote_token']) ? trim(sanitize_text_field((string) $input['remote_token'])) : ''; $redirect_rules = sanitize_redirect_rules($input['redirect_rules'] ?? null); $use_old_slugs = !empty($input['use_old_slugs']); return [ 'post_types' => $post_types ?: $defaults['post_types'], 'period' => in_array($period, $periods, true) ? $period : $defaults['period'], 'metric' => in_array($metric, $metrics, true) ? $metric : $defaults['metric'], 'cache_ttl' => max(300, $cache_ttl), 'source' => $source, 'remote_url' => $remote_url, 'remote_site_id' => max(1, $remote_site_id), 'remote_token' => $remote_token, 'redirect_rules' => $redirect_rules, 'use_old_slugs' => $use_old_slugs, ]; } /** * @return array */ function get_period_choices(): array { return [ 'today' => __('Today', 'admin-views-column-for-matomo'), 'yesterday' => __('Yesterday', 'admin-views-column-for-matomo'), 'last7' => __('Last 7 days', 'admin-views-column-for-matomo'), 'last30' => __('Last 30 days', 'admin-views-column-for-matomo'), 'last90' => __('Last 90 days', 'admin-views-column-for-matomo'), 'last365' => __('Last 365 days', 'admin-views-column-for-matomo'), 'year' => __('Current year', 'admin-views-column-for-matomo'), 'all' => __('All time', 'admin-views-column-for-matomo'), ]; } /** * Short period labels, for the column header only: the full wording stretches * the column, while the settings page and the screen options keep it. * * @return array */ function get_period_short_choices(): array { return [ 'today' => _x('today', 'short period label', 'admin-views-column-for-matomo'), 'yesterday' => _x('yesterday', 'short period label', 'admin-views-column-for-matomo'), 'last7' => _x('7d', 'short period label', 'admin-views-column-for-matomo'), 'last30' => _x('30d', 'short period label', 'admin-views-column-for-matomo'), 'last90' => _x('90d', 'short period label', 'admin-views-column-for-matomo'), 'last365' => _x('365d', 'short period label', 'admin-views-column-for-matomo'), 'year' => _x('year', 'short period label', 'admin-views-column-for-matomo'), 'all' => _x('all', 'short period label', 'admin-views-column-for-matomo'), ]; } /** * @return array */ function get_metric_choices(): array { return [ 'nb_hits' => __('Page views', 'admin-views-column-for-matomo'), 'nb_visits' => __('Unique views', 'admin-views-column-for-matomo'), ]; } function render_settings_page(): void { if (!current_user_can('manage_options')) { return; } $options = get_plugin_options(); $post_types = get_supported_post_types(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only confirmation flag set by clear_cache_action(). $cache_cleared = !empty($_GET['admivico_cache_cleared']); ?>

> > >

post_type, $options['post_types'], true)) { continue; } $period = get_list_period($post->post_type); $views[$post_id] = views_column_html($post_id, get_post_views($post_id, $period), $period); } wp_send_json_success(['views' => $views]); } function ajax_test_connection(): void { check_ajax_referer('admivico_test_connection'); if (!current_user_can('manage_options')) { wp_send_json_error(['message' => __('Permission denied.', 'admin-views-column-for-matomo')], 403); } $source = isset($_POST['source']) && $_POST['source'] === 'remote' ? 'remote' : 'local'; if ($source === 'remote') { $url = isset($_POST['remote_url']) ? esc_url_raw(wp_unslash($_POST['remote_url'])) : ''; $site_id = isset($_POST['remote_site_id']) ? absint(wp_unslash($_POST['remote_site_id'])) : 0; $token = isset($_POST['remote_token']) ? trim(sanitize_text_field(wp_unslash($_POST['remote_token']))) : ''; $result = test_remote_connection($url, $site_id, $token); } else { $result = test_local_connection(); } if (is_wp_error($result)) { wp_send_json_error(['message' => $result->get_error_message()]); } wp_send_json_success(['message' => $result]); } /** * @return string|\WP_Error Success message, or the error explaining what failed. */ function test_local_connection() { if (!class_exists('\WP_REST_Request')) { return new \WP_Error('admivico_no_rest', __('The WordPress REST API is not available on this site.', 'admin-views-column-for-matomo')); } $request = new \WP_REST_Request('GET', '/matomo/v1/api/processed_report'); $request->set_query_params([ 'period' => 'day', 'date' => 'yesterday', 'apiModule' => 'Actions', 'apiAction' => 'getPageUrls', 'filter_limit' => '1', ]); $response = rest_do_request($request); if ($response->is_error()) { $error = $response->as_error(); return new \WP_Error('admivico_local_error', sprintf( /* translators: %s: error message returned by the Matomo for WordPress plugin. */ __('Matomo for WordPress responded with an error: %s. Is the Matomo Analytics plugin installed and active?', 'admin-views-column-for-matomo'), $error ? $error->get_error_message() : __('unknown error', 'admin-views-column-for-matomo') )); } return __('Success! Matomo for WordPress is active and responding.', 'admin-views-column-for-matomo'); } /** * @return string|\WP_Error Success message, or the error explaining what failed. */ function test_remote_connection(string $url, int $site_id, string $token) { if ($url === '' || $site_id < 1 || $token === '') { return new \WP_Error('admivico_missing_fields', __('Please fill in the Matomo URL, the site ID and the auth token first.', 'admin-views-column-for-matomo')); } $response = wp_remote_post( trailingslashit($url) . 'index.php', [ 'timeout' => 15, 'body' => [ 'module' => 'API', 'method' => 'Actions.getPageUrls', 'idSite' => (string) $site_id, 'period' => 'day', 'date' => 'yesterday', 'format' => 'JSON', 'filter_limit' => '1', 'token_auth' => $token, ], ] ); if (is_wp_error($response)) { return new \WP_Error('admivico_http_error', sprintf( /* translators: %s: HTTP error message. */ __('Could not reach the Matomo server: %s', 'admin-views-column-for-matomo'), $response->get_error_message() )); } $code = wp_remote_retrieve_response_code($response); $data = json_decode(wp_remote_retrieve_body($response), true); if (is_array($data) && isset($data['result']) && $data['result'] === 'error') { return new \WP_Error('admivico_api_error', sprintf( /* translators: %s: error message returned by the Matomo API. */ __('Matomo responded with an error: %s', 'admin-views-column-for-matomo'), isset($data['message']) ? sanitize_text_field((string) $data['message']) : __('unknown error', 'admin-views-column-for-matomo') )); } if ($code !== 200 || !is_array($data)) { return new \WP_Error('admivico_bad_response', sprintf( /* translators: %d: HTTP status code. */ __('Unexpected response from the Matomo server (HTTP %d). Check the URL.', 'admin-views-column-for-matomo'), $code )); } return __('Success! Matomo responded correctly for this site ID and token.', 'admin-views-column-for-matomo'); } function register_dashboard_widget(): void { if (!current_user_can('edit_posts')) { return; } wp_add_dashboard_widget( 'admivico_top_content', __('Matomo — Top content', 'admin-views-column-for-matomo'), __NAMESPACE__ . '\\render_dashboard_widget' ); } /** * Periods offered by the dashboard widget filter. * * @return array */ function widget_period_choices(): array { $choices = get_period_choices(); return [ 'today' => $choices['today'], 'last7' => $choices['last7'], 'last30' => $choices['last30'], 'last365' => $choices['last365'], 'all' => $choices['all'], ]; } function default_widget_period(): string { $options = get_plugin_options(); return array_key_exists($options['period'], widget_period_choices()) ? $options['period'] : 'last30'; } /** * Period the dashboard widget opens on: the one this user last picked in the * widget, falling back to the site-wide setting the first time. */ function get_widget_period(): string { $user_id = get_current_user_id(); if ($user_id > 0) { $stored = get_user_meta($user_id, WIDGET_PERIOD_META, true); if (is_string($stored) && array_key_exists($stored, widget_period_choices())) { return $stored; } } return default_widget_period(); } /** * Period an admin list reports on: the one this user picked in the screen * options for that post type, falling back to the site-wide setting. */ function get_list_period(string $post_type): string { $options = get_plugin_options(); $user_id = get_current_user_id(); if ($user_id > 0 && $post_type !== '') { $stored = get_user_meta($user_id, LIST_PERIODS_META, true); if (is_array($stored) && isset($stored[$post_type]) && is_string($stored[$post_type]) && array_key_exists($stored[$post_type], get_period_choices())) { return $stored[$post_type]; } } return $options['period']; } function render_dashboard_widget(): void { $selected = get_widget_period(); ?>
get_dashboard_widget_html($period)]); } /** * Escaped HTML listing the most viewed content for the given period. */ function get_dashboard_widget_html(string $period): string { $options = get_plugin_options(); $cache_key = CACHE_PREFIX . 'widget_' . md5(wp_json_encode([ // The plugin version invalidates cached widget HTML on every update, // so markup changes are never served from a stale cache. 'v' => VERSION, 'period' => $period, 'metric' => $options['metric'], 'post_types' => $options['post_types'], 'home' => home_url('/'), 'src' => get_source_fingerprint($options), ])); $cached = get_transient($cache_key); if (is_string($cached) && $cached !== '') { return $cached; } $stats = get_url_stats_for_period($period); if ($stats === null) { return '

' . esc_html__('Matomo statistics are unavailable. Use the Test connection button in the plugin settings to diagnose the problem.', 'admin-views-column-for-matomo') . '

'; } arsort($stats); $items = []; $checked = 0; foreach ($stats as $key => $value) { // Each URL is indexed under both a path key and a full-URL key with // the same value: only walk the path keys to avoid double work. if (strpos($key, '/') !== 0) { continue; } if (++$checked > 100 || count($items) >= 10) { break; } $post_id = url_to_postid(home_url($key)); if ($post_id === 0 || isset($items[$post_id])) { continue; } $post = get_post($post_id); if (!$post || $post->post_status !== 'publish' || !in_array($post->post_type, $options['post_types'], true)) { continue; } $items[$post_id] = ['title' => get_the_title($post), 'views' => (int) $value]; } if (!$items) { $html = '

' . esc_html__('No Matomo data for the selected period yet.', 'admin-views-column-for-matomo') . '

'; set_plugin_transient($cache_key, $html, min($options['cache_ttl'], 900)); return $html; } $html = '
    '; foreach ($items as $post_id => $item) { $html .= sprintf( '
  • %s%s
  • ', esc_url((string) get_permalink($post_id)), esc_html($item['title'] !== '' ? $item['title'] : __('(no title)', 'admin-views-column-for-matomo')), widget_count_html((int) $post_id, (int) $item['views'], $period) ); } $html .= '
'; $report_url = get_matomo_report_url($period); if ($report_url !== '') { $html .= ''; } set_plugin_transient($cache_key, $html, $options['cache_ttl']); return $html; } /** * Escaped HTML for a count in the dashboard widget, linked to the Matomo report * of that page when one can be built. */ function widget_count_html(int $post_id, int $views, string $period): string { $number = esc_html(number_format_i18n($views)); $report_url = get_page_report_url($post_id, $period); if ($report_url === '') { return '' . $number . ''; } return sprintf( '%s', esc_url($report_url), esc_attr__('Open the Matomo report for this page', 'admin-views-column-for-matomo'), $number ); } function clear_cache_action(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'admin-views-column-for-matomo')); } check_admin_referer('admivico_clear_cache'); clear_plugin_cache(); wp_safe_redirect(add_query_arg('admivico_cache_cleared', '1', wp_get_referer() ?: admin_url(SETTINGS_PAGE))); exit; } function clear_plugin_cache(): void { $cache_keys = get_option(CACHE_KEYS_OPTION, []); if (!is_array($cache_keys)) { $cache_keys = []; } foreach ($cache_keys as $cache_key) { if (is_string($cache_key) && strpos($cache_key, CACHE_PREFIX) === 0) { delete_transient($cache_key); } } delete_option(CACHE_KEYS_OPTION); } /** * @param mixed $value */ function set_plugin_transient(string $cache_key, $value, int $expiration): bool { remember_plugin_cache_key($cache_key); return set_transient($cache_key, $value, $expiration); } function remember_plugin_cache_key(string $cache_key): void { if (strpos($cache_key, CACHE_PREFIX) !== 0) { return; } $cache_keys = get_option(CACHE_KEYS_OPTION, []); if (!is_array($cache_keys)) { $cache_keys = []; } if (in_array($cache_key, $cache_keys, true)) { return; } $cache_keys[] = $cache_key; update_option(CACHE_KEYS_OPTION, array_values($cache_keys), false); }