api_client = $api_client; } /** * Data for rendering, or null when there is nothing to render. * * Null means the caller renders nothing (for "unavailable") or its own fallback (for a * miss). Use `is_unavailable()` to tell the two apart before falling back to a snapshot. * Never makes an HTTP call; schedules a refresh when the entry is stale or missing. * * @param string $path Public API path, for example `/v1/episodes/{id}`. * @return mixed */ public function get( string $path ) { $entry = $this->read( $path ); if ( null === $entry || $this->is_stale( $entry ) ) { $this->schedule_refresh( $path, null === $entry ? 0 : $entry['next_attempt'] ); } if ( null === $entry || self::STATE_OK !== $entry['state'] ) { return null; } return $entry['data']; } /** * Whether the API said this resource is unavailable. Callers must then render nothing, * not a snapshot saved in the block. * * @param string $path Public API path. */ public function is_unavailable( string $path ): bool { $entry = $this->read( $path ); return null !== $entry && self::STATE_UNAVAILABLE === $entry['state']; } /** * Refresh-aware episode visibility for a cached list or latest response. * * A fresh public response containing the episode can supersede an older marker * for this render. Keep the marker stored until its own refresh succeeds: list * items are not complete episode payloads, and old sources must still be hidden. * Equal timestamps favour the marker because their ordering is unknown. * * @param mixed $episode_id Episode UUID in the public response. * @param string $source_path Cache path of that list or latest response. */ public function is_episode_unavailable( $episode_id, string $source_path ): bool { if ( ! is_string( $episode_id ) || '' === $episode_id ) { return false; } $path = '/v1/episodes/' . $episode_id; $marker = $this->read( $path ); if ( null === $marker || self::STATE_UNAVAILABLE !== $marker['state'] ) { return false; } // This read schedules a stale marker's refresh and honours its existing back-off. // Do not fan out requests for episodes without an unavailable marker. $this->get( $path ); $source = $this->read( $source_path ); if ( null === $source || self::STATE_OK !== $source['state'] || $this->is_stale( $source ) || $source['fetched_at'] <= $marker['fetched_at'] ) { return true; } $data = $source['data']['data'] ?? null; if ( ! is_array( $data ) ) { return true; } $episodes = isset( $data['id'] ) ? array( $data ) : $data; foreach ( $episodes as $episode ) { if ( is_array( $episode ) && ( $episode['id'] ?? null ) === $episode_id ) { return false; } } return true; } /** * Refreshes one entry. Runs from WP-Cron only. * * @param mixed $path Public API path, as passed to the cron event. */ public function refresh( $path ): void { if ( ! is_string( $path ) || '' === $path ) { return; } $entry = $this->read( $path ); if ( null !== $entry && self::STATE_PENDING !== $entry['state'] && ! $this->is_stale( $entry ) ) { // Still fresh, for example just stored by the sync: nothing to ask. return; } if ( null !== $entry && $entry['next_attempt'] > time() ) { $this->schedule_refresh( $path, $entry['next_attempt'] ); return; } $etag = null !== $entry && self::STATE_OK === $entry['state'] ? $entry['etag'] : null; $result = $this->api_client->get( $path, $etag ); $now = time(); switch ( $result->type() ) { case Api_Result::SUCCESS: $this->store( $path, $result, $entry ); return; case Api_Result::NOT_MODIFIED: if ( null !== $entry && self::STATE_OK === $entry['state'] ) { $entry['fetched_at'] = $now; $entry['backoff'] = 0; $entry['next_attempt'] = 0; $entry['etag'] = null !== $result->etag() ? $result->etag() : $entry['etag']; $this->write( $path, $entry ); return; } break; case Api_Result::UNAVAILABLE: $this->write( $path, array( 'state' => self::STATE_UNAVAILABLE, 'data' => null, 'etag' => null, 'fetched_at' => $now, 'backoff' => 0, 'next_attempt' => 0, ) ); if ( null === $entry || self::STATE_UNAVAILABLE !== $entry['state'] ) { self::purge_posts( $path ); } return; } $this->back_off( $path, $entry, $result ); } /** * Stores a successful public API answer the sync already fetched (the artwork step reads * the same `/v1/episodes/{id}` the players render from), so the first render after a post * is created can use it. Also the refresh's own success path. * * @param string $path Public API path. * @param Api_Result $result A successful result. * @param array|null $previous The entry it replaces, if already read. */ public function store( string $path, Api_Result $result, ?array $previous = null ): void { if ( ! $result->is( Api_Result::SUCCESS ) ) { return; } $previous = $previous ?? $this->read( $path ); $this->write( $path, array( 'state' => self::STATE_OK, 'data' => $result->data(), 'etag' => $result->etag(), 'fetched_at' => time(), 'backoff' => 0, 'next_attempt' => 0, ) ); if ( null === $previous || self::STATE_OK !== $previous['state'] || $previous['data'] !== $result->data() ) { self::purge_posts( $path ); } } /** * Cleans the post cache of every post that renders this path, so page-cache plugins that * hook clean_post_cache purge the pages showing the old render (or the cold one, with no * data). Matches synced posts by their episode, other posts by the ID or slug in a show.fm * block or shortcode, and posts that show such a post as a synced pattern. Runs only when * an entry changed, from WP-Cron or the sync. * * @param string $path Public API path. * @return int[] The posts cleaned. */ public static function purge_posts( string $path ): array { global $wpdb; if ( ! preg_match( '~\A/v1/(episodes|podcasts)/([a-z0-9-]+)~i', $path, $match ) ) { return array(); } $token = strtolower( $match[2] ); $name = 'episodes' === strtolower( $match[1] ) ? 'episode' : 'podcast'; // SQL only prefilters: a show.fm block or shortcode, and the ID or slug anywhere. Each // candidate is confirmed with WordPress's own block and shortcode parsers, so every // form the renderer accepts matches and "news" never matches "newsroom". Candidates // are read in ID order until PURGE_LIMIT are confirmed or they run out, so posts that // only mention the value can't use up the limit, and at most PURGE_SCAN_LIMIT are read. $budget = self::PURGE_SCAN_LIMIT; $found = self::scan( false, $token, static function ( string $content ) use ( $name, $token ): bool { return self::renders( $content, $name, $token ); }, self::PURGE_LIMIT, $budget ); // A synced pattern (wp_block) is shown wherever a post references it, and a pattern // can reference another, so follow the references within the same limits. $patterns = array_keys( $found, 'wp_block', true ); $room = self::PURGE_LIMIT - count( $found ); while ( $patterns && $room > 0 && $budget > 0 ) { $ref = (int) array_shift( $patterns ); $refs = self::scan( true, (string) $ref, static function ( string $content ) use ( $ref ): bool { return self::references( $content, $ref ); }, $room, $budget ); foreach ( $refs as $id => $type ) { if ( ! isset( $found[ $id ] ) ) { $found[ $id ] = $type; --$room; if ( 'wp_block' === $type ) { $patterns[] = $id; } } } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A bounded lookup, run only when a cached entry changes. $synced = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_showfm_episode_id' AND meta_value = %s LIMIT 200", $token ) ); $ids = array_values( array_unique( array_map( 'intval', array_merge( array_keys( $found ), $synced ) ) ) ); foreach ( $ids as $id ) { clean_post_cache( $id ); } return $ids; } /** * Reads the published posts that match a prefilter a page at a time, in ID order, and * keeps the ones the check confirms. The prefilter is a show.fm block or shortcode with the * value anywhere in the content, or a block comment with a "ref" key followed somewhere by * the value, so JSON whitespace like {"ref" : 12} still passes. * * @param bool $patterns Whether to look for synced pattern blocks. * @param string $value The ID or slug, or for patterns the pattern's ID. * @param callable(string): bool $confirm Confirms a post from its content. * @param int $want Stops once this many are confirmed. * @param int $budget How many candidates may still be read, reduced by each one read. * @return array The confirmed posts' IDs and post types. */ private static function scan( bool $patterns, string $value, callable $confirm, int $want, int &$budget ): array { global $wpdb; $like = '%' . ( $patterns ? $wpdb->esc_like( '"ref"' ) . '%' : '' ) . $wpdb->esc_like( $value ) . '%'; $found = array(); $after = 0; while ( $budget > 0 && $want > 0 ) { $page = min( self::PURGE_PAGE, $budget ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A bounded lookup, run only when a cached entry changes. $rows = (array) ( $patterns ? $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type, post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','future','private') AND ID > %d AND post_content LIKE %s AND post_content LIKE %s ORDER BY ID LIMIT %d", $after, '%' . $wpdb->esc_like( 'wp:block' ) . '%', $like, $page ) ) : $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type, post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','future','private') AND ID > %d AND ( post_content LIKE %s OR post_content LIKE %s ) AND post_content LIKE %s ORDER BY ID LIMIT %d", $after, '%' . $wpdb->esc_like( '