Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const SearchResults = () => {
if (isErrored) {
return (
<div className="banner banner-error">
<p>{__('An error occurred while fetching search results. Please try again.')}</p>
<p>{__('An error occurred while fetching search results. Please try again.', 'code-snippets')}</p>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const DownloadOrViewButton: React.FC<DownloadOrViewButtonProps> = ({

if (localSnippetId) {
return (
<a className="button button-primary" href={getSnippetEditUrl({ id: localSnippetId })} target="_blank" rel="noreferrer">
<a className="button button-primary" href={getSnippetEditUrl({ id: localSnippetId })} target="_blank" rel="noopener noreferrer">
{__('View', 'code-snippets')}
</a>
)
Expand Down
1 change: 0 additions & 1 deletion src/js/types/Window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ declare global {
welcome: string
settings: string
cloud: string
connectCloud: string
}
banner: {
key: string
Expand Down
128 changes: 44 additions & 84 deletions src/php/Client/Cloud_API.php
Original file line number Diff line number Diff line change
Expand Up @@ -359,17 +359,14 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar
}

/**
* Transient key for cached cloud types (languages).
*/
private const TYPES_TRANSIENT_KEY = 'cs_cloud_types';

/**
* Transient key for cached cloud categories.
* Option key holding the current featured-snippets cache version.
*
* Bumped on flush so old transient keys become unreachable and expire naturally.
*/
private const CATEGORIES_TRANSIENT_KEY = 'cs_cloud_categories';
private const FEATURED_VERSION_OPTION = 'cs_featured_cache_version';

/**
* Transient key for cached featured snippets.
* Base transient key for cached featured snippets.
*/
private const FEATURED_TRANSIENT_KEY = 'cs_featured_snippets';

Expand All @@ -378,6 +375,40 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar
*/
private const FEATURED_MIN_TTL = 3600;

/**
* Get the current featured-snippets cache version, initialising it if absent.
*
* @return int
*/
private static function get_featured_cache_version(): string {
$version = get_transient( self::FEATURED_VERSION_OPTION );

if ( ! $version ) {
$version = (string) ( microtime( true ) * 1000 );
set_transient( self::FEATURED_VERSION_OPTION, $version, MONTH_IN_SECONDS );
}

return $version;
}

/**
* Build the transient key for a specific (version, page, per_page, filters) slot.
*
* @param int $page Page number (1-indexed).
* @param int $per_page Results per page.
* @param array<string,string> $filters Filter values.
*
* @return string
*/
private static function build_featured_cache_key( int $page, int $per_page, array $filters ): string {
$active_filters = array_filter( $filters );
$encoded = wp_json_encode( $active_filters );
$filter_hash = md5( false === $encoded ? '' : $encoded );
$version = self::get_featured_cache_version();

return self::FEATURED_TRANSIENT_KEY . "_v{$version}_p{$page}_pp{$per_page}_{$filter_hash}";
}

/**
* Retrieve featured snippets from the cloud API, with transient caching.
*
Expand All @@ -389,9 +420,7 @@ public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): ar
*/
public static function get_featured_snippets( int $page = 1, int $per_page = 10, array $filters = [] ): Cloud_Snippets {
$per_page = min( self::MAX_RESULTS_PER_PAGE, max( 1, $per_page ) );
$encoded = wp_json_encode( $filters );
$filter_hash = md5( false === $encoded ? '' : $encoded );
$cache_key = self::FEATURED_TRANSIENT_KEY . "_p{$page}_pp{$per_page}_{$filter_hash}";
$cache_key = self::build_featured_cache_key( $page, $per_page, $filters );

$cached = get_transient( $cache_key );

Expand Down Expand Up @@ -445,89 +474,20 @@ public static function get_featured_snippets( int $page = 1, int $per_page = 10,
return $result;
}

/**
* Retrieve available snippet types (languages) from the cloud API, with transient caching.
*
* @return array<int, array{id: int, name: string, snippet_count: int}> List of types.
*/
public static function get_cloud_types(): array {
$cached = get_transient( self::TYPES_TRANSIENT_KEY );

if ( is_array( $cached ) ) {
return $cached;
}

$url = self::get_cloud_api_url() . 'public/types';
$response = wp_remote_get( $url );

if ( is_wp_error( $response ) ) {
return [];
}

$json = self::unpack_request_json( $response );

if ( ! is_array( $json ) || ! isset( $json['data'] ) ) {
return [];
}

$types = $json['data'];
set_transient( self::TYPES_TRANSIENT_KEY, $types, DAY_IN_SECONDS );

return $types;
}

/**
* Retrieve available snippet categories from the cloud API, with transient caching.
*
* @return array<int, array{id: int, name: string, snippet_count: int}> List of categories.
*/
public static function get_cloud_categories(): array {
$cached = get_transient( self::CATEGORIES_TRANSIENT_KEY );

if ( is_array( $cached ) ) {
return $cached;
}

$url = self::get_cloud_api_url() . 'public/categories';
$response = wp_remote_get( $url );

if ( is_wp_error( $response ) ) {
return [];
}

$json = self::unpack_request_json( $response );

if ( ! is_array( $json ) || ! isset( $json['data'] ) ) {
return [];
}

$categories = $json['data'];
set_transient( self::CATEGORIES_TRANSIENT_KEY, $categories, DAY_IN_SECONDS );

return $categories;
}

/**
* Refresh the cached synced data.
*
* Bumps the featured-cache version counter so previously cached keys
* become unreachable and expire via WordPress's normal transient path.
*
* @return void
*/
public function clear_caches() {
global $wpdb;

$this->cached_cloud_links = null;

delete_transient( self::CLOUD_MAP_TRANSIENT_KEY );
delete_transient( self::TYPES_TRANSIENT_KEY );
delete_transient( self::CATEGORIES_TRANSIENT_KEY );
delete_transient( 'cs_codevault_snippets' );

$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
$wpdb->esc_like( '_transient_' . self::FEATURED_TRANSIENT_KEY ) . '%',
$wpdb->esc_like( '_transient_timeout_' . self::FEATURED_TRANSIENT_KEY ) . '%'
)
);
delete_transient( self::FEATURED_VERSION_OPTION );
}
}
4 changes: 1 addition & 3 deletions src/php/Model/Cloud_Snippets.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
* @property int $total_pages Total number of available pages of items.
* @property int $total_snippets Total number of available snippet items.
* @property array $cloud_id_rev An array of all cloud snippet IDs and their revision numbers.
* @property bool $success If the request has any results.
* @property array $available_filters An array of available filters that can be applied to the collection.
*/
class Cloud_Snippets extends Model {
Expand Down Expand Up @@ -50,7 +49,7 @@ class Cloud_Snippets extends Model {
/**
* Class constructor.
*
* @param Cloud_Snippet $initial_data Initial data.
* @param array|null $initial_data Initial data from the cloud API response.
*/
public function __construct( $initial_data = null ) {
parent::__construct( $this->normalize_cloud_api( $initial_data ) );
Expand Down Expand Up @@ -103,7 +102,6 @@ protected function prepare_snippets( $snippets ): array {
* @return mixed Normalized data array or original value when no normalization is required.
*/
private function normalize_cloud_api( $initial_data ) {
// pagination metadata is nested under a 'meta' key.
if ( is_array( $initial_data ) && isset( $initial_data['meta'] ) ) {
$meta = $initial_data['meta'];
$normalized = [];
Expand Down
51 changes: 6 additions & 45 deletions src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,25 @@ final class Cloud_Snippets_REST_Controller extends Cloud_Collection_REST_Control
/**
* Common filter args shared across search and featured endpoints.
*
* Each filter accepts a single numeric ID (e.g. category=12). The cloud API
* resolves IDs to the underlying name/slug.
*
* @return array<string, array<string, mixed>>
*/
private function get_filter_args(): array {
return [
'category' => [
'description' => esc_html__( 'Filter by category name (comma-separated).', 'code-snippets' ),
'description' => esc_html__( 'Filter by category ID.', 'code-snippets' ),
'type' => 'string',
'default' => '',
],
'type' => [
'description' => esc_html__( 'Filter by language/type name (comma-separated).', 'code-snippets' ),
'description' => esc_html__( 'Filter by language/type ID.', 'code-snippets' ),
'type' => 'string',
'default' => '',
],
'status' => [
'description' => esc_html__( 'Filter by status ID (comma-separated).', 'code-snippets' ),
'description' => esc_html__( 'Filter by status ID.', 'code-snippets' ),
'type' => 'string',
'default' => '',
],
Expand Down Expand Up @@ -155,30 +158,6 @@ public function register_routes() {
]
);

register_rest_route(
$this->namespace,
$this->rest_base . '/types',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_types' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
],
]
);

register_rest_route(
$this->namespace,
$this->rest_base . '/categories',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_categories' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
],
]
);

register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<id>\d+)/download',
Expand Down Expand Up @@ -239,24 +218,6 @@ public function get_featured_items( WP_REST_Request $request ): WP_REST_Response
return $cloud_snippets->to_rest_response();
}

/**
* Retrieve available snippet types (languages) from the cloud API.
*
* @return WP_REST_Response
*/
public function get_types(): WP_REST_Response {
return rest_ensure_response( Cloud_API::get_cloud_types() );
}

/**
* Retrieve available snippet categories from the cloud API.
*
* @return WP_REST_Response
*/
public function get_categories(): WP_REST_Response {
return rest_ensure_response( Cloud_API::get_cloud_categories() );
}

/**
* Get the user's snippets per-page preference for Screen Options pagination.
*
Expand Down
7 changes: 3 additions & 4 deletions tests/e2e/code-snippets-community-featured.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ test.describe('Community Cloud Featured Snippets', () => {
.waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT })
.catch(() => undefined)

const featuredHeading = page.locator('.cloud-featured-heading')
const featuredHeading = page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' })
const headingVisible = await featuredHeading
.waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT })
.then(() => true)
.catch(() => false)

if (headingVisible) {
// Featured snippets loaded — verify the heading text.
await expect(featuredHeading).toContainText('Featured Snippets')
} else {
// Cloud API unreachable — verify no crash: the search form is still functional.
Expand All @@ -64,7 +63,7 @@ test.describe('Community Cloud Featured Snippets', () => {
.waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT })
.catch(() => undefined)

// The "Featured Snippets" heading should no longer be visible.
await expect(page.locator('.cloud-featured-heading')).not.toBeVisible()
// The "Featured Snippets" heading should no longer be visible (search-mode heading replaces it).
await expect(page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' })).not.toBeVisible()
})
})
46 changes: 44 additions & 2 deletions tests/phpunit/test-cloud-api-featured.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ public function tear_down() {
* @return string
*/
private function transient_key( int $page = 1, int $per_page = 10, array $filters = [] ): string {
$encoded = wp_json_encode( $filters );
$active_filters = array_filter( $filters );
$encoded = wp_json_encode( $active_filters );
$hash = md5( false === $encoded ? '' : $encoded );
return "cs_featured_snippets_p{$page}_pp{$per_page}_{$hash}";
$version = get_transient( 'cs_featured_cache_version' );
if ( ! $version ) {
$version = (string) ( microtime( true ) * 1000 );
}
return "cs_featured_snippets_v{$version}_p{$page}_pp{$per_page}_{$hash}";
}

/**
Expand All @@ -75,6 +80,7 @@ private function transient_key( int $page = 1, int $per_page = 10, array $filter
*/
private function clear_featured_transients(): void {
delete_transient( $this->transient_key() );
delete_transient( 'cs_featured_cache_version' );
}

/**
Expand Down Expand Up @@ -340,4 +346,40 @@ public function test_available_filters_preserved(): void {
$this->assertArrayHasKey( 'types', $result->available_filters );
$this->assertArrayHasKey( 'statuses', $result->available_filters );
}

/**
* Calling clear_caches() causes the next get_featured_snippets() to miss cache and re-fetch.
*
* @return void
*/
public function test_clear_caches_invalidates_featured_cache(): void {
Cloud_API::get_featured_snippets();
$this->assertSame( 1, $this->http_request_count );

$api = new Cloud_API();
$api->clear_caches();

Cloud_API::get_featured_snippets();
$this->assertSame( 2, $this->http_request_count, 'Expected a fresh HTTP request after cache invalidation.' );
}

/**
* Empty filter values produce the same cache key as omitted filters.
*
* @return void
*/
public function test_empty_filters_produce_same_cache_key(): void {
$key_empty = $this->transient_key(
1,
10,
[
'category' => '',
'type' => '',
'status' => '',
]
);
$key_none = $this->transient_key( 1, 10, [] );

$this->assertSame( $key_none, $key_empty, 'Empty filter values should hash identically to no filters.' );
}
}
Loading