*/ public static function load( $range, $view ) { $range = self::sanitize_range( $range ); $view = 'overview' === $view ? 'overview' : 'dashboard'; $creds = Causewise_Credentials::get(); if ( '' === $creds['api_key'] ) { return self::scrub( self::failure( 409, 'not_configured', __( 'Add a management API key in settings to show analytics here.', 'causewise-analytics' ) ) ); } $site = self::resolve_site( $creds['api_key'], $creds['site_id'] ); if ( empty( $site['ok'] ) ) { return self::scrub( $site ); } $cache_key = self::cache_key( $creds['api_key'], $site['site']['id'], $range, $view ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) && ! empty( $cached['ok'] ) ) { return $cached; } $overview = self::get_json( self::site_path( $site['site']['id'], '/overview', $range ), $creds['api_key'] ); $summary = null; $funnels = array(); if ( 'dashboard' === $view ) { $summary = self::get_json( self::site_path( $site['site']['id'], '/analytics/summary', $range ), $creds['api_key'] ); $funnels = self::load_funnel_details( $creds['api_key'], $site['site']['id'], $range, $summary ); } $payload = self::compose( $site['site'], $range, $overview, $summary, $funnels ); if ( ! empty( $payload['ok'] ) ) { set_transient( $cache_key, $payload, self::CACHE_TTL ); } return $payload; } /** * Recent events for the analytics screen. This is separate from the * dashboard summary, which only includes eight events. * * @param mixed $range Requested range. * @param mixed $filter all, pageviews, or custom. * @param mixed $cursor Page cursor. Empty for the first page. * @param mixed $limit Page size. * @return array */ public static function load_events( $range, $filter, $cursor, $limit ) { $range = self::sanitize_range( $range ); $filter = self::sanitize_event_filter( $filter ); $cursor = self::sanitize_event_cursor( $cursor ); $limit = self::sanitize_event_limit( $limit ); $creds = Causewise_Credentials::get(); if ( '' === $creds['api_key'] ) { return self::scrub( self::failure( 409, 'not_configured', __( 'Add a management API key in settings to show analytics here.', 'causewise-analytics' ) ) ); } $site = self::resolve_site( $creds['api_key'], $creds['site_id'] ); if ( empty( $site['ok'] ) ) { return self::scrub( $site ); } $result = self::get_json( self::events_path( $site['site']['id'], $range, $filter, $cursor, $limit ), $creds['api_key'] ); $body = self::success_body( $result ); if ( null === $body ) { $error = self::transport_error( $result ); return self::scrub( $error ); } $next = ''; if ( isset( $body['nextCursor'] ) && is_string( $body['nextCursor'] ) ) { $next = self::sanitize_event_cursor( $body['nextCursor'] ); } return self::scrub( array( 'ok' => true, 'status' => 200, 'range' => self::range_field( $body, $range ), 'filter' => self::sanitize_event_filter( isset( $body['filter'] ) ? $body['filter'] : $filter ), 'limit' => $limit, 'events' => self::recent_events( isset( $body['events'] ) ? $body['events'] : array(), $limit ), 'nextCursor' => '' === $next ? null : $next, ) ); } /** * @param mixed $filter Requested filter. * @return string */ public static function sanitize_event_filter( $filter ) { $filter = strtolower( trim( (string) $filter ) ); if ( in_array( $filter, array( 'all', 'pageviews', 'custom' ), true ) ) { return $filter; } return 'all'; } /** * @param mixed $limit Requested page size. * @return int */ public static function sanitize_event_limit( $limit ) { $limit = (int) $limit; if ( in_array( $limit, array( 10, 20, 50 ), true ) ) { return $limit; } return 20; } /** * @param mixed $cursor Requested cursor. * @return string */ public static function sanitize_event_cursor( $cursor ) { $cursor = trim( (string) $cursor ); if ( '' === $cursor || strlen( $cursor ) > 200 || ! preg_match( '/^[A-Za-z0-9_-]+$/', $cursor ) ) { return ''; } return $cursor; } /** * Build the JSON the admin UI can render. * * Overview supplies page views, sessions, top pages, referrers, and the * event trend. Summary supplies visitors, goals, funnels, and recent events. * Percentage changes are not included because the API does not return a * prior-period comparison. * * @param array $site id, name, domain. * @param string $range Requested range. * @param array $overview Transport result. * @param array|null $summary Transport result, or null for widget view. * @param array> $funnels Funnel analytics results keyed by id. * @return array */ public static function compose( $site, $range, $overview, $summary, $funnels ) { $overview_body = self::success_body( $overview ); $summary_body = null === $summary ? null : self::success_body( $summary ); $overview_error = null === $overview_body ? self::transport_error( $overview ) : null; $summary_error = ( null !== $summary && null === $summary_body ) ? self::transport_error( $summary ) : null; if ( null === $overview_body && ( null === $summary || null === $summary_body ) ) { $primary = null !== $overview_error ? $overview_error : $summary_error; if ( ! is_array( $primary ) ) { $primary = self::transport_error( array( 'status' => 0, 'body' => null ) ); } return self::scrub( $primary ); } $stats = array( 'pageviews' => 0, 'sessions' => 0, 'sessionsNote' => '', 'visitors' => null, 'visitorsNote' => '', ); $trend = null; $top_pages = array(); $referrers = null; $period = array( 'range' => $range, 'start' => '', 'end' => '', 'retentionDays' => null, ); $actual_range = $range; if ( is_array( $overview_body ) ) { $actual_range = self::range_field( $overview_body, $range ); $stats['pageviews'] = self::count_value( isset( $overview_body['pageviews'] ) ? $overview_body['pageviews'] : 0 ); $stats['sessions'] = self::count_value( isset( $overview_body['uniqueSessions'] ) ? $overview_body['uniqueSessions'] : 0 ); $top_pages = self::top_pages( isset( $overview_body['topPages'] ) ? $overview_body['topPages'] : array() ); $referrers = self::referrers( isset( $overview_body['referrers'] ) ? $overview_body['referrers'] : array() ); $trend = array( 'range' => $actual_range, 'start' => self::text_field( isset( $overview_body['start'] ) ? $overview_body['start'] : '' ), 'end' => self::text_field( isset( $overview_body['end'] ) ? $overview_body['end'] : '' ), 'seriesLabel' => 'Events', 'trend' => self::trend_points( isset( $overview_body['trend'] ) ? $overview_body['trend'] : array() ), ); $period['range'] = $actual_range; $period['start'] = $trend['start']; $period['end'] = $trend['end']; } $goals = null; $funnel_section = null; $recent = null; if ( is_array( $summary_body ) ) { if ( null === $overview_body ) { $actual_range = self::range_field( isset( $summary_body['period'] ) && is_array( $summary_body['period'] ) ? $summary_body['period'] : array(), $range ); $stats['pageviews'] = self::count_value( isset( $summary_body['pageviews'] ) ? $summary_body['pageviews'] : 0 ); $stats['sessions'] = self::count_value( isset( $summary_body['sessions'] ) ? $summary_body['sessions'] : 0 ); $top_pages = self::top_pages( isset( $summary_body['topPages'] ) ? $summary_body['topPages'] : array() ); } if ( array_key_exists( 'visitors', $summary_body ) && null === $summary_body['visitors'] ) { $stats['visitors'] = null; } elseif ( array_key_exists( 'visitors', $summary_body ) ) { $stats['visitors'] = self::count_value( $summary_body['visitors'] ); } $stats['sessionsNote'] = self::plain_sessions_note( isset( $summary_body['sessionsNote'] ) ? $summary_body['sessionsNote'] : '' ); $stats['visitorsNote'] = self::plain_visitors_note( isset( $summary_body['visitorsNote'] ) ? $summary_body['visitorsNote'] : '', $stats['visitors'] ); if ( isset( $summary_body['period'] ) && is_array( $summary_body['period'] ) ) { $period['range'] = self::range_field( $summary_body['period'], $actual_range ); $period['start'] = self::text_field( isset( $summary_body['period']['start'] ) ? $summary_body['period']['start'] : $period['start'] ); $period['end'] = self::text_field( isset( $summary_body['period']['end'] ) ? $summary_body['period']['end'] : $period['end'] ); if ( isset( $summary_body['period']['retentionDays'] ) && is_numeric( $summary_body['period']['retentionDays'] ) ) { $period['retentionDays'] = self::count_value( $summary_body['period']['retentionDays'] ); } $actual_range = $period['range']; } $goals = self::goals( isset( $summary_body['goals'] ) ? $summary_body['goals'] : null ); $funnel_section = self::funnels( isset( $summary_body['funnels'] ) ? $summary_body['funnels'] : null, $funnels ); $recent = self::recent_events( isset( $summary_body['recentEvents'] ) ? $summary_body['recentEvents'] : array() ); } $payload = array( 'ok' => true, 'status' => 200, 'range' => $actual_range, 'site' => array( 'id' => self::text_field( isset( $site['id'] ) ? $site['id'] : '' ), 'name' => self::text_field( isset( $site['name'] ) ? $site['name'] : '' ), 'domain' => self::text_field( isset( $site['domain'] ) ? $site['domain'] : '' ), ), 'advancedUrl' => self::advanced_url( isset( $site['id'] ) ? $site['id'] : '' ), 'period' => $period, 'stats' => $stats, 'trend' => $trend, 'topPages' => $top_pages, 'referrers' => $referrers, 'goals' => $goals, 'funnels' => $funnel_section, 'recentEvents' => $recent, 'overviewError' => $overview_error, 'summaryError' => $summary_error, ); return self::scrub( $payload ); } /** * Match the saved site ID, or the public tracking key, to a CauseWise site. * * @param string $api_key Management key. * @param string $site_id Saved site ID. May be empty. * @return array */ public static function resolve_site( $api_key, $site_id ) { $public_key = ''; $settings = Causewise_Settings::get_settings(); if ( Causewise_Settings::is_valid_public_key( $settings['site_key'] ) ) { $public_key = $settings['site_key']; } $cache_key = 'cw_site_' . substr( hash( 'sha256', $api_key . '|' . $site_id . '|' . $public_key ), 0, 32 ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['id'] ) ) { return array( 'ok' => true, 'site' => $cached, ); } if ( Causewise_Credentials::is_valid_site_id( $site_id ) ) { $result = self::get_json( '/api/v1/sites/' . rawurlencode( $site_id ), $api_key ); if ( 200 === (int) $result['status'] && isset( $result['body']['site'] ) && is_array( $result['body']['site'] ) ) { $site = self::public_site( $result['body']['site'] ); set_transient( $cache_key, $site, 600 ); return array( 'ok' => true, 'site' => $site, ); } if ( 401 === (int) $result['status'] || 402 === (int) $result['status'] || 404 === (int) $result['status'] ) { return self::transport_error( $result ); } $site = array( 'id' => $site_id, 'name' => '', 'domain' => '', ); return array( 'ok' => true, 'site' => $site, ); } $listed = self::get_json( '/api/v1/sites', $api_key ); if ( 200 !== (int) $listed['status'] || ! isset( $listed['body']['sites'] ) || ! is_array( $listed['body']['sites'] ) ) { if ( 403 === (int) $listed['status'] ) { return array( 'ok' => false, 'status' => 409, 'code' => 'site_required', 'message' => __( 'CauseWise could not match this site automatically. Enter the site ID in settings.', 'causewise-analytics' ), 'sites' => array(), ); } return self::transport_error( $listed ); } $sites = array(); $matches = array(); foreach ( $listed['body']['sites'] as $row ) { if ( ! is_array( $row ) ) { continue; } $public = self::public_site( $row ); if ( '' === $public['id'] ) { continue; } $sites[] = $public; $tracking = isset( $row['publicTrackingKey'] ) ? Causewise_Settings::normalize_site_key( $row['publicTrackingKey'] ) : ''; if ( '' !== $public_key && $tracking === $public_key ) { $matches[] = $public; } } $chosen = null; if ( 1 === count( $matches ) ) { $chosen = $matches[0]; } elseif ( '' === $public_key && 1 === count( $sites ) ) { $chosen = $sites[0]; } if ( is_array( $chosen ) ) { set_transient( $cache_key, $chosen, 600 ); return array( 'ok' => true, 'site' => $chosen, ); } if ( 0 === count( $sites ) ) { $message = __( 'This connection does not have a site yet. Create a site in CauseWise, then return here.', 'causewise-analytics' ); } elseif ( '' !== $public_key && 0 === count( $matches ) ) { $message = __( 'The tracking key on this site does not match a CauseWise site. Enter the site ID in settings.', 'causewise-analytics' ); } else { $message = __( 'This connection can see more than one site. Enter the site ID in settings.', 'causewise-analytics' ); } return array( 'ok' => false, 'status' => 409, 'code' => 'site_required', 'message' => $message, 'sites' => self::site_choices( $sites ), ); } /** * CauseWise site dashboard for this site, or the workspace dashboard. * * @param string $site_id Site ID. * @return string */ public static function advanced_url( $site_id ) { if ( Causewise_Credentials::is_valid_site_id( $site_id ) ) { return self::BASE_URL . '/sites/' . rawurlencode( $site_id ); } return self::BASE_URL . '/dashboard'; } /** * HTTPS URL on causewise.io for a path that starts with /api/v1/. * * @param string $path Absolute API path and query. * @return string */ public static function endpoint( $path ) { if ( 0 !== strpos( $path, '/api/v1/' ) ) { return ''; } if ( false !== strpos( $path, '..' ) || false !== strpos( $path, '\\' ) || false !== strpos( $path, "\n" ) || false !== strpos( $path, "\r" ) ) { return ''; } return self::BASE_URL . $path; } /** * GET a CauseWise JSON route. The management key is sent only on this request. * * @param string $path API path. * @param string $api_key Management key. * @return array{status: int, body: array|null} */ public static function get_json( $path, $api_key ) { $url = self::endpoint( $path ); if ( '' === $url ) { return array( 'status' => 0, 'body' => null, ); } if ( isset( $GLOBALS['cw_http'] ) && is_callable( $GLOBALS['cw_http'] ) ) { $result = call_user_func( $GLOBALS['cw_http'], $url, $api_key ); $status = is_array( $result ) && isset( $result['status'] ) ? (int) $result['status'] : 0; $body = is_array( $result ) && isset( $result['body'] ) && is_array( $result['body'] ) ? $result['body'] : null; return array( 'status' => $status, 'body' => $body, ); } $response = wp_remote_get( $url, array( 'timeout' => 12, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $api_key, ), 'user-agent' => 'CauseWise-Analytics/' . CAUSEWISE_ANALYTICS_VERSION, ) ); if ( is_wp_error( $response ) ) { return array( 'status' => 0, 'body' => null, ); } $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); return array( 'status' => (int) wp_remote_retrieve_response_code( $response ), 'body' => is_array( $decoded ) ? $decoded : null, ); } /** * Remove secrets and fields the dashboard does not render. * * @param mixed $value Payload. * @return mixed */ public static function scrub( $value ) { if ( is_string( $value ) ) { if ( false !== strpos( $value, 'cw_api_' ) || self::is_internal_copy( $value ) ) { return ''; } return $value; } if ( ! is_array( $value ) ) { return $value; } $hidden = array( 'apiKey', 'api_key', 'authorization', 'publicTrackingKey', 'propertyConditions', 'eventCoverage', 'properties', 'workspaceId', 'conversionRateDefinition', 'overallConversionRateDefinition', ); $clean = array(); foreach ( $value as $key => $item ) { if ( is_string( $key ) && in_array( $key, $hidden, true ) ) { continue; } $clean[ $key ] = self::scrub( $item ); } return $clean; } /** * @param string $api_key Management key. * @param string $site_id Site ID. * @param string $range Range. * @param string $view Cache variant. * @return string */ private static function cache_key( $api_key, $site_id, $range, $view ) { return 'cw_an_' . substr( hash( 'sha256', $api_key . '|' . $site_id . '|' . $range . '|' . $view ), 0, 32 ); } /** * @param string $site_id Site ID. * @param string $suffix Path after the site ID. * @param string $range Range. * @return string */ private static function site_path( $site_id, $suffix, $range ) { return '/api/v1/sites/' . rawurlencode( $site_id ) . $suffix . '?range=' . rawurlencode( $range ); } /** * @param string $site_id Site ID. * @param string $range Range. * @param string $filter Event filter. * @param string $cursor Page cursor. * @param int $limit Page size. * @return string */ private static function events_path( $site_id, $range, $filter, $cursor, $limit ) { $path = '/api/v1/sites/' . rawurlencode( $site_id ) . '/events?range=' . rawurlencode( $range ); $path .= '&filter=' . rawurlencode( $filter ); $path .= '&limit=' . rawurlencode( (string) $limit ); if ( '' !== $cursor ) { $path .= '&cursor=' . rawurlencode( $cursor ); } return $path; } /** * @param array|null $result Transport result. * @return array|null */ private static function success_body( $result ) { if ( ! is_array( $result ) || 200 !== (int) $result['status'] || ! isset( $result['body'] ) || ! is_array( $result['body'] ) ) { return null; } if ( isset( $result['body']['error'] ) ) { return null; } return $result['body']; } /** * @param array $result Transport result. * @return array */ private static function transport_error( $result ) { $status = is_array( $result ) && isset( $result['status'] ) ? (int) $result['status'] : 0; $code = 'causewise_error'; $message = __( 'Analytics could not be loaded.', 'causewise-analytics' ); if ( 0 === $status ) { $code = 'network_error'; $message = __( 'CauseWise could not be reached.', 'causewise-analytics' ); } elseif ( is_array( $result ) && isset( $result['body']['error'] ) && is_array( $result['body']['error'] ) ) { if ( isset( $result['body']['error']['code'] ) ) { $code = sanitize_key( (string) $result['body']['error']['code'] ); } if ( isset( $result['body']['error']['message'] ) ) { $text = sanitize_text_field( (string) $result['body']['error']['message'] ); if ( '' !== $text ) { $message = $text; } } } elseif ( null === $result['body'] ) { $message = __( 'CauseWise returned an unexpected response.', 'causewise-analytics' ); } if ( '' === $code ) { $code = 'causewise_error'; } return array( 'ok' => false, 'status' => $status > 0 ? $status : 502, 'code' => $code, 'message' => self::plain_api_message( $code, $message ), ); } /** * @param int $status HTTP status for the WordPress response. * @param string $code Error code. * @param string $message Admin-facing message. * @return array */ private static function failure( $status, $code, $message ) { return array( 'ok' => false, 'status' => (int) $status, 'code' => $code, 'message' => $message, ); } /** * @param string $api_key Management key. * @param string $site_id Site ID. * @param string $range Range. * @param array|null $summary Summary transport result. * @return array> */ private static function load_funnel_details( $api_key, $site_id, $range, $summary ) { $details = array(); $body = self::success_body( $summary ); if ( ! is_array( $body ) || ! isset( $body['funnels'] ) || ! is_array( $body['funnels'] ) ) { return $details; } if ( empty( $body['funnels']['available'] ) || ! isset( $body['funnels']['funnels'] ) || ! is_array( $body['funnels']['funnels'] ) ) { return $details; } $count = 0; foreach ( $body['funnels']['funnels'] as $funnel ) { if ( $count >= self::FUNNEL_DETAIL_LIMIT || ! is_array( $funnel ) || empty( $funnel['id'] ) ) { continue; } $id = self::text_field( $funnel['id'] ); if ( ! Causewise_Credentials::is_valid_site_id( $id ) && ! preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $id ) ) { continue; } $details[ $id ] = self::get_json( self::site_path( $site_id, '/funnels/' . rawurlencode( $id ) . '/analytics', $range ), $api_key ); $count++; } return $details; } /** * @param array $row Site record from the API. * @return array{id: string, name: string, domain: string} */ private static function public_site( $row ) { return array( 'id' => self::text_field( isset( $row['id'] ) ? $row['id'] : '' ), 'name' => self::text_field( isset( $row['name'] ) ? $row['name'] : '' ), 'domain' => self::text_field( isset( $row['domain'] ) ? $row['domain'] : '' ), ); } /** * @param array $sites Sites. * @return array */ private static function site_choices( $sites ) { $choices = array(); foreach ( $sites as $site ) { $choices[] = array( 'id' => $site['id'], 'name' => $site['name'], 'domain' => $site['domain'], ); if ( count( $choices ) >= 20 ) { break; } } return $choices; } /** * @param array $source Source object. * @param string $fallback Range to use when the source has none. * @return string */ private static function range_field( $source, $fallback ) { if ( isset( $source['range'] ) ) { return self::sanitize_range( $source['range'] ); } return self::sanitize_range( $fallback ); } /** * Hide rate-definition and permission jargon from the admin payload. * * @param string $value Candidate string. * @return bool */ private static function is_internal_copy( $value ) { return 1 === preg_match( '/dashboard_goal_rollup|dashboard_funnel|\bdenominator\b|\bscope\b/i', $value ); } /** * @param string $code API error code. * @param string $message API error message. * @return string */ private static function plain_api_message( $code, $message ) { $messages = array( 'subscription_required' => __( 'Showing analytics in WordPress needs an active CauseWise subscription.', 'causewise-analytics' ), 'unauthorized' => __( 'CauseWise did not accept this connection. Check the management API key in settings.', 'causewise-analytics' ), 'revoked' => __( 'This connection was turned off in CauseWise. Create a new management API key and save it in settings.', 'causewise-analytics' ), 'expired' => __( 'This connection has expired. Create a new management API key in CauseWise and save it in settings.', 'causewise-analytics' ), 'rate_limited' => __( 'CauseWise is busy right now. Wait a moment and refresh this page.', 'causewise-analytics' ), 'forbidden' => __( 'This connection is not allowed to view analytics. Update the key in CauseWise, then save it again in settings.', 'causewise-analytics' ), 'not_found' => __( 'CauseWise could not find this site. Check the site ID in settings.', 'causewise-analytics' ), 'invalid_range' => __( 'Choose today, the last 7 days, or the last 30 days.', 'causewise-analytics' ), 'network_error' => __( 'CauseWise could not be reached. Try again in a moment.', 'causewise-analytics' ), ); if ( isset( $messages[ $code ] ) ) { return $messages[ $code ]; } if ( self::is_internal_copy( $message ) ) { return __( 'Analytics could not be loaded. Check the connection in settings.', 'causewise-analytics' ); } return $message; } /** * @param mixed $note API sessions note. * @return string */ private static function plain_sessions_note( $note ) { if ( '' === self::text_field( $note ) ) { return ''; } return __( 'A session is one visit to your site.', 'causewise-analytics' ); } /** * @param mixed $note API visitors note. * @param int|null $visitors Visitor count already taken from the API. * @return string */ private static function plain_visitors_note( $note, $visitors ) { $note = self::text_field( $note ); if ( '' === $note ) { return ''; } if ( false !== stripos( $note, 'no events' ) ) { return __( 'No visits in this period.', 'causewise-analytics' ); } if ( null === $visitors ) { return __( 'A visitor count is not available for this period.', 'causewise-analytics' ); } return __( 'Each person is counted once.', 'causewise-analytics' ); } /** * @param string $feature goals or funnels. * @return string */ private static function feature_unavailable( $feature ) { if ( 'funnels' === $feature ) { return __( 'Funnels are not available for this connection. Update the key in CauseWise, then save it again in settings.', 'causewise-analytics' ); } return __( 'Goals are not available for this connection. Update the key in CauseWise, then save it again in settings.', 'causewise-analytics' ); } /** * @param mixed $value Raw value. * @return string */ private static function text_field( $value ) { if ( ! is_scalar( $value ) ) { return ''; } $text = sanitize_text_field( (string) $value ); if ( strlen( $text ) > 2000 ) { $text = substr( $text, 0, 2000 ); } return $text; } /** * @param mixed $value Raw count. * @return int */ private static function count_value( $value ) { if ( ! is_numeric( $value ) ) { return 0; } $number = (float) $value; if ( $number < 0 ) { return 0; } if ( $number > PHP_INT_MAX ) { return PHP_INT_MAX; } return (int) $number; } /** * @param mixed $value Raw rate or null. * @return float|int|null */ private static function number_or_null( $value ) { if ( null === $value || '' === $value ) { return null; } if ( ! is_numeric( $value ) ) { return null; } return $value + 0; } /** * @param mixed $rows API top pages. * @return array */ private static function top_pages( $rows ) { $pages = array(); if ( ! is_array( $rows ) ) { return $pages; } foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! isset( $row['path'] ) ) { continue; } $pages[] = array( 'path' => self::text_field( $row['path'] ), 'count' => self::count_value( isset( $row['count'] ) ? $row['count'] : 0 ), ); if ( count( $pages ) >= 20 ) { break; } } return $pages; } /** * @param mixed $rows API referrers. * @return array */ private static function referrers( $rows ) { $list = array(); if ( ! is_array( $rows ) ) { return $list; } foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $list[] = array( 'label' => self::text_field( isset( $row['label'] ) ? $row['label'] : '' ), 'count' => self::count_value( isset( $row['count'] ) ? $row['count'] : 0 ), ); if ( count( $list ) >= 20 ) { break; } } return $list; } /** * @param mixed $rows API trend. * @return array */ private static function trend_points( $rows ) { $points = array(); if ( ! is_array( $rows ) ) { return $points; } foreach ( $rows as $row ) { if ( ! is_array( $row ) || ! isset( $row['label'] ) ) { continue; } $points[] = array( 'label' => self::text_field( $row['label'] ), 'count' => self::count_value( isset( $row['count'] ) ? $row['count'] : 0 ), ); if ( count( $points ) >= 200 ) { break; } } return $points; } /** * @param mixed $section Goals section from summary. * @return array|null */ private static function goals( $section ) { if ( ! is_array( $section ) || ! array_key_exists( 'available', $section ) ) { return null; } if ( empty( $section['available'] ) ) { return array( 'available' => false, 'reason' => self::feature_unavailable( 'goals' ), ); } $goals = array(); if ( isset( $section['goals'] ) && is_array( $section['goals'] ) ) { foreach ( $section['goals'] as $goal ) { if ( ! is_array( $goal ) ) { continue; } $goals[] = array( 'id' => self::text_field( isset( $goal['id'] ) ? $goal['id'] : '' ), 'name' => self::text_field( isset( $goal['name'] ) ? $goal['name'] : '' ), 'eventName' => self::text_field( isset( $goal['eventName'] ) ? $goal['eventName'] : '' ), 'conversions' => self::count_value( isset( $goal['conversions'] ) ? $goal['conversions'] : 0 ), 'trackingStatus' => self::text_field( isset( $goal['trackingStatus'] ) ? $goal['trackingStatus'] : '' ), 'lastMatchingAt' => self::nullable_text( isset( $goal['lastMatchingAt'] ) ? $goal['lastMatchingAt'] : null ), ); } } return array( 'available' => true, 'activeGoalCount' => self::count_value( isset( $section['activeGoalCount'] ) ? $section['activeGoalCount'] : 0 ), 'totalConversions' => self::count_value( isset( $section['totalConversions'] ) ? $section['totalConversions'] : 0 ), 'conversionRate' => self::number_or_null( isset( $section['conversionRate'] ) ? $section['conversionRate'] : null ), 'truncated' => ! empty( $section['truncated'] ), 'goals' => $goals, ); } /** * @param mixed $section Summary funnels. * @param array> $details Funnel analytics responses. * @return array|null */ private static function funnels( $section, $details ) { if ( ! is_array( $section ) || ! array_key_exists( 'available', $section ) ) { return null; } if ( empty( $section['available'] ) ) { return array( 'available' => false, 'reason' => self::feature_unavailable( 'funnels' ), ); } $funnels = array(); if ( isset( $section['funnels'] ) && is_array( $section['funnels'] ) ) { foreach ( $section['funnels'] as $funnel ) { if ( ! is_array( $funnel ) ) { continue; } $id = self::text_field( isset( $funnel['id'] ) ? $funnel['id'] : '' ); $mapped = self::funnel_summary( $funnel ); $detail = isset( $details[ $id ] ) ? self::success_body( $details[ $id ] ) : null; if ( is_array( $detail ) ) { $mapped = self::funnel_detail( $mapped, $detail ); } $funnels[] = $mapped; } } return array( 'available' => true, 'truncated' => ! empty( $section['truncated'] ), 'funnels' => $funnels, ); } /** * @param array $funnel Summary funnel. * @return array */ private static function funnel_summary( $funnel ) { return array( 'id' => self::text_field( isset( $funnel['id'] ) ? $funnel['id'] : '' ), 'name' => self::text_field( isset( $funnel['name'] ) ? $funnel['name'] : '' ), 'isActive' => ! empty( $funnel['isActive'] ), 'displayState' => self::text_field( isset( $funnel['displayState'] ) ? $funnel['displayState'] : '' ), 'entryVisitors' => self::count_value( isset( $funnel['entryVisitors'] ) ? $funnel['entryVisitors'] : 0 ), 'completionCount' => self::count_value( isset( $funnel['completionCount'] ) ? $funnel['completionCount'] : 0 ), 'overallConversionRate' => self::number_or_null( isset( $funnel['overallConversionRate'] ) ? $funnel['overallConversionRate'] : null ), 'conversionWindow' => self::count_value( isset( $funnel['conversionWindow'] ) ? $funnel['conversionWindow'] : 0 ), 'largestDropOff' => self::drop_off( isset( $funnel['largestDropOff'] ) ? $funnel['largestDropOff'] : null ), ); } /** * Overlay step detail from funnel analytics without recalculating rates. * * @param array $summary Summary funnel. * @param array $detail Funnel analytics body. * @return array */ private static function funnel_detail( $summary, $detail ) { $summary['entryVisitors'] = self::count_value( isset( $detail['entryVisitors'] ) ? $detail['entryVisitors'] : $summary['entryVisitors'] ); $summary['completionCount'] = self::count_value( isset( $detail['completionCount'] ) ? $detail['completionCount'] : $summary['completionCount'] ); if ( array_key_exists( 'overallConversionRate', $detail ) ) { $summary['overallConversionRate'] = self::number_or_null( $detail['overallConversionRate'] ); } if ( isset( $detail['displayState'] ) ) { $summary['displayState'] = self::text_field( $detail['displayState'] ); } if ( isset( $detail['conversionWindow'] ) ) { $summary['conversionWindow'] = self::count_value( $detail['conversionWindow'] ); } if ( isset( $detail['medianConversionTime'] ) ) { $summary['medianConversionTime'] = self::nullable_text( $detail['medianConversionTime'] ); } if ( array_key_exists( 'largestDropOff', $detail ) ) { $summary['largestDropOff'] = self::drop_off( $detail['largestDropOff'] ); } if ( isset( $detail['steps'] ) && is_array( $detail['steps'] ) ) { $steps = array(); foreach ( $detail['steps'] as $step ) { if ( ! is_array( $step ) ) { continue; } $steps[] = array( 'stepId' => self::text_field( isset( $step['stepId'] ) ? $step['stepId'] : '' ), 'name' => self::text_field( isset( $step['name'] ) ? $step['name'] : '' ), 'position' => self::count_value( isset( $step['position'] ) ? $step['position'] : 0 ), 'visitors' => self::count_value( isset( $step['visitors'] ) ? $step['visitors'] : 0 ), 'continuedPct' => self::number_or_null( isset( $step['continuedPct'] ) ? $step['continuedPct'] : null ), 'dropoffCount' => self::number_or_null( isset( $step['dropoffCount'] ) ? $step['dropoffCount'] : null ), 'dropoffPct' => self::number_or_null( isset( $step['dropoffPct'] ) ? $step['dropoffPct'] : null ), ); } $summary['steps'] = $steps; } return $summary; } /** * @param mixed $drop Largest drop-off object. * @return array|null */ private static function drop_off( $drop ) { if ( ! is_array( $drop ) ) { return null; } return array( 'stepId' => self::text_field( isset( $drop['stepId'] ) ? $drop['stepId'] : '' ), 'name' => self::text_field( isset( $drop['name'] ) ? $drop['name'] : '' ), 'position' => isset( $drop['position'] ) ? self::count_value( $drop['position'] ) : null, 'dropoffCount' => self::number_or_null( isset( $drop['dropoffCount'] ) ? $drop['dropoffCount'] : null ), 'dropoffPct' => self::number_or_null( isset( $drop['dropoffPct'] ) ? $drop['dropoffPct'] : null ), ); } /** * @param mixed $rows Recent events. * @return array */ private static function recent_events( $rows, $limit = 8 ) { $events = array(); $limit = max( 1, (int) $limit ); if ( ! is_array( $rows ) ) { return $events; } foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $events[] = array( 'id' => self::text_field( isset( $row['id'] ) ? $row['id'] : '' ), 'type' => self::text_field( isset( $row['type'] ) ? $row['type'] : '' ), 'path' => self::text_field( isset( $row['path'] ) ? $row['path'] : '' ), 'timestamp' => self::text_field( isset( $row['timestamp'] ) ? $row['timestamp'] : '' ), ); if ( count( $events ) >= $limit ) { break; } } return $events; } /** * @param mixed $value Raw value. * @return string|null */ private static function nullable_text( $value ) { if ( null === $value ) { return null; } $text = self::text_field( $value ); return '' === $text ? null : $text; } }