$section, 'level' => $level, 'code' => $code, 'message' => $message, 'temporary' => (bool) $temporary, ); }; self::check_period( $period, $today, $add ); $labels = self::labels(); $any = false; foreach ( Profile::all_sections() as $section ) { if ( ! in_array( $section, $site->sections, true ) ) { continue; } if ( ! $site->wants( $section ) || ( Profile::SECTION_TRAFFIC === $section && ! $site->traffic_metrics ) ) { $add( $section, self::WARNING, 'not_configured', Profile::SECTION_TRAFFIC === $section && $site->wants( $section ) /* translators: %s: report section name. */ ? sprintf( __( '%s is switched on but no GA4 metrics are chosen, so it is left out.', 'turtlereport' ), $labels[ $section ] ) /* translators: %s: report section name. */ : sprintf( __( '%s is switched on but has no data source chosen, so it is left out.', 'turtlereport' ), $labels[ $section ] ) ); continue; } $any = true; if ( ! isset( $data[ $section ] ) ) { continue; } $part = $data[ $section ]; if ( isset( $part['error'] ) ) { $reason = isset( $part['reason'] ) ? (string) $part['reason'] : ApiException::GOOGLE; list( $code, $temporary ) = self::classify( $reason, $section ); $add( $section, self::BLOCKED, $code, self::error_message( $code, $labels[ $section ], (string) $part['error'] ), $temporary ); // Kept so problems() can say it once when several sections fail the same way. $checks[ count( $checks ) - 1 ] += array( 'label' => $labels[ $section ], 'detail' => (string) $part['error'], ); continue; } self::check_section( $section, $part, $period, $labels[ $section ], $add ); } if ( ! $any ) { $add( '', self::BLOCKED, 'no_sections', __( 'Nothing to report: no section is switched on with a data source.', 'turtlereport' ) ); } $level = self::READY; $temporary = false; $blocked = 0; foreach ( $checks as $c ) { if ( self::BLOCKED === $c['level'] ) { ++$blocked; $level = self::BLOCKED; $temporary = $temporary || $c['temporary']; } elseif ( self::WARNING === $c['level'] && self::READY === $level ) { $level = self::WARNING; } } // Only "temporary" when every blocking problem may clear by itself. if ( $blocked ) { foreach ( $checks as $c ) { if ( self::BLOCKED === $c['level'] && ! $c['temporary'] ) { $temporary = false; } } } return array( 'level' => $level, 'checks' => $checks, 'temporary' => $temporary, ); } /** * Checks that the period is complete and its comparison lines up. * * @param array $period Period. * @param string|null $today Today. * @param callable $add Adds a check. * @return void */ private static function check_period( array $period, $today, callable $add ) { $valid = static function ( $d ) { $x = \DateTimeImmutable::createFromFormat( '!Y-m-d', (string) $d, new \DateTimeZone( 'UTC' ) ); return $x && $x->format( 'Y-m-d' ) === $d; }; foreach ( array( 's', 'e', 'ps', 'pe' ) as $k ) { if ( ! isset( $period[ $k ] ) || ! $valid( $period[ $k ] ) ) { $add( '', self::BLOCKED, 'period', __( 'The reporting period is invalid.', 'turtlereport' ) ); return; } } if ( $period['s'] > $period['e'] || $period['ps'] > $period['pe'] || ( $period['pe'] >= $period['s'] && $period['ps'] <= $period['e'] ) ) { $add( '', self::BLOCKED, 'period', __( 'The reporting period or its comparison is invalid (the dates overlap or run backwards).', 'turtlereport' ) ); return; } $last = Periods::end_date( $today ); if ( $period['e'] > $last ) { $add( '', self::BLOCKED, 'period', /* translators: 1: period end date, 2: last date with settled data. */ sprintf( __( 'The period ends on %1$s, but Google data is only settled up to %2$s. Send it later.', 'turtlereport' ), $period['e'], $last ) ); } } /** * Checks one section that loaded. * * @param string $section Section key. * @param array $part Section data. * @param array $period Period. * @param string $label Section name. * @param callable $add Adds a check. * @return void */ private static function check_section( $section, array $part, array $period, $label, callable $add ) { $status = isset( $part['status'] ) ? (string) $part['status'] : 'ok'; if ( 'no_data' === $status && Profile::SECTION_CONVERSIONS !== $section ) { /* translators: %s: report section name. */ $add( $section, self::WARNING, 'no_data', sprintf( __( '%s returned no data at all for this period or the one before. The property may be new, the wrong one, or not collecting data yet.', 'turtlereport' ), $label ) ); return; } if ( 'zero' === $status ) { /* translators: %s: report section name. */ $add( $section, self::OK, 'zero', sprintf( __( '%s is working; this period\'s numbers are genuinely zero.', 'turtlereport' ), $label ) ); } if ( Profile::SECTION_CONVERSIONS === $section ) { if ( 'no_events' === $status ) { $add( $section, self::WARNING, 'no_events', __( 'No GA4 events are chosen as conversions, so conversions show as none.', 'turtlereport' ) ); return; } if ( ! empty( $part['missing_events'] ) ) { $add( $section, self::WARNING, 'missing_events', sprintf( /* translators: 1: comma-separated GA4 event names, 2: number of days. */ _n( 'This conversion event wasn\'t recorded by GA4 in the last %2$d days: %1$s. Check the event name (it is case-sensitive) and that tracking still works.', 'These conversion events weren\'t recorded by GA4 in the last %2$d days: %1$s. Check the event names (they are case-sensitive) and that tracking still works.', count( $part['missing_events'] ), 'turtlereport' ), implode( ', ', $part['missing_events'] ), \TurtleReport\Reports\Summary::SEEN_DAYS ) ); } if ( ! empty( $part['overlaps'] ) ) { $add( $section, self::NOTICE, 'overlap', sprintf( /* translators: %s: comma-separated GA4 event names. */ __( 'Some events are in more than one conversion group (%s). Each group counts them, but the total counts each event once.', 'turtlereport' ), implode( ', ', array_keys( $part['overlaps'] ) ) ) ); } } $last = isset( $part['last_date'] ) ? (string) $part['last_date'] : ''; if ( '' !== $last && $last < $period['e'] && in_array( $section, array( Profile::SECTION_SEARCH, Profile::SECTION_BUSINESS ), true ) ) { $add( $section, self::WARNING, 'incomplete', /* translators: 1: report section name, 2: first missing date, 3: period end date. */ sprintf( __( '%1$s has no data yet for %2$s to %3$s, so the last days of the period may be missing.', 'turtlereport' ), $label, Periods::add_days( $last, 1 ), $period['e'] ) ); } } /** * Check code for an API error reason, and whether it may clear by itself. * * @param string $reason ApiException reason. * @param string $section Section key. * @return array{0:string,1:bool} */ public static function classify( $reason, $section = '' ) { switch ( $reason ) { case ApiException::RECONNECT: return array( 'expired', false ); case ApiException::PERMISSION: case ApiException::MISSING_SCOPE: return array( 'access_denied', false ); case ApiException::API_DISABLED: return array( 'api_disabled', false ); case ApiException::QUOTA: // For Business Profile a quota error almost always means Google hasn't approved the project. return Profile::SECTION_BUSINESS === $section ? array( 'not_approved', false ) : array( 'unavailable', true ); case ApiException::NETWORK: case ApiException::GOOGLE: return array( 'unavailable', true ); default: return array( 'not_configured', false ); } } /** * Message for a blocking error. * * @param string $code Check code. * @param string $label Section name. * @param string $detail Message from Google or the plugin. * @return string */ private static function error_message( $code, $label, $detail ) { switch ( $code ) { case 'expired': if ( '' !== trim( $detail ) ) { // The plugin's own message already says what expired and how to fix it. /* translators: 1: report section name, 2: what expired and how to reconnect. */ $text = __( '%1$s couldn\'t load. %2$s', 'turtlereport' ); break; } /* translators: 1: report section name, 2: details (empty). */ $text = __( '%1$s couldn\'t load: the Google sign-in expired or was revoked. Reconnect it on the Connections screen.%2$s', 'turtlereport' ); break; case 'access_denied': /* translators: 1: report section name, 2: details. */ $text = __( '%1$s couldn\'t load: Google denied access to this property. (%2$s)', 'turtlereport' ); break; case 'api_disabled': /* translators: 1: report section name, 2: details. */ $text = __( '%1$s couldn\'t load: an API is switched off in the Google Cloud project. (%2$s)', 'turtlereport' ); break; case 'not_approved': /* translators: 1: report section name, 2: details. */ $text = __( '%1$s couldn\'t load: Google hasn\'t approved Business Profile API access for this Cloud project yet. (%2$s)', 'turtlereport' ); break; case 'unavailable': /* translators: 1: report section name, 2: details. */ $text = __( '%1$s couldn\'t load: Google was temporarily unavailable. (%2$s)', 'turtlereport' ); break; default: /* translators: 1: report section name, 2: details. */ $text = __( '%1$s couldn\'t load: it isn\'t set up correctly. (%2$s)', 'turtlereport' ); } return sprintf( $text, $label, $detail ); } /** * Section names for messages. * * @return array */ public static function labels() { return array( Profile::SECTION_SEARCH => __( 'Search Console', 'turtlereport' ), Profile::SECTION_CONVERSIONS => __( 'GA4 conversions', 'turtlereport' ), Profile::SECTION_AI => __( 'AI assistant traffic', 'turtlereport' ), Profile::SECTION_TRAFFIC => __( 'GA4 website traffic', 'turtlereport' ), Profile::SECTION_BUSINESS => __( 'Business Profile', 'turtlereport' ), ); } /** * Human label for a verdict. * * @param string $level Verdict. * @return string */ public static function label( $level ) { switch ( $level ) { case self::READY: return __( 'Ready', 'turtlereport' ); case self::WARNING: return __( 'Warning', 'turtlereport' ); default: return __( 'Blocked', 'turtlereport' ); } } /** * The checks a reader needs to see: problems first, without "all fine" lines. * * @param array $result Result of check(). * @return array[] */ public static function problems( array $result ) { $order = array( self::BLOCKED => 0, self::WARNING => 1, self::NOTICE => 2, ); $rows = array_values( array_filter( isset( $result['checks'] ) ? (array) $result['checks'] : array(), static function ( $c ) use ( $order ) { return isset( $order[ $c['level'] ] ); } ) ); usort( $rows, static function ( $a, $b ) use ( $order ) { return $order[ $a['level'] ] <=> $order[ $b['level'] ]; } ); // One line per cause: four sections failing on the same expired sign-in read as one problem. $merged = array(); $groups = array(); foreach ( $rows as $row ) { if ( self::BLOCKED !== $row['level'] || ! isset( $row['label'], $row['detail'] ) ) { $merged[] = $row; continue; } $key = $row['code'] . '|' . $row['detail']; if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = count( $merged ); $merged[] = $row + array( 'labels' => array() ); } $merged[ $groups[ $key ] ]['labels'][] = $row['label']; } foreach ( $groups as $i ) { $labels = $merged[ $i ]['labels']; if ( count( $labels ) > 1 ) { $merged[ $i ]['message'] = self::error_message( $merged[ $i ]['code'], wp_sprintf( '%l', $labels ), $merged[ $i ]['detail'] ); $merged[ $i ]['sections'] = count( $labels ); } unset( $merged[ $i ]['labels'] ); } return $merged; } }