diff --git a/build/bulk-optimizer.min.asset.php b/build/bulk-optimizer.min.asset.php new file mode 100644 index 0000000..fdb8c51 --- /dev/null +++ b/build/bulk-optimizer.min.asset.php @@ -0,0 +1 @@ + array(), 'version' => 'd69f4bfea2649c9caaee'); diff --git a/build/bulk-optimizer.min.js b/build/bulk-optimizer.min.js new file mode 100644 index 0000000..f576e24 --- /dev/null +++ b/build/bulk-optimizer.min.js @@ -0,0 +1 @@ +document.addEventListener("DOMContentLoaded",(()=>{const e="nfd-bulk-optimize-btn";let t=!1;const n=["button","media-button","select-mode-toggle-button"],o=["button","media-button","button-primary","button-large","delete-selected-button"],r=()=>{const e=document.getElementById("nfd-bulk-modal");e&&e.remove(),window.location.reload()},i=e=>{const t=e.closest(".media-frame-content"),n=t?.querySelector(".filename");return n?.textContent||"Unknown File"},d=async()=>{const e=Array.from(document.querySelectorAll(".attachment.selected")).map((e=>({id:e.getAttribute("data-id"),name:i(e)})));if(!e.length)return;const n=window.nfdPerformance?.imageOptimization?.bulkOptimizer?.apiUrl;if(!n)return;const{progressBar:o,modalTitle:d,currentFileName:a}=(()=>{t=!1;const{progressBar:e,modalTitle:n,currentFileName:o}=(()=>{const e=document.createElement("div");e.id="nfd-bulk-modal",e.style.cssText="\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n justify-content: center;\n align-items: center;\n z-index: 9999;\n ";const n=document.createElement("div");n.style.cssText="\n background: white;\n padding: 2rem;\n border-radius: 8px;\n text-align: center;\n width: 400px;\n box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);\n ";const o=document.createElement("h2");o.id="nfd-modal-title",o.textContent="Optimizing Images...";const i=document.createElement("p");i.id="nfd-current-file",i.textContent="Preparing files...";const d=document.createElement("div");d.style.cssText="\n width: 100%;\n height: 20px;\n background: #eee;\n border-radius: 10px;\n margin: 1rem 0;\n overflow: hidden;\n position: relative;\n ";const a=document.createElement("div");a.id="nfd-progress-bar",a.style.cssText="\n height: 100%;\n width: 0;\n background: #007cba;\n transition: width 0.3s ease;\n ";const c=document.createElement("button");return c.textContent="Cancel",c.className="button button-secondary",c.style.marginTop="1rem",c.addEventListener("click",(()=>{t=!0,r(),window.location.reload()})),d.appendChild(a),n.append(o,i,d,c),e.appendChild(n),document.body.appendChild(e),{modal:e,progressBar:a,modalTitle:o,currentFileName:i}})();return e.style.width="0%",o.textContent="",{progressBar:e,modalTitle:n,currentFileName:o}})();try{for(let r=0;r{const t=()=>{const t=document.querySelectorAll(".attachment.selected").length>0;e.disabled=!t},n=document.querySelector(".media-frame-content");n&&(new MutationObserver(t).observe(n,{childList:!0,subtree:!0}),t())},c=(e,t)=>e?.classList.length===t.length&&t.every((t=>e.classList.contains(t)));new MutationObserver((()=>{const t=document.querySelector(".button.media-button.select-mode-toggle-button");c(t,n)?(()=>{const t=document.getElementById(e);t&&t.remove()})():(()=>{if(document.getElementById(e))return;const t=document.querySelector(".button.media-button.button-primary.button-large.delete-selected-button");if(!c(t,o))return;const n=(()=>{const t=document.createElement("button");return t.id=e,t.className="button media-button button-large button-primary",t.textContent="Optimize",t.disabled=!0,t.addEventListener("click",d),t})();t.parentElement.insertBefore(n,t.nextSibling),a(n)})()})).observe(document.body,{childList:!0,subtree:!0})})); \ No newline at end of file diff --git a/components/imageOptimizationSettings/index.js b/components/imageOptimizationSettings/index.js index d6e6d56..0141d0f 100644 --- a/components/imageOptimizationSettings/index.js +++ b/components/imageOptimizationSettings/index.js @@ -1,14 +1,15 @@ -// WordPress import { useState, useEffect } from '@wordpress/element'; +import { + Alert, + Container, + ToggleField, + Button, +} from '@newfold/ui-component-library'; -// Components -import { Alert, Container, ToggleField } from '@newfold/ui-component-library'; - -// Classes and functions import defaultText from '../performance/defaultText'; const ImageOptimizationSettings = ( { methods } ) => { - const [ settings, setSettings ] = useState( null ); // Local state for settings + const [ settings, setSettings ] = useState( null ); const [ isError, setIsError ] = useState( false ); const [ isLoading, setIsLoading ] = useState( true ); @@ -19,6 +20,7 @@ const ImageOptimizationSettings = ( { methods } ) => { const fetchSettings = async () => { setIsLoading( true ); setIsError( false ); + try { const fetchedSettings = await methods.apiFetch( { path: apiUrl } ); setSettings( fetchedSettings.nfd_image_optimization || {} ); @@ -29,7 +31,7 @@ const ImageOptimizationSettings = ( { methods } ) => { } }; - // Update settings via the REST API + // Update settings via REST API const updateSettings = async ( newSettings ) => { setIsError( false ); try { @@ -39,6 +41,7 @@ const ImageOptimizationSettings = ( { methods } ) => { data: { nfd_image_optimization: newSettings }, } ); setSettings( updatedSettings.nfd_image_optimization || {} ); + notify.push( 'image-optimization-updated', { title: defaultText.imageOptimizationUpdatedTitle, description: defaultText.imageOptimizationUpdatedDescription, @@ -47,39 +50,62 @@ const ImageOptimizationSettings = ( { methods } ) => { } ); } catch ( error ) { setIsError( true ); + notify.push( 'image-optimization-update-error', { + title: defaultText.imageOptimizationUpdateErrorTitle, + description: defaultText.imageOptimizationGenericErrorMessage, + variant: 'error', + autoDismiss: 8000, + } ); } }; - // Handle toggle changes + // Handle Toggle Changes const handleToggleChange = ( field, value ) => { const updatedSettings = { ...settings }; - if ( field === 'enabled' ) { - updatedSettings.enabled = value; - - // Automatically enable/disable dependent settings - updatedSettings.auto_optimized_uploaded_images = { - enabled: value, - auto_delete_original_image: value, - }; - updatedSettings.lazy_loading = { enabled: value }; - } else if ( field === 'autoOptimizeEnabled' ) { - updatedSettings.auto_optimized_uploaded_images.enabled = value; - if ( ! value ) { - updatedSettings.auto_optimized_uploaded_images.auto_delete_original_image = false; - } - } else if ( field === 'autoDeleteOriginalImage' ) { - updatedSettings.auto_optimized_uploaded_images.auto_delete_original_image = - value; - } else if ( field === 'lazyLoading' ) { - updatedSettings.lazy_loading.enabled = value; + switch ( field ) { + case 'enabled': + updatedSettings.enabled = value; + updatedSettings.auto_optimized_uploaded_images.enabled = value; + updatedSettings.bulk_optimization = value; + updatedSettings.lazy_loading.enabled = value; + break; + + case 'autoOptimizeEnabled': + updatedSettings.auto_optimized_uploaded_images.enabled = value; + break; + + case 'bulkOptimize': + updatedSettings.bulk_optimization = value; + break; + + case 'lazyLoading': + updatedSettings.lazy_loading.enabled = value; + break; + + case 'autoDeleteOriginalImage': + updatedSettings.auto_optimized_uploaded_images.auto_delete_original_image = + value; + break; + + default: + break; + } + + // Auto-disable Auto Delete Original Image if both options are off + if ( + field !== 'autoDeleteOriginalImage' && + ! updatedSettings.bulk_optimization && + ! updatedSettings.auto_optimized_uploaded_images.enabled + ) { + updatedSettings.auto_optimized_uploaded_images.auto_delete_original_image = false; } setSettings( updatedSettings ); updateSettings( updatedSettings ); }; - // Fetch settings on component mount + // Fetch settings on mount useEffect( () => { fetchSettings(); }, [] ); @@ -107,11 +133,11 @@ const ImageOptimizationSettings = ( { methods } ) => { ); } - // Destructure settings with camel case for internal use const { enabled, auto_optimized_uploaded_images: autoOptimizedUploadedImages, lazy_loading: lazyLoading = { enabled: true }, + bulk_optimization: bulkOptimization = false, } = settings || {}; const { @@ -136,61 +162,105 @@ const ImageOptimizationSettings = ( { methods } ) => { handleToggleChange( 'enabled', ! enabled ) } /> -
- - handleToggleChange( - 'autoOptimizeEnabled', - ! autoOptimizeEnabled - ) - } - disabled={ ! enabled } - /> -
- + handleToggleChange( + 'autoOptimizeEnabled', + ! autoOptimizeEnabled + ) + } + disabled={ ! enabled } + /> + + + { defaultText.imageOptimizationAutoDeleteDescription } - checked={ autoDeleteOriginalImage } - onChange={ () => - handleToggleChange( - 'autoDeleteOriginalImage', - ! autoDeleteOriginalImage - ) - } - disabled={ ! enabled || ! autoOptimizeEnabled } - /> -
-
- - handleToggleChange( - 'lazyLoading', - ! lazyLoading.enabled - ) +

+ { + defaultText.imageOptimizationAutoDeleteCaution + } +

+ + } + checked={ autoDeleteOriginalImage } + onChange={ () => + handleToggleChange( + 'autoDeleteOriginalImage', + ! autoDeleteOriginalImage + ) + } + disabled={ + ! enabled || + ( ! autoOptimizeEnabled && ! bulkOptimization ) + } + /> + + + handleToggleChange( + 'lazyLoading', + ! lazyLoading.enabled + ) + } + disabled={ ! enabled } + /> + + + handleToggleChange( 'bulkOptimize', ! bulkOptimization ) + } + disabled={ ! enabled } + /> + + { bulkOptimization && ( +
+
-
+ ) }
); diff --git a/components/performance/defaultText.js b/components/performance/defaultText.js index ee4f42f..27298eb 100644 --- a/components/performance/defaultText.js +++ b/components/performance/defaultText.js @@ -93,13 +93,17 @@ const defaultText = { 'wp-module-performance' ), imageOptimizationAutoDeleteLabel: __( - 'Auto Delete Original Image (Recommended)', + 'Auto Delete Original Image', 'wp-module-performance' ), imageOptimizationAutoDeleteDescription: __( 'When enabled, the original uploaded image is deleted and replaced with the optimized version, helping to save storage space. If disabled, the optimized image is saved as a separate file, retaining the original.', 'wp-module-performance' ), + imageOptimizationAutoDeleteCaution: __( + 'Caution: If the original image is being referenced elsewhere (e.g., in posts, pages, or custom templates), those references will break. You will need to manually update those references to use the optimized image.', + 'wp-module-performance' + ), imageOptimizationNoSettings: __( 'No settings available.', 'wp-module-performance' @@ -136,6 +140,26 @@ const defaultText = { 'Oops! There was an error updating the lazy loading settings.', 'wp-module-performance' ), + imageOptimizationBulkOptimizeLabel: __( + 'Enable Bulk Optimization of Images', + 'wp-module-performance' + ), + imageOptimizationBulkOptimizeDescription: __( + 'When enabled, allows bulk optimization of images in the media library.', + 'wp-module-performance' + ), + imageOptimizationBulkOptimizeButtonLabel: __( + 'Go to Media Library', + 'wp-module-performance' + ), + imageOptimizationUpdateErrorTitle: __( + 'Error Updating Settings', + 'wp-module-performance' + ), + imageOptimizationGenericErrorMessage: __( + 'Something went wrong while updating the settings. Please try again.', + 'wp-module-performance' + ), linkPrefetchDescription: __( 'Asks the browser to download and cache links on the page ahead of them being clicked on, so that when they are clicked they load almost instantly. ', 'wp-module-performance' diff --git a/includes/Images/ImageBulkOptimizer.php b/includes/Images/ImageBulkOptimizer.php new file mode 100644 index 0000000..9b56187 --- /dev/null +++ b/includes/Images/ImageBulkOptimizer.php @@ -0,0 +1,59 @@ +get_inline_script(), + 'before' + ); + + wp_enqueue_script( 'nfd-performance-bulk-optimizer' ); + } + + /** + * Generates inline settings for the bulk optimizer script. + * + * @return string JavaScript code to inline. + */ + private function get_inline_script() { + $api_url = add_query_arg( + 'rest_route', + '/newfold-performance/v1/images/optimize', + get_rest_url() + ); + + return sprintf( + 'window.nfdPerformance = window.nfdPerformance || {}; + window.nfdPerformance.imageOptimization = window.nfdPerformance.imageOptimization || {}; + window.nfdPerformance.imageOptimization.bulkOptimizer = { + apiUrl: "%s" + };', + esc_url( $api_url ) + ); + } +} diff --git a/includes/Images/ImageManager.php b/includes/Images/ImageManager.php index 76f2b1b..c37bf10 100644 --- a/includes/Images/ImageManager.php +++ b/includes/Images/ImageManager.php @@ -2,18 +2,23 @@ namespace NewfoldLabs\WP\Module\Performance\Images; +use NewfoldLabs\WP\Module\Performance\Permissions; +use NewfoldLabs\WP\Module\Performance\Images\RestApi\RestApi; + /** * Manages the initialization of image optimization settings and listeners. */ class ImageManager { /** * Constructor to initialize the ImageManager. - * It registers settings and initializes the listener conditionally. + * It registers settings and conditionally initializes services. */ public function __construct() { $this->initialize_settings(); - $this->maybe_initialize_listener(); + $this->maybe_initialize_upload_listener(); $this->maybe_initialize_lazy_loader(); + $this->maybe_initialize_bulk_optimizer(); + $this->maybe_initialize_rest_api(); } /** @@ -26,7 +31,7 @@ private function initialize_settings() { /** * Conditionally initializes the ImageUploadListener based on the settings. */ - private function maybe_initialize_listener() { + private function maybe_initialize_upload_listener() { if ( ImageSettings::is_optimization_enabled() && ImageSettings::is_auto_optimization_enabled() ) { $auto_delete_original_image = ImageSettings::is_auto_delete_enabled(); new ImageUploadListener( $auto_delete_original_image ); @@ -34,11 +39,29 @@ private function maybe_initialize_listener() { } /** - * Conditionally initializes the LazyLoader based on the lazy loading setting. + * Conditionally initializes the LazyLoader based on settings. */ private function maybe_initialize_lazy_loader() { if ( ImageSettings::is_optimization_enabled() && ImageSettings::is_lazy_loading_enabled() ) { new LazyLoader(); } } + + /** + * Conditionally initializes the ImageBulkOptimizer only within `wp-admin`. + */ + private function maybe_initialize_bulk_optimizer() { + if ( Permissions::is_authorized_admin() && ImageSettings::is_bulk_optimization_enabled() ) { + new ImageBulkOptimizer(); + } + } + + /** + * Conditionally initializes the REST API routes only when called via REST. + */ + private function maybe_initialize_rest_api() { + if ( Permissions::rest_is_authorized_admin() ) { + new RestApi(); + } + } } diff --git a/includes/Images/ImageService.php b/includes/Images/ImageService.php index f0f77b2..276ff02 100644 --- a/includes/Images/ImageService.php +++ b/includes/Images/ImageService.php @@ -155,4 +155,114 @@ private function get_response_message( $response ) { return null; } + + /** + * Replaces the original file with the optimized WebP file in the Media Library. + * + * @param int|string $media_id_or_path Media ID or original file path. + * @param string $webp_file_path The path to the optimized WebP file. + * @return array|WP_Error The updated upload array or WP_Error on failure. + */ + public function replace_original_with_webp( $media_id_or_path, $webp_file_path ) { + $original_file_path = ''; + $upload_dir = wp_upload_dir(); + $webp_file_url = trailingslashit( $upload_dir['url'] ) . wp_basename( $webp_file_path ); + + // Determine if media_id_or_path is a media ID or file path + if ( is_numeric( $media_id_or_path ) ) { + $original_file_path = get_attached_file( $media_id_or_path ); + } elseif ( is_string( $media_id_or_path ) && file_exists( $media_id_or_path ) ) { + $original_file_path = $media_id_or_path; + } else { + return new \WP_Error( + 'nfd_performance_error', + __( 'Invalid media ID or file path provided.', 'wp-module-performance' ) + ); + } + + // Ensure the WebP file exists + if ( ! file_exists( $webp_file_path ) || filesize( $webp_file_path ) === 0 ) { + return new \WP_Error( + 'nfd_performance_error', + __( 'WebP file is missing or empty.', 'wp-module-performance' ) + ); + } + + // Delete the original file from disk + if ( ! $this->delete_original_file( $original_file_path ) ) { + return new \WP_Error( + 'nfd_performance_error', + __( 'Failed to delete the original file.', 'wp-module-performance' ) + ); + } + + // Update the attachment metadata if a media ID was provided + if ( is_numeric( $media_id_or_path ) ) { + update_attached_file( $media_id_or_path, $webp_file_path ); + require_once ABSPATH . 'wp-admin/includes/image.php'; + $metadata = wp_generate_attachment_metadata( $media_id_or_path, $webp_file_path ); + if ( is_wp_error( $metadata ) || empty( $metadata ) ) { + return new \WP_Error( + 'nfd_performance_error', + __( 'Failed to generate attachment metadata.', 'wp-module-performance' ) + ); + } + + wp_update_attachment_metadata( $media_id_or_path, $metadata ); + } + + // Return the updated upload array + return array( + 'file' => $webp_file_path, + 'url' => $webp_file_url, + 'type' => 'image/webp', + ); + } + + /** + * Registers the WebP file as a standalone media item in the Media Library. + * + * @param string $webp_file_path The path to the optimized WebP file. + * @return int|WP_Error The attachment ID of the new media item, or WP_Error on failure. + */ + public function register_webp_as_new_media( $webp_file_path ) { + $upload_dir = wp_upload_dir(); + $webp_url = trailingslashit( $upload_dir['url'] ) . wp_basename( $webp_file_path ); + + // Prepare the attachment data + $attachment_data = array( + 'post_mime_type' => 'image/webp', + 'post_title' => wp_basename( $webp_file_path ), + 'post_content' => '', + 'post_status' => 'inherit', + ); + + // Insert the WebP file as a new attachment + $attachment_id = wp_insert_attachment( $attachment_data, $webp_file_path ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + // Generate and update attachment metadata + require_once ABSPATH . 'wp-admin/includes/image.php'; + $metadata = wp_generate_attachment_metadata( $attachment_id, $webp_file_path ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + return $attachment_id; + } + + /** + * Deletes the original uploaded file from the filesystem. + * + * @param string $file_path The path to the original file. + * @return bool True on success, false on failure. + */ + public function delete_original_file( $file_path ) { + if ( file_exists( $file_path ) ) { + return wp_delete_file( $file_path ); + } + + return false; + } } diff --git a/includes/Images/ImageSettings.php b/includes/Images/ImageSettings.php index d11e890..58f39dd 100644 --- a/includes/Images/ImageSettings.php +++ b/includes/Images/ImageSettings.php @@ -18,6 +18,7 @@ class ImageSettings { */ private const DEFAULT_SETTINGS = array( 'enabled' => true, + 'bulk_optimization' => true, 'auto_optimized_uploaded_images' => array( 'enabled' => true, 'auto_delete_original_image' => true, @@ -51,7 +52,7 @@ private function register_settings() { 'schema' => array( 'type' => 'object', 'properties' => array( - 'enabled' => array( + 'enabled' => array( 'type' => 'boolean', 'description' => __( 'Enable image optimization.', 'wp-module-performance' ), 'default' => self::DEFAULT_SETTINGS['enabled'], @@ -72,7 +73,7 @@ private function register_settings() { ), ), ), - 'lazy_loading' => array( + 'lazy_loading' => array( 'type' => 'object', 'description' => __( 'Settings for lazy loading.', 'wp-module-performance' ), 'properties' => array( @@ -83,6 +84,11 @@ private function register_settings() { ), ), ), + 'bulk_optimization' => array( + 'type' => 'boolean', + 'description' => __( 'Enable bulk optimization of images.', 'wp-module-performance' ), + 'default' => self::DEFAULT_SETTINGS['bulk_optimization'], + ), ), 'additionalProperties' => false, // Disallow undefined properties ), @@ -119,6 +125,7 @@ public function sanitize_settings( $settings ) { 'lazy_loading' => array( 'enabled' => ! empty( $settings['lazy_loading']['enabled'] ), ), + 'bulk_optimization' => ! empty( $settings['bulk_optimization'] ), ); } @@ -161,4 +168,14 @@ public static function is_lazy_loading_enabled() { $settings = get_option( self::SETTING_KEY, self::DEFAULT_SETTINGS ); return ! empty( $settings['lazy_loading']['enabled'] ); } + + /** + * Checks if bulk optimization is enabled. + * + * @return bool True if bulk optimization is enabled, false otherwise. + */ + public static function is_bulk_optimization_enabled() { + $settings = get_option( self::SETTING_KEY, self::DEFAULT_SETTINGS ); + return ! empty( $settings['bulk_optimization'] ); + } } diff --git a/includes/Images/ImageUploadListener.php b/includes/Images/ImageUploadListener.php index b21d0bc..1990324 100644 --- a/includes/Images/ImageUploadListener.php +++ b/includes/Images/ImageUploadListener.php @@ -52,78 +52,11 @@ public function handle_media_upload( $upload ) { } if ( $this->delete_original ) { - $upload = $this->replace_original_with_webp( $upload, $optimized_image_path ); + $upload = $this->image_service->replace_original_with_webp( $upload['file'], $optimized_image_path ); } else { - $this->register_webp_as_new_media( $optimized_image_path ); + $this->image_service->register_webp_as_new_media( $optimized_image_path ); } return $upload; } - - /** - * Replaces the original file with the optimized WebP file in the Media Library. - * - * @param array $upload The original upload array. - * @param string $webp_file_path The path to the optimized WebP file. - * @return array The updated upload array. - */ - private function replace_original_with_webp( $upload, $webp_file_path ) { - // Delete the original file from disk - $original_file_path = $upload['file']; - - if ( $this->delete_original_file( $original_file_path ) ) { - // Update the upload array to use the WebP file - $upload['file'] = $webp_file_path; - $upload['url'] = trailingslashit( wp_upload_dir()['url'] ) . wp_basename( $webp_file_path ); - $upload['type'] = 'image/webp'; - } - - return $upload; - } - - /** - * Registers the WebP file as a standalone media item in the Media Library. - * - * @param string $webp_file_path The path to the optimized WebP file. - */ - private function register_webp_as_new_media( $webp_file_path ) { - $upload_dir = wp_upload_dir(); - $webp_url = trailingslashit( $upload_dir['url'] ) . wp_basename( $webp_file_path ); - - // Prepare the attachment data - $attachment_data = array( - 'post_mime_type' => 'image/webp', - 'post_title' => wp_basename( $webp_file_path ), - 'post_content' => '', - 'post_status' => 'inherit', - ); - - // Insert the WebP file as a new attachment - $attachment_id = wp_insert_attachment( $attachment_data, $webp_file_path ); - - if ( is_wp_error( $attachment_id ) ) { - return; - } - - // Generate and update attachment metadata - require_once ABSPATH . 'wp-admin/includes/image.php'; - $metadata = wp_generate_attachment_metadata( $attachment_id, $webp_file_path ); - wp_update_attachment_metadata( $attachment_id, $metadata ); - } - - /** - * Deletes the original uploaded file from the filesystem. - * - * @param string $file_path The path to the original file. - * @return bool True on success, false on failure. - */ - private function delete_original_file( $file_path ) { - if ( file_exists( $file_path ) ) { - if ( wp_delete_file( $file_path ) ) { - return true; - } - } - - return false; - } } diff --git a/includes/Images/RestApi/ImagesController.php b/includes/Images/RestApi/ImagesController.php new file mode 100644 index 0000000..744f4f5 --- /dev/null +++ b/includes/Images/RestApi/ImagesController.php @@ -0,0 +1,115 @@ +namespace, + $this->rest_base . '/optimize', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'optimize_image' ), + 'permission_callback' => function () { + return current_user_can( 'upload_files' ); + }, + 'args' => array( + 'media_id' => array( + 'required' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the media item to optimize.', 'wp-module-performance' ), + 'validate_callback' => function ( $param ) { + return is_numeric( $param ) && $param > 0; + }, + ), + ), + ) + ); + } + + /** + * Optimizes a single media item. + * + * @param \WP_REST_Request $request The REST API request. + * @return \WP_REST_Response + */ + public function optimize_image( \WP_REST_Request $request ) { + $media_id = $request->get_param( 'media_id' ); + + $file_path = get_attached_file( $media_id ); + $image_url = wp_get_attachment_url( $media_id ); + + if ( empty( $file_path ) || empty( $image_url ) ) { + return new \WP_REST_Response( + array( + 'success' => false, + 'error' => __( 'Invalid media ID or media item not found.', 'wp-module-performance' ), + ), + 400 + ); + } + + $image_service = new ImageService(); + $delete_original = ImageSettings::is_auto_delete_enabled(); + $optimized_result = $image_service->optimize_image( $image_url, $file_path ); + + if ( is_wp_error( $optimized_result ) ) { + return new \WP_REST_Response( + array( + 'success' => false, + 'error' => $optimized_result->get_error_message(), + ), + 500 + ); + } + + if ( $delete_original ) { + $response = $image_service->replace_original_with_webp( $media_id, $optimized_result ); + } else { + $response = $image_service->register_webp_as_new_media( $optimized_result ); + } + + if ( is_wp_error( $response ) ) { + return new \WP_REST_Response( + array( + 'success' => false, + 'error' => $response->get_error_message(), + ), + 500 + ); + } + + return new \WP_REST_Response( + array( + 'success' => true, + 'message' => __( 'Image successfully optimized.', 'wp-module-performance' ), + ), + 200 + ); + } +} diff --git a/includes/Images/RestApi/RestApi.php b/includes/Images/RestApi/RestApi.php new file mode 100644 index 0000000..9a301da --- /dev/null +++ b/includes/Images/RestApi/RestApi.php @@ -0,0 +1,41 @@ +controllers as $controller ) { + /** + * Get an instance of the WP_REST_Controller. + * + * @var $instance WP_REST_Controller + */ + $instance = new $controller(); + $instance->register_routes(); + } + } +} diff --git a/includes/Permissions.php b/includes/Permissions.php index 1b5bf86..79bf4fe 100644 --- a/includes/Permissions.php +++ b/includes/Permissions.php @@ -1,52 +1,71 @@ { + const { __ } = wp.i18n; + + const bulkOptimizeButtonId = 'nfd-bulk-optimize-btn'; + let cancelRequested = false; + + // Exact class lists for Bulk Select and Delete Permanently buttons + const bulkSelectButtonClasses = [ + 'button', + 'media-button', + 'select-mode-toggle-button', + ]; + const deletePermanentlyButtonClasses = [ + 'button', + 'media-button', + 'button-primary', + 'button-large', + 'delete-selected-button', + ]; + + /** + * Removes the "Bulk Optimize" button if present. + */ + const removeBulkOptimizeButton = () => { + const bulkOptimizeButton = + document.getElementById( bulkOptimizeButtonId ); + if ( bulkOptimizeButton ) bulkOptimizeButton.remove(); + }; + + /** + * Creates a modal with progress bar and filename display. + */ + const createModal = () => { + const modal = document.createElement( 'div' ); + modal.id = 'nfd-bulk-modal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + `; + + const modalContent = document.createElement( 'div' ); + modalContent.style.cssText = ` + background: white; + padding: 2rem; + border-radius: 8px; + text-align: center; + width: 400px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); + `; + + const modalTitle = document.createElement( 'h2' ); + modalTitle.id = 'nfd-modal-title'; + modalTitle.textContent = __( + 'Optimizing Images…', + 'wp-module-performance' + ); + + const currentFileName = document.createElement( 'p' ); + currentFileName.id = 'nfd-current-file'; + currentFileName.textContent = __( + 'Preparing files…', + 'wp-module-performance' + ); + + const progressContainer = document.createElement( 'div' ); + progressContainer.style.cssText = ` + width: 100%; + height: 20px; + background: #eee; + border-radius: 10px; + margin: 1rem 0; + overflow: hidden; + position: relative; + `; + + const progressBar = document.createElement( 'div' ); + progressBar.id = 'nfd-progress-bar'; + progressBar.style.cssText = ` + height: 100%; + width: 0; + background: #007cba; + transition: width 0.3s ease; + `; + + const cancelButton = document.createElement( 'button' ); + cancelButton.textContent = __( 'Cancel', 'wp-module-performance' ); + cancelButton.className = 'button button-secondary'; + cancelButton.style.marginTop = '1rem'; + cancelButton.addEventListener( 'click', () => { + cancelRequested = true; + closeModal(); + window.location.reload(); // Reload on cancel + } ); + + progressContainer.appendChild( progressBar ); + modalContent.append( + modalTitle, + currentFileName, + progressContainer, + cancelButton + ); + modal.appendChild( modalContent ); + document.body.appendChild( modal ); + + return { modal, progressBar, modalTitle, currentFileName }; + }; + + /** + * Opens the modal and resets its display. + */ + const openModal = () => { + cancelRequested = false; + const { progressBar, modalTitle, currentFileName } = createModal(); + progressBar.style.width = '0%'; + currentFileName.textContent = ''; + return { progressBar, modalTitle, currentFileName }; + }; + + /** + * Closes the modal and reloads the page. + */ + const closeModal = () => { + const modal = document.getElementById( 'nfd-bulk-modal' ); + if ( modal ) modal.remove(); + window.location.reload(); // Reload on close + }; + + /** + * Extracts the file name from the attachment element. + */ + const getFileName = ( attachment ) => { + const mediaContent = attachment.closest( '.media-frame-content' ); + const fileNameElement = mediaContent?.querySelector( '.filename' ); + return ( + fileNameElement?.textContent || + __( 'Unknown File', 'wp-module-performance' ) + ); + }; + + /** + * Handles bulk optimization with progress bar and filename display. + */ + const handleBulkOptimize = async () => { + const selectedItems = Array.from( + document.querySelectorAll( '.attachment.selected' ) + ).map( ( attachment ) => ( { + id: attachment.getAttribute( 'data-id' ), + name: getFileName( attachment ), + } ) ); + + if ( ! selectedItems.length ) return; + + const apiUrl = + window.nfdPerformance?.imageOptimization?.bulkOptimizer?.apiUrl; + + if ( ! apiUrl ) { + return; + } + + // Open modal and start progress + const { progressBar, modalTitle, currentFileName } = openModal(); + + try { + for ( let i = 0; i < selectedItems.length; i++ ) { + if ( cancelRequested ) { + modalTitle.textContent = __( + 'Optimization Canceled', + 'wp-module-performance' + ); + break; + } + + const { id: mediaId, name: fileName } = selectedItems[ i ]; + currentFileName.textContent = + __( 'Optimizing:', 'wp-module-performance' ) + + ` ${ fileName }`; + + try { + await wp.apiFetch( { + url: apiUrl, + method: 'POST', + data: { media_id: parseInt( mediaId, 10 ) }, + } ); + + const progress = ( ( i + 1 ) / selectedItems.length ) * 100; + progressBar.style.width = `${ progress }%`; + } catch ( error ) { + console.error( + __( + 'Error optimizing media ID:', + 'wp-module-performance' + ) + ` ${ mediaId }`, + error + ); + } + } + + if ( ! cancelRequested ) { + modalTitle.textContent = __( + 'Optimization Complete!', + 'wp-module-performance' + ); + setTimeout( closeModal, 2000 ); + } + } catch ( error ) { + modalTitle.textContent = __( + 'An error occurred.', + 'wp-module-performance' + ); + setTimeout( closeModal, 3000 ); + } + }; + + const createBulkOptimizeButton = () => { + const bulkOptimizeButton = document.createElement( 'button' ); + bulkOptimizeButton.id = bulkOptimizeButtonId; + bulkOptimizeButton.className = + 'button media-button button-large button-primary'; + bulkOptimizeButton.textContent = __( + 'Optimize', + 'wp-module-performance' + ); + bulkOptimizeButton.disabled = true; + bulkOptimizeButton.addEventListener( 'click', handleBulkOptimize ); + return bulkOptimizeButton; + }; + + const addBulkOptimizeButton = () => { + if ( document.getElementById( bulkOptimizeButtonId ) ) return; + + const deletePermanentlyButton = document.querySelector( + '.button.media-button.button-primary.button-large.delete-selected-button' + ); + + if ( + ! hasExactClassList( + deletePermanentlyButton, + deletePermanentlyButtonClasses + ) + ) + return; + + const bulkOptimizeButton = createBulkOptimizeButton(); + deletePermanentlyButton.parentElement.insertBefore( + bulkOptimizeButton, + deletePermanentlyButton.nextSibling + ); + + monitorSelectedItems( bulkOptimizeButton ); + }; + + const monitorSelectedItems = ( bulkOptimizeButton ) => { + const updateButtonState = () => { + const hasSelectedItems = + document.querySelectorAll( '.attachment.selected' ).length > 0; + bulkOptimizeButton.disabled = ! hasSelectedItems; + }; + + const mediaFrameContent = document.querySelector( + '.media-frame-content' + ); + if ( mediaFrameContent ) { + const observer = new MutationObserver( updateButtonState ); + observer.observe( mediaFrameContent, { + childList: true, + subtree: true, + } ); + updateButtonState(); + } + }; + + const hasExactClassList = ( element, classList ) => + element?.classList.length === classList.length && + classList.every( ( cls ) => element.classList.contains( cls ) ); + + const observer = new MutationObserver( () => { + const bulkSelectButton = document.querySelector( + '.button.media-button.select-mode-toggle-button' + ); + + hasExactClassList( bulkSelectButton, bulkSelectButtonClasses ) + ? removeBulkOptimizeButton() + : addBulkOptimizeButton(); + } ); + + observer.observe( document.body, { childList: true, subtree: true } ); +} );