\d+)/stats', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'stats' ), 'permission_callback' => array( $this, 'can_view_post' ), 'args' => array_merge( array( 'id' => self::arg( array( 'type' => 'integer', 'minimum' => 1, ) ), ), self::range_args( false ) ), ) ); } /** * Permission callback: VIEW and edit_post. A missing post is a 404 for * viewers (it reveals nothing they could not learn from the editor). * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function can_view_post( \WP_REST_Request $request ) { if ( ! $this->can_view() ) { return false; } $id = (int) $request->get_param( 'id' ); if ( null === get_post( $id ) ) { return self::not_found(); } return current_user_can( 'edit_post', $id ); } /** * GET /posts/{id}/stats → {data: {stats, sources, queries, behavior}, meta}. * * Each Google part fails independently: a failing part is null and its * error slug is listed in meta.errors. When every part that ran failed, * the first error is returned instead, unless Clarity is on (then the * response keeps the post's behaviour, with every part's error in * meta.errors). With plain permalinks nothing is queried (GA4 cannot * tell posts apart by path) and every part is null. * * data.behavior is the post's Clarity behaviour over the range (or * null), with meta.behavior_status ok, no_token, no_data or off. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function stats( \WP_REST_Request $request ) { $post = get_post( (int) $request->get_param( 'id' ) ); $settings = $this->plugin->settings(); if ( null === $post || 'publish' !== $post->post_status || ! in_array( $post->post_type, (array) $settings->get( 'post_types' ), true ) ) { return self::not_found(); } $range = self::range_from( $request, false ); $data = array( 'stats' => null, 'sources' => null, 'queries' => null, ); $meta = array( 'stale' => false, 'stale_parts' => array(), 'generated_at' => null, 'property_id' => (string) $settings->get( 'property_id' ), 'range' => $range->to_array(), 'errors' => array(), ); $paths = PostPaths::for_post( $post ); if ( null === $paths ) { $meta['errors'] = array_fill_keys( array_keys( $data ), 'plain_permalinks' ); $meta['message'] = __( 'Per-post stats need pretty permalinks. Choose any structure except Plain in Settings > Permalinks.', 'tagsmith' ); return new \WP_REST_Response( $this->with_behavior( $data, $meta, null, $range ) ); } $parts = array( 'stats' => array( 'post_stats', array( 'paths' => $paths ) ), 'sources' => array( 'post_sources', array( 'paths' => $paths ) ), ); if ( $settings->get( 'gsc_enabled' ) ) { $parts['queries'] = array( 'gsc_post_queries', array( 'page_url' => (string) get_permalink( $post ) ) ); } $first = null; foreach ( $parts as $part => [ $report, $args ] ) { try { $result = $this->plugin->reports()->run( $report, $range, $args ); $data[ $part ] = 'stats' === $part ? self::trend( $result['data'] ) : $result['data']; $generated = (int) ( $result['meta']['generated_at'] ?? 0 ); // The oldest part decides how fresh the response is. $meta['generated_at'] = null === $meta['generated_at'] ? $generated : min( $meta['generated_at'], $generated ); if ( ! empty( $result['meta']['stale'] ) ) { $meta['stale'] = true; $meta['stale_parts'][] = $part; } } catch ( ApiError $e ) { $meta['errors'][ $part ] = $e->slug(); $first = $first ?? $e; } } if ( null !== $first && count( $meta['errors'] ) === count( $parts ) && ! $this->clarity_enabled() ) { return self::error( $first ); } return new \WP_REST_Response( $this->with_behavior( $data, $meta, $paths, $range ) ); } /** * The response body with the post's Clarity behaviour added: * data.behavior and meta.behavior_status. * * @param array $data Response data. * @param array $meta Response meta. * @param string[]|null $paths The post's paths, null with plain permalinks. * @param DateRange $range Range. * @return array{data: array, meta: array} */ private function with_behavior( array $data, array $meta, ?array $paths, DateRange $range ): array { $insights = new Insights( $this->plugin->clarity(), $this->plugin->clarity_store() ); $behavior = $insights->for_post( $paths, $range, $this->clarity_enabled() ); $data['behavior'] = $behavior['behavior']; $meta['behavior_status'] = $behavior['status']; return array( 'data' => $data, 'meta' => $meta, ); } /** * Whether Microsoft Clarity is on: switched on, with a project id (the * tag can't print without one). */ private function clarity_enabled(): bool { $settings = $this->plugin->settings(); return (bool) $settings->get( 'clarity_enabled' ) && '' !== (string) $settings->get( 'clarity_project_id' ); } /** * Shapes post_stats (dimension: date) into a Y-m-d trend plus totals. * * @param array $data post_stats result data. * @return array{trend: array, totals: array} */ private static function trend( array $data ): array { $trend = array(); foreach ( (array) ( $data['rows'] ?? array() ) as $row ) { if ( ! is_array( $row ) ) { continue; } $raw = (string) ( $row['date'] ?? '' ); $trend[] = array( 'date' => 1 === preg_match( '/^(\d{4})(\d{2})(\d{2})$/', $raw, $m ) ? "$m[1]-$m[2]-$m[3]" : $raw, 'screenPageViews' => (float) ( $row['screenPageViews'] ?? 0 ), 'activeUsers' => (float) ( $row['activeUsers'] ?? 0 ), ); } usort( $trend, static fn( array $a, array $b ): int => strcmp( $a['date'], $b['date'] ) ); return array( 'trend' => $trend, 'totals' => is_array( $data['totals'] ?? null ) ? array_map( 'floatval', $data['totals'] ) : array(), ); } /** * The 404 for missing, unpublished or untracked posts. */ private static function not_found(): \WP_Error { return new \WP_Error( 'tagsmith_post_not_found', __( 'Stats are only available for published posts of an enabled post type.', 'tagsmith' ), array( 'status' => 404 ) ); } }