PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.16
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.16
4.2.16 4.2.15 4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 All 137 releases
optimole-wp / inc / abilities.php

abilities.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization 4.2.16, at inc/abilities.php

1,173 lines 34.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use Optimole\Sdk\ValueObject\Position;
4
5 /**
6 * Class Optml_Abilities.
7 *
8 * Registers Optimole abilities with the WordPress Abilities API (WP 6.9+).
9 */
10 class Optml_Abilities {
11
12 const CATEGORY = 'optimole';
13
14 /**
15 * Maximum number of attachments accepted by the batch abilities. The work is done in time limited chunks.
16 */
17 const MAX_MEDIA_IDS = 500;
18
19 /**
20 * Default and maximum number of seconds a single call can spend on a batch.
21 */
22 const DEFAULT_TIME_BUDGET = 20;
23 const MAX_TIME_BUDGET = 60;
24
25 /**
26 * Maximum number of attachments that can be inspected in a single status call.
27 */
28 const MAX_STATUS_IDS = 100;
29
30 /**
31 * Settings that accept the enabled/disabled values.
32 *
33 * @var string[]
34 */
35 private static $toggle_settings = [
36 'image_replacer',
37 'cdn',
38 'autoquality',
39 'best_format',
40 'strip_metadata',
41 'network_optimization',
42 'retina_images',
43 'scale',
44 'resize_smart',
45 'limit_dimensions',
46 'lazyload',
47 'lazyload_placeholder',
48 'native_lazyload',
49 'video_lazyload',
50 'bg_replacer',
51 'no_script',
52 'css_minify',
53 'js_minify',
54 ];
55
56 /**
57 * Settings that accept a value from a fixed list.
58 *
59 * @var array<string, string[]>
60 */
61 private static $enum_settings = [
62 'compression_mode' => [ 'speed_optimized', 'quality_optimized', 'custom' ],
63 'lazyload_type' => [ 'fixed', 'viewport', 'all', 'fixed|viewport' ],
64 'wm_position' => [
65 Position::NORTH,
66 Position::NORTH_EAST,
67 Position::NORTH_WEST,
68 Position::CENTER,
69 Position::EAST,
70 Position::WEST,
71 Position::SOUTH_EAST,
72 Position::SOUTH,
73 Position::SOUTH_WEST,
74 ],
75 ];
76
77 /**
78 * Settings that accept a bounded integer.
79 *
80 * @var array<string, int[]>
81 */
82 private static $integer_settings = [
83 'quality' => [ 50, 100 ],
84 'limit_width' => [ 100, 5000 ],
85 'limit_height' => [ 100, 5000 ],
86 'skip_lazyload_images' => [ 0, 100 ],
87 ];
88
89 /**
90 * Settings that accept a number.
91 *
92 * @var string[]
93 */
94 private static $number_settings = [ 'wm_opacity', 'wm_scale', 'wm_x', 'wm_y' ];
95
96 /**
97 * Optml_Abilities constructor.
98 */
99 public function __construct() {
100 if ( ! function_exists( 'wp_register_ability' ) ) {
101 return;
102 }
103
104 add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] );
105 add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
106 }
107
108 /**
109 * Register the ability category.
110 *
111 * @return void
112 */
113 public function register_category() {
114 if ( ! function_exists( 'wp_register_ability_category' ) ) {
115 return;
116 }
117
118 wp_register_ability_category(
119 self::CATEGORY,
120 [
121 'label' => __( 'Optimole', 'optimole-wp' ),
122 'description' => __( 'Image delivery, cloud offloading and cache operations provided by Optimole.', 'optimole-wp' ),
123 ]
124 );
125 }
126
127 /**
128 * Register the abilities.
129 *
130 * @return void
131 */
132 public function register_abilities() {
133 if ( ! function_exists( 'wp_register_ability' ) ) {
134 return;
135 }
136
137 wp_register_ability(
138 'optimole/get-delivery-settings',
139 [
140 'label' => __( 'Get Optimole delivery settings', 'optimole-wp' ),
141 'description' => __( 'Returns the effective Optimole image delivery settings: quality and compression, resize, lazy loading, exclusion rules and watermark. Credentials are never returned.', 'optimole-wp' ),
142 'category' => self::CATEGORY,
143 'input_schema' => [
144 'type' => 'object',
145 'default' => [],
146 'additionalProperties' => false,
147 ],
148 'output_schema' => [
149 'type' => 'object',
150 'properties' => [
151 'connected' => [ 'type' => 'boolean' ],
152 'settings' => [ 'type' => 'object' ],
153 ],
154 ],
155 'execute_callback' => [ $this, 'get_delivery_settings' ],
156 'permission_callback' => [ $this, 'can_manage' ],
157 'meta' => [
158 'annotations' => [
159 'readonly' => true,
160 'destructive' => false,
161 'idempotent' => true,
162 ],
163 'show_in_rest' => true,
164 ],
165 ]
166 );
167
168 wp_register_ability(
169 'optimole/update-delivery-settings',
170 [
171 'label' => __( 'Update Optimole delivery settings', 'optimole-wp' ),
172 'description' => __( 'Updates Optimole image delivery settings. Only the provided settings are changed. Exclusion rules are added or removed individually.', 'optimole-wp' ),
173 'category' => self::CATEGORY,
174 'input_schema' => $this->get_update_settings_schema(),
175 'output_schema' => [
176 'type' => 'object',
177 'properties' => [
178 'dry_run' => [ 'type' => 'boolean' ],
179 'changes' => [ 'type' => 'object' ],
180 'settings' => [ 'type' => 'object' ],
181 ],
182 ],
183 'execute_callback' => [ $this, 'update_delivery_settings' ],
184 'permission_callback' => [ $this, 'can_manage' ],
185 'meta' => [
186 'annotations' => [
187 'readonly' => false,
188 'destructive' => false,
189 'idempotent' => true,
190 ],
191 'show_in_rest' => true,
192 ],
193 ]
194 );
195
196 wp_register_ability(
197 'optimole/offload-media',
198 [
199 'label' => __( 'Offload to Optimole', 'optimole-wp' ),
200 'description' => __( 'Moves the selected media library images to Optimole Cloud and removes the local files, using the same process as the "Offload to Optimole" media library action. Requires the offload media option to be enabled. Large batches are processed in chunks: when done is false, call again with the same media_ids and the returned cursor.', 'optimole-wp' ),
201 'category' => self::CATEGORY,
202 'input_schema' => $this->get_media_ids_schema( self::MAX_MEDIA_IDS, true, true ),
203 'output_schema' => $this->get_move_output_schema(),
204 'execute_callback' => [ $this, 'offload_media' ],
205 'permission_callback' => [ $this, 'can_move_media' ],
206 'meta' => [
207 'annotations' => [
208 'readonly' => false,
209 'destructive' => true,
210 'idempotent' => true,
211 ],
212 'show_in_rest' => true,
213 'task' => [
214 'mode' => 'cursor',
215 'results_key' => 'results',
216 ],
217 ],
218 ]
219 );
220
221 wp_register_ability(
222 'optimole/restore-media',
223 [
224 'label' => __( 'Restore Offloaded Images', 'optimole-wp' ),
225 'description' => __( 'Restores the selected offloaded images from Optimole Cloud back to the media library, using the same process as the "Restore image to media library" action. Large batches are processed in chunks: when done is false, call again with the same media_ids and the returned cursor.', 'optimole-wp' ),
226 'category' => self::CATEGORY,
227 'input_schema' => $this->get_media_ids_schema( self::MAX_MEDIA_IDS, true, true ),
228 'output_schema' => $this->get_move_output_schema(),
229 'execute_callback' => [ $this, 'restore_media' ],
230 'permission_callback' => [ $this, 'can_move_media' ],
231 'meta' => [
232 'annotations' => [
233 'readonly' => false,
234 'destructive' => true,
235 'idempotent' => true,
236 ],
237 'show_in_rest' => true,
238 'task' => [
239 'mode' => 'cursor',
240 'results_key' => 'results',
241 ],
242 ],
243 ]
244 );
245
246 wp_register_ability(
247 'optimole/get-offload-job',
248 [
249 'label' => __( 'Get Optimole offload status', 'optimole-wp' ),
250 'description' => __( 'Returns the progress of the current offload or restore transfer and, when media IDs are given, the offload state of each image.', 'optimole-wp' ),
251 'category' => self::CATEGORY,
252 'input_schema' => $this->get_media_ids_schema( self::MAX_STATUS_IDS, false ),
253 'output_schema' => [
254 'type' => 'object',
255 'properties' => [
256 'offload_enabled' => [ 'type' => 'boolean' ],
257 'transfer' => [
258 'type' => 'object',
259 'properties' => [
260 'action' => [ 'type' => 'string' ],
261 'offload_in_progress' => [ 'type' => 'boolean' ],
262 'restore_in_progress' => [ 'type' => 'boolean' ],
263 'count' => [ 'type' => 'integer' ],
264 'remaining' => [ 'type' => 'integer' ],
265 'minutes_elapsed' => [ 'type' => 'number' ],
266 'offload_limit' => [ 'type' => 'integer' ],
267 'offload_limit_reached' => [ 'type' => 'boolean' ],
268 ],
269 ],
270 'items' => [
271 'type' => 'array',
272 'items' => [
273 'type' => 'object',
274 'properties' => [
275 'media_id' => [ 'type' => 'integer' ],
276 'state' => [ 'type' => 'string' ],
277 'error' => [ 'type' => 'string' ],
278 ],
279 ],
280 ],
281 ],
282 ],
283 'execute_callback' => [ $this, 'get_offload_job' ],
284 'permission_callback' => [ $this, 'can_manage' ],
285 'meta' => [
286 'annotations' => [
287 'readonly' => true,
288 'destructive' => false,
289 'idempotent' => true,
290 ],
291 'show_in_rest' => true,
292 ],
293 ]
294 );
295
296 wp_register_ability(
297 'optimole/purge-image-cache',
298 [
299 'label' => __( 'Clear Cached Images', 'optimole-wp' ),
300 'description' => __( 'Invalidates the optimized variants of the selected images. Pass all=true instead of media IDs to clear the cache for every optimized image (allowed once every 5 minutes). Large batches are processed in chunks: when done is false, call again with the same media_ids and the returned cursor.', 'optimole-wp' ),
301 'category' => self::CATEGORY,
302 'input_schema' => [
303 'type' => 'object',
304 'default' => [],
305 'properties' => array_merge(
306 [
307 'media_ids' => [
308 'type' => 'array',
309 'description' => __( 'Attachment IDs of the images.', 'optimole-wp' ),
310 'items' => [
311 'type' => 'integer',
312 'minimum' => 1,
313 ],
314 'maxItems' => self::MAX_MEDIA_IDS,
315 ],
316 'all' => [
317 'type' => 'boolean',
318 'description' => __( 'Clear the cache for all optimized images. Ignored when media_ids is provided.', 'optimole-wp' ),
319 'default' => false,
320 ],
321 ],
322 $this->get_chunk_input_properties()
323 ),
324 'additionalProperties' => false,
325 ],
326 'output_schema' => [
327 'type' => 'object',
328 'properties' => array_merge(
329 [ 'scope' => [ 'type' => 'string' ] ],
330 $this->get_chunk_output_properties( [ 'purged', 'skipped' ] )
331 ),
332 ],
333 'execute_callback' => [ $this, 'purge_image_cache' ],
334 'permission_callback' => [ $this, 'can_manage' ],
335 'meta' => [
336 'annotations' => [
337 'readonly' => false,
338 'destructive' => true,
339 'idempotent' => true,
340 ],
341 'show_in_rest' => true,
342 'task' => [
343 'mode' => 'cursor',
344 'results_key' => 'results',
345 ],
346 ],
347 ]
348 );
349 }
350
351 /**
352 * Permission check used by the settings, cache and transfer status REST routes.
353 *
354 * @return bool
355 */
356 public function can_manage() {
357 return current_user_can( 'manage_options' );
358 }
359
360 /**
361 * Permission check used by the move image REST route. Per image checks are done when executing.
362 *
363 * @return bool
364 */
365 public function can_move_media() {
366 return current_user_can( 'upload_files' );
367 }
368
369 /**
370 * Get the delivery settings.
371 *
372 * @return array<string, mixed>
373 */
374 public function get_delivery_settings() {
375 $settings = new Optml_Settings();
376
377 return [
378 'connected' => $settings->is_connected(),
379 'settings' => $this->read_delivery_settings( $settings ),
380 ];
381 }
382
383 /**
384 * Update the delivery settings.
385 *
386 * @param mixed $input Ability input.
387 *
388 * @return array<string, mixed>|WP_Error
389 */
390 public function update_delivery_settings( $input ) {
391 $input = is_array( $input ) ? $input : [];
392 $dry_run = ! empty( $input['dry_run'] );
393 $changes = [];
394
395 $requested = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : [];
396
397 foreach ( $requested as $key => $value ) {
398 if ( in_array( $key, self::$toggle_settings, true ) ) {
399 if ( ! in_array( $value, [ 'enabled', 'disabled' ], true ) ) {
400 return $this->invalid_setting( $key, __( 'Expected "enabled" or "disabled".', 'optimole-wp' ) );
401 }
402 } elseif ( isset( self::$enum_settings[ $key ] ) ) {
403 if ( ! in_array( $value, self::$enum_settings[ $key ], true ) ) {
404 /* translators: %s: list of accepted values. */
405 return $this->invalid_setting( $key, sprintf( __( 'Expected one of: %s.', 'optimole-wp' ), implode( ', ', self::$enum_settings[ $key ] ) ) );
406 }
407 } elseif ( isset( self::$integer_settings[ $key ] ) ) {
408 list( $min, $max ) = self::$integer_settings[ $key ];
409 if ( ! is_numeric( $value ) || (int) $value < $min || (int) $value > $max ) {
410 /* translators: 1: minimum value, 2: maximum value. */
411 return $this->invalid_setting( $key, sprintf( __( 'Expected an integer between %1$d and %2$d.', 'optimole-wp' ), $min, $max ) );
412 }
413 $value = (int) $value;
414 } elseif ( in_array( $key, self::$number_settings, true ) ) {
415 if ( ! is_numeric( $value ) ) {
416 return $this->invalid_setting( $key, __( 'Expected a number.', 'optimole-wp' ) );
417 }
418 $value = (float) $value;
419 } elseif ( $key === 'wm_id' ) {
420 if ( ! is_numeric( $value ) ) {
421 return $this->invalid_setting( $key, __( 'Expected an integer.', 'optimole-wp' ) );
422 }
423 $value = (int) $value;
424 } elseif ( $key === 'placeholder_color' ) {
425 if ( ! is_string( $value ) ) {
426 return $this->invalid_setting( $key, __( 'Expected a string.', 'optimole-wp' ) );
427 }
428 $value = sanitize_text_field( $value );
429 } else {
430 return $this->invalid_setting( (string) $key, __( 'Unknown setting.', 'optimole-wp' ) );
431 }
432
433 $changes[ $key ] = $value;
434 }
435
436 $filters = [];
437 foreach (
438 [
439 'add_exclusions' => true,
440 'remove_exclusions' => false,
441 ] as $field => $state
442 ) {
443 if ( ! isset( $input[ $field ] ) ) {
444 continue;
445 }
446 if ( ! is_array( $input[ $field ] ) ) {
447 return new WP_Error( 'optimole_invalid_exclusion', __( 'Exclusions must be a list of rules.', 'optimole-wp' ) );
448 }
449 foreach ( $input[ $field ] as $rule ) {
450 $rule = $this->sanitize_exclusion( $rule );
451 if ( is_wp_error( $rule ) ) {
452 return $rule;
453 }
454 $filters[ $rule['scope'] ][ $rule['rule'] ][ $rule['value'] ] = $state;
455 }
456 }
457 if ( ! empty( $filters ) ) {
458 $changes['filters'] = $filters;
459 }
460
461 if ( empty( $changes ) ) {
462 return new WP_Error( 'optimole_no_changes', __( 'No setting to update', 'optimole-wp' ) );
463 }
464
465 $settings = new Optml_Settings();
466
467 if ( ! $dry_run ) {
468 $settings->parse_settings( $changes );
469 $settings = new Optml_Settings();
470 }
471
472 return [
473 'dry_run' => $dry_run,
474 'changes' => $changes,
475 'settings' => $this->read_delivery_settings( $settings ),
476 ];
477 }
478
479 /**
480 * Offload the selected images.
481 *
482 * @param mixed $input Ability input.
483 *
484 * @return array<string, mixed>|WP_Error
485 */
486 public function offload_media( $input ) {
487 return $this->move_media( 'offload_images', $input );
488 }
489
490 /**
491 * Restore the selected images.
492 *
493 * @param mixed $input Ability input.
494 *
495 * @return array<string, mixed>|WP_Error
496 */
497 public function restore_media( $input ) {
498 return $this->move_media( 'rollback_images', $input );
499 }
500
501 /**
502 * Get the transfer progress and the state of the selected images.
503 *
504 * @param mixed $input Ability input.
505 *
506 * @return array<string, mixed>|WP_Error
507 */
508 public function get_offload_job( $input ) {
509 $ids = $this->parse_media_ids( $input, self::MAX_STATUS_IDS, false );
510 if ( is_wp_error( $ids ) ) {
511 return $ids;
512 }
513
514 $settings = new Optml_Settings();
515 $meta = Optml_Media_Offload::get_process_meta();
516 $items = [];
517
518 foreach ( $ids as $id ) {
519 $items[] = $this->get_media_state( $id );
520 }
521
522 return [
523 'offload_enabled' => $settings->is_offload_enabled(),
524 'transfer' => [
525 'action' => (string) $settings->get( 'transfer_status' ),
526 'offload_in_progress' => $settings->get( 'offloading_status' ) === 'enabled',
527 'restore_in_progress' => $settings->get( 'rollback_status' ) === 'enabled',
528 'count' => (int) $meta['count'],
529 'remaining' => (int) $meta['remaining'],
530 'minutes_elapsed' => round( (float) $meta['time_passed'], 2 ),
531 'offload_limit' => (int) $settings->get( 'offload_limit' ),
532 'offload_limit_reached' => $settings->is_offload_limit_reached(),
533 ],
534 'items' => $items,
535 ];
536 }
537
538 /**
539 * Purge the image cache.
540 *
541 * @param mixed $input Ability input.
542 *
543 * @return array<string, mixed>|WP_Error
544 */
545 public function purge_image_cache( $input ) {
546 $ids = $this->parse_media_ids( $input, self::MAX_MEDIA_IDS, false );
547 if ( is_wp_error( $ids ) ) {
548 return $ids;
549 }
550
551 $settings = new Optml_Settings();
552 if ( ! $settings->is_connected() ) {
553 return $this->not_connected();
554 }
555
556 if ( empty( $ids ) ) {
557 if ( ! is_array( $input ) || empty( $input['all'] ) ) {
558 return new WP_Error( 'optimole_missing_media_ids', __( 'Provide media_ids, or all=true to clear the cache for every image.', 'optimole-wp' ) );
559 }
560
561 $response = $settings->clear_cache( 'images' );
562 if ( is_wp_error( $response ) ) {
563 return $response;
564 }
565
566 return array_merge( [ 'scope' => 'all' ], $this->chunk_output( [], 1, 1, '' ) );
567 }
568
569 $chunk = $this->parse_chunk_input( $input, 'purge', $ids );
570 if ( is_wp_error( $chunk ) ) {
571 return $chunk;
572 }
573
574 $results = [];
575 $total = count( $ids );
576 $offset = $chunk['offset'];
577
578 while ( $offset < $total && ( empty( $results ) || microtime( true ) < $chunk['deadline'] ) ) {
579 $results[] = $this->purge_single_image( $settings, $ids[ $offset ] );
580 ++$offset;
581 }
582
583 return array_merge(
584 [ 'scope' => 'media' ],
585 $this->chunk_output( $results, $offset, $total, $chunk['key'] )
586 );
587 }
588
589 /**
590 * Purge the cache of a single image.
591 *
592 * @param Optml_Settings $settings Settings instance.
593 * @param int $id Attachment ID.
594 *
595 * @return array<string, mixed>
596 */
597 private function purge_single_image( $settings, $id ) {
598 if ( get_post_type( $id ) !== 'attachment' ) {
599 return $this->item_result( $id, 'skipped', 'not_found' );
600 }
601
602 $meta = wp_get_attachment_metadata( $id );
603 if ( ! is_array( $meta ) || empty( $meta['file'] ) || ! is_string( $meta['file'] ) ) {
604 return $this->item_result( $id, 'skipped', 'not_an_image' );
605 }
606
607 // Same call as Optml_Admin::purge_image_cache().
608 $response = $settings->clear_cache( wp_basename( $meta['file'] ) );
609 if ( is_wp_error( $response ) ) {
610 return $this->item_result( $id, 'skipped', (string) $response->get_error_code(), $response->get_error_message() );
611 }
612
613 return $this->item_result( $id, 'purged' );
614 }
615
616 /**
617 * Move the images to or from the cloud, one at a time, as the move image REST route does.
618 *
619 * @param string $action Either offload_images or rollback_images.
620 * @param mixed $input Ability input.
621 *
622 * @return array<string, mixed>|WP_Error
623 */
624 private function move_media( $action, $input ) {
625 $ids = $this->parse_media_ids( $input, self::MAX_MEDIA_IDS, true );
626 if ( is_wp_error( $ids ) ) {
627 return $ids;
628 }
629
630 $settings = new Optml_Settings();
631 if ( ! $settings->is_connected() ) {
632 return $this->not_connected();
633 }
634 if ( ! $settings->is_offload_enabled() ) {
635 return new WP_Error( 'optimole_offload_disabled', __( 'You need to have the offload_media option enabled in order to use this command', 'optimole-wp' ) );
636 }
637
638 $chunk = $this->parse_chunk_input( $input, $action, $ids );
639 if ( is_wp_error( $chunk ) ) {
640 return $chunk;
641 }
642
643 $results = [];
644 $total = count( $ids );
645 $offset = $chunk['offset'];
646
647 while ( $offset < $total && ( empty( $results ) || microtime( true ) < $chunk['deadline'] ) ) {
648 $results[] = $this->move_single_image( $action, $settings, $ids[ $offset ] );
649 ++$offset;
650 }
651
652 return array_merge(
653 [ 'action' => $action === 'offload_images' ? 'offload' : 'restore' ],
654 $this->chunk_output( $results, $offset, $total, $chunk['key'] )
655 );
656 }
657
658 /**
659 * Move a single image to or from the cloud.
660 *
661 * @param string $action Either offload_images or rollback_images.
662 * @param Optml_Settings $settings Settings instance.
663 * @param int $id Attachment ID.
664 *
665 * @return array<string, mixed>
666 */
667 private function move_single_image( $action, $settings, $id ) {
668 $is_offload = $action === 'offload_images';
669
670 if ( get_post_type( $id ) !== 'attachment' ) {
671 return $this->item_result( $id, 'skipped', 'not_found' );
672 }
673 if ( ! current_user_can( 'edit_post', $id ) || ! current_user_can( 'delete_post', $id ) ) {
674 return $this->item_result( $id, 'skipped', 'forbidden' );
675 }
676
677 $meta = wp_get_attachment_metadata( $id );
678 if (
679 ! is_array( $meta ) || empty( $meta['file'] ) || ! is_string( $meta['file'] ) ||
680 wp_check_filetype( $meta['file'], Optml_Config::$all_extensions )['ext'] === false
681 ) {
682 return $this->item_result( $id, 'skipped', 'unsupported_file' );
683 }
684 if ( Optml_Media_Offload::is_uploaded_image( $meta['file'] ) === $is_offload ) {
685 return $this->item_result( $id, 'skipped', $is_offload ? 'already_offloaded' : 'not_offloaded' );
686 }
687
688 try {
689 Optml_Media_Offload::instance()->move_single_image( $action, $id );
690 } catch ( Exception $e ) {
691 return $this->item_result( $id, 'skipped', 'move_failed', wp_strip_all_tags( $e->getMessage() ) );
692 }
693
694 $meta = wp_get_attachment_metadata( $id );
695 $moved = is_array( $meta ) && ! empty( $meta['file'] ) && Optml_Media_Offload::is_uploaded_image( $meta['file'] ) === $is_offload;
696 if ( ! $moved ) {
697 $error_key = Optml_Media_Offload::META_KEYS[ $is_offload ? 'offload_error' : 'rollback_error' ];
698 $reason = ! empty( get_post_meta( $id, $error_key, true ) ) ? 'move_error' : 'not_moved';
699 if ( $is_offload && $settings->is_offload_limit_reached() ) {
700 $reason = 'offload_limit_reached';
701 }
702
703 return $this->item_result( $id, 'skipped', $reason );
704 }
705
706 return $this->item_result( $id, 'moved' );
707 }
708
709 /**
710 * Read the cursor and the time budget of a chunked call.
711 *
712 * The cursor is the offset into the media IDs plus a hash binding it to the same action and IDs.
713 *
714 * @param mixed $input Ability input.
715 * @param string $action Action the cursor belongs to.
716 * @param int[] $ids Media IDs of the batch.
717 *
718 * @return array{offset: int, deadline: float, key: string}|WP_Error
719 */
720 private function parse_chunk_input( $input, $action, $ids ) {
721 $input = is_array( $input ) ? $input : [];
722 $budget = isset( $input['time_budget'] ) && is_numeric( $input['time_budget'] ) ? (int) $input['time_budget'] : self::DEFAULT_TIME_BUDGET;
723 $budget = max( 1, min( self::MAX_TIME_BUDGET, $budget ) );
724 $key = substr( md5( $action . ':' . implode( ',', $ids ) ), 0, 12 );
725 $offset = 0;
726
727 if ( isset( $input['cursor'] ) && $input['cursor'] !== '' ) {
728 $parts = is_string( $input['cursor'] ) ? explode( ':', $input['cursor'] ) : [];
729 if (
730 count( $parts ) !== 2 || ! ctype_digit( $parts[0] ) || $parts[1] !== $key ||
731 (int) $parts[0] > count( $ids )
732 ) {
733 return new WP_Error( 'optimole_invalid_cursor', __( 'The cursor is not valid for these media IDs. Use the cursor returned by the previous call with the same media_ids.', 'optimole-wp' ) );
734 }
735 $offset = (int) $parts[0];
736 }
737
738 return [
739 'offset' => $offset,
740 'deadline' => microtime( true ) + $budget,
741 'key' => $key,
742 ];
743 }
744
745 /**
746 * Build the common output of a chunked call.
747 *
748 * @param array<int, array<string, mixed>> $results Items handled in this call.
749 * @param int $offset Number of items handled so far.
750 * @param int $total Total number of items.
751 * @param string $key Hash binding the cursor to the input.
752 *
753 * @return array<string, mixed>
754 */
755 private function chunk_output( $results, $offset, $total, $key ) {
756 $done = $offset >= $total;
757
758 return [
759 'results' => $results,
760 'done' => $done,
761 'cursor' => $done ? '' : $offset . ':' . $key,
762 'progress' => [
763 'current' => $offset,
764 'total' => $total,
765 /* translators: 1: number of processed images, 2: total number of images. */
766 'message' => sprintf( __( '%1$d of %2$d images processed.', 'optimole-wp' ), $offset, $total ),
767 ],
768 ];
769 }
770
771 /**
772 * Get the offload state of an attachment.
773 *
774 * @param int $id Attachment ID.
775 *
776 * @return array<string, mixed>
777 */
778 private function get_media_state( $id ) {
779 $item = [
780 'media_id' => $id,
781 'state' => 'not_found',
782 'error' => '',
783 ];
784
785 if ( get_post_type( $id ) !== 'attachment' ) {
786 return $item;
787 }
788
789 $meta = wp_get_attachment_metadata( $id );
790 if ( ! is_array( $meta ) || empty( $meta['file'] ) || ! is_string( $meta['file'] ) ) {
791 $item['state'] = 'unsupported_file';
792
793 return $item;
794 }
795
796 $item['state'] = Optml_Media_Offload::is_uploaded_image( $meta['file'] ) ? 'offloaded' : 'local';
797
798 if ( ! empty( get_post_meta( $id, Optml_Media_Offload::META_KEYS['offload_error'], true ) ) ) {
799 $item['error'] = 'offload_error';
800 } elseif ( ! empty( get_post_meta( $id, Optml_Media_Offload::META_KEYS['rollback_error'], true ) ) ) {
801 $item['error'] = 'rollback_error';
802 }
803
804 return $item;
805 }
806
807 /**
808 * Read the delivery settings exposed by the abilities.
809 *
810 * @param Optml_Settings $settings Settings instance.
811 *
812 * @return array<string, mixed>
813 */
814 private function read_delivery_settings( $settings ) {
815 $result = [];
816 $keys = array_merge(
817 self::$toggle_settings,
818 array_keys( self::$integer_settings ),
819 [ 'compression_mode', 'lazyload_type', 'placeholder_color' ]
820 );
821
822 foreach ( $keys as $key ) {
823 $result[ $key ] = $settings->get( $key );
824 }
825
826 $result['effective_quality'] = $settings->get_quality();
827 $result['exclusions'] = [];
828
829 foreach ( $settings->get_filters() as $scope => $rules ) {
830 if ( ! is_array( $rules ) ) {
831 continue;
832 }
833 foreach ( $rules as $rule => $values ) {
834 if ( ! is_array( $values ) ) {
835 continue;
836 }
837 foreach ( $values as $value => $state ) {
838 if ( $state === false || $state === 'false' ) {
839 continue;
840 }
841 $result['exclusions'][] = [
842 'scope' => (string) $scope,
843 'rule' => (string) $rule,
844 'value' => (string) $value,
845 ];
846 }
847 }
848 }
849
850 $result['watermark'] = $settings->get_watermark();
851
852 return $result;
853 }
854
855 /**
856 * Validate an exclusion rule.
857 *
858 * @param mixed $rule Exclusion rule.
859 *
860 * @return array{scope: string, rule: string, value: string}|WP_Error
861 */
862 private function sanitize_exclusion( $rule ) {
863 $scopes = [ Optml_Settings::FILTER_TYPE_LAZYLOAD, Optml_Settings::FILTER_TYPE_OPTIMIZE ];
864 $types = [
865 Optml_Settings::FILTER_EXT,
866 Optml_Settings::FILTER_FILENAME,
867 Optml_Settings::FILTER_URL,
868 Optml_Settings::FILTER_URL_MATCH,
869 Optml_Settings::FILTER_CLASS,
870 ];
871
872 if (
873 ! is_array( $rule ) ||
874 ! isset( $rule['scope'], $rule['rule'], $rule['value'] ) ||
875 ! in_array( $rule['scope'], $scopes, true ) ||
876 ! in_array( $rule['rule'], $types, true ) ||
877 ! is_string( $rule['value'] )
878 ) {
879 return new WP_Error( 'optimole_invalid_exclusion', __( 'Each exclusion needs a valid scope, rule and value.', 'optimole-wp' ) );
880 }
881
882 $value = sanitize_text_field( $rule['value'] );
883 if ( $value === '' ) {
884 return new WP_Error( 'optimole_invalid_exclusion', __( 'The exclusion value can not be empty.', 'optimole-wp' ) );
885 }
886
887 return [
888 'scope' => $rule['scope'],
889 'rule' => $rule['rule'],
890 'value' => $value,
891 ];
892 }
893
894 /**
895 * Parse the media IDs from the input.
896 *
897 * @param mixed $input Ability input.
898 * @param int $max Maximum number of IDs.
899 * @param bool $required Whether at least one ID is required.
900 *
901 * @return int[]|WP_Error
902 */
903 private function parse_media_ids( $input, $max, $required ) {
904 $ids = is_array( $input ) && isset( $input['media_ids'] ) && is_array( $input['media_ids'] ) ? $input['media_ids'] : [];
905 $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
906
907 if ( $required && empty( $ids ) ) {
908 return new WP_Error( 'optimole_missing_media_ids', __( 'At least one media ID is required.', 'optimole-wp' ) );
909 }
910 if ( count( $ids ) > $max ) {
911 /* translators: %d: maximum number of media IDs. */
912 return new WP_Error( 'optimole_too_many_media_ids', sprintf( __( 'A maximum of %d media IDs can be processed at once.', 'optimole-wp' ), $max ) );
913 }
914
915 return $ids;
916 }
917
918 /**
919 * Build a per item result.
920 *
921 * @param int $id Attachment ID.
922 * @param string $status Item status.
923 * @param string $reason Reason code when the item was skipped.
924 * @param string $message Optional details.
925 *
926 * @return array<string, mixed>
927 */
928 private function item_result( $id, $status, $reason = '', $message = '' ) {
929 return [
930 'media_id' => $id,
931 'status' => $status,
932 'reason' => $reason,
933 'message' => $message,
934 ];
935 }
936
937 /**
938 * Error for an invalid setting value.
939 *
940 * @param string $key Setting key.
941 * @param string $message Error details.
942 *
943 * @return WP_Error
944 */
945 private function invalid_setting( $key, $message ) {
946 return new WP_Error(
947 'optimole_invalid_setting',
948 /* translators: 1: setting name, 2: error details. */
949 sprintf( __( 'Invalid value for "%1$s". %2$s', 'optimole-wp' ), $key, $message ),
950 [ 'setting' => $key ]
951 );
952 }
953
954 /**
955 * Error for a site that is not connected to Optimole.
956 *
957 * @return WP_Error
958 */
959 private function not_connected() {
960 return new WP_Error( 'optimole_not_connected', __( 'This site is not connected to Optimole.', 'optimole-wp' ) );
961 }
962
963 /**
964 * Input schema for the update settings ability.
965 *
966 * @return array<string, mixed>
967 */
968 private function get_update_settings_schema() {
969 $properties = [];
970
971 foreach ( self::$toggle_settings as $key ) {
972 $properties[ $key ] = [
973 'type' => 'string',
974 'enum' => [ 'enabled', 'disabled' ],
975 ];
976 }
977 $properties['scale']['description'] = __( 'Legacy inverted flag: "disabled" means images are scaled to the visitor screen, "enabled" turns scaling off.', 'optimole-wp' );
978 $properties['quality'] = [
979 'type' => 'integer',
980 'minimum' => 50,
981 'maximum' => 100,
982 'description' => __( 'Used only when autoquality is disabled.', 'optimole-wp' ),
983 ];
984
985 foreach ( self::$enum_settings as $key => $values ) {
986 $properties[ $key ] = [
987 'type' => 'string',
988 'enum' => $values,
989 ];
990 }
991 foreach ( self::$integer_settings as $key => $bounds ) {
992 if ( isset( $properties[ $key ] ) ) {
993 continue;
994 }
995 $properties[ $key ] = [
996 'type' => 'integer',
997 'minimum' => $bounds[0],
998 'maximum' => $bounds[1],
999 ];
1000 }
1001 foreach ( self::$number_settings as $key ) {
1002 $properties[ $key ] = [ 'type' => 'number' ];
1003 }
1004 $properties['wm_id'] = [
1005 'type' => 'integer',
1006 'description' => __( 'Watermark ID from the Optimole dashboard, -1 to disable the watermark.', 'optimole-wp' ),
1007 ];
1008 $properties['placeholder_color'] = [ 'type' => 'string' ];
1009
1010 $exclusions = [
1011 'type' => 'array',
1012 'items' => [
1013 'type' => 'object',
1014 'properties' => [
1015 'scope' => [
1016 'type' => 'string',
1017 'enum' => [ Optml_Settings::FILTER_TYPE_LAZYLOAD, Optml_Settings::FILTER_TYPE_OPTIMIZE ],
1018 ],
1019 'rule' => [
1020 'type' => 'string',
1021 'enum' => [
1022 Optml_Settings::FILTER_EXT,
1023 Optml_Settings::FILTER_FILENAME,
1024 Optml_Settings::FILTER_URL,
1025 Optml_Settings::FILTER_URL_MATCH,
1026 Optml_Settings::FILTER_CLASS,
1027 ],
1028 ],
1029 'value' => [ 'type' => 'string' ],
1030 ],
1031 'required' => [ 'scope', 'rule', 'value' ],
1032 'additionalProperties' => false,
1033 ],
1034 ];
1035
1036 return [
1037 'type' => 'object',
1038 'properties' => [
1039 'settings' => [
1040 'type' => 'object',
1041 'properties' => $properties,
1042 'additionalProperties' => false,
1043 ],
1044 'add_exclusions' => $exclusions,
1045 'remove_exclusions' => $exclusions,
1046 'dry_run' => [
1047 'type' => 'boolean',
1048 'description' => __( 'Validate and return the changes without saving them.', 'optimole-wp' ),
1049 'default' => false,
1050 ],
1051 ],
1052 'additionalProperties' => false,
1053 ];
1054 }
1055
1056 /**
1057 * Input schema for abilities that take a list of media IDs.
1058 *
1059 * @param int $max Maximum number of IDs.
1060 * @param bool $required Whether the list is required.
1061 * @param bool $chunked Whether the ability works in chunks.
1062 *
1063 * @return array<string, mixed>
1064 */
1065 private function get_media_ids_schema( $max, $required, $chunked = false ) {
1066 $schema = [
1067 'type' => 'object',
1068 'properties' => [
1069 'media_ids' => [
1070 'type' => 'array',
1071 'description' => __( 'Attachment IDs of the images.', 'optimole-wp' ),
1072 'items' => [
1073 'type' => 'integer',
1074 'minimum' => 1,
1075 ],
1076 'maxItems' => $max,
1077 ],
1078 ],
1079 'additionalProperties' => false,
1080 ];
1081
1082 if ( $chunked ) {
1083 $schema['properties'] = array_merge( $schema['properties'], $this->get_chunk_input_properties() );
1084 }
1085
1086 if ( $required ) {
1087 $schema['properties']['media_ids']['minItems'] = 1;
1088 $schema['required'] = [ 'media_ids' ];
1089 } else {
1090 $schema['default'] = [];
1091 }
1092
1093 return $schema;
1094 }
1095
1096 /**
1097 * Input properties shared by the abilities that work in chunks.
1098 *
1099 * @return array<string, mixed>
1100 */
1101 private function get_chunk_input_properties() {
1102 return [
1103 'cursor' => [
1104 'type' => 'string',
1105 'description' => __( 'Cursor returned by the previous call, to continue the same media_ids batch.', 'optimole-wp' ),
1106 ],
1107 'time_budget' => [
1108 'type' => 'integer',
1109 'description' => __( 'Maximum number of seconds to work before returning.', 'optimole-wp' ),
1110 'minimum' => 1,
1111 'maximum' => self::MAX_TIME_BUDGET,
1112 'default' => self::DEFAULT_TIME_BUDGET,
1113 ],
1114 ];
1115 }
1116
1117 /**
1118 * Output properties shared by the abilities that work in chunks.
1119 *
1120 * @param string[] $statuses Possible item statuses.
1121 *
1122 * @return array<string, mixed>
1123 */
1124 private function get_chunk_output_properties( $statuses ) {
1125 return [
1126 'results' => [
1127 'type' => 'array',
1128 'description' => __( 'Items handled in this call only.', 'optimole-wp' ),
1129 'items' => [
1130 'type' => 'object',
1131 'properties' => [
1132 'media_id' => [ 'type' => 'integer' ],
1133 'status' => [
1134 'type' => 'string',
1135 'enum' => $statuses,
1136 ],
1137 'reason' => [ 'type' => 'string' ],
1138 'message' => [ 'type' => 'string' ],
1139 ],
1140 ],
1141 ],
1142 'done' => [ 'type' => 'boolean' ],
1143 'cursor' => [
1144 'type' => 'string',
1145 'description' => __( 'Empty when done.', 'optimole-wp' ),
1146 ],
1147 'progress' => [
1148 'type' => 'object',
1149 'properties' => [
1150 'current' => [ 'type' => 'integer' ],
1151 'total' => [ 'type' => 'integer' ],
1152 'message' => [ 'type' => 'string' ],
1153 ],
1154 ],
1155 ];
1156 }
1157
1158 /**
1159 * Output schema for the offload and restore abilities.
1160 *
1161 * @return array<string, mixed>
1162 */
1163 private function get_move_output_schema() {
1164 return [
1165 'type' => 'object',
1166 'properties' => array_merge(
1167 [ 'action' => [ 'type' => 'string' ] ],
1168 $this->get_chunk_output_properties( [ 'moved', 'skipped' ] )
1169 ),
1170 ];
1171 }
1172 }
1173