Files
2026-07-02 15:54:39 -06:00

684 lines
19 KiB
PHP

<?php
/**
* The Global Classes rest class file.
*
* @package GenerateBlocksPro\Global_Classes_Rest
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
require_once GENERATEBLOCKS_PRO_DIR . 'includes/utils/trait-usage-search.php';
/**
* Main class for the Global Classes Rest functions.
*
* @since 1.9
*/
class GenerateBlocks_Pro_Styles_Rest extends GenerateBlocks_Pro_Singleton {
use GenerateBlocks_Pro_Usage_Search;
/**
* Initialize all hooks.
*
* @return void
*/
public function init() {
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the REST routes.
*
* @return void
*/
public function register_routes(): void {
$namespace = 'generateblocks-pro/v1';
register_rest_route(
$namespace,
'/global-classes/check_class_name',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'check_class_name' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
register_rest_route(
$namespace,
'/global-classes/get',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_global_styles' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
register_rest_route(
$namespace,
'/global-classes/get_css',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_global_class_css' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
register_rest_route(
$namespace,
'/global-classes/get_styles',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_global_class_styles' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
register_rest_route(
$namespace,
'/global-styles/update_menu_order',
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_menu_order' ),
'permission_callback' => array( $this, 'can_manage' ),
)
);
register_rest_route(
$namespace,
'/global-classes/(?P<id>\d+)/usage',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_global_style_usage' ),
'permission_callback' => array( $this, 'can_manage' ),
'args' => array(
'id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
'limit' => array(
'required' => false,
'type' => 'integer',
'default' => 50,
'sanitize_callback' => 'absint',
),
'refresh' => array(
'required' => false,
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'rest_sanitize_boolean',
),
),
)
);
}
/**
* Manage options permission callback.
*
* @return bool
*/
public function can_create(): bool {
return current_user_can( 'edit_posts' );
}
/**
* Permission callback for endpoints that require elevated management access.
*
* @return bool
*/
public function can_manage(): bool {
return GenerateBlocks_Pro_Styles::can_manage_styles();
}
/**
* Check if a class name already exists.
*
* @param WP_REST_Request $request The request.
*
* @return WP_REST_Response
*/
public function check_class_name( WP_REST_Request $request ): WP_REST_RESPONSE {
$class_name = $request->get_param( 'className' );
if ( empty( $class_name ) ) {
return $this->failed( __( 'Style name cannot be empty', 'generateblocks-pro' ) );
}
$existing_class = new WP_Query(
[
'post_type' => 'gblocks_styles',
'posts_per_page' => -1,
'post_status' => 'any',
'meta_query' => [
[
'key' => 'gb_style_selector',
'value' => $class_name,
'compare' => '=',
],
],
]
);
if ( ! empty( $existing_class->found_posts ) ) {
return $this->failed( __( 'Style name already exists', 'generateblocks-pro' ) );
}
return $this->success( [ 'success' ] );
}
/**
* Returns a list of active or inactive classes.
*
* @param WP_REST_Request $request The request.
*
* @return WP_REST_Response
*/
public function get_global_styles( WP_REST_Request $request ): WP_REST_Response {
$status = $request->get_param( 'status' ) ?? 'publish';
$custom_args = [
'post_status' => $status,
];
$styles = GenerateBlocks_Pro_Styles::get_styles( $custom_args );
$class_data = [
'styles' => $styles,
];
return $this->success( $class_data );
}
/**
* Returns the CSS for the complete set of global classes or a specific class if specified.
*
* @param WP_REST_Request $request The request.
*
* @return WP_REST_Response
*/
public function get_global_class_css( WP_REST_Request $request ): WP_REST_Response {
$class_name = $request->get_param( 'className' );
if ( $class_name ) {
$class = GenerateBlocks_Pro_Styles::get_class_by_name( $class_name );
if ( ! isset( $class->ID ) ) {
return $this->failed( 'No CSS found.' );
}
$css = get_post_meta( $class->ID, 'gb_style_css', true );
} else {
$css = GenerateBlocks_Pro_Styles::get_styles_css();
}
return $this->success( [ $css ] );
}
/**
* Returns the styles for a specific class name.
*
* @param WP_REST_Request $request The request.
*
* @return WP_REST_Response
*/
public function get_global_class_styles( WP_REST_Request $request ): WP_REST_Response {
$class_name = $request->get_param( 'globalClass' );
$class = GenerateBlocks_Pro_Styles::get_class_by_name( $class_name );
if ( ! isset( $class ) ) {
return $this->failed( __( 'Style does not exist', 'generateblocks-pro' ) );
}
$post_id = $class->ID;
$styles = get_post_meta( $post_id, 'gb_style_data', true );
return $this->success(
[
'postId' => $post_id,
'styles' => $styles ?? [],
]
);
}
/**
* Handles updating global style menu order
*
* @param WP_REST_Request $request WP Request object.
* @return WP_REST_RESPONSE;
*/
public function update_menu_order( WP_REST_Request $request ): WP_REST_Response {
$order = $request->get_param( 'order' );
if ( empty( $order ) || ! is_array( $order ) ) {
return $this->failed( __( 'Order parameter is invalid.', 'generateblocks-pro' ) );
}
global $wpdb;
$sql = '';
$rows_affected = 0;
foreach ( $order as $index => $post_id ) {
$post_id = absint( $post_id );
if ( ! $post_id ) {
continue;
}
$query = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->posts} SET menu_order = %d WHERE ID = %d AND post_type = %s;",
[
$index,
$post_id,
'gblocks_styles',
]
)
);
if ( false === $query ) {
return $this->failed( __( 'Failed to update menu order.', 'generateblocks-pro' ) );
} else {
$rows_affected += $query;
}
}
$wpdb->flush();
// Flush the cache after the operation completes.
GenerateBlocks_Pro_Enqueue_Styles::get_instance()->build_css();
return $this->success(
[
'message' => __( 'Menu order updated', 'generateblocks-pro' ),
'rows_affected' => $rows_affected,
]
);
}
/**
* Get global style usage across the site.
*
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response
*/
public function get_global_style_usage( WP_REST_Request $request ): WP_REST_Response {
$style_id = $request->get_param( 'id' );
$limit = $request->get_param( 'limit' );
$refresh = $request->get_param( 'refresh' );
if ( empty( $style_id ) ) {
return $this->failed( __( 'Style ID is required.', 'generateblocks-pro' ) );
}
// Verify the style exists and get the class name.
$style_post = get_post( $style_id );
if ( ! $style_post || 'gblocks_styles' !== $style_post->post_type ) {
return $this->failed( __( 'Style not found.', 'generateblocks-pro' ) );
}
$class_name = get_post_meta( $style_id, 'gb_style_selector', true );
if ( empty( $class_name ) ) {
return $this->failed( __( 'Style class name not found.', 'generateblocks-pro' ) );
}
// Check cache unless refresh is requested.
$cache_key = sprintf( 'gb_style_usage_%d_limit%d', $style_id, $limit );
if ( ! $refresh ) {
$cached_data = $this->get_usage_cache( $cache_key );
if ( false !== $cached_data ) {
$cached_data['cached'] = true;
$cached_data['cached_at'] = $cached_data['cached_at'] ?? time();
return $this->success( $cached_data );
}
}
$usage_data = $this->search_global_style_usage( $class_name, $limit );
// Cache for 15 minutes (900 seconds).
// Note: Cache invalidation is intentionally manual (via refresh button in UI).
// Automatic invalidation on post save/delete was deemed too complex and risky
// due to the need to clear caches across all styles when any post changes.
// Users can click refresh to get updated results when needed.
$usage_data['cached_at'] = time();
$this->set_usage_cache( $cache_key, $usage_data, 900 );
$usage_data['cached'] = false;
return $this->success( $usage_data );
}
/**
* Search for global style usage across different data sources.
*
* @param string $class_name The class name to search for.
* @param int $limit Maximum total results to return (default: 50).
* @return array
*/
private function search_global_style_usage( string $class_name, int $limit = 50 ): array {
$all_usage = array();
$total_found = 0;
$has_more = false;
$limitations = array();
$has_limitations = false;
// Define search handlers - easily extensible for future use cases.
$search_handlers = array(
'style_usage' => array(
'method' => 'search_global_style_attribute_usage',
'label' => __( 'Style Usage', 'generateblocks-pro' ),
),
);
// Apply filters to allow other plugins/themes to add search handlers.
$search_handlers = apply_filters( 'generateblocks_global_style_usage_handlers', $search_handlers, $class_name );
foreach ( $search_handlers as $handler_key => $handler_config ) {
// Stop processing if we've found enough results.
if ( $total_found >= $limit ) {
$has_more = true;
break;
}
if ( ! method_exists( $this, $handler_config['method'] ) ) {
continue;
}
// Calculate how many more results we need.
$remaining = $limit - $total_found;
$handler_results = $this->{$handler_config['method']}( $class_name, $remaining );
if (
! empty( $handler_results['items'] )
|| ! empty( $handler_results['has_more'] )
|| ! empty( $handler_results['limited'] )
) {
// Take only what we need.
$items = array_slice( $handler_results['items'], 0, $remaining );
$all_usage[ $handler_key ] = array(
'label' => $handler_config['label'],
'items' => $items,
'has_more' => ! empty( $handler_results['has_more'] ) || ( count( $handler_results['items'] ) > $remaining ),
);
$total_found += count( $items );
// If any handler has more results, set the overall has_more flag.
if ( ! empty( $handler_results['has_more'] ) ) {
$has_more = true;
}
if ( ! empty( $handler_results['limited'] ) ) {
$has_limitations = true;
}
if ( ! empty( $handler_results['limits'] ) && is_array( $handler_results['limits'] ) ) {
foreach ( $handler_results['limits'] as $limit_key => $limit_data ) {
if ( ! isset( $limitations[ $limit_key ] ) ) {
$limitations[ $limit_key ] = $limit_data;
continue;
}
$current = $limitations[ $limit_key ];
if ( isset( $limit_data['max_posts'] ) ) {
$current['max_posts'] = isset( $current['max_posts'] )
? max( $current['max_posts'], $limit_data['max_posts'] )
: $limit_data['max_posts'];
}
if ( isset( $limit_data['scanned_posts'] ) ) {
$current['scanned_posts'] = isset( $current['scanned_posts'] )
? max( $current['scanned_posts'], $limit_data['scanned_posts'] )
: $limit_data['scanned_posts'];
}
if ( isset( $limit_data['total_posts'] ) ) {
$current['total_posts'] = isset( $current['total_posts'] )
? max( $current['total_posts'], $limit_data['total_posts'] )
: $limit_data['total_posts'];
}
$limitations[ $limit_key ] = $current;
}
}
}
}
return array(
'usage' => $all_usage,
'total' => $total_found,
'has_more' => $has_more,
'limited' => $has_limitations,
'limitations' => $limitations,
);
}
/**
* Search for blocks using this style via the globalClasses attribute.
*
* @param string $class_name The class name to search for.
* @param int $limit Maximum results to return.
* @return array
*/
private function search_global_style_attribute_usage( string $class_name, int $limit ): array {
global $wpdb;
// Validate and sanitize inputs.
$limit = absint( $limit );
if ( empty( $class_name ) ) {
return array(
'items' => array(),
'has_more' => false,
);
}
// Get post types that support the block editor.
$post_types = get_post_types_by_support( 'editor' );
$excluded_types = array( 'revision', 'attachment', 'nav_menu_item' );
$post_types = array_diff( $post_types, $excluded_types );
$post_types = apply_filters( 'generateblocks_usage_search_post_types', $post_types, 'styles' );
if ( empty( $post_types ) ) {
return array(
'items' => array(),
'has_more' => false,
);
}
$post_statuses = array( 'publish', 'private', 'draft' );
// Strip leading dot if present - HTML class attributes don't have dots.
$class_name = ltrim( $class_name, '.' );
// Search in the rendered HTML (class="...") instead of the JSON.
// This avoids unicode encoding issues and is much simpler and more reliable.
// Pattern: class="...class-name..." (with word boundaries for accuracy).
$search_pattern = '%class="%' . $wpdb->esc_like( $class_name ) . '%"%';
// Query one more than requested to determine if there are more results.
$query_limit = $limit + 1;
// Cap the number of posts scanned (prevents slow queries on large sites).
// Users can adjust this via filter if needed.
$max_posts_to_scan = apply_filters( 'generateblocks_usage_search_max_posts', 5000 );
$post_types_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) );
$status_placeholders = implode( ', ', array_fill( 0, count( $post_statuses ), '%s' ) );
// Determine dataset size so we can surface limitations to the UI.
$total_posts_sql = sprintf(
"SELECT COUNT(ID)
FROM {$wpdb->posts}
WHERE post_status IN (%s)
AND post_type IN (%s)",
$status_placeholders,
$post_types_placeholders
);
$total_posts_query = $wpdb->prepare(
$total_posts_sql, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
array_merge( $post_statuses, $post_types )
);
$total_posts = absint( $wpdb->get_var( $total_posts_query ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$scanned_posts = min( $total_posts, $max_posts_to_scan );
$limited_scan = $total_posts > $max_posts_to_scan;
// Two-phase query: First get recent posts, then search within them.
// This prevents full table scans on large sites.
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$sql = sprintf(
"SELECT ID, post_title, post_type, post_status, post_content
FROM (
SELECT ID, post_title, post_type, post_status, post_content, post_modified
FROM {$wpdb->posts}
WHERE post_status IN ('publish', 'private', 'draft')
AND post_type IN (%s)
ORDER BY post_modified DESC
LIMIT %%d
) AS recent_posts
WHERE post_content LIKE %%s
LIMIT %%d",
$post_types_placeholders
);
$results_query = $wpdb->prepare(
$sql, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
array_merge( $post_types, array( $max_posts_to_scan, $search_pattern, $query_limit ) )
);
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$results = $wpdb->get_results( $results_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
// Check if there are more results.
$has_more = count( $results ) > $limit;
// Remove the extra result if we got it.
if ( $has_more ) {
array_pop( $results );
}
$formatted_results = array();
foreach ( $results as $result ) {
// Extract class attribute values so we can inspect individual tokens.
preg_match_all( '/class=(["\'])([^"\']+)\1/i', $result->post_content, $class_attr_matches );
$match_count = 0;
if ( ! empty( $class_attr_matches[2] ) ) {
foreach ( $class_attr_matches[2] as $class_attr ) {
$tokens = preg_split( '/\s+/', trim( $class_attr ) );
if ( empty( $tokens ) ) {
continue;
}
foreach ( $tokens as $token ) {
if ( $class_name === $token ) {
$match_count++;
}
}
}
}
if ( 0 === $match_count ) {
continue;
}
$post_type_object = get_post_type_object( $result->post_type );
$post_type_label = $post_type_object ? $post_type_object->labels->singular_name : $result->post_type;
// Check if post type is publicly viewable.
$is_public = $post_type_object && ( $post_type_object->public || $post_type_object->publicly_queryable );
// translators: %1$s: Post type label, %2$d: Post ID.
$fallback_title = $result->post_title ? $result->post_title : sprintf( __( '%1$s #%2$d', 'generateblocks-pro' ), $post_type_label, $result->ID );
$formatted_results[] = array(
'id' => absint( $result->ID ),
'title' => $fallback_title,
'type' => $result->post_type,
'type_label' => $post_type_label,
'status' => $result->post_status,
'edit_url' => get_edit_post_link( $result->ID, 'raw' ),
'view_url' => ( 'publish' === $result->post_status && $is_public ) ? get_permalink( $result->ID ) : null,
'usage_type' => 'global_style_usage',
'block_count' => $match_count,
);
}
return array(
'items' => $formatted_results,
'has_more' => $has_more || $limited_scan,
'limited' => $limited_scan,
'limits' => $limited_scan
? array(
'recent_post_scan' => array(
'max_posts' => $max_posts_to_scan,
'scanned_posts' => $scanned_posts,
'total_posts' => $total_posts,
),
)
: array(),
);
}
/**
* Returns a success response.
*
* @param array $data The data.
*
* @return WP_REST_Response
*/
private function success( array $data ): WP_REST_Response {
return new WP_REST_Response(
array(
'success' => true,
'response' => array(
'data' => $data,
),
),
200
);
}
/**
* Returns a success response.
*
* @param string $message The error message.
*
* @return WP_REST_Response
*/
private function failed( string $message ): WP_REST_Response {
return new WP_REST_Response(
array(
'success' => false,
'response' => $message,
),
200
);
}
/**
* Returns a error response.
*
* @param int $code Error code.
* @param string $message Error message.
*
* @return WP_REST_Response
*/
private function error( int $code, string $message = '' ): WP_REST_Response {
return new WP_REST_Response(
array(
'error' => true,
'success' => false,
'error_code' => $code,
'response' => $message,
),
$code
);
}
}
GenerateBlocks_Pro_Styles_Rest::get_instance()->init();