PluginProbe
FrontBlocks Site Tools / trunk
FrontBlocks Site Tools vtrunk
1.5.3 1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 All 28 releases
frontblocks / includes / Admin / ImageManagement.php

ImageManagement.php in FrontBlocks Site Tools trunk, at includes/Admin/ImageManagement.php

619 lines 24.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Image Management settings
4 *
5 * @package FrontBlocks
6 * @author Closemarketing
7 * @copyright 2026 Closemarketing
8 * @version 1.0.0
9 */
10
11 namespace FrontBlocks\Admin;
12
13 use FrontBlocks\Frontend\ImageManagement as FrontendImageManagement;
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * Adds the Image Management section to the FrontBlocks settings page,
19 * handles its sanitization, and processes the bulk regenerate/convert
20 * AJAX actions.
21 */
22 class ImageManagement {
23
24 /**
25 * Settings page slug (matches Admin\Settings::$page_slug).
26 *
27 * @var string
28 */
29 private $page_slug = 'frontblocks-settings';
30
31 /**
32 * Constructor.
33 */
34 public function __construct() {
35 add_action( 'frontblocks_register_settings', array( $this, 'register_settings' ) );
36 add_filter( 'sanitize_option_frontblocks_settings', array( $this, 'sanitize_settings' ), 20, 2 );
37 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
38
39 add_action( 'wp_ajax_frbl_list_image_attachment_ids', array( $this, 'ajax_list_attachment_ids' ) );
40 add_action( 'wp_ajax_frbl_start_image_bulk_job', array( $this, 'ajax_start_bulk_job' ) );
41 add_action( 'wp_ajax_frbl_image_bulk_job_status', array( $this, 'ajax_bulk_job_status' ) );
42 add_action( self::PROCESS_ITEM_ACTION, array( $this, 'process_bulk_item' ), 10, 3 );
43 }
44
45 /**
46 * Action Scheduler hook used to process one attachment of a bulk job.
47 */
48 const PROCESS_ITEM_ACTION = 'frontblocks_image_management_process_item';
49
50 /**
51 * Action Scheduler group every Image Management bulk action is
52 * scheduled under, so they're easy to find/inspect via Tools > Scheduled
53 * Actions independently of any other plugin's (e.g. WooCommerce's) use
54 * of the same shared library.
55 */
56 const JOB_GROUP = 'frontblocks-image-management';
57
58 /**
59 * The three supported bulk job types.
60 */
61 const JOB_TYPES = array( 'regenerate', 'convert', 'cleanup' );
62
63 /**
64 * Register the settings section and fields.
65 *
66 * @return void
67 */
68 public function register_settings() {
69 add_settings_section(
70 'frontblocks_section_image_management',
71 __( 'Image Management', 'frontblocks' ),
72 array( $this, 'section_callback' ),
73 $this->page_slug
74 );
75
76 add_settings_field(
77 'enable_image_management',
78 __( 'Image Management', 'frontblocks' ),
79 array( $this, 'field_enable_image_management' ),
80 $this->page_slug,
81 'frontblocks_section_image_management'
82 );
83 }
84
85 /**
86 * Section description.
87 *
88 * @return void
89 */
90 public function section_callback() {
91 echo '<p class="tw:text-sm tw:text-gray-500">' . esc_html__( 'Control which image sizes WordPress generates and automatically serve WebP/AVIF versions of your images.', 'frontblocks' ) . '</p>';
92 }
93
94 /**
95 * Never persist a target the server can't actually produce — the select
96 * shown to the user already hides unsupported options, but the saved
97 * option is re-checked here too, in case support changed since the page
98 * was loaded (e.g. a value saved on a different host, or a PHP extension
99 * that was removed after the settings screen was last opened).
100 *
101 * @param string $target Requested target: 'none', 'webp', 'avif', or 'both'.
102 * @return string A target the current server can produce, or 'none'.
103 */
104 private function downgrade_target_to_supported_formats( $target ) {
105 $webp_supported = wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) );
106 $avif_supported = wp_image_editor_supports( array( 'mime_type' => 'image/avif' ) );
107
108 if ( 'both' === $target ) {
109 if ( $webp_supported && $avif_supported ) {
110 return 'both';
111 }
112 $target = $avif_supported ? 'avif' : ( $webp_supported ? 'webp' : 'none' );
113 }
114
115 if ( 'webp' === $target && ! $webp_supported ) {
116 $target = 'none';
117 }
118
119 if ( 'avif' === $target && ! $avif_supported ) {
120 $target = 'none';
121 }
122
123 return $target;
124 }
125
126 /**
127 * Registered size names (core + additional), used by both the field
128 * render and sanitization.
129 *
130 * @return string[]
131 */
132 private function get_registered_size_names() {
133 global $_wp_additional_image_sizes;
134
135 return array_values( array_unique( array_merge( FrontendImageManagement::CORE_SIZES, array_keys( (array) $_wp_additional_image_sizes ) ) ) );
136 }
137
138 /**
139 * Build the list of registered sizes with dimensions/crop/source, for
140 * the settings-page table and for JS localization.
141 *
142 * @return array
143 */
144 private function get_registered_sizes_info() {
145 global $_wp_additional_image_sizes;
146
147 $options = get_option( 'frontblocks_settings', array() );
148 $own_custom_names = wp_list_pluck( (array) ( $options['image_sizes_custom'] ?? array() ), 'name' );
149
150 $sizes = array();
151
152 foreach ( FrontendImageManagement::CORE_SIZES as $name ) {
153 $sizes[] = array(
154 'name' => $name,
155 'width' => (int) get_option( "{$name}_size_w" ),
156 'height' => (int) get_option( "{$name}_size_h" ),
157 'crop' => (bool) get_option( "{$name}_crop" ),
158 'source' => 'core',
159 );
160 }
161
162 foreach ( (array) $_wp_additional_image_sizes as $name => $data ) {
163 // Our own custom sizes are registered via add_image_size() too,
164 // but are listed separately (and are editable/removable) in the
165 // "custom" section below — skip them here to avoid duplicates.
166 if ( in_array( $name, FrontendImageManagement::CORE_SIZES, true ) || in_array( $name, $own_custom_names, true ) ) {
167 continue;
168 }
169 $sizes[] = array(
170 'name' => $name,
171 'width' => (int) ( $data['width'] ?? 0 ),
172 'height' => (int) ( $data['height'] ?? 0 ),
173 'crop' => (bool) ( $data['crop'] ?? false ),
174 'source' => 'theme/plugin',
175 );
176 }
177
178 return $sizes;
179 }
180
181 /**
182 * Render the enable toggle plus the sizes table, format options, and
183 * bulk-action controls.
184 *
185 * @return void
186 */
187 public function field_enable_image_management() {
188 $options = get_option( 'frontblocks_settings', array() );
189 $disabled = (array) ( $options['image_sizes_disabled'] ?? array() );
190 $overrides = (array) ( $options['image_sizes_overrides'] ?? array() );
191 $custom = (array) ( $options['image_sizes_custom'] ?? array() );
192 $format_target = (string) ( $options['image_format_target'] ?? 'none' );
193 $quality_by_fmt = FrontendImageManagement::get_quality_settings( $options );
194 $max_upload_dimension_enabled = (bool) ( $options['image_max_upload_dimension_enabled'] ?? true );
195 $max_upload_dimension = (int) ( $options['image_max_upload_dimension'] ?? 2048 );
196
197 $sizes_info = $this->get_registered_sizes_info();
198 $usage = FrontendImageManagement::estimate_disk_usage_by_size( wp_list_pluck( $sizes_info, 'name' ) );
199
200 $config = array(
201 'sizes' => $sizes_info,
202 'disabled' => $disabled,
203 'overrides' => $overrides,
204 'custom' => $custom,
205 'usage' => $usage,
206 );
207
208 $sizes_config_json = wp_json_encode(
209 array(
210 'disabled' => $disabled,
211 'overrides' => $overrides,
212 'custom' => $custom,
213 )
214 );
215 ?>
216 <div class="frbl-image-management">
217 <div id="image-management-fields-wrapper">
218 <input type="hidden" id="frbl-image-sizes-config" name="frontblocks_settings[image_sizes_config]" value="<?php echo esc_attr( $sizes_config_json ); ?>" />
219
220 <div class="tw:p-4 tw:bg-gray-50 tw:rounded-lg tw:border tw:border-gray-200 tw:mb-4">
221 <p class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'Registered image sizes', 'frontblocks' ); ?></p>
222 <div id="frbl-image-sizes-table"></div>
223 </div>
224
225 <div class="tw:p-4 tw:bg-gray-50 tw:rounded-lg tw:border tw:border-gray-200 tw:mb-4">
226 <div class="tw:flex tw:items-center tw:justify-between">
227 <div>
228 <label for="image_max_upload_dimension_enabled" class="tw:text-sm tw:font-medium tw:text-gray-700"><?php echo esc_html__( 'Limit max upload dimension', 'frontblocks' ); ?></label>
229 <p class="tw:text-xs tw:text-gray-500 tw:mt-1"><?php echo esc_html__( 'On upload, downscale the stored original if it exceeds this size, so oversized source images don\'t bloat storage or slow down thumbnail generation. Off: keep full-size originals untouched.', 'frontblocks' ); ?></p>
230 </div>
231 <label class="frbl-toggle">
232 <input type="checkbox"
233 id="image_max_upload_dimension_enabled"
234 name="frontblocks_settings[image_max_upload_dimension_enabled]"
235 value="1"
236 <?php checked( true, $max_upload_dimension_enabled ); ?>
237 />
238 <span></span>
239 </label>
240 </div>
241 <div class="tw:mt-3" style="<?php echo $max_upload_dimension_enabled ? '' : 'display: none;'; ?>" id="image-max-upload-dimension-field">
242 <label for="image_max_upload_dimension" class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'Max width/height (pixels)', 'frontblocks' ); ?></label>
243 <input type="number" id="image_max_upload_dimension" name="frontblocks_settings[image_max_upload_dimension]" min="300" max="10000" step="1" value="<?php echo esc_attr( $max_upload_dimension ); ?>" class="tw:block tw:w-full tw:px-3 tw:py-2 tw:border tw:border-gray-300 tw:rounded-lg tw:text-base" />
244 </div>
245 </div>
246
247 <div class="tw:p-4 tw:bg-gray-50 tw:rounded-lg tw:border tw:border-gray-200 tw:mb-4">
248 <div class="tw:grid tw:grid-cols-2 tw:gap-4">
249 <div>
250 <?php
251 $webp_supported = wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) );
252 $avif_supported = wp_image_editor_supports( array( 'mime_type' => 'image/avif' ) );
253 ?>
254 <label for="image_format_target" class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'Modern format', 'frontblocks' ); ?></label>
255 <select id="image_format_target" name="frontblocks_settings[image_format_target]" class="tw:block tw:w-full tw:px-3 tw:py-2 tw:border tw:border-gray-300 tw:rounded-lg tw:text-base">
256 <option value="none" <?php selected( $format_target, 'none' ); ?>><?php echo esc_html__( 'Off', 'frontblocks' ); ?></option>
257 <?php if ( $webp_supported ) : ?>
258 <option value="webp" <?php selected( $format_target, 'webp' ); ?>><?php echo esc_html__( 'WebP', 'frontblocks' ); ?></option>
259 <?php endif; ?>
260 <?php if ( $avif_supported ) : ?>
261 <option value="avif" <?php selected( $format_target, 'avif' ); ?>><?php echo esc_html__( 'AVIF', 'frontblocks' ); ?></option>
262 <?php endif; ?>
263 <?php if ( $webp_supported && $avif_supported ) : ?>
264 <option value="both" <?php selected( $format_target, 'both' ); ?>><?php echo esc_html__( 'WebP and AVIF', 'frontblocks' ); ?></option>
265 <?php endif; ?>
266 </select>
267 <?php if ( ! $webp_supported || ! $avif_supported ) : ?>
268 <p class="tw:text-xs tw:text-amber-600 tw:mt-2">
269 <?php
270 echo $avif_supported
271 ? esc_html__( 'This server does not support generating WebP images, so that option is hidden.', 'frontblocks' )
272 : ( $webp_supported
273 ? esc_html__( 'This server does not support generating AVIF images, so that option is hidden.', 'frontblocks' )
274 : esc_html__( 'This server does not support generating WebP or AVIF images. Ask your host to enable the Imagick or GD PHP extension with modern-format support.', 'frontblocks' ) );
275 ?>
276 </p>
277 <?php endif; ?>
278 </div>
279 <div>
280 <label for="image_format_quality_webp" class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'WebP quality', 'frontblocks' ); ?> (<span id="frbl-image-quality-webp-value"><?php echo esc_html( $quality_by_fmt['webp'] ); ?></span>)</label>
281 <input type="range" id="image_format_quality_webp" name="frontblocks_settings[image_format_quality_webp]" min="1" max="100" value="<?php echo esc_attr( $quality_by_fmt['webp'] ); ?>" class="tw:block tw:w-full tw:mb-4" />
282 <label for="image_format_quality_avif" class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'AVIF quality', 'frontblocks' ); ?> (<span id="frbl-image-quality-avif-value"><?php echo esc_html( $quality_by_fmt['avif'] ); ?></span>)</label>
283 <input type="range" id="image_format_quality_avif" name="frontblocks_settings[image_format_quality_avif]" min="1" max="100" value="<?php echo esc_attr( $quality_by_fmt['avif'] ); ?>" class="tw:block tw:w-full" />
284 <p class="tw:text-xs tw:text-gray-500 tw:mt-1"><?php echo esc_html__( 'AVIF defaults lower than WebP: the same numeric quality produces a noticeably smaller AVIF file at a visually comparable result.', 'frontblocks' ); ?></p>
285 </div>
286 </div>
287 </div>
288
289 <div class="tw:p-4 tw:bg-gray-50 tw:rounded-lg tw:border tw:border-gray-200">
290 <p class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2"><?php echo esc_html__( 'Existing media library items', 'frontblocks' ); ?></p>
291 <p class="tw:text-xs tw:text-gray-500 tw:mb-3"><?php echo esc_html__( 'Save your settings above before running these — they use the saved size and format configuration.', 'frontblocks' ); ?></p>
292 <div class="tw:flex tw:gap-3 tw:mb-3">
293 <button type="button" class="button" id="frbl-bulk-regenerate"><?php echo esc_html__( 'Regenerate thumbnails', 'frontblocks' ); ?></button>
294 <button type="button" class="button" id="frbl-bulk-convert"><?php echo esc_html__( 'Convert to modern formats', 'frontblocks' ); ?></button>
295 <button type="button" class="button" id="frbl-bulk-cleanup"><?php echo esc_html__( 'Delete files for disabled sizes', 'frontblocks' ); ?></button>
296 </div>
297 <div id="frbl-image-bulk-progress" class="frbl-image-bulk-progress" style="display: none;">
298 <div class="frbl-image-bulk-progress__bar"><div class="frbl-image-bulk-progress__fill"></div></div>
299 <p class="frbl-image-bulk-progress__label"></p>
300 </div>
301 </div>
302 </div>
303 </div>
304 <script type="application/json" id="frbl-image-management-config"><?php echo wp_json_encode( $config ); ?></script>
305 <?php
306 }
307
308 /**
309 * Sanitize the module's settings. Hooked onto the same core filter
310 * register_setting() uses for Settings::sanitize_settings(), which
311 * starts from the previously saved option and only copies forward
312 * keys it explicitly recognizes — so our fields must be read directly
313 * from $_POST rather than from $value. See docs/EXTENDING-SETTINGS.md.
314 *
315 * @param array $value Sanitized value built by Settings::sanitize_settings().
316 * @param string $option Option name.
317 * @return array
318 */
319 public function sanitize_settings( $value, $option ) {
320 if ( 'frontblocks_settings' !== $option || ! is_array( $value ) ) {
321 return $value;
322 }
323
324 $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
325 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'frontblocks_settings-options' ) ) {
326 return $value;
327 }
328
329 if ( ! isset( $_POST['frontblocks_settings'] ) || ! is_array( $_POST['frontblocks_settings'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- nonce verified above.
330 return $value;
331 }
332
333 $posted = wp_unslash( $_POST['frontblocks_settings'] ); // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; every value read from $posted below is sanitized individually.
334
335 $allowed_targets = array( 'none', 'webp', 'avif', 'both' );
336 $posted_target = $posted['image_format_target'] ?? 'none';
337 $requested_target = in_array( $posted_target, $allowed_targets, true ) ? $posted_target : 'none';
338 $value['image_format_target'] = $this->downgrade_target_to_supported_formats( $requested_target );
339
340 $value['image_max_upload_dimension_enabled'] = ! empty( $posted['image_max_upload_dimension_enabled'] );
341
342 $max_dimension = absint( $posted['image_max_upload_dimension'] ?? 2048 );
343 $value['image_max_upload_dimension'] = $max_dimension >= 300 ? min( $max_dimension, 10000 ) : 2048;
344
345 $webp_quality = absint( $posted['image_format_quality_webp'] ?? 82 );
346 $value['image_format_quality_webp'] = $webp_quality > 0 ? min( $webp_quality, 100 ) : 82;
347
348 $avif_quality = absint( $posted['image_format_quality_avif'] ?? 60 );
349 $value['image_format_quality_avif'] = $avif_quality > 0 ? min( $avif_quality, 100 ) : 60;
350
351 $config = array();
352 if ( ! empty( $posted['image_sizes_config'] ) ) {
353 $decoded = json_decode( $posted['image_sizes_config'], true );
354 if ( is_array( $decoded ) ) {
355 $config = $decoded;
356 }
357 }
358
359 $allowed_size_names = $this->get_registered_size_names();
360
361 $value['image_sizes_disabled'] = array_values(
362 array_intersect(
363 array_map( 'sanitize_key', (array) ( $config['disabled'] ?? array() ) ),
364 $allowed_size_names
365 )
366 );
367
368 $overrides = array();
369 foreach ( (array) ( $config['overrides'] ?? array() ) as $name => $size ) {
370 $name = sanitize_key( $name );
371 if ( ! in_array( $name, $allowed_size_names, true ) || ! is_array( $size ) ) {
372 continue;
373 }
374 $overrides[ $name ] = array(
375 'width' => absint( $size['width'] ?? 0 ),
376 'height' => absint( $size['height'] ?? 0 ),
377 'crop' => ! empty( $size['crop'] ),
378 );
379 }
380 $value['image_sizes_overrides'] = $overrides;
381
382 $custom = array();
383 foreach ( (array) ( $config['custom'] ?? array() ) as $size ) {
384 if ( ! is_array( $size ) || empty( $size['name'] ) ) {
385 continue;
386 }
387 $custom[] = array(
388 'name' => sanitize_key( $size['name'] ),
389 'width' => absint( $size['width'] ?? 0 ),
390 'height' => absint( $size['height'] ?? 0 ),
391 'crop' => ! empty( $size['crop'] ),
392 'label' => sanitize_text_field( $size['label'] ?? '' ),
393 'show_in_picker' => ! empty( $size['show_in_picker'] ),
394 );
395 }
396 $value['image_sizes_custom'] = $custom;
397
398 // Core sizes (thumbnail/medium/medium_large/large) are stored as
399 // their own wp_options, not via add_image_size(), so apply those
400 // overrides here once at save time instead of on every request.
401 foreach ( FrontendImageManagement::CORE_SIZES as $core_name ) {
402 if ( empty( $overrides[ $core_name ] ) ) {
403 continue;
404 }
405 update_option( "{$core_name}_size_w", $overrides[ $core_name ]['width'] );
406 update_option( "{$core_name}_size_h", $overrides[ $core_name ]['height'] );
407 update_option( "{$core_name}_crop", $overrides[ $core_name ]['crop'] );
408 }
409
410 return $value;
411 }
412
413 /**
414 * Enqueue the settings-page script/style for this module.
415 *
416 * @param string $hook Current admin page hook.
417 * @return void
418 */
419 public function enqueue_assets( $hook ) {
420 if ( 'appearance_page_' . $this->page_slug !== $hook ) {
421 return;
422 }
423
424 wp_enqueue_style(
425 'frontblocks-image-management',
426 FRBL_PLUGIN_URL . 'assets/image-management/frontblocks-image-management.css',
427 array(),
428 FRBL_VERSION
429 );
430
431 wp_enqueue_script(
432 'frontblocks-image-management',
433 FRBL_PLUGIN_URL . 'assets/image-management/frontblocks-image-management.js',
434 array(),
435 FRBL_VERSION,
436 true
437 );
438
439 wp_localize_script(
440 'frontblocks-image-management',
441 'frblImageManagement',
442 array(
443 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
444 'nonce' => wp_create_nonce( 'frbl_image_management' ),
445 'i18n' => array(
446 /* translators: %1$d: items processed so far, %2$d: total items. */
447 'processing' => __( 'Processing %1$d of %2$d…', 'frontblocks' ),
448 /* translators: %d: total items processed. */
449 'done' => __( 'Done — processed %d items.', 'frontblocks' ),
450 'error' => __( 'Something went wrong, please try again.', 'frontblocks' ),
451 ),
452 )
453 );
454 }
455
456 /**
457 * AJAX: list all image attachment IDs, used by the bulk-action JS to
458 * build its batch queue.
459 *
460 * @return void
461 */
462 public function ajax_list_attachment_ids() {
463 check_ajax_referer( 'frbl_image_management', 'nonce' );
464
465 if ( ! current_user_can( 'edit_theme_options' ) ) {
466 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'frontblocks' ) ), 403 );
467 }
468
469 $ids = get_posts(
470 array(
471 'post_type' => 'attachment',
472 'post_status' => 'inherit',
473 'post_mime_type' => 'image',
474 'posts_per_page' => -1,
475 'fields' => 'ids',
476 )
477 );
478
479 wp_send_json_success( array( 'ids' => array_map( 'intval', $ids ) ) );
480 }
481
482 /**
483 * AJAX: start a bulk job (regenerate/convert/cleanup) — schedules one
484 * Action Scheduler action per image attachment on a background queue,
485 * so processing continues even if this browser tab is closed, and each
486 * attachment is its own request-sized unit of work (no bulk-timeout risk
487 * on large libraries).
488 *
489 * @return void
490 */
491 public function ajax_start_bulk_job() {
492 check_ajax_referer( 'frbl_image_management', 'nonce' );
493
494 if ( ! current_user_can( 'edit_theme_options' ) ) {
495 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'frontblocks' ) ), 403 );
496 }
497
498 $job_type = isset( $_POST['job_type'] ) ? sanitize_key( wp_unslash( $_POST['job_type'] ) ) : '';
499 if ( ! in_array( $job_type, self::JOB_TYPES, true ) ) {
500 wp_send_json_error( array( 'message' => __( 'Invalid job type.', 'frontblocks' ) ), 400 );
501 }
502
503 if ( ! function_exists( 'as_enqueue_async_action' ) ) {
504 wp_send_json_error( array( 'message' => __( 'The background job library is unavailable.', 'frontblocks' ) ), 500 );
505 }
506
507 $ids = get_posts(
508 array(
509 'post_type' => 'attachment',
510 'post_status' => 'inherit',
511 'post_mime_type' => 'image',
512 'posts_per_page' => -1,
513 'fields' => 'ids',
514 )
515 );
516
517 $job_id = wp_generate_uuid4();
518
519 update_option(
520 $this->job_option_name( $job_id ),
521 array(
522 'total' => count( $ids ),
523 'done' => 0,
524 ),
525 false
526 );
527
528 foreach ( $ids as $attachment_id ) {
529 as_enqueue_async_action(
530 self::PROCESS_ITEM_ACTION,
531 array( $job_type, (int) $attachment_id, $job_id ),
532 self::JOB_GROUP
533 );
534 }
535
536 wp_send_json_success(
537 array(
538 'job_id' => $job_id,
539 'total' => count( $ids ),
540 )
541 );
542 }
543
544 /**
545 * AJAX: report a bulk job's progress so far.
546 *
547 * @return void
548 */
549 public function ajax_bulk_job_status() {
550 check_ajax_referer( 'frbl_image_management', 'nonce' );
551
552 if ( ! current_user_can( 'edit_theme_options' ) ) {
553 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'frontblocks' ) ), 403 );
554 }
555
556 $job_id = isset( $_POST['job_id'] ) ? sanitize_key( wp_unslash( $_POST['job_id'] ) ) : '';
557 $option_name = $this->job_option_name( $job_id );
558 $progress = get_option( $option_name, false );
559
560 if ( false === $progress || ! isset( $progress['total'], $progress['done'] ) ) {
561 wp_send_json_error( array( 'message' => __( 'Unknown job.', 'frontblocks' ) ), 404 );
562 }
563
564 if ( $progress['done'] >= $progress['total'] ) {
565 delete_option( $option_name );
566 }
567
568 wp_send_json_success( $progress );
569 }
570
571 /**
572 * Action Scheduler callback: process one attachment for a bulk job.
573 *
574 * @param string $job_type One of self::JOB_TYPES.
575 * @param int $attachment_id Attachment post ID.
576 * @param string $job_id Job id, to update its progress counter.
577 * @return void
578 */
579 public function process_bulk_item( $job_type, $attachment_id, $job_id ) {
580 switch ( $job_type ) {
581 case 'regenerate':
582 require_once ABSPATH . 'wp-admin/includes/image.php';
583 $file = get_attached_file( $attachment_id );
584 if ( $file && file_exists( $file ) ) {
585 $metadata = wp_generate_attachment_metadata( $attachment_id, $file );
586 if ( ! is_wp_error( $metadata ) && ! empty( $metadata ) ) {
587 wp_update_attachment_metadata( $attachment_id, $metadata );
588 }
589 }
590 break;
591
592 case 'convert':
593 FrontendImageManagement::convert_attachment( $attachment_id );
594 break;
595
596 case 'cleanup':
597 FrontendImageManagement::cleanup_disabled_size_files( $attachment_id );
598 break;
599 }
600
601 $option_name = $this->job_option_name( $job_id );
602 $progress = get_option( $option_name, false );
603 if ( false !== $progress && isset( $progress['total'], $progress['done'] ) ) {
604 $progress['done'] = min( $progress['total'], $progress['done'] + 1 );
605 update_option( $option_name, $progress, false );
606 }
607 }
608
609 /**
610 * Build the option name storing one bulk job's progress.
611 *
612 * @param string $job_id Job id.
613 * @return string
614 */
615 private function job_option_name( $job_id ) {
616 return 'frbl_image_job_' . $job_id;
617 }
618 }
619