*/ public static function defaults(): array { return array( 'enabled' => false, 'measurement_id' => '', 'property_id' => '', 'clarity_id' => '', // Microsoft Clarity project id. 'anonymize_ip' => true, 'exclude_loggedin' => true, 'exclude_roles' => array( 'administrator', 'editor' ), ) + Services\Matomo::defaults() + Services\Consent::defaults(); } public function register( Container $container ): void {} public function boot( Container $container ): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); // Consent Mode v2 defaults must precede the gtag loader — priority 1. /* * wp_enqueue_scripts, not wp_head. * * These four used to print their tags straight into wp_head at * priorities 1 to 6, which was the right hook for printing and is the * wrong one for enqueuing: core registers its own wp_enqueue_scripts * at wp_head priority 1, so a plugin enqueuing at the same priority is * relying on registration order to be served at all. Now that they * enqueue rather than print, they belong on the hook that exists for * it, and the consent ordering is a dependency instead of a number. */ Guard::action( 'wp_enqueue_scripts', array( $this, 'consent_defaults' ), 1 ); Guard::action( 'wp_enqueue_scripts', array( $this, 'tag' ), 4 ); Guard::action( 'wp_enqueue_scripts', array( $this, 'matomo_tag' ), 5 ); Guard::action( 'wp_enqueue_scripts', array( $this, 'clarity_tag' ), 6 ); // The banner's own stylesheet and behaviour, which must be in the head // even though the markup itself is emitted at wp_body_open. Guard::action( 'wp_enqueue_scripts', array( $this, 'consent_assets' ), 7 ); // wp_body_open for the bar itself; wp_footer as the fallback for the // rare theme that never calls it. Guard::action( 'wp_body_open', array( $this, 'consent_bar' ), 1 ); Guard::action( 'wp_footer', array( $this, 'consent_bar' ), 99 ); } /** * Output the Matomo snippet. * * The same exclusions as GA4: a site that does not want to count its own * editors does not want to count them twice. */ public function matomo_tag(): void { $settings = $this->settings(); if ( empty( $settings['matomo_enabled'] ) || $this->is_excluded_user( $settings ) || Services\Consent::blocks_tracking( $settings ) ) { return; } Inline::script( 'airanksuite-matomo', ( new Services\Matomo( $this->options() ) )->snippet() ); } /** * Output the GA4 tag, honouring the exclusion settings. */ public function tag(): void { $settings = $this->settings(); $id = trim( (string) $settings['measurement_id'] ); if ( empty( $settings['enabled'] ) || '' === $id || ! preg_match( '/^G-[A-Z0-9]{4,}$/i', $id ) ) { return; } if ( $this->is_excluded_user( $settings ) || Services\Consent::blocks_tracking( $settings ) ) { return; } $config = array( 'send_page_view' => true ); if ( ! empty( $settings['anonymize_ip'] ) ) { $config['anonymize_ip'] = true; } /* * Enqueued, with the ordering expressed as a dependency. * * This used to print the two tags directly, on the reasoning that * Google Consent Mode needs the consent default to execute before the * tag loads and only wp_head order could guarantee that. The reasoning * was right about the requirement and wrong about the remedy: printed * tags are invisible to the script loader, so any theme or plugin that * also prints to wp_head could land between them. Declared as a * dependency on the consent handle, the order is guaranteed by the * loader instead of by luck. */ wp_enqueue_script( 'airanksuite-gtag', 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $id ), array( Services\Consent::GCM_HANDLE ), null, array( 'strategy' => 'async', 'in_footer' => false ) ); Inline::script( 'airanksuite-gtag-config', sprintf( 'window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag("js",new Date());gtag("config",%s,%s);', wp_json_encode( $id ), wp_json_encode( $config ) ), array( 'airanksuite-gtag' ) ); } /** * Print the GDPR cookie bar. Hooked to wp_body_open AND wp_footer (the * fallback for themes that never call the former) — the static flag makes * sure a theme that calls both gets exactly one bar. */ /** The cookie banner's stylesheet and script, enqueued in the head. */ public function consent_assets(): void { Services\Consent::enqueue( $this->settings() ); } public function consent_bar(): void { static $done = false; if ( $done ) { return; } $done = true; Services\Consent::render( $this->settings() ); } /** Google Consent Mode v2 default state, before any tag loads. */ public function consent_defaults(): void { Services\Consent::gcm_defaults( $this->settings() ); } /** * Output the Microsoft Clarity tag — same enable and exclusion rules as GA4. */ public function clarity_tag(): void { $settings = $this->settings(); $id = trim( (string) ( $settings['clarity_id'] ?? '' ) ); if ( empty( $settings['enabled'] ) || '' === $id || ! preg_match( '/^[a-z0-9]{5,24}$/i', $id ) ) { return; } if ( $this->is_excluded_user( $settings ) || Services\Consent::blocks_tracking( $settings ) ) { return; } // The official loader, with the project id JSON-encoded into it. Inline::script( 'airanksuite-clarity', sprintf( '(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};' . 't=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;' . 'y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})' . '(window,document,"clarity","script",%s);', wp_json_encode( $id ) ) ); } /** * @param array $settings Module settings. */ private function is_excluded_user( array $settings ): bool { if ( empty( $settings['exclude_loggedin'] ) || ! is_user_logged_in() ) { return false; } $user = wp_get_current_user(); $roles = (array) $settings['exclude_roles']; if ( empty( $roles ) ) { return true; } return ! empty( array_intersect( (array) $user->roles, $roles ) ); } /** * GA4 properties the connected Google account can read. * * @return array>|WP_Error */ private function properties(): array|WP_Error { $client = \AiRankSuite\Kernel::service( GoogleClient::class ); $data = $client->get( 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=200' ); if ( $data instanceof WP_Error ) { return $data; } $out = array(); foreach ( (array) ( $data['accountSummaries'] ?? array() ) as $account ) { foreach ( (array) ( $account['propertySummaries'] ?? array() ) as $property ) { $out[] = array( // "properties/123456" → "123456". 'id' => (string) str_replace( 'properties/', '', (string) ( $property['property'] ?? '' ) ), 'name' => (string) ( $property['displayName'] ?? '' ), ); } } return $out; } /** * Run a compact GA4 report (cached for 6 hours). * * @return array|WP_Error */ private function report( int $days ): array|WP_Error { $settings = $this->settings(); $property = trim( (string) $settings['property_id'] ); if ( '' === $property ) { return new WP_Error( 'airanksuite_ga4_no_property', __( 'Choose a GA4 property first.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $cache_key = 'airanksuite_ga4_' . md5( $property . '|' . $days ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $client = \AiRankSuite\Kernel::service( GoogleClient::class ); $url = 'https://analyticsdata.googleapis.com/v1beta/properties/' . rawurlencode( $property ) . ':runReport'; $range = array( array( 'startDate' => $days . 'daysAgo', 'endDate' => 'yesterday' ) ); $totals = $client->post( $url, array( 'dateRanges' => $range, 'metrics' => array( array( 'name' => 'activeUsers' ), array( 'name' => 'sessions' ), array( 'name' => 'screenPageViews' ), array( 'name' => 'engagementRate' ), ), ) ); if ( $totals instanceof WP_Error ) { return $totals; } $pages = $client->post( $url, array( 'dateRanges' => $range, 'dimensions' => array( array( 'name' => 'pagePath' ) ), 'metrics' => array( array( 'name' => 'screenPageViews' ), array( 'name' => 'activeUsers' ) ), 'orderBys' => array( array( 'metric' => array( 'metricName' => 'screenPageViews' ), 'desc' => true ) ), 'limit' => 10, ) ); $series = $client->post( $url, array( 'dateRanges' => $range, 'dimensions' => array( array( 'name' => 'date' ) ), 'metrics' => array( array( 'name' => 'activeUsers' ) ), 'orderBys' => array( array( 'dimension' => array( 'dimensionName' => 'date' ) ) ), 'limit' => 400, ) ); $row = $totals['rows'][0]['metricValues'] ?? array(); $report = array( 'days' => $days, 'totals' => array( 'users' => (int) ( $row[0]['value'] ?? 0 ), 'sessions' => (int) ( $row[1]['value'] ?? 0 ), 'pageviews' => (int) ( $row[2]['value'] ?? 0 ), 'engagement' => round( 100 * (float) ( $row[3]['value'] ?? 0 ), 1 ), ), 'series' => $this->series( $series ), 'pages' => $this->pages( $pages ), ); set_transient( $cache_key, $report, 6 * HOUR_IN_SECONDS ); return $report; } /** * @param array|WP_Error $result GA4 response. * @return array> */ private function series( $result ): array { if ( $result instanceof WP_Error || ! is_array( $result ) ) { return array(); } $out = array(); foreach ( (array) ( $result['rows'] ?? array() ) as $row ) { $raw = (string) ( $row['dimensionValues'][0]['value'] ?? '' ); if ( 8 !== strlen( $raw ) ) { continue; } $out[] = array( 'date' => substr( $raw, 0, 4 ) . '-' . substr( $raw, 4, 2 ) . '-' . substr( $raw, 6, 2 ), 'value' => (int) ( $row['metricValues'][0]['value'] ?? 0 ), ); } return $out; } /** * @param array|WP_Error $result GA4 response. * @return array> */ private function pages( $result ): array { if ( $result instanceof WP_Error || ! is_array( $result ) ) { return array(); } $out = array(); foreach ( (array) ( $result['rows'] ?? array() ) as $row ) { $out[] = array( 'path' => (string) ( $row['dimensionValues'][0]['value'] ?? '' ), 'pageviews' => (int) ( $row['metricValues'][0]['value'] ?? 0 ), 'users' => (int) ( $row['metricValues'][1]['value'] ?? 0 ), ); } return $out; } public function routes(): void { /* * The consent record. * * Writing is deliberately public: the caller is an anonymous visitor * clicking a cookie banner, so there is no user and no nonce to check. * That is safe because the route accepts nothing but three booleans and * a path, stores no address or user agent in the clear, and cannot read * anything back. Reading the record needs the settings capability. */ register_rest_route( self::NAMESPACE, '/consent/record', array( 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => function ( WP_REST_Request $request ) { $settings = $this->options()->get( 'analytics', self::defaults() ); if ( empty( $settings['consent_log'] ) ) { return new WP_REST_Response( array( 'logged' => false ) ); } $result = Services\Consent::log( $settings, array( 'analytics' => (bool) $request->get_param( 'analytics' ), 'marketing' => (bool) $request->get_param( 'marketing' ), 'decision' => (string) $request->get_param( 'decision' ), 'url' => (string) $request->get_param( 'url' ), ) ); if ( $result instanceof WP_Error ) { // A refused record must not break the banner: the choice // is already in the cookie and the tags already gated. The // caller is told why with a proper 429 and a Retry-After. $response = new WP_REST_Response( array( 'logged' => false, 'code' => $result->get_error_code() ), 429 ); $response->header( 'Retry-After', '60' ); return $response; } return new WP_REST_Response( array( 'logged' => true ) ); }, 'args' => array( 'analytics' => array( 'type' => 'boolean', 'default' => false ), 'marketing' => array( 'type' => 'boolean', 'default' => false ), 'decision' => array( 'type' => 'string', 'default' => 'custom', 'enum' => array( 'accept_all', 'reject_all', 'custom' ) ), // Capped at the request boundary as well as in clean_path(): // a megabyte of "path" should be refused before it is parsed. 'url' => array( 'type' => 'string', 'default' => '', 'maxLength' => 512 ), ), ) ); register_rest_route( self::NAMESPACE, '/consent/log', array( array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => function () { return new WP_REST_Response( Services\Consent::log_summary( $this->options()->get( 'analytics', self::defaults() ) ) ); }, ), array( // Erase the record. Separate from uninstall on purpose: a // site may need to clear it without removing the plugin. 'methods' => 'DELETE', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => function () { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', \AiRankSuite\Support\Database::consent_log() ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from this plugin's own Database class/adapter, never from input; values go through $wpdb->prepare(). return new WP_REST_Response( Services\Consent::log_summary( $this->options()->get( 'analytics', self::defaults() ) ) ); }, ), ) ); // The export a regulator or a DPO actually asks for — in pages of up // to 2,000 rows by id cursor, so a year of a busy site's consents is // many small responses rather than one that exhausts PHP memory or // silently stops at a cap. The client stitches the pages; every cell // still goes through the one escaper. register_rest_route( self::NAMESPACE, '/consent/log/csv', array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'after' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0 ), 'per_page' => array( 'type' => 'integer', 'default' => 2000, 'minimum' => 1, 'maximum' => 2000 ), ), 'callback' => function ( \WP_REST_Request $request ) { global $wpdb; $table = \AiRankSuite\Support\Database::consent_log(); $after = max( 0, (int) $request->get_param( 'after' ) ); $per_page = max( 1, min( 2000, (int) ( $request->get_param( 'per_page' ) ?: 2000 ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i", $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, created_at, decision, analytics, marketing, region, policy_version, visitor_hash, url FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d", $after, $per_page ), ARRAY_A ); $cell = array( Services\Consent::class, 'csv_cell' ); $out = 0 === $after ? implode( ',', array_map( $cell, array( 'recorded_at_utc', 'decision', 'analytics', 'marketing', 'region', 'policy_version', 'visitor_hash', 'path' ) ) ) . "\r\n" : ''; $last = $after; foreach ( (array) $rows as $r ) { $last = max( $last, (int) $r['id'] ); $out .= implode( ',', array_map( $cell, array( $r['created_at'], $r['decision'], (int) $r['analytics'], (int) $r['marketing'], $r['region'], (int) $r['policy_version'], $r['visitor_hash'], (string) $r['url'], ) ) ) . "\r\n"; } return new WP_REST_Response( array( 'csv' => $out, 'rows' => count( (array) $rows ), 'next' => count( (array) $rows ) === $per_page ? $last : null, 'total' => $total, ) ); }, ) ); register_rest_route( self::NAMESPACE, '/settings/analytics', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'enabled' => array( 'type' => 'boolean' ), 'measurement_id' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'property_id' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'clarity_id' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'consent_enabled' => array( 'type' => 'boolean' ), 'consent_region' => array( 'type' => 'string', 'enum' => array( 'eu', 'us', 'auto' ) ), 'consent_layout' => array( 'type' => 'string', 'enum' => array( 'card', 'bar' ) ), 'consent_position' => array( 'type' => 'string', 'enum' => array( 'left', 'right' ) ), 'consent_theme' => array( 'type' => 'string', 'enum' => array( 'auto', 'light', 'dark' ) ), 'consent_title' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_message' => array( 'type' => 'string', 'sanitize_callback' => 'wp_kses_post' ), 'consent_accept' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_reject' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_save' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_manage' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_dns' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'consent_policy' => array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw' ), 'consent_reopen' => array( 'type' => 'boolean' ), 'consent_gcm' => array( 'type' => 'boolean' ), 'consent_marketing' => array( 'type' => 'boolean' ), 'consent_expiry' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 730 ), 'anonymize_ip' => array( 'type' => 'boolean' ), 'exclude_loggedin' => array( 'type' => 'boolean' ), 'exclude_roles' => array( 'type' => 'array' ), 'matomo_enabled' => array( 'type' => 'boolean' ), 'matomo_url' => array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ), 'matomo_site_id' => array( 'type' => 'integer' ), 'matomo_track' => array( 'type' => 'boolean' ), 'matomo_cookieless' => array( 'type' => 'boolean' ), ), ), ) ); register_rest_route( self::NAMESPACE, '/analytics/properties', array( 'methods' => 'GET', 'callback' => array( $this, 'list_properties' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); // Matomo: the customer's own instance, reached with their own token. register_rest_route( self::NAMESPACE, '/analytics/matomo', array( array( 'methods' => 'GET', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => function ( WP_REST_Request $request ) { $matomo = new Services\Matomo( $this->options() ); if ( ! $matomo->is_configured() ) { return new WP_REST_Response( array( 'configured' => false, 'hint' => $matomo->token_hint(), 'assistants' => array_values( array_unique( array_values( Services\Matomo::assistants() ) ) ), ) ); } $report = $matomo->report( (int) $request->get_param( 'days' ) ); if ( $report instanceof WP_Error ) { return $report; } return new WP_REST_Response( $report + array( 'configured' => true, 'hint' => $matomo->token_hint(), ) ); }, 'args' => array( 'days' => array( 'type' => 'integer', 'default' => 30 ), ), ), array( 'methods' => 'PUT', 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'callback' => function ( WP_REST_Request $request ) { $matomo = new Services\Matomo( $this->options() ); $token = $request->get_param( 'token' ); // Null means "leave it alone"; an empty string means // "forget it". Treating those the same would wipe the // stored token every time somebody saved the URL. if ( null !== $token ) { $matomo->save_token( (string) $token ); } return new WP_REST_Response( array( 'stored' => $matomo->has_token(), 'hint' => $matomo->token_hint(), ) ); }, ), ) ); register_rest_route( self::NAMESPACE, '/analytics/matomo/verify', array( 'methods' => 'POST', 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'matomo', 60 ); }, 'callback' => function () { $result = ( new Services\Matomo( $this->options() ) )->verify(); return $result instanceof WP_Error ? $result : new WP_REST_Response( $result ); }, ) ); register_rest_route( self::NAMESPACE, '/analytics/report', array( 'methods' => 'GET', 'callback' => array( $this, 'get_report' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'days' => array( 'type' => 'integer', 'default' => 28, ), ), ) ); // The local-data inventory: what the plugin keeps in the customer's // own database, how far back it reaches, and the controls over it. register_rest_route( self::NAMESPACE, '/analytics/database', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_database' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_database' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'retention_days' => array( 'type' => 'integer', 'required' => true, ), ), ), ) ); register_rest_route( self::NAMESPACE, '/analytics/database/refresh', array( 'methods' => 'POST', 'callback' => array( $this, 'refresh_database' ), 'permission_callback' => static function () { if ( ! current_user_can( 'manage_options' ) ) { return self::forbidden(); } return self::spend( 'db_refresh', 12 ); }, ) ); register_rest_route( self::NAMESPACE, '/analytics/database/purge', array( 'methods' => 'POST', 'callback' => array( $this, 'purge_database' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'target' => array( 'type' => 'string', 'required' => true, 'enum' => array( 'ranks', 'cache' ), ), ), ) ); } /** * How long rank snapshots are kept, in days. * * One option, one default, one clamp — the REST routes here and the rank * tracker's daily trim all read this method, so the number the customer * saves is exactly the number the cron enforces. */ public static function retention_days(): int { return min( 365, max( 30, (int) get_option( 'airanksuite_data_retention', 90 ) ) ); } public function get_database(): WP_REST_Response { return new WP_REST_Response( $this->database_payload() ); } public function update_database( WP_REST_Request $request ): WP_REST_Response { $days = min( 365, max( 30, (int) $request->get_param( 'retention_days' ) ) ); update_option( 'airanksuite_data_retention', $days, false ); return $this->get_database(); } /** * "Update data now": drop every cached report, then pull fresh rank data * if Search Console is connected. * * The sync is allowed to fail without failing the call — the caches are * already cleared at that point, which is the half of the promise this * endpoint can always keep. A refused sync travels back as `sync_error` * so the UI can say so instead of pretending. */ public function refresh_database(): WP_REST_Response { $this->flush_transients(); $sync_error = null; if ( '' !== \AiRankSuite\Kernel::service( GoogleClient::class )->property() ) { try { $result = \AiRankSuite\Kernel::service( \AiRankSuite\Modules\RankTracker\Services\RankTracker::class )->sync( true ); if ( $result instanceof WP_Error ) { $sync_error = $result->get_error_message(); } } catch ( \Throwable $e ) { $sync_error = $e->getMessage(); } } $payload = $this->database_payload() + array( 'refreshed' => true ); if ( null !== $sync_error ) { $payload['sync_error'] = $sync_error; } return new WP_REST_Response( $payload ); } /** * Delete rank snapshots or cached reports — and nothing else. * * The mentions table is deliberately not a target here: a past AI answer * cannot be queried again for a past date, so that history is the one * thing in the plugin's tables that can never be rebuilt once deleted. */ public function purge_database( WP_REST_Request $request ): WP_REST_Response { global $wpdb; if ( 'ranks' === $request->get_param( 'target' ) ) { // Snapshots only. The keyword list survives and refills its // history from the next sync onwards. $ranks = Database::ranks(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM %i", $ranks ) ); } else { $this->flush_transients(); } return $this->get_database(); } /** * What is stored locally: rows, distinct days of history, date range and * on-disk size per table, plus the cached-report count. * * @return array */ private function database_payload(): array { global $wpdb; $ranks = Database::ranks(); $mentions = Database::mentions(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rank_stats = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS rows_total, COUNT(DISTINCT snapshot_date) AS days_total, MIN(snapshot_date) AS oldest, MAX(snapshot_date) AS newest FROM %i", $ranks ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $mention_stats = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS rows_total, COUNT(DISTINCT DATE(checked_at)) AS days_total, MIN(checked_at) AS oldest, MAX(checked_at) AS newest FROM %i", $mentions ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $cache_entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( '_transient_airanksuite_' ) . '%' ) ); return array( 'retention_days' => self::retention_days(), 'tables' => array( array( 'key' => 'ranks', 'label' => __( 'Rank snapshots', 'ai-rank-suite' ), 'rows' => (int) ( $rank_stats->rows_total ?? 0 ), 'days' => (int) ( $rank_stats->days_total ?? 0 ), 'oldest' => ( $rank_stats->oldest ?? null ) ? (string) $rank_stats->oldest : null, 'newest' => ( $rank_stats->newest ?? null ) ? (string) $rank_stats->newest : null, 'bytes' => $this->table_bytes( $ranks ), ), array( 'key' => 'mentions', 'label' => __( 'AI visibility runs', 'ai-rank-suite' ), 'rows' => (int) ( $mention_stats->rows_total ?? 0 ), 'days' => (int) ( $mention_stats->days_total ?? 0 ), 'oldest' => ( $mention_stats->oldest ?? null ) ? (string) $mention_stats->oldest : null, 'newest' => ( $mention_stats->newest ?? null ) ? (string) $mention_stats->newest : null, 'bytes' => $this->table_bytes( $mentions ), ), ), 'cache_entries' => $cache_entries, ); } /** * On-disk size of a table, when the server can say. * * information_schema is a MySQL/MariaDB thing; under the SQLite drop-in * there is nothing comparable to ask, so the answer is honestly null and * the UI shows an em-dash rather than a guess. */ private function table_bytes( string $table ): ?int { global $wpdb; if ( empty( $wpdb->is_mysql ) ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $bytes = $wpdb->get_var( $wpdb->prepare( 'SELECT DATA_LENGTH + INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) ); return null === $bytes ? null : (int) $bytes; } /** * Drop every plugin transient — the same statement the DevTools * maintenance task runs. Cached reports rebuild on their next view. */ private function flush_transients(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s', $wpdb->options, $wpdb->esc_like( '_transient_airanksuite_' ) . '%', $wpdb->esc_like( '_transient_timeout_airanksuite_' ) . '%' ) ); } public function get_settings(): WP_REST_Response { $roles = array(); foreach ( wp_roles()->roles as $slug => $role ) { $roles[] = array( 'slug' => $slug, 'name' => translate_user_role( $role['name'] ), ); } return new WP_REST_Response( array( 'settings' => $this->settings(), 'roles' => $roles, 'connected' => \AiRankSuite\Kernel::service( GoogleClient::class )->connected(), ) ); } public function update_settings( WP_REST_Request $request ): WP_REST_Response { $defaults = self::defaults(); $values = array(); foreach ( array_keys( $defaults ) as $key ) { if ( ! $request->has_param( $key ) ) { continue; } if ( 'exclude_roles' === $key ) { $values[ $key ] = array_values( array_intersect( array_map( 'sanitize_key', (array) $request->get_param( $key ) ), array_keys( wp_roles()->roles ) ) ); continue; } $values[ $key ] = $request->get_param( $key ); } \AiRankSuite\Kernel::service( Options::class )->set( 'analytics', $values, $defaults ); return $this->get_settings(); } public function list_properties(): WP_REST_Response|WP_Error { $properties = $this->properties(); return $properties instanceof WP_Error ? $properties : new WP_REST_Response( array( 'properties' => $properties ) ); } public function get_report( WP_REST_Request $request ): WP_REST_Response|WP_Error { $days = in_array( (int) $request->get_param( 'days' ), array( 7, 28, 90 ), true ) ? (int) $request->get_param( 'days' ) : 28; $report = $this->report( $days ); return $report instanceof WP_Error ? $report : new WP_REST_Response( $report ); } /** @return array */ private function options(): Options { return \AiRankSuite\Kernel::service( Options::class ); } private function settings(): array { return \AiRankSuite\Kernel::service( Options::class )->get( 'analytics', self::defaults() ); } }