Files
dostavka_vodi/wp-content/plugins/!robin-image-optimizer/includes/classes/class-rio-image-query.php
User A0264400 a766acdc90 first commit
2026-04-01 23:20:16 +03:00

510 lines
12 KiB
PHP

<?php
/**
* Image Query class.
*
* @package Robin_Image_Optimizer
*/
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WRIO_Image_Query
*/
class WRIO_Image_Query {
/**
* The single instance of the class.
*
* @var WRIO_Image_Query|null
*/
protected static $instance = null;
/**
* Cache for allowed formats SQL string
*
* @var string
*/
protected $allowed_formats_sql;
/**
* Cache for required conversion types
*
* @var string[]|null
*/
protected $required_types = null;
/**
* Cache group for all query results
*
* @var string
*/
const CACHE_GROUP = 'wrio_image_query';
/**
* Constructor
*/
public function __construct() {
$formats = wrio_get_allowed_formats( true );
$this->allowed_formats_sql = is_array( $formats ) ? implode( ', ', $formats ) : $formats;
}
/**
* Register cache invalidation hooks.
*
* Call this once during plugin initialization to automatically
* clear query caches when images are optimized, restored, or deleted.
*
* @since 1.5.0
*
* @return void
*/
public static function register_hooks() {
add_action( 'wbcr/riop/queue_item_saved', [ __CLASS__, 'clear_cache' ], 100 );
add_action( 'wbcr/rio/attachment_restored', [ __CLASS__, 'clear_cache' ], 100 );
add_action( 'delete_attachment', [ __CLASS__, 'clear_cache' ], 100 );
}
/**
* Get singleton instance
*
* @return WRIO_Image_Query
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Build list of required conversion types based on enabled formats.
* Computed fresh each call to avoid stale data.
*
* @since 1.5.0
*
* @return string[] Array of required item_types
*/
protected function build_required_types() {
$types = [ 'attachment' ]; // Basic optimization always required
if ( class_exists( 'WRIO_Format_Converter_Factory' ) ) {
if ( WRIO_Format_Converter_Factory::is_webp_enabled() ) {
$types[] = 'webp';
}
if ( WRIO_Format_Converter_Factory::is_avif_enabled() ) {
$types[] = 'avif';
}
}
return $types;
}
/**
* Get required conversion types.
* Lazy loads on first access.
*
* @since 1.5.0
*
* @return string[]
*/
public function get_required_types() {
if ( null === $this->required_types ) {
$this->required_types = $this->build_required_types();
}
return $this->required_types;
}
/**
* Get sanitized optimization order.
* Prevents SQL injection by validating order parameter.
*
* @since 1.5.0
*
* @return string 'ASC' or 'DESC'
*/
protected function get_optimize_order() {
$order = WRIO_Plugin::app()->getOption( 'image_optimization_order', 'asc' );
// Whitelist validation - only allow 'DESC', all others default to 'ASC'
return strtolower( $order ) === 'desc' ? 'DESC' : 'ASC';
}
/**
* Get WPML exclusion clause for filtering translation duplicates.
*
* @since 1.5.0
*
* @return string SQL clause or empty string if WPML not active
*/
protected function get_wpml_exclusion_clause() {
global $wpdb;
if ( ! defined( 'WPML_PLUGIN_FILE' ) ) {
return '';
}
return " AND NOT EXISTS (
SELECT trnsl.element_id
FROM {$wpdb->prefix}icl_translations AS trnsl
WHERE trnsl.element_id = posts.ID
AND trnsl.element_type = 'post_attachment'
AND trnsl.source_language_code IS NOT NULL
)";
}
/**
* Append pagination to SQL query.
*
* @since 1.5.0
*
* @param string $sql SQL query.
* @param int|null $limit Number of results to return.
* @param int $offset Number of results to skip.
*
* @return string SQL with pagination appended
*/
protected function append_pagination( $sql, $limit = null, $offset = 0 ) {
if ( $limit ) {
$sql .= sprintf( ' LIMIT %d, %d', absint( $offset ), absint( $limit ) );
}
return $sql;
}
/**
* Get cached count or compute and cache result.
*
* @since 1.5.0
*
* @param string $cache_key Cache key (will be namespaced).
* @param callable $callback Callback that returns the count.
*
* @return int
*/
protected function get_cached_count( $cache_key, $callback ) {
$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
if ( false !== $cached ) {
return (int) $cached;
}
$count = (int) call_user_func( $callback );
wp_cache_set( $cache_key, $count, self::CACHE_GROUP, HOUR_IN_SECONDS );
return $count;
}
/**
* Build base attachment query with common conditions.
*
* @since 1.5.0
*
* @param string $select_clause The SELECT portion (e.g., 'DISTINCT posts.ID').
*
* @return string
*/
protected function get_base_query( $select_clause ) {
global $wpdb;
return "SELECT {$select_clause}
FROM {$wpdb->posts} posts
WHERE posts.post_type = 'attachment'
AND posts.post_status = 'inherit'
AND posts.post_mime_type IN ( {$this->allowed_formats_sql} )";
}
/**
* Build optimization status EXISTS clause.
*
* @since 1.5.0
*
* @param bool $negate Use NOT EXISTS instead of EXISTS.
* @param bool $all_types Check all required types are complete.
*
* @return string SQL clause
*/
protected function get_optimization_exists_clause( $negate = false, $all_types = true ) {
$db_table = RIO_Process_Queue::table_name();
$exists = $negate ? 'NOT EXISTS' : 'EXISTS';
$types = $this->get_required_types();
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$clause = "{$exists} (
SELECT 1
FROM {$db_table} rio
WHERE rio.object_id = posts.ID
AND rio.item_type IN ( {$placeholders} )
AND rio.result_status = 'success'";
if ( $all_types ) {
$clause .= '
GROUP BY rio.object_id
HAVING COUNT(DISTINCT rio.item_type) = ' . count( $types );
}
$clause .= '
)';
return $clause;
}
/**
* Build error status EXISTS clause.
*
* @since 1.5.0
*
* @return string SQL clause
*/
protected function get_error_exists_clause() {
$db_table = RIO_Process_Queue::table_name();
return "EXISTS (
SELECT 1
FROM {$db_table} rio
WHERE rio.object_id = posts.ID
AND rio.result_status = 'error'
)";
}
/**
* Get IDs of fully optimized images.
*
* An image is optimized if it has successful conversions for ALL required types.
*
* @since 1.5.0
*
* @param int|null $limit Number of results to return. NULL for no limit.
* @param int $offset Number of results to skip.
*
* @return int[] Array of attachment IDs
*/
public function get_optimized_ids( $limit = null, $offset = 0 ) {
global $wpdb;
$sql = $this->get_base_query( 'DISTINCT posts.ID' );
$sql .= ' AND ' . $this->get_optimization_exists_clause( false, true );
$sql .= ' ORDER BY posts.ID ' . $this->get_optimize_order();
$sql = $this->append_pagination( $sql, $limit, $offset );
$sql = $wpdb->prepare( $sql, $this->get_required_types() );
$result = $wpdb->get_col( $sql );
return array_map( 'absint', $result ?? [] );
}
/**
* Get IDs of unoptimized images.
*
* Unoptimized images are those missing ANY required conversion with success status.
* Includes: never queued, partial, failed, and processing images.
*
* @since 1.5.0
*
* @param int|null $limit Number of results to return. NULL for no limit.
* @param int $offset Number of results to skip.
* @param bool $exclude_wpml_dupes Whether to exclude WPML translation duplicates.
*
* @return int[] Array of attachment IDs
*/
public function get_unoptimized_ids( $limit = null, $offset = 0, $exclude_wpml_dupes = true ) {
global $wpdb;
$sql = $this->get_base_query( 'DISTINCT posts.ID' );
$sql .= ' AND ' . $this->get_optimization_exists_clause( true, true );
if ( $exclude_wpml_dupes ) {
$sql .= $this->get_wpml_exclusion_clause();
}
$sql .= ' ORDER BY posts.ID ' . $this->get_optimize_order();
$sql = $this->append_pagination( $sql, $limit, $offset );
$sql = $wpdb->prepare( $sql, $this->get_required_types() );
$result = $wpdb->get_col( $sql );
return array_map( 'absint', $result ?? [] );
}
/**
* Get IDs of images with optimization errors.
*
* @since 1.5.0
*
* @param int|null $limit Number of results to return. NULL for no limit.
* @param int $offset Number of results to skip.
*
* @return int[] Array of attachment IDs
*/
public function get_error_ids( $limit = null, $offset = 0 ) {
global $wpdb;
$sql = $this->get_base_query( 'DISTINCT posts.ID' );
$sql .= ' AND ' . $this->get_error_exists_clause();
$sql .= ' ORDER BY posts.ID ASC';
$sql = $this->append_pagination( $sql, $limit, $offset );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query built with safe methods, no user input.
$result = $wpdb->get_col( $sql );
return array_map( 'absint', $result ?? [] );
}
/**
* Count fully optimized images.
*
* @since 1.5.0
*
* @return int
*/
public function count_optimized() {
return $this->get_cached_count(
'count_optimized',
function () {
global $wpdb;
$sql = $this->get_base_query( 'COUNT(DISTINCT posts.ID)' );
$sql .= ' AND ' . $this->get_optimization_exists_clause( false, true );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query built with safe methods and prepared with types.
$sql = $wpdb->prepare( $sql, $this->get_required_types() );
return (int) $wpdb->get_var( $sql );
}
);
}
/**
* Count unoptimized images.
*
* @since 1.5.0
*
* @param bool $exclude_wpml_dupes Whether to exclude WPML translation duplicates.
*
* @return int
*/
public function count_unoptimized( $exclude_wpml_dupes = true ) {
$cache_suffix = $exclude_wpml_dupes ? '1' : '0';
$cache_key = "count_unoptimized_{$cache_suffix}";
return $this->get_cached_count(
$cache_key,
function () use ( $exclude_wpml_dupes ) {
global $wpdb;
$sql = $this->get_base_query( 'COUNT(DISTINCT posts.ID)' );
$sql .= ' AND ' . $this->get_optimization_exists_clause( true, true );
if ( $exclude_wpml_dupes ) {
$sql .= $this->get_wpml_exclusion_clause();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query built with safe methods and prepared with types.
$sql = $wpdb->prepare( $sql, $this->get_required_types() );
return (int) $wpdb->get_var( $sql );
}
);
}
/**
* Count images with optimization errors.
*
* @since 1.5.0
*
* @return int
*/
public function count_error() {
return $this->get_cached_count(
'count_error',
function () {
global $wpdb;
$sql = $this->get_base_query( 'COUNT(DISTINCT posts.ID)' );
$sql .= ' AND ' . $this->get_error_exists_clause();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query built with safe methods, no user input.
return (int) $wpdb->get_var( $sql );
}
);
}
/**
* Count total attachment images with allowed formats.
*
* @since 1.5.0
*
* @param bool $exclude_wpml_dupes Whether to exclude WPML translation duplicates.
*
* @return int
*/
public function count_total_attachments( $exclude_wpml_dupes = true ) {
$cache_suffix = $exclude_wpml_dupes ? '1' : '0';
$cache_key = "count_total_attachments_{$cache_suffix}";
return $this->get_cached_count(
$cache_key,
function () use ( $exclude_wpml_dupes ) {
global $wpdb;
$sql = $this->get_base_query( 'COUNT(DISTINCT posts.ID)' );
if ( $exclude_wpml_dupes ) {
$sql .= $this->get_wpml_exclusion_clause();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query built with safe methods, no user input.
return (int) $wpdb->get_var( $sql );
}
);
}
/**
* Clear all query caches.
*
* Call after image optimization, restoration, or deletion.
*
* @since 1.5.0
*
* @return void
*/
public static function clear_cache() {
$keys = [
'count_optimized',
'count_unoptimized_0',
'count_unoptimized_1',
'count_error',
'count_total_attachments_0',
'count_total_attachments_1',
];
foreach ( $keys as $key ) {
wp_cache_delete( $key, self::CACHE_GROUP );
}
}
/**
* Refresh instance data after settings change.
*
* Use this if WebP/AVIF settings are changed mid-request.
*
* @since 1.5.0
*
* @return void
*/
public function refresh() {
$this->required_types = null;
$formats = wrio_get_allowed_formats( true );
$this->allowed_formats_sql = is_array( $formats ) ? implode( ', ', $formats ) : $formats;
self::clear_cache();
}
}