array(), 'has_more' => false, 'error' => null ); } $api_url = DynamiRSS_Settings::get_api_url(); $payload = array( 'limit' => max( 1, min( 200, $limit ) ), 'offset' => max( 0, $offset ), 'fetch_extras' => $fetch_extras, ); // Omit "query" entirely for category-only browsing rather than sending // an empty array/string — some backends choke on an empty query value. if ( ! empty( $terms ) ) { $payload['query'] = count( $terms ) === 1 ? $terms[0] : $terms; } if ( $categories ) { $payload['category'] = count( $categories ) === 1 ? $categories[0] : $categories; } // Appended last, and only the ones actually set, so a block that uses // none of them sends (and caches under) exactly the payload it always has. foreach ( $filters as $key => $value ) { $payload[ $key ] = is_array( $value ) && count( $value ) === 1 ? $value[0] : $value; } $cache_ttl = DynamiRSS_Settings::get_cache_ttl(); $cache_key = 'dynamirss_scan_' . md5( $api_url . wp_json_encode( $payload ) ); if ( $cache_ttl > 0 ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } $headers = array( 'Content-Type' => 'application/json' ); $api_key = DynamiRSS_Settings::get_api_key(); if ( $api_key ) { $headers['Authorization'] = 'Bearer ' . $api_key; } $response = wp_remote_post( $api_url, array( 'headers' => $headers, 'body' => wp_json_encode( $payload ), 'timeout' => 25, ) ); if ( is_wp_error( $response ) ) { return array( 'articles' => array(), 'has_more' => false, 'error' => $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { $error = sprintf( 'dynamirss: API returned HTTP %d', $code ); // A rejected filter (e.g. a start date after the end date) comes back // as { "error": "..." } — pass that on so the editor can see what to fix. if ( is_array( $body ) && is_string( $body['error'] ?? null ) && '' !== $body['error'] ) { $error .= ': ' . $body['error']; } return array( 'articles' => array(), 'has_more' => false, 'error' => $error ); } if ( ! is_array( $body ) ) { return array( 'articles' => array(), 'has_more' => false, 'error' => 'dynamirss: API returned an unreadable response.' . self::describe_raw_body( $response ), ); } $result = array( 'articles' => is_array( $body['articles'] ?? null ) ? $body['articles'] : array(), 'has_more' => ! empty( $body['has_more'] ), // The API quietly caps "limit" at the plan's articles-per-request and // echoes the value it used; the next page starts that far on, not at // the limit we asked for. 'page_size' => isset( $body['limit'] ) ? max( 1, (int) $body['limit'] ) : count( is_array( $body['articles'] ?? null ) ? $body['articles'] : array() ), 'error' => null, ); if ( $cache_ttl > 0 ) { set_transient( $cache_key, $result, $cache_ttl ); } return $result; } /** * Reduce a loose set of extra /scan filters (block attributes, REST params) * to just the ones that are set and valid, keyed by their API names. Both * the block's render.php and the REST controller hand their raw values to * this, so there is one definition of what the plugin will send. * * List-type filters always come back as arrays here; scan() collapses a * single-item list to a plain string when it builds the payload. * * @param array $filters Any of: language (string), source, exclude_source, exclude_query * (string|string[]), date, date_from, date_to (YYYY-MM-DD), date_preset * (today|week|month, resolved to dates), query_mode, search_in, sort * (string), has_image (bool). * @return array */ public static function normalize_filters( array $filters ): array { $out = array(); // A rolling period ("today", "week", "month") stands in for fixed dates. $preset = self::resolve_date_preset( $filters['date_preset'] ?? '' ); if ( $preset ) { $filters = array_merge( $filters, array( 'date' => '', 'date_from' => '', 'date_to' => '' ), $preset ); } // One language code; the API doesn't take a list here. $language = is_string( $filters['language'] ?? null ) ? trim( $filters['language'] ) : ''; if ( preg_match( '/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)*$/', $language ) ) { $out['language'] = $language; } foreach ( array( 'source', 'exclude_source' ) as $key ) { $list = self::to_list( $filters[ $key ] ?? null ); if ( $list ) { $out[ $key ] = $list; } } // "date" wins over a range on the API side, so don't send both. $date = self::valid_date( $filters['date'] ?? '' ); if ( '' !== $date ) { $out['date'] = $date; } else { foreach ( array( 'date_from', 'date_to' ) as $key ) { $value = self::valid_date( $filters[ $key ] ?? '' ); if ( '' !== $value ) { $out[ $key ] = $value; } } } // Only the non-default values are worth sending, except "sort": a // visitor may explicitly switch a "newest" block back to "balanced". if ( 'all' === ( $filters['query_mode'] ?? '' ) ) { $out['query_mode'] = 'all'; } if ( 'title' === ( $filters['search_in'] ?? '' ) ) { $out['search_in'] = 'title'; } $exclude_query = self::to_list( $filters['exclude_query'] ?? null ); if ( $exclude_query ) { $out['exclude_query'] = $exclude_query; } if ( ! empty( $filters['has_image'] ) ) { $out['has_image'] = true; } if ( in_array( $filters['sort'] ?? '', array( 'balanced', 'newest' ), true ) ) { $out['sort'] = $filters['sort']; } return $out; } /** * Whether a normalized filter set is enough to search on by itself, i.e. * without a query or category. Everything else only refines a search. */ public static function filters_can_search( array $filters ): bool { return ! empty( $filters['language'] ) || ! empty( $filters['source'] ) || ! empty( $filters['date'] ) || ! empty( $filters['date_from'] ) || ! empty( $filters['date_to'] ); } /** * Turn a rolling period into the date filters for it as of right now. The * API counts days in UTC, so "today" and the period boundaries are UTC too; * weeks start on the day set under Settings > General. * * @param mixed $preset 'today', 'week' or 'month'. * @return array Either a 'date' or a 'date_from' entry, or nothing for an unknown preset. */ public static function resolve_date_preset( $preset ): array { $now = time(); switch ( $preset ) { case 'today': return array( 'date' => gmdate( 'Y-m-d', $now ) ); case 'week': $days_in = ( (int) gmdate( 'w', $now ) - (int) get_option( 'start_of_week', 1 ) + 7 ) % 7; return array( 'date_from' => gmdate( 'Y-m-d', $now - $days_in * DAY_IN_SECONDS ) ); case 'month': return array( 'date_from' => gmdate( 'Y-m-01', $now ) ); } return array(); } /** * @param mixed $value A string, a list of strings, or nothing. * @return string[] Trimmed, de-duplicated, empties removed. */ public static function to_list( $value ): array { $list = array(); foreach ( (array) $value as $item ) { $item = is_scalar( $item ) ? trim( (string) $item ) : ''; if ( '' !== $item && ! in_array( $item, $list, true ) ) { $list[] = $item; } } return $list; } /** @return string The date if it is a real YYYY-MM-DD day, otherwise ''. */ private static function valid_date( $value ): string { $value = is_string( $value ) ? trim( $value ) : ''; if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) { return $value; } return ''; } /** * GET /channels — the channel names /scan's source / exclude_source * filters accept. Unlike /categories, this one does need the API key. * * @return array{channels:array,error:?string} */ public function fetch_channels(): array { $api_key = DynamiRSS_Settings::get_api_key(); if ( '' === $api_key ) { return array( 'channels' => array(), 'error' => 'dynamirss: no API key set.' ); } $response = wp_remote_get( DynamiRSS_Settings::get_channels_url(), array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { return array( 'channels' => array(), 'error' => $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return array( 'channels' => array(), 'error' => sprintf( 'dynamirss: channels endpoint returned HTTP %d', $code ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || ! is_array( $body['channels'] ?? null ) ) { return array( 'channels' => array(), 'error' => 'dynamirss: channels endpoint returned an unreadable response.' . self::describe_raw_body( $response ), ); } $channels = array(); foreach ( $body['channels'] as $channel ) { $name = is_array( $channel ) ? trim( (string) ( $channel['name'] ?? '' ) ) : ''; if ( '' === $name ) { continue; } $channels[] = array( 'name' => $name, 'categories' => self::to_list( $channel['categories'] ?? null ), 'language' => (string) ( $channel['language'] ?? '' ), ); } return array( 'channels' => $channels, 'error' => null ); } /** * GET /categories — unlike /scan this needs no API key, since it's just * the list of tag strings /scan's category filter accepts, not user data. * * @return array{categories:string[],error:?string} */ public function fetch_categories(): array { $response = wp_remote_get( DynamiRSS_Settings::get_categories_url(), array( 'timeout' => 15 ) ); if ( is_wp_error( $response ) ) { return array( 'categories' => array(), 'error' => $response->get_error_message() ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return array( 'categories' => array(), 'error' => sprintf( 'dynamirss: categories endpoint returned HTTP %d', $code ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || ! is_array( $body['categories'] ?? null ) ) { return array( 'categories' => array(), 'error' => 'dynamirss: categories endpoint returned an unreadable response.' . self::describe_raw_body( $response ), ); } return array( 'categories' => array_values( array_filter( array_map( 'strval', $body['categories'] ) ) ), 'error' => null, ); } /** * Appends a short, truncated snippet of the raw HTTP body to a JSON-parse * error so an admin can see what actually came back (HTML error page, * empty body, plain text, etc.) instead of just "unreadable response". * * @param array $response wp_remote_* response. */ private static function describe_raw_body( array $response ): string { $raw = trim( (string) wp_remote_retrieve_body( $response ) ); if ( '' === $raw ) { return ' (empty body)'; } $snippet = function_exists( 'mb_substr' ) ? mb_substr( $raw, 0, 200 ) : substr( $raw, 0, 200 ); return ' Raw response: ' . $snippet; } }