JezK
Edit File: OptimizeMedia.php
<?php declare(strict_types=1); namespace Imagify\Abilities; /** * MCP ability: optimize a media on-demand. * * Registers itself with the WP Abilities API under the slug * `imagify/optimize-media` and delegates to the existing * `Imagify\Optimization\Process\WP` class. * * @since 2.3.0 */ class OptimizeMedia extends AbstractAbility implements CreditConsumingAbilityInterface { const ABILITY_ID = 'imagify/optimize-media'; const ABILITY_NAME = 'Optimize media'; /** * Returns the ability slug. * * @return string */ public function get_id(): string { return self::ABILITY_ID; } /** * Returns the human-readable ability label. * * @return string */ public function get_name(): string { return self::ABILITY_NAME; } /** * Register the ability with the WP Abilities API. * * No-ops gracefully when the API is not available (WP < 6.9). * * @return void */ public function register(): void { if ( ! function_exists( 'wp_register_ability' ) ) { return; } $media_properties = [ 'media_id' => [ 'type' => 'integer', 'description' => __( 'The WordPress attachment ID to optimize. Provide media_filename or media_url instead when the ID is unknown.', 'imagify' ), ], ] + MediaResolver::get_input_schema_properties(); wp_register_ability( 'imagify/optimize-media', [ 'label' => __( 'Optimize media', 'imagify' ), 'description' => __( 'Optimizes a specific media on-demand using Imagify.', 'imagify' ), 'category' => 'imagify', 'input_schema' => [ 'type' => 'object', 'properties' => $media_properties + [ 'optimization_level' => [ 'type' => 'integer', 'description' => __( 'Optimization level: 0 (normal), 1 (aggressive), or 2 (ultra). If omitted, uses the global setting.', 'imagify' ), 'minimum' => 0, 'maximum' => 2, ], 'confirm' => [ 'type' => 'boolean', 'description' => __( 'Set to true to execute after reviewing the credit-consumption preview returned by a prior call without this flag.', 'imagify' ), 'default' => false, ], ], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'status' => [ 'type' => 'string', 'description' => __( 'Result status: "success", "error", "confirmation_required", "insufficient_quota", or "invalid_api_key".', 'imagify' ), 'enum' => [ 'success', 'error', 'confirmation_required', 'insufficient_quota', 'invalid_api_key' ], ], 'original_size' => [ 'type' => [ 'integer', 'null' ], 'description' => __( 'Original file size in bytes before optimization, or null on error.', 'imagify' ), ], 'optimized_size' => [ 'type' => [ 'integer', 'null' ], 'description' => __( 'Optimized file size in bytes after optimization, or null on error or if not yet available.', 'imagify' ), ], 'savings_percent' => [ 'type' => [ 'number', 'null' ], 'description' => __( 'Percentage savings, or null on error.', 'imagify' ), ], 'error_message' => [ 'type' => [ 'string', 'null' ], 'description' => __( 'Human-readable error message on failure, or null on success.', 'imagify' ), ], ], ], 'execute_callback' => [ $this, 'execute' ], 'permission_callback' => [ $this, 'check_permissions' ], 'meta' => [ 'show_in_rest' => true, 'mcp' => [ 'public' => true, ], 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false, ], ], ] ); } /** * Check if the current user has permission to execute this ability. * * Routes through Imagify's capability abstraction so the `imagify_capacity` * filter and multisite network-admin logic are honoured. * * Note: `manual-optimize` is not used here because `check_permissions()` is * called before `execute()` and receives no `media_id`, making the underlying * `edit_post` check ambiguous. The `manage` descriptor is the correct top-level * gate consistent with existing AJAX equivalents. * * @return bool True when the current user has the Imagify `manage` capability. */ protected function has_permission(): bool { return imagify_get_context( 'wp' )->current_user_can( 'manage' ); } /** * Returns the credit-consumption impact estimate for a single media optimization. * * @param array $args Input arguments (unused: optimizing a single media always costs 1 unit). * @return array{unit: string, count: int, label: string} */ public function get_impact_estimate( array $args ): array { return [ 'unit' => 'image', 'count' => 1, 'label' => 'this media', ]; } /** * Execute the ability: optimize the media. * * Wraps the real execution behind `guard_credit_confirmation()` so the * caller must pass `confirm: true` once quota is confirmed (and is not * over quota, and the API key is valid). Fires `imagify_mcp_ability_executed` * after the ability resolves so that tracking and other subscribers can * react to every outcome (previews included). * * @param array $args Input arguments. Expects `media_id` (int) and optionally `optimization_level` (int), `confirm` (bool). * @return array<string, mixed> Guard response (invalid_api_key/insufficient_quota/confirmation_required) or the do_execute() result shape. */ public function execute( array $args = [] ): array { $start_time = microtime( true ); $result = $this->guard_credit_confirmation( $args, function ( array $a ) { return $this->do_execute( $a ); } ); $this->fire_executed( $result, $start_time, $args ); return $result; } /** * Internal execution logic for the ability. * * Separated from execute() so that the do_action hook fires for every * outcome (success and all error paths) with a single call site. * * @param array $args Input arguments. * @return array{status: string, original_size: int|null, optimized_size: int|null, savings_percent: float|null, error_message: string|null} */ private function do_execute( array $args ): array { $media_id = MediaResolver::resolve_id( $args ); if ( is_wp_error( $media_id ) ) { return $this->error_response( $media_id->get_error_message() ); } // Verify the attachment exists. $post = get_post( $media_id ); if ( ! $post ) { return $this->error_response( 'Invalid media.' ); } // Verify the post is an attachment. if ( 'attachment' !== get_post_type( $post ) ) { return $this->error_response( 'The provided ID is not a media attachment.' ); } // Determine optimization level. $optimization_level = null; if ( isset( $args['optimization_level'] ) ) { $optimization_level = (int) $args['optimization_level']; } // Get the process for this media. $process = imagify_get_optimization_process( $media_id, 'wp' ); if ( ! $process ) { return $this->error_response( 'Could not initialize optimization process.' ); } // Capture the original size before optimization. $original_size = $this->get_media_original_size( $process ); // Determine whether to optimize or reoptimize. $data = $process->get_data(); if ( $data->is_optimized() ) { // Re-optimize the media. $result = $process->reoptimize( $optimization_level ); } else { // First-time optimization. $result = $process->optimize( $optimization_level ); } // Handle errors from the process. if ( is_wp_error( $result ) ) { return $this->error_response( $result->get_error_message() ); } // Capture the optimized size after optimization. // Note: The process queues a background job, so optimized_size may be 0 until job completes. $optimized_size = $this->get_media_optimized_size( $process ); // Calculate savings percentage. $savings_percent = null; if ( $original_size > 0 && null !== $optimized_size ) { $savings_percent = (float) round( ( ( $original_size - $optimized_size ) / $original_size ) * 100, 1 ); } return [ 'status' => 'success', 'original_size' => $original_size, 'optimized_size' => $optimized_size, 'savings_percent' => $savings_percent, 'error_message' => null, ]; } /** * Build an error response array. * * @param string $error_message The error message. * @return array{status: string, original_size: null, optimized_size: null, savings_percent: null, error_message: string} */ private function error_response( string $error_message ): array { return [ 'status' => 'error', 'original_size' => null, 'optimized_size' => null, 'savings_percent' => null, 'error_message' => $error_message, ]; } /** * Get the original size of the media before optimization. * * Extracted into a protected method so unit tests can override. * * @param \Imagify\Optimization\Process\ProcessInterface $process The optimization process. * @return int Original file size in bytes, or 0 if unavailable. */ protected function get_media_original_size( $process ): int { $data = $process->get_data(); if ( ! $data ) { return 0; } // If already optimized, use the original_size from optimization stats. if ( $data->is_optimized() ) { $optimization_data = $data->get_optimization_data(); if ( isset( $optimization_data['stats']['original_size'] ) ) { return (int) $optimization_data['stats']['original_size']; } } // Otherwise, get the original file size from the media object. $media = $process->get_media(); if ( ! $media ) { return 0; } $path = $media->get_raw_original_path(); if ( ! $path || ! file_exists( $path ) ) { return 0; } return (int) filesize( $path ); } /** * Get the optimized size of the media after optimization. * * For newly-queued jobs, this may return 0 until the background job completes. * Clients should poll imagify_get_media_status to track final results. * * Extracted into a protected method so unit tests can override. * * @param \Imagify\Optimization\Process\ProcessInterface $process The optimization process. * @return int|null Optimized file size in bytes, or null if unavailable. */ protected function get_media_optimized_size( $process ): ?int { $data = $process->get_data(); if ( ! $data ) { return null; } $optimization_data = $data->get_optimization_data(); if ( isset( $optimization_data['stats']['optimized_size'] ) ) { return (int) $optimization_data['stats']['optimized_size']; } return null; } }