*/ private array $views_by_path = array(); /** * Whether the one `posts_column` request for this screen was a cache * miss (a warm-up event has been scheduled; nothing to show yet). * * @var bool */ private bool $loading = false; /** * The message to show instead of a value when resolving the cache key * itself failed (never a Google API failure: compute() never calls * Google). * * @var string|null */ private ?string $error_message = null; /** * Constructor. * * @param Plugin $plugin Plugin service locator. */ public function __construct( Plugin $plugin ) { $this->plugin = $plugin; } /** * Hooks the columns and custom-column callbacks for every enabled post * type. Admin-only; call register_cron() unconditionally as well, since * wp-cron requests run outside admin. */ public function register(): void { foreach ( (array) $this->plugin->settings()->get( 'post_types' ) as $type ) { if ( ! is_string( $type ) || '' === $type ) { continue; } add_filter( "manage_{$type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); } } /** * Hooks the wp-cron warm-up handler and the cache-cleared listener. * Registered unconditionally in Plugin::boot() (not only when * is_admin()), because the scheduled event runs on its own request, and * a reconnect (OAuthFlow::finalize()) can also fire the action from * outside admin. */ public function register_cron(): void { add_action( self::WARM_HOOK, array( $this, 'warm' ), 10, 1 ); add_action( 'tagsmith_cache_cleared', array( $this, 'clear_error' ) ); } /** * Clears the warm-up error cooldown, so the next cache miss retries * immediately instead of waiting out the hour: called after * `tagsmith_cache_cleared` (a manual "Clear cache", or a successful * reconnect), since either one is a sign the underlying problem may now * be fixed. */ public function clear_error(): void { delete_transient( self::ERROR_TRANSIENT ); } /** * The wp-cron callback: runs the real `posts_column` request for the * given paths so the next page load finds it cached. Never fatal: on * failure, the ApiError slug is recorded so compute() can show why * instead of showing "Loading" forever and rescheduling on every page * view; on success, that record is cleared. * * @param array $paths GA4 pagePath values to warm. */ public function warm( array $paths ): void { try { $this->plugin->reports()->run( 'posts_column', DateRange::from_request( '28d' ), array( 'paths' => $paths ) ); } catch ( ApiError $e ) { set_transient( self::ERROR_TRANSIENT, $e->slug(), HOUR_IN_SECONDS ); return; } delete_transient( self::ERROR_TRANSIENT ); } /** * Adds the column, only when the user can view reports and the plugin * is connected with a property selected. * * @param array $columns Existing columns. * @return array */ public function add_column( array $columns ): array { if ( ! $this->enabled() ) { return $columns; } $columns[ self::COLUMN ] = esc_html__( 'Views (28 days)', 'tagsmith' ); return $columns; } /** * Renders one cell: `—` for unpublished posts or plain permalinks (no * API call), the summed views once a cached `posts_column` result * exists, `—` with a "Loading" title while it is being warmed, or `—` * with the error message in a `title` attribute if resolving the * request itself failed. Per-post stats also need edit_post on that * post (spec §3), so the cell is left empty for posts the user cannot * edit. * * @param string $column Column id. * @param int $post_id Post id. */ public function render_column( string $column, int $post_id ): void { if ( self::COLUMN !== $column || ! $this->enabled() ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $post = get_post( $post_id ); if ( null === $post || 'publish' !== $post->post_status ) { echo '—'; return; } $paths = PostPaths::for_post( $post ); if ( null === $paths ) { echo '—'; return; } $this->compute(); if ( null !== $this->error_message ) { printf( '—', esc_attr( $this->error_message ) ); return; } if ( $this->loading ) { printf( '—', esc_attr( __( 'Loading. Refresh the page in a moment.', 'tagsmith' ) ) ); return; } $views = 0.0; foreach ( $paths as $path ) { $views += $this->views_by_path[ $path ] ?? 0.0; } echo esc_html( number_format_i18n( $views ) ); } /** * Whether the column should appear at all on this request. */ private function enabled(): bool { return current_user_can( Capabilities::VIEW ) && $this->plugin->connection()->is_connected() && '' !== (string) $this->plugin->settings()->get( 'property_id' ); } /** * Reads the one cached `posts_column` result for every published, * path-eligible post on the current screen, and builds the * pagePath => views map. Never calls Google directly: on a cache miss * it schedules a wp-cron warm-up and marks the column as loading. * Runs at most once per request. */ private function compute(): void { if ( $this->computed ) { return; } $this->computed = true; $posts = $GLOBALS['wp_query']->posts ?? array(); $paths = array(); foreach ( (array) $posts as $post ) { if ( ! ( $post instanceof \WP_Post ) || 'publish' !== $post->post_status ) { continue; } $post_paths = PostPaths::for_post( $post ); if ( null === $post_paths ) { continue; } foreach ( $post_paths as $path ) { $paths[] = $path; } } $paths = array_values( array_unique( $paths ) ); if ( array() === $paths ) { return; } try { $result = $this->plugin->reports()->cached( 'posts_column', DateRange::from_request( '28d' ), array( 'paths' => $paths ) ); } catch ( ApiError $e ) { $this->error_message = $e->user_message(); return; } if ( null === $result ) { $last_error_slug = get_transient( self::ERROR_TRANSIENT ); if ( is_string( $last_error_slug ) && '' !== $last_error_slug ) { $this->error_message = ( new ApiError( $last_error_slug ) )->user_message(); return; } $this->loading = true; $this->schedule_warm( $paths ); return; } foreach ( (array) ( $result['data']['rows'] ?? array() ) as $row ) { if ( ! is_array( $row ) ) { continue; } $path = (string) ( $row['pagePath'] ?? '' ); $views = (float) ( $row['screenPageViews'] ?? 0 ); if ( '' === $path ) { continue; } $this->views_by_path[ $path ] = ( $this->views_by_path[ $path ] ?? 0.0 ) + $views; } } /** * Schedules the one-off warm-up event, unless one is already pending * with the same paths. * * @param array $paths GA4 pagePath values to warm. */ private function schedule_warm( array $paths ): void { if ( false === wp_next_scheduled( self::WARM_HOOK, array( $paths ) ) ) { wp_schedule_single_event( time(), self::WARM_HOOK, array( $paths ) ); } } }