PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
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 3.11.3 3.12.0 All 135 releases
← All changes | inc/media_offload.php +2248 -538 3.1.0 → trunk View file →
@@ -5,13 +5,25 @@
5 5 * @package \Optimole\Inc
6 6 * @author Optimole <[email protected]>
7 7 */
8 8
9 +use OptimoleWP\Offload\Loader;
10 +use Optimole\Sdk\Exception\InvalidArgumentException;
11 +use Optimole\Sdk\Exception\InvalidUploadApiResponseException;
12 +use Optimole\Sdk\Exception\RuntimeException;
13 +use Optimole\Sdk\Exception\UploadApiException;
14 +use Optimole\Sdk\Exception\UploadFailedException;
15 +use Optimole\Sdk\Exception\UploadLimitException;
16 +use Optimole\Sdk\Optimole;
17 +
9 18 /**
10 19 * Class Optml_Admin
11 20 */
12 21 class Optml_Media_Offload extends Optml_App_Replacer {
13 22 use Optml_Normalizer;
23 + use Optml_Dam_Offload_Utils;
24 +
25 +
14 26 /**
15 27 * Hold the settings object.
16 28 *
17 29 * @var Optml_Settings Settings object.
@@ -16,14 +28,47 @@
16 28 *
17 29 * @var Optml_Settings Settings object.
18 30 */
19 31 public $settings;
32 + /**
33 + * Cached object instance.
34 + *
35 + * @var Optml_Media_Offload
36 + */
37 + private static $instance = null;
20 38
39 + /**
40 + * Hold the logger object.
41 + *
42 + * @var Optml_Logger
43 + */
44 + public $logger;
45 +
21 46 const KEYS = [
22 - 'uploaded_flag' => 'id:',
23 - 'not_processed_flag' => 'process:',
47 + 'uploaded_flag' => 'id:',
48 + 'not_processed_flag' => 'process:',
24 49 ];
50 + const META_KEYS = [
51 + 'offloaded' => 'optimole_offload',
52 + 'offload_error' => 'optimole_offload_error',
53 + 'rollback_error' => 'optimole_rollback_error',
54 + ];
55 + const OM_OFFLOADED_FLAG = 'om_image_offloaded';
56 + const POST_OFFLOADED_FLAG = 'optimole_offload_post';
57 + const POST_ROLLBACK_FLAG = 'optimole_rollback_post';
58 + const RETRYABLE_META_COUNTER = '_optimole_retryable_errors';
59 +
25 60 /**
61 + * Transient name for the transfer lock.
62 + */
63 + const TRANSFER_LOCK_TRANSIENT = 'optml_transfer_lock';
64 +
65 + /**
66 + * Time to live for the transfer lock, in seconds.
67 + */
68 + const TRANSFER_LOCK_TTL = 600;
69 +
70 + /**
26 71 * Flag used inside wp_get_attachment url filter.
27 72 *
28 73 * @var bool Whether or not to return the original url of the image.
29 74 */
@@ -28,60 +73,69 @@
28 73 * @var bool Whether or not to return the original url of the image.
29 74 */
30 75 private static $return_original_url = false;
31 76 /**
32 - * Enqueue script for generating cloud media tab.
77 + * Flag used inside wp_get_attachment url filter.
78 + *
79 + * @var bool Whether or not to return the original url of the image.
33 80 */
34 - public function add_cloud_script( $hook ) {
35 - if ( $hook === 'post.php' || $hook === 'post-new.php' ) {
36 - wp_enqueue_script( 'optimole_media', OPTML_URL . 'assets/js/optimole_media.js' );
37 - }
38 - }
81 + private static $offload_update_post = false;
39 82
40 83 /**
41 - * Generate exactly the response format expected by wp media modal.
84 + * Flag used inside wp_unique_filename filter.
42 85 *
43 - * @param string $url Original url to be optimized.
44 - * @param string $resource_id Image id from cloud.
45 - * @param int $width Image width.
46 - * @param int $height Image height.
47 - * @return array The format expected for a single image in the media modal.
86 + * @var bool|string Whether to skip our custom deduplication.
48 87 */
49 - private function media_attachment_template( $url, $resource_id, $width, $height, $last_attach ) {
50 - $filename = pathinfo( $url, PATHINFO_FILENAME );
51 - $optimized_url = $this->get_media_optimized_url( $url, $resource_id, $width, $height );
52 - return
53 - [
54 - 'id' => $last_attach + 1 + crc32( $filename ),
55 - 'title' => $filename,
56 - 'url' => $optimized_url,
57 - 'link' => $optimized_url,
58 - 'alt' => '',
59 - 'author' => 1,
60 - 'status' => 'inherit',
61 - 'menuOrder' => 0,
62 - 'mime' => 'image/jpeg',
63 - 'type' => 'image',
64 - 'subtype' => 'jpeg',
65 - 'icon' => $optimized_url,
66 - 'editLink' => $optimized_url,
67 - 'authorName' => 'Optimole',
68 - 'height' => $height,
69 - 'width' => $width,
70 - 'orientation' => 'landscape',
71 - // just adding the thumbnail size for for smooth display inside the modal
72 - // this sizes are ignored for everything else no point to define them
73 - 'sizes' =>
74 - [
75 - 'thumbnail' =>
88 + private static $current_file_deduplication = false;
89 + /**
90 + * Keeps the last deduplicated lower case value.
91 + *
92 + * @var bool|string Used to check if the current processed image was deduplicated.
93 + */
94 + private static $last_deduplicated = false;
95 + /**
96 + * Checks if the plugin was installed before adding POST_OFFLOADED_FLAG.
97 + *
98 + * @var bool Used when applying the flags for the page query.
99 + */
100 + private static $is_legacy_install = null;
101 +
102 + /**
103 + * Adds page meta query args
104 + *
105 + * @param string $action The action for which the args are needed.
106 + * @param array $args The initial args without the added meta_query args.
107 + *
108 + * @return array The args with the added meta_query args.
109 + */
110 + public static function add_page_meta_query_args( $action, $args ) {
111 + if ( $action === 'offload_images' ) {
112 + $args['meta_query'] = [
113 + 'relation' => 'AND',
76 114 [
77 - 'height' => '150',
78 - 'width' => '150',
79 - 'url' => $this->get_media_optimized_url( $url, $resource_id, 150, 150, $this->to_optml_crop( true ) ),
80 - 'orientation' => 'landscape',
115 + 'key' => self::POST_OFFLOADED_FLAG,
116 + 'compare' => 'NOT EXISTS',
81 117 ],
82 - ],
83 - ];
118 + ];
119 + }
120 + if ( $action === 'rollback_images' ) {
121 + $args['meta_query'] = [
122 + 'relation' => 'AND',
123 + [
124 + 'key' => self::POST_ROLLBACK_FLAG,
125 + 'compare' => 'NOT EXISTS',
126 + ],
127 + ];
128 + if ( self::$is_legacy_install ) {
129 + $args['meta_query'][] = [
130 + 'key' => self::POST_OFFLOADED_FLAG,
131 + 'value' => 'true',
132 + 'compare' => '=',
133 + ];
134 + }
135 + }
136 +
137 + return $args;
84 138 }
85 139
86 140 /**
87 141 * Get count of all images from db.
@@ -89,121 +143,259 @@
89 143 * @return int Number of all images.
90 144 */
91 145 public static function number_of_all_images() {
92 146 $total_images_by_mime = wp_count_attachments( 'image' );
93 - return array_sum( (array) $total_images_by_mime );
147 +
148 + return array_sum( (array) $total_images_by_mime );
94 149 }
150 +
95 151 /**
96 - * Parse images from api endpoint response images and send them to wp media modal.
152 + * Optml_Media_Offload constructor.
97 153 */
98 - public function pull_images() {
99 - $images_on_page = 40;
100 - $last_attach = self::number_of_all_images();
101 - if ( ! current_user_can( 'upload_files' ) ) {
102 - wp_send_json_error();
103 - }
104 - if ( isset( $_REQUEST['query'] ) && isset( $_REQUEST['query']['post_mime_type'] ) && $_REQUEST['query']['post_mime_type'][0] === 'optml_cloud' ) {
105 - $search = '';
106 - if ( isset( $_REQUEST['query']['s'] ) ) {
107 - $search = $_REQUEST['query']['s'];
154 + public static function instance() {
155 + if ( null === self::$instance || self::is_phpunit_test() ) {
156 + self::$instance = new self();
157 + self::$instance->settings = new Optml_Settings();
158 + self::$instance->logger = Optml_Logger::instance();
159 +
160 + if ( self::$instance->settings->is_connected() ) {
161 + self::$instance->init();
108 162 }
109 - $page = 0;
110 - if ( isset( $_REQUEST['query']['paged'] ) ) {
111 - $page = $_REQUEST['query']['paged'] - 1;
112 - }
113 - $images = [];
114 - $view_sites = [];
115 - $all_sites = false;
116 - $filter_sites = $this->settings->get( 'cloud_sites' );
163 + if ( self::$instance->settings->is_offload_enabled() ) {
164 + add_filter( 'image_downsize', [ self::$instance, 'generate_filter_downsize_urls' ], 10, 3 );
165 + add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 );
166 + add_filter( 'wp_get_attachment_url', [ self::$instance, 'get_image_attachment_url' ], - 999, 2 );
167 + add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_uploaded_images' ] );
117 168
118 - if ( isset( $filter_sites['all'] ) && $filter_sites['all'] === 'true' ) {
119 - $all_sites = true;
120 - }
121 - if ( ! $all_sites ) {
122 - foreach ( $filter_sites as $site => $value ) {
123 - if ( $value === 'true' ) {
124 - $view_sites[] = $site;
125 - }
126 - }
127 - }
128 - $cloud_images = [];
129 - $request = new Optml_Api();
130 - $decoded_response = $request->get_cloud_images( $page, $view_sites, $search );
169 + self::$instance->add_new_actions();
131 170
132 - if ( isset( $decoded_response['images'] ) ) {
133 - $cloud_images = $decoded_response['images'];
134 - }
171 + add_action( 'delete_attachment', [ self::$instance, 'delete_attachment_hook' ], 10 );
172 + add_filter( 'handle_bulk_actions-upload', [ self::$instance, 'bulk_action_handler' ], 10, 3 );
173 + // TODO: Uncomment this when bulk actions are implemented
174 + // add_filter( 'bulk_actions-upload', [ self::$instance, 'register_bulk_media_actions' ] );
175 + add_filter( 'media_row_actions', [ self::$instance, 'add_inline_media_action' ], 10, 2 );
176 + add_filter( 'wp_calculate_image_srcset', [ self::$instance, 'calculate_image_srcset' ], 1, 5 );
177 + add_action( 'post_updated', [ self::$instance, 'update_offload_meta' ], 10, 3 );
135 178
136 - foreach ( $cloud_images as $index => $image ) {
137 - $width = 'auto';
138 - $height = 'auto';
139 - if ( ! isset( $image['meta']['originURL'] ) || ! isset( $image['meta']['resourceS3'] ) ) {
140 - continue;
179 + // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
180 + $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
181 + if ( $below_6_0_0 ) {
182 + add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 );
183 + } else {
184 + add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 );
141 185 }
142 - if ( isset( $image['meta']['originalHeight'] ) ) {
143 - $height = $image['meta']['originalHeight'];
186 +
187 + add_action( 'optml_start_processing_images', [ self::$instance, 'start_processing_images' ], 10, 6 );
188 + add_action(
189 + 'optml_move_images_by_id',
190 + [
191 + self::$instance,
192 + 'move_single_image',
193 + ],
194 + 10,
195 + 2
196 + );
197 + add_action( 'init', [ self::$instance, 'maybe_reschedule' ] );
198 + if ( self::$is_legacy_install === null ) {
199 + self::$is_legacy_install = get_option( 'optimole_wp_install', 0 ) > 1677171600;
144 200 }
145 - if ( isset( $image['meta']['originalWidth'] ) ) {
146 - $width = $image['meta']['originalWidth'];
147 - }
148 - $images[] = $this->media_attachment_template( $image['meta']['originURL'], $image['meta']['resourceS3'], $width, $height, $last_attach );
201 + ( new Loader() )->register_hooks();
149 202 }
150 - wp_send_json_success( $images );
151 203 }
204 +
205 + return self::$instance;
152 206 }
207 +
153 208 /**
154 - * Optml_Media_Offload constructor.
209 + * Reschedule the transfer cron in case is missing or was lost.
210 + *
211 + * @return void
155 212 */
156 - public function __construct() {
157 - $this->settings = new Optml_Settings();
158 - if ( $this->settings->is_connected() ) {
159 - parent::init();
213 + public function maybe_reschedule() {
214 + $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
215 + // If this is in pending, we do nothing.
216 + if ( false !== $lock ) {
217 + return;
160 218 }
161 - if ( $this->settings->get( 'cloud_images' ) === 'enabled' ) {
162 - add_action( 'wp_ajax_query-attachments', [$this, 'pull_images'], -2 );
163 - add_action( 'admin_enqueue_scripts', [$this, 'add_cloud_script'] );
219 + // If there is no transfer in progress, we do nothing.
220 + if ( self::$instance->settings->get( 'transfer_status' ) === 'disabled' ) {
221 + return;
164 222 }
165 - if ( $this->settings->get( 'offload_media' ) === 'enabled' ) {
166 - add_filter( 'image_downsize', [$this, 'generate_filter_downsize_urls'], 10, 3 );
167 - add_filter( 'wp_generate_attachment_metadata', [$this, 'generate_image_meta'], 10, 2 );
168 - add_filter( 'wp_get_attachment_url', [$this, 'get_image_attachment_url'], -999, 2 );
169 - add_action( 'wp_insert_post_data', [$this, 'filter_uploaded_images'] );
170 - add_action( 'delete_attachment', [$this, 'delete_attachment_hook'], 10 );
171 - add_filter( 'handle_bulk_actions-upload', [$this, 'bulk_action_handler'], 10, 3 );
172 - add_filter( 'bulk_actions-upload', [$this, 'register_bulk_media_actions'] );
173 - add_action( 'admin_notices', [$this, 'bulk_action_notices'] );
174 - add_filter( 'media_row_actions', [$this, 'add_inline_media_action'], 10, 2 );
175 - add_filter( 'wp_calculate_image_srcset', [ $this, 'calculate_image_srcset' ], 1, 5 );
223 + $transfer_type = self::$instance->settings->get( 'transfer_status' );
224 + $in_progress = self::$instance->settings->get( 'rollback_images' === $transfer_type ? 'rollback_status' : 'offloading_status' ) !== 'disabled';
225 + // We check if there is an in progress transfer.
226 + if ( ! $in_progress ) {
227 + return;
176 228 }
229 + self::$instance->logger->add_log( $transfer_type, 'Cron missed, attempt to reschedule.' );
230 + self::move_images( $transfer_type, false );
177 231 }
232 +
178 233 /**
234 + * Function for `update_attached_file` filter-hook.
235 + *
236 + * @param string $file Path to the attached file to update.
237 + * @param int $attachment_id Attachment ID.
238 + *
239 + * @return string
240 + */
241 + public function wp_update_attached_file_filter( $file, $attachment_id ) {
242 +
243 + if ( OPTML_DEBUG_MEDIA ) {
244 + do_action( 'optml_log', 'called updated attached' );
245 + }
246 + $info = pathinfo( $file );
247 + $file_name = basename( $file );
248 + $no_ext_file_name = basename( $file, '.' . $info['extension'] );
249 + // if we have current deduplication set and it contains the filename that is updated
250 + // we replace the updated filename with the deduplicated filename
251 + if ( ! empty( self::$current_file_deduplication ) && stripos( self::$current_file_deduplication, $no_ext_file_name ) !== false ) {
252 + $file = str_replace( $file_name, self::$current_file_deduplication, $file );
253 + // we need to store the filename we replaced to check when uploading the image if it was deduplicated
254 + self::$last_deduplicated = $file_name;
255 +
256 + self::$current_file_deduplication = false;
257 + }
258 + if ( OPTML_DEBUG_MEDIA ) {
259 + do_action( 'optml_log', self::$last_deduplicated );
260 + }
261 + remove_filter( 'update_attached_file', [ self::$instance, 'wp_update_attached_file_filter' ], 10 );
262 +
263 + return $file;
264 + }
265 +
266 + /**
267 + * Function for `wp_insert_attachment_data` filter-hook.
268 + * Because we remove the images when new images are added the wp deduplication using the files will not work
269 + * To overcome this we hook the attachment data when it's added to the database and we use the post name (slug) which is unique against the database
270 + * For creating a unique quid by replacing the filename with the slug inside the existing guid
271 + * This will ensure the guid is unique and the next step will be to make sure the attached_file meta for the image is also unique
272 + * For this we will hook `update_attached_file` filter which is called after the data is inserted and there we will make sure we replace the filename
273 + * with the deduplicated one which we stored into `$current_file_deduplication` variable
274 + *
275 + * @param array $data An array of slashed, sanitized, and processed attachment post data.
276 + * @param array $postarr An array of slashed and sanitized attachment post data, but not processed.
277 + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data as originally passed to wp_insert_post().
278 + * @param bool $update Whether this is an existing attachment post being updated.
279 + *
280 + * @return array
281 + * @see self::insert_legacy() for backwards compatibility with older versions of WordPress < 6.0.0.
282 + */
283 + public function insert( $data, $postarr, $unsanitized_postarr, $update ) {
284 +
285 + // the post name is unique against the database so not affected by removing the files
286 + // https://developer.wordpress.org/reference/functions/wp_unique_post_slug/
287 + if ( OPTML_DEBUG_MEDIA ) {
288 + do_action( 'optml_log', 'data before' );
289 + do_action( 'optml_log', $data );
290 + }
291 + if ( empty( $data['guid'] ) ) {
292 + return $data;
293 + }
294 +
295 + $filename = wp_basename( $data['guid'] );
296 + $ext = $this->get_ext( $filename );
297 + // skip if the file is not an image
298 + if ( ! isset( Optml_Config::$all_extensions[ $ext ] ) && ! in_array( $ext, [ 'jpg', 'jpeg', 'jpe' ], true ) ) {
299 + return $data;
300 + }
301 +
302 + // on some instances (just unit tests) the post name has the extension appended like this : `image-1-jpg`
303 + // we remove that as it is redundant for the file name deduplication we are using it
304 + $sanitized_post_name = str_replace( '-' . $ext, '', $data['post_name'] );
305 +
306 + // with the wp deduplication working the post_title is identical to the post_name
307 + // so when they are different it means we need to deduplicate using the post_name
308 + if ( ! empty( $data['post_name'] ) && $data['post_title'] !== $sanitized_post_name ) {
309 + // we append the extension to the post_name to create a filename
310 + // and use it to replace the filename in the guid
311 + $no_ext_filename = str_replace( '.' . $ext, '', $filename );
312 +
313 + $no_ext_filename_sanitized = sanitize_title( $no_ext_filename );
314 +
315 + // get the deduplication addition from the database post_name
316 + $diff = str_replace( strtolower( $no_ext_filename_sanitized ), '', $sanitized_post_name );
317 +
318 + // create the deduplicated filename
319 + $to_replace_with = $no_ext_filename . $diff . '.' . $ext;
320 +
321 + $data['guid'] = str_replace( $filename, $to_replace_with, $data['guid'] );
322 + // we store the deduplication to be used and add the filter for updating the attached_file meta
323 + self::$current_file_deduplication = $to_replace_with;
324 + add_filter( 'update_attached_file', [ self::$instance, 'wp_update_attached_file_filter' ], 10, 2 );
325 + }
326 + if ( OPTML_DEBUG_MEDIA ) {
327 + do_action( 'optml_log', 'data after' );
328 + do_action( 'optml_log', $data );
329 + }
330 +
331 + return $data;
332 + }
333 +
334 + /**
335 + * Wrapper for the `insert` method for WP versions < 6.0.0.
336 + *
337 + * @param array $data An array of slashed, sanitized, and processed attachment post data.
338 + * @param array $postarr An array of slashed and sanitized attachment post data, but not processed.
339 + * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed attachment post data as originally passed to wp_insert_post().
340 + *
341 + * @return array
342 + */
343 + public function insert_legacy( $data, $postarr, $unsanitized_postarr ) {
344 + return $this->insert( $data, $postarr, $unsanitized_postarr, false );
345 + }
346 +
347 + /**
348 + * Update offload meta when the page is updated.
349 + *
350 + * @param int $post_ID Updated post id.
351 + * @param WP_Post $post_after Post before the update.
352 + * @param WP_Post $post_before Post after the update.
353 + *
354 + * @return void
355 + * @uses action:post_updated
356 + */
357 + public function update_offload_meta( $post_ID, $post_after, $post_before ) {
358 + if ( self::$offload_update_post === true ) {
359 + return;
360 + }
361 + if ( get_post_type( $post_ID ) === 'attachment' ) {
362 + return;
363 + }
364 +
365 + // revisions are skipped inside the function no need to check them before
366 + delete_post_meta( $post_ID, self::POST_ROLLBACK_FLAG );
367 + }
368 +
369 + /**
179 370 * Get image size name from width and meta.
180 371 *
181 - * @param array $sizes Image sizes .
182 - * @param string $width Size width.
183 - * @param string $filename Image filename.
372 + * @param array $sizes Image sizes .
373 + * @param integer $width Size width.
374 + * @param string $filename Image filename.
184 375 *
185 - * @return null|string
376 + * @return null|string|array
186 377 */
187 - public static function get_image_name_from_width( $sizes, $width, $filename ) {
378 + public static function get_image_size_from_width( $sizes, $width, $filename, $just_name = true ) {
188 379 foreach ( $sizes as $name => $size ) {
189 380 if ( $width === absint( $size['width'] ) && $size['file'] === $filename ) {
190 - return $name;
381 + return $just_name ? $name : array_merge( $size, [ 'name' => $name ] );
191 382 }
192 383 }
193 384
194 385 return null;
195 386 }
387 +
196 388 /**
197 389 * Replace image URLs in the srcset attributes.
198 390 *
199 - * @param array $sources Array of image sources.
200 - * @param array $size_array Array of width and height values in pixels (in that order).
201 - * @param string $image_src The 'src' of the image.
202 - * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
203 - * @param int $attachment_id Image attachment ID.
391 + * @param mixed|array<int, array{url: string, descriptor: string, value: int}> $sources Array of image sources.
392 + * @param array{0: int, 1: int} $size_array Array of width and height values in pixels (in that order).
393 + * @param string $image_src The 'src' of the image.
394 + * @param array<string, mixed> $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
395 + * @param int $attachment_id Image attachment ID or 0.
204 396 *
205 - * @return array
397 + * @return array<int, array{url: string, descriptor: string, value: int}>|mixed
206 398 */
207 399 public function calculate_image_srcset( $sources, $size_array, $image_src, $image_meta, $attachment_id ) {
208 400
209 401 if ( ! is_array( $sources ) ) {
@@ -209,60 +401,127 @@
209 401 if ( ! is_array( $sources ) ) {
210 402 return $sources;
211 403 }
212 404
213 - if ( ! Optml_Media_Offload::is_uploaded_image( $image_src ) || ! isset( $image_meta['file'] ) || ! Optml_Media_Offload::is_uploaded_image( $image_meta['file'] ) ) {
405 + if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
406 + if ( ! Optml_Media_Offload::is_uploaded_image( $image_src ) || ! isset( $image_meta['file'] ) || ! Optml_Media_Offload::is_uploaded_image( $image_meta['file'] ) ) {
407 + return $sources;
408 + }
409 + foreach ( $sources as $width => $source ) {
410 + $filename = wp_basename( $image_meta['file'] );
411 + $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename );
412 + $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
413 +
414 + if ( false === $optimized_url ) {
415 + continue;
416 + }
417 +
418 + $sources[ $width ]['url'] = $optimized_url[0];
419 + }
420 +
214 421 return $sources;
215 422 }
423 +
424 + if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
425 + return $sources;
426 + }
427 +
428 + $requested_width = $size_array[0];
429 + $requested_height = $size_array[1];
430 +
431 + if ( $requested_height < 1 || $requested_width < 1 ) {
432 + return $sources;
433 + }
434 +
435 + $image_sizes = $this->get_all_image_sizes();
436 + $crop = false;
437 +
438 + // Loop through image sizes to make sure we're using the right cropping.
439 + foreach ( $image_sizes as $size_name => $args ) {
440 + if ( $args['width'] !== $requested_width && $args['height'] !== $requested_height ) {
441 + continue;
442 + }
443 +
444 + if ( isset( $args['crop'] ) ) {
445 + $crop = (bool) $args['crop'];
446 + }
447 + }
216 448 foreach ( $sources as $width => $source ) {
217 - $filename = wp_basename( $image_meta['file'] );
218 - $size = $this->get_image_name_from_width( $image_meta['sizes'], $width, $filename );
219 - $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
449 + $filename = ( $image_meta['file'] );
450 + $size = $this->get_image_size_from_width( $image_meta['sizes'], $width, $filename, false );
220 451
221 - if ( false === $optimized_url || ! isset( $optimized_url[0] ) ) {
452 + if ( $size === null || ! isset( $size['name'] ) ) {
453 + unset( $sources[ $width ] );
454 +
222 455 continue;
223 456 }
224 457
458 + if ( ! isset( $image_sizes[ $size['name'] ] ) || (bool) $image_sizes[ $size['name'] ]['crop'] !== $crop ) {
459 + unset( $sources[ $width ] );
460 +
461 + continue;
462 + }
463 +
464 + // Some image sizes might have 0 values for width or height.
465 + if ( $size['width'] < 1 || $size['height'] < 1 ) {
466 + unset( $sources[ $width ] );
467 +
468 + continue;
469 + }
470 +
471 + if ( ! wp_image_matches_ratio( $size['width'], $size['height'], $requested_width, $requested_height ) ) {
472 + continue;
473 + }
474 +
475 + $optimized_url = wp_get_attachment_image_src( $attachment_id, $size['name'] );
476 +
477 + if ( false === $optimized_url ) {
478 + unset( $sources[ $width ] );
479 +
480 + continue;
481 + }
225 482 $sources[ $width ]['url'] = $optimized_url[0];
226 483 }
484 + // Add the requested size to the srcset.
485 + $sources[ $requested_width ] = [
486 + 'url' => $image_src,
487 + 'descriptor' => 'w',
488 + 'value' => $requested_width,
489 + ];
490 +
491 + if ( $this->settings->get( 'retina_images' ) === 'enabled' ) {
492 + $max_width = max( array_keys( $sources ) );
493 + $sources[ $max_width * 2 ] = [
494 + 'url' => str_replace( '/w:', '/dpr:2/w:', $sources[ $max_width ]['url'] ),
495 + 'descriptor' => 'x',
496 + 'value' => 2,
497 + ];
498 + }
227 499 return $sources;
228 500 }
229 - /**
230 - * Get the dimension from optimized url.
231 - *
232 - * @param string $url The image url.
233 - * @return array Contains the width and height values in this order.
234 - */
235 - public static function parse_dimension_from_optimized_url( $url ) {
236 - $catch = [];
237 - $height = 'auto';
238 - $width = 'auto';
239 - preg_match( '/\/w:(.*)\/h:(.*)\/q:/', $url, $catch );
240 - if ( isset( $catch[1] ) && isset( $catch[2] ) ) {
241 - $width = $catch[1];
242 - $height = $catch[2];
243 - }
244 - return [$width, $height];
245 - }
246 501
247 502 /**
248 503 * Check if the image is stored on our servers or not.
249 504 *
250 505 * @param string $src Image src or url.
506 + *
251 507 * @return bool Whether image is upload or not.
252 508 */
253 509 public static function is_not_processed_image( $src ) {
254 510 return strpos( $src, self::KEYS['not_processed_flag'] ) !== false;
255 511 }
512 +
256 513 /**
257 514 * Check if the image is stored on our servers or not.
258 515 *
259 516 * @param string $src Image src or url.
517 + *
260 518 * @return bool Whether image is upload or not.
261 519 */
262 520 public static function is_uploaded_image( $src ) {
263 521 return strpos( $src, '/' . self::KEYS['uploaded_flag'] ) !== false;
264 522 }
523 +
265 524 /**
266 525 * Get the attachment ID from the image tag.
267 526 *
268 527 * @param string $image Image tag.
@@ -280,16 +539,41 @@
280 539
281 540 /**
282 541 * Get attachment id from url
283 542 *
284 - * @param string $url The optimized url .
543 + * @param string $url The optimized url .
544 + *
285 545 * @return false|mixed The attachment id .
286 546 */
287 547 public static function get_attachment_id_from_url( $url ) {
288 548 preg_match( '/\/' . Optml_Media_Offload::KEYS['not_processed_flag'] . '([^\/]*)\//', $url, $attachment_id );
549 +
289 550 return isset( $attachment_id[1] ) ? $attachment_id[1] : false;
290 551 }
552 +
291 553 /**
554 + * Get attachment id from local url
555 + *
556 + * @param string $url The url to look for.
557 + *
558 + * @return array The attachment id and the size from the url.
559 + */
560 + public function get_local_attachement_id_from_url( $url ) {
561 +
562 + $size = 'full';
563 + $found_size = $this->parse_dimensions_from_filename( $url );
564 + $url = $this->add_schema( $url );
565 + if ( $found_size[0] !== false && $found_size[1] !== false ) {
566 + $size = $found_size;
567 +
568 + }
569 + $url = $this->add_schema( $url );
570 + $attachment_id = $this->attachment_url_to_post_id( $url );
571 +
572 + return [ 'attachment_id' => $attachment_id, 'size' => $size ];
573 + }
574 +
575 + /**
292 576 * Filter out the urls that are saved to our servers when saving to the DB.
293 577 *
294 578 * @param array $data The post data array to save.
295 579 *
@@ -298,24 +582,24 @@
298 582 */
299 583 public function filter_uploaded_images( $data ) {
300 584
301 585 $content = trim( wp_unslash( $data['post_content'] ) );
302 - if ( OPTML_DEBUG ) {
586 + if ( OPTML_DEBUG_MEDIA ) {
303 587 do_action( 'optml_log', 'content to update' );
304 588 do_action( 'optml_log', $content );
305 589 }
306 - $images = Optml_Manager::instance()->extract_urls_from_content( $content );
590 + $images = Optml_Manager::instance()->extract_urls_from_content( $content );
307 591 if ( ! isset( $images[0] ) ) {
308 592 return $data;
309 593 }
310 - if ( OPTML_DEBUG ) {
594 + if ( OPTML_DEBUG_MEDIA ) {
311 595 do_action( 'optml_log', 'images to update' );
312 596 do_action( 'optml_log', $images );
313 597 }
314 598 foreach ( $images as $url ) {
315 599 $is_original_uploaded = self::is_uploaded_image( $url );
316 - $size = 'full';
317 - $attachment_id = false;
600 + $attachment_id = false;
601 + $size = 'thumbnail';
318 602 if ( $is_original_uploaded ) {
319 603 $found_size = $this->parse_dimension_from_optimized_url( $url );
320 604 if ( $found_size[0] !== 'auto' && $found_size[1] !== 'auto' ) {
321 605 $size = $found_size;
@@ -321,27 +605,23 @@
321 605 $size = $found_size;
322 606 }
323 607 $attachment_id = self::get_attachment_id_from_url( $url );
324 608 } else {
325 - $found_size = $this->parse_dimensions_from_filename( $url );
326 - $strip_url = $url;
327 - if ( $found_size[0] !== false && $found_size[1] !== false ) {
328 - $size = $found_size;
329 - $strip_url = str_replace( '-' . $found_size[0] . 'x' . $found_size[1], '', $url );
330 - }
331 - $attachment_id = attachment_url_to_postid( $strip_url );
609 + $id_and_size = $this->get_local_attachement_id_from_url( $url );
610 + $attachment_id = $id_and_size['attachment_id'];
611 + $size = $id_and_size['size'];
332 612 }
333 613
334 - if ( OPTML_DEBUG ) {
614 + if ( OPTML_DEBUG_MEDIA ) {
335 615 do_action( 'optml_log', 'image id and found size' );
336 616 do_action( 'optml_log', $attachment_id );
337 617 do_action( 'optml_log', $size );
338 618 }
339 - if ( false === $attachment_id || ! wp_attachment_is_image( $attachment_id ) ) {
619 + if ( false === $attachment_id || ! $this->is_legacy_offloaded_attachment( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) {
340 620 continue;
341 621 }
342 622 $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
343 - if ( OPTML_DEBUG ) {
623 + if ( OPTML_DEBUG_MEDIA ) {
344 624 do_action( 'optml_log', ' image url to replace with ' );
345 625 do_action( 'optml_log', $optimized_url );
346 626 }
347 627
@@ -353,129 +633,198 @@
353 633 }
354 634 $content = str_replace( $url, $optimized_url[0], $content );
355 635 }
356 636 $data['post_content'] = wp_slash( $content );
637 +
357 638 return $data;
358 639 }
640 +
359 641 /**
360 - * Trigger an update on content that contains the same attachment ID as the modified image.
642 + * Get all images that need to be updated from a post.
643 + *
644 + * @param string $post_content The content of the post.
645 + * @param string $job The job name.
646 + *
647 + * @return array An array containing the image ids.
361 648 */
362 - public function update_content() {
363 - if ( OPTML_DEBUG ) {
649 + public function get_image_id_from_content( $post_content, $job ) {
650 + $content = trim( wp_unslash( $post_content ) );
651 + $images = Optml_Manager::instance()->extract_urls_from_content( $content );
652 + $found_images = [];
653 + if ( isset( $images[0] ) ) {
654 + foreach ( $images as $url ) {
655 + $is_original_uploaded = self::is_uploaded_image( $url );
656 + $attachment_id = false;
657 + if ( $is_original_uploaded ) {
658 + if ( $job === 'rollback_images' ) {
659 + $attachment_id = self::get_attachment_id_from_url( $url );
660 + }
661 + } else {
662 + if ( $job === 'offload_images' ) {
663 + $id_and_size = $this->get_local_attachement_id_from_url( $url );
664 + $attachment_id = $id_and_size['attachment_id'];
665 + }
666 + }
667 + if ( false === $attachment_id || $attachment_id === 0 || ! wp_attachment_is_image( $attachment_id ) ) {
668 + continue;
669 + }
670 + $found_images[] = intval( $attachment_id );
671 + }
672 + }
673 +
674 + return apply_filters( 'optml_content_images_to_update', $found_images, $content );
675 + }
676 +
677 + /**
678 + * Get the posts ids and the images from them that need sync/rollback.
679 + *
680 + * @param int $page The current page from the query.
681 + * @param string $job The job name rollback_images/offload_images.
682 + * @param int $batch How many posts to query on a page.
683 + * @param array $page_in The pages that need to be updated.
684 + *
685 + * @return array An array containing the page of the query and an array containing the images for every post that need to be updated.
686 + */
687 + public function update_content( $page, $job, $batch = 1, $page_in = [] ) {
688 + if ( OPTML_DEBUG_MEDIA ) {
364 689 do_action( 'optml_log', ' updating_content ' );
365 690 }
366 - $have_posts = true;
367 - $page = 1;
368 - while ( $have_posts ) {
369 - $have_posts = false;
370 - $query_args = apply_filters(
371 - 'optml_replacement_wp_query_args',
372 - ['post_type' => 'any', 'post_status' => 'any', 'fields' => 'ids',
373 - 'posts_per_page' => 15,
374 - 'update_post_meta_cache' => false,
691 + $post_types = array_values(
692 + array_filter(
693 + get_post_types(),
694 + function ( $post_type ) {
695 + if ( $post_type === 'attachment' || $post_type === 'revision' ) {
696 + return false;
697 + }
698 +
699 + return true;
700 + }
701 + )
702 + );
703 + $query_args = apply_filters(
704 + 'optml_replacement_wp_query_args',
705 + [
706 + 'post_type' => $post_types,
707 + 'post_status' => 'any',
708 + 'fields' => 'ids',
709 + 'posts_per_page' => $batch,
710 + 'update_post_meta_cache' => true,
375 711 'update_post_term_cache' => false,
376 - ]
377 - );
378 - $query_args['paged'] = $page;
379 - $content = new \WP_Query( $query_args );
380 - if ( OPTML_DEBUG ) {
381 - do_action( 'optml_log', $page );
382 - }
383 - if ( $content->have_posts() ) {
384 - $have_posts = true;
385 - while ( $content->have_posts() ) {
386 - $content->the_post();
387 - $content_id = get_the_ID();
388 - if ( get_post_type() !== 'attachment' ) {
389 - if ( OPTML_DEBUG ) {
390 - do_action( 'optml_log', 'post_id ' . $content_id );
712 + ]
713 + );
714 +
715 + $query_args = self::add_page_meta_query_args( $job, $query_args );
716 +
717 + if ( ! empty( $page_in ) ) {
718 + $query_args['post__in'] = $page_in;
719 + }
720 +
721 + $content = new \WP_Query( $query_args );
722 + if ( OPTML_DEBUG_MEDIA ) {
723 + do_action( 'optml_log', $page );
724 + }
725 + $images_to_update = [];
726 + if ( $content->have_posts() ) {
727 + while ( $content->have_posts() ) {
728 + $content->the_post();
729 + $content_id = get_the_ID();
730 + if ( get_post_type() !== 'attachment' ) {
731 + $ids = $this->get_image_id_from_content( get_post_field( 'post_content', $content_id ), $job );
732 + if ( count( $ids ) > 0 ) {
733 + $images_to_update[ $content_id ] = $ids;
734 + $duplicated_pages = apply_filters( 'optml_offload_duplicated_images', [], $content_id );
735 + if ( is_array( $duplicated_pages ) && ! empty( $duplicated_pages ) ) {
736 + foreach ( $duplicated_pages as $duplicated_id ) {
737 + $duplicated_ids = $this->get_image_id_from_content( get_post_field( 'post_content', $duplicated_id ), $job );
738 + $images_to_update[ $duplicated_id ] = $duplicated_ids;
739 + }
391 740 }
392 - wp_update_post( ['ID' => $content_id] );
393 741 }
742 + if ( $job === 'offload_images' ) {
743 + update_post_meta( $content_id, self::POST_OFFLOADED_FLAG, 'true' );
744 + delete_post_meta( $content_id, self::POST_ROLLBACK_FLAG );
745 + }
746 + if ( $job === 'rollback_images' ) {
747 + update_post_meta( $content_id, self::POST_ROLLBACK_FLAG, 'true' );
748 + delete_post_meta( $content_id, self::POST_OFFLOADED_FLAG );
749 + }
394 750 }
395 - $page ++;
396 751 }
752 + ++$page;
397 753 }
754 + $result['page'] = $page;
755 + $result['imagesToUpdate'] = $images_to_update;
756 +
757 + return $result;
398 758 }
759 +
399 760 /**
400 761 * Add inline action to push to our servers.
401 762 *
402 763 * @param array $actions All actions.
403 - * @param \WP_Post $post The current post image object.
764 + * @param \WP_Post $post The current post image object.
404 765 *
405 766 * @return array
406 767 */
407 768 public function add_inline_media_action( $actions, $post ) {
408 - $file = wp_get_attachment_metadata( $post->ID )['file'];
769 + $meta = wp_get_attachment_metadata( $post->ID );
770 + if ( ! isset( $meta['file'] ) ) {
771 + return $actions;
772 + }
773 + $file = $meta['file'];
409 774 if ( wp_check_filetype( $file, Optml_Config::$all_extensions )['ext'] === false || ! current_user_can( 'delete_post', $post->ID ) ) {
410 775 return $actions;
411 776 }
412 - if ( ! self::is_uploaded_image( $file ) ) {
413 - $upload_action_url = add_query_arg(
414 - [
415 - 'action' => 'optimole_upload_image',
416 - 'media[]' => $post->ID,
417 - '_wpnonce' => wp_create_nonce( 'bulk-media' ),
418 - ],
419 - 'upload.php'
420 - );
777 + $actions['optml_actions'] = sprintf(
778 + '<span class="spinner"></span><a class="move-image-optml %s" data-action="offload_image" href="#" aria-label="%s" data-id="%s">%s</a><a class="move-image-optml %s" data-action="rollback_image" href="#" aria-label="%s" data-id="%s">%s</a>',
779 + self::is_uploaded_image( $file ) ? 'hidden' : '',
780 + esc_attr__( 'Offload to Optimole', 'optimole-wp' ),
781 + $post->ID,
782 + esc_html__( 'Offload to Optimole', 'optimole-wp' ),
783 + self::is_uploaded_image( $file ) ? '' : 'hidden',
784 + esc_attr__( 'Restore image to media library', 'optimole-wp' ),
785 + $post->ID,
786 + esc_html__( 'Restore image to media library', 'optimole-wp' )
787 + );
421 788
422 - $actions['optimole_upload_image'] = sprintf(
423 - '<a href="%s" aria-label="%s">%s</a>',
424 - $upload_action_url,
425 - esc_attr__( 'Offload to Optimole', 'optimole-wp' ),
426 - esc_html__( 'Offload to Optimole', 'optimole-wp' )
427 - );
428 - }
429 - if ( self::is_uploaded_image( $file ) ) {
430 - $rollback_action_url = add_query_arg(
431 - [
432 - 'action' => 'optimole_back_to_media',
433 - 'media[]' => $post->ID,
434 - '_wpnonce' => wp_create_nonce( 'bulk-media' ),
435 - ],
436 - 'upload.php'
437 - );
438 - $actions['optimole_back_to_media'] = sprintf(
439 - '<a href="%s" aria-label="%s">%s</a>',
440 - $rollback_action_url,
441 - esc_attr__( 'Restore image to media library', 'optimole-wp' ),
442 - esc_html__( 'Restore image to media library', 'optimole-wp' )
443 - );
444 - }
445 789 return $actions;
446 790 }
791 +
447 792 /**
448 793 * Upload images to our servers and update inside pages.
449 794 *
450 795 * @param array $image_ids The id of the attachments for the selected images.
796 + *
451 797 * @return int The number of successfully processed images.
452 798 */
453 799 public function upload_and_update_existing_images( $image_ids ) {
454 800 $success_up = 0;
455 - if ( OPTML_DEBUG ) {
801 + if ( OPTML_DEBUG_MEDIA ) {
456 802 do_action( 'optml_log', ' images to upload ' );
457 803 do_action( 'optml_log', $image_ids );
458 804 }
459 805 foreach ( $image_ids as $id ) {
460 806 if ( self::is_uploaded_image( wp_get_attachment_metadata( $id )['file'] ) ) {
461 - $success_up ++;
807 + // if this meta flag below failed at the initial update but the file meta above is updated it will cause an infinite query loop
808 + update_post_meta( $id, self::META_KEYS['offloaded'], 'true' );
809 + update_post_meta( $id, self::OM_OFFLOADED_FLAG, true );
810 + ++$success_up;
462 811 continue;
463 812 }
464 813
465 814 $meta = $this->generate_image_meta( wp_get_attachment_metadata( $id ), $id );
466 815 if ( isset( $meta['file'] ) && self::is_uploaded_image( $meta['file'] ) ) {
467 - $success_up ++;
816 + ++$success_up;
468 817 wp_update_attachment_metadata( $id, $meta );
469 818 }
470 819 }
471 820 if ( $success_up > 0 ) {
472 - if ( OPTML_DEBUG ) {
821 + if ( OPTML_DEBUG_MEDIA ) {
473 822 do_action( 'optml_log', ' call post update, succesful images: ' );
474 823 do_action( 'optml_log', $success_up );
475 824 }
476 - $this->update_content();
477 825 }
826 +
478 827 return $success_up;
479 828 }
480 829
481 830 /**
@@ -481,74 +830,90 @@
481 830 /**
482 831 * Return the original url of an image attachment.
483 832 *
484 833 * @param integer $post_id Image attachment id.
485 - * @return string The original url of the image.
834 + *
835 + * @return string|bool The original url of the image.
486 836 */
487 837 public static function get_original_url( $post_id ) {
488 838 self::$return_original_url = true;
489 - $original_url = wp_get_attachment_url( $post_id );
839 + $original_url = wp_get_attachment_url( $post_id );
490 840 self::$return_original_url = false;
841 +
491 842 return $original_url;
492 843 }
844 +
493 845 /**
494 846 * Bring images back to media library and update inside pages.
495 847 *
496 848 * @param array $image_ids The id of the attachments for the selected images.
849 + *
497 850 * @return int The number of successfully processed images.
498 851 */
499 852 public function rollback_and_update_images( $image_ids ) {
500 853 $success_back = 0;
501 - if ( OPTML_DEBUG ) {
854 + if ( OPTML_DEBUG_MEDIA ) {
502 855 do_action( 'optml_log', ' images to rollback ' );
503 856 do_action( 'optml_log', $image_ids );
504 857 }
858 +
505 859 foreach ( $image_ids as $id ) {
860 + // Skip DAM attachment filtering.
861 + if ( $this->is_dam_imported_image( $id ) ) {
862 + continue;
863 + }
506 864 $current_meta = wp_get_attachment_metadata( $id );
507 865 if ( ! isset( $current_meta['file'] ) || ! self::is_uploaded_image( $current_meta['file'] ) ) {
508 - delete_post_meta( $id, 'optimole_offload' );
509 - $success_back++;
866 + delete_post_meta( $id, self::META_KEYS['offloaded'] );
867 + delete_post_meta( $id, self::OM_OFFLOADED_FLAG );
868 + ++$success_back;
510 869 continue;
511 870 }
512 - $table_id = [];
513 - $filename = pathinfo( $current_meta['file'], PATHINFO_BASENAME );
514 - preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $current_meta['file'], $table_id );
515 - if ( ! isset( $table_id[1] ) ) {
871 +
872 + // Account for scaled images.
873 + $source_file = isset( $current_meta['original_image'] ) ? $current_meta['original_image'] : $current_meta['file'];
874 + $filename = pathinfo( $source_file, PATHINFO_BASENAME );
875 + $image_id = preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $current_meta['file'], $matches ) ? $matches[1] : null;
876 +
877 + if ( null === $image_id ) {
516 878 continue;
517 879 }
518 - $table_id = $table_id[1];
519 - if ( OPTML_DEBUG ) {
880 +
881 + if ( OPTML_DEBUG_MEDIA ) {
520 882 do_action( 'optml_log', ' image cloud id ' );
521 - do_action( 'optml_log', $table_id );
883 + do_action( 'optml_log', $image_id );
522 884 }
523 - $request = new Optml_Api();
524 - $get_response = $request->call_upload_api( '', 'false', $table_id, 'false', 'true' );
525 885
526 - if ( is_wp_error( $get_response ) || wp_remote_retrieve_response_code( $get_response ) !== 200 ) {
527 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
528 - if ( OPTML_DEBUG ) {
886 + $image_url = Optimole::offload()->getImageUrl( $image_id );
887 +
888 + if ( null === $image_url ) {
889 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
890 + if ( OPTML_DEBUG_MEDIA ) {
529 891 do_action( 'optml_log', ' error get url' );
530 892 }
893 +
894 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error getting URL.' );
895 +
531 896 continue;
532 897 }
533 898
534 - $get_url = json_decode( $get_response['body'], true )['getUrl'];
535 -
536 899 if ( ! function_exists( 'download_url' ) ) {
537 900 include_once ABSPATH . 'wp-admin/includes/file.php';
538 901 }
539 902 if ( ! function_exists( 'download_url' ) ) {
540 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
903 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
541 904 continue;
542 905 }
543 906 $timeout_seconds = 60;
544 - $temp_file = download_url( $get_url, $timeout_seconds );
907 + $temp_file = download_url( $image_url, $timeout_seconds );
545 908
546 909 if ( is_wp_error( $temp_file ) ) {
547 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
548 - if ( OPTML_DEBUG ) {
910 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
911 + if ( OPTML_DEBUG_MEDIA ) {
549 912 do_action( 'optml_log', ' download_url error ' );
550 913 }
914 +
915 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error downloading URL.' );
551 916 continue;
552 917 }
553 918
554 919 $extension = $this->get_ext( $filename );
@@ -553,13 +918,15 @@
553 918
554 919 $extension = $this->get_ext( $filename );
555 920
556 921 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
557 - if ( OPTML_DEBUG ) {
922 + if ( OPTML_DEBUG_MEDIA ) {
558 923 do_action( 'optml_log', ' image has invalid extension' );
559 924 do_action( 'optml_log', $extension );
560 925 }
561 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
926 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
927 +
928 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has invalid extension.' );
562 929 continue;
563 930 }
564 931
565 932 $type = Optml_Config::$image_extensions [ $extension ];
@@ -572,12 +939,12 @@
572 939 ];
573 940
574 941 $overrides = [
575 942 // do not expect the default form data from normal uploads
576 - 'test_form' => false,
943 + 'test_form' => false,
577 944
578 945 // Setting this to false lets WordPress allow empty files, not recommended.
579 - 'test_size' => true,
946 + 'test_size' => true,
580 947
581 948 // A properly uploaded file will pass this test. There should be no reason to override this one.
582 949 'test_upload' => true,
583 950 ];
@@ -585,19 +952,21 @@
585 952 if ( ! function_exists( 'wp_handle_sideload' ) ) {
586 953 include_once ABSPATH . '/wp-admin/includes/file.php';
587 954 }
588 955 if ( ! function_exists( 'wp_handle_sideload' ) ) {
589 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
956 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
590 957 continue;
591 958 }
592 959
593 960 // Move the temporary file into the uploads directory.
594 - $results = wp_handle_sideload( $file, $overrides );
961 + $results = wp_handle_sideload( $file, $overrides, get_the_date( 'Y/m', $id ) );
595 962 if ( ! empty( $results['error'] ) ) {
596 - if ( OPTML_DEBUG ) {
963 + if ( OPTML_DEBUG_MEDIA ) {
597 964 do_action( 'optml_log', ' wp_handle_sideload error' );
598 965 }
599 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
966 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
967 +
968 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' faced wp_handle_sideload error.' );
600 969 continue;
601 970 }
602 971
603 972 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
@@ -603,23 +972,23 @@
603 972 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
604 973 include_once ABSPATH . '/wp-admin/includes/image.php';
605 974 }
606 975 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
607 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
976 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
608 977 continue;
609 978 }
610 - wp_create_image_subsizes( $results['file'], $id );
979 + $original_meta = wp_create_image_subsizes( $results['file'], $id );
611 980 if ( $type === 'image/svg+xml' ) {
612 981 if ( ! function_exists( 'wp_get_attachment_metadata' ) || ! function_exists( 'wp_update_attachment_metadata' ) ) {
613 982 include_once ABSPATH . '/wp-admin/includes/post.php';
614 983 }
615 984 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
616 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
985 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
617 986 continue;
618 987 }
619 988 $meta = wp_get_attachment_metadata( $id );
620 989 if ( ! isset( $meta['file'] ) ) {
621 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
990 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
622 991 continue;
623 992 }
624 993 $meta['file'] = $results['file'];
625 994 wp_update_attachment_metadata( $id, $meta );
@@ -628,26 +997,50 @@
628 997 if ( ! function_exists( 'update_attached_file' ) ) {
629 998 include_once ABSPATH . '/wp-admin/includes/post.php';
630 999 }
631 1000 if ( ! function_exists( 'update_attached_file' ) ) {
632 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
1001 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
633 1002 continue;
634 1003 }
635 1004 update_attached_file( $id, $results['file'] );
636 - $success_back++;
637 - $original_url = self::get_original_url( $id );
1005 +
1006 + $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $id );
1007 + if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1008 + foreach ( $duplicated_images as $duplicated_id ) {
1009 + $duplicated_meta = wp_get_attachment_metadata( $duplicated_id );
1010 + if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1011 + $duplicated_meta['file'] = $results['file'];
1012 + if ( isset( $meta ) ) {
1013 + foreach ( $meta['sizes'] as $key => $value ) {
1014 + if ( isset( $original_meta['sizes'][ $key ]['file'] ) ) {
1015 + $duplicated_meta['sizes'][ $key ]['file'] = $original_meta['sizes'][ $key ]['file'];
1016 + }
1017 + }
1018 + }
1019 + wp_update_attachment_metadata( $duplicated_id, $duplicated_meta );
1020 + delete_post_meta( $duplicated_id, self::META_KEYS['offloaded'] );
1021 + delete_post_meta( $duplicated_id, self::OM_OFFLOADED_FLAG );
1022 + }
1023 + }
1024 + }
1025 + ++$success_back;
1026 +
1027 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has been rolled back.' );
1028 +
1029 + $original_url = self::get_original_url( $id );
638 1030 if ( $original_url === false ) {
639 1031 continue;
640 1032 }
641 - $this->delete_attachment_from_server( $original_url, $id, $table_id );
1033 + $this->delete_attachment_from_server( $original_url, $id, $image_id );
642 1034 }
1035 +
643 1036 if ( $success_back > 0 ) {
644 - if ( OPTML_DEBUG ) {
1037 + if ( OPTML_DEBUG_MEDIA ) {
645 1038 do_action( 'optml_log', ' call update post, success rollback' );
646 1039 do_action( 'optml_log', $success_back );
647 1040 }
648 - $this->update_content();
649 1041 }
1042 +
650 1043 return $success_back;
651 1044 }
652 1045
653 1046 /**
@@ -652,150 +1045,58 @@
652 1045
653 1046 /**
654 1047 * Handle the bulk actions.
655 1048 *
656 - * @param string $redirect The current url from the media library.
657 - * @param string $doaction The current action selected.
1049 + * @param string $redirect The current url from the media library.
1050 + * @param string $doaction The current action selected.
658 1051 * @param array $image_ids The id of the attachments for the selected images.
1052 + *
659 1053 * @return string The url with the correspondent query args for the executed actions.
660 1054 */
661 1055 public function bulk_action_handler( $redirect, $doaction, $image_ids ) {
1056 +
662 1057 if ( empty( $image_ids ) ) {
663 1058 return $redirect;
664 1059 }
665 - $redirect = remove_query_arg( [ 'optimole_offload_images_succes', 'optimole_offload_images_failed', 'optimole_back_to_media_success', 'optimole_back_to_media_failed', 'optimole_offload_images_extra', 'optimole_rollback_images_extra' ], $redirect );
666 1060
667 - if ( $doaction === 'optimole_upload_image' ) {
668 - if ( count( $image_ids ) > 5 ) {
669 - $redirect = add_query_arg( 'optimole_offload_images_extra', 'true', $redirect );
670 - $image_ids = array_slice( $image_ids, 0, 5, true );
671 - }
672 - $success_up = $this->upload_and_update_existing_images( $image_ids );
673 - $redirect = add_query_arg( 'optimole_offload_images_succes', $success_up, $redirect );
674 - $failed_up = count( $image_ids ) - $success_up;
675 - if ( $failed_up > 0 ) {
676 - $redirect = add_query_arg( 'optimole_offload_images_failed', $failed_up, $redirect );
677 - }
678 - }
1061 + $image_ids = array_slice( $image_ids, 0, 20, true );
1062 + $redirect = 'admin.php';
1063 + $redirect = add_query_arg( 'optimole_action', $doaction, $redirect );
1064 + $redirect = add_query_arg( 'page', 'optimole', $redirect );
1065 + $redirect = add_query_arg( $image_ids, $redirect );
679 1066
680 - if ( $doaction === 'optimole_back_to_media' ) {
681 - if ( count( $image_ids ) > 5 ) {
682 - $redirect = add_query_arg( 'optimole_rollback_images_extra', 'true', $redirect );
683 - $image_ids = array_slice( $image_ids, 0, 5, true );
684 - }
685 - $success_back = $this->rollback_and_update_images( $image_ids );
686 - $failed_down = count( $image_ids ) - $success_back;
687 - if ( $failed_down > 0 ) {
688 - $redirect = add_query_arg( 'optimole_back_to_media_failed', $failed_down, $redirect );
689 - }
690 - $redirect = add_query_arg( 'optimole_back_to_media_success', $success_back, $redirect );
691 - }
692 1067 return $redirect;
1068 + }
693 1069
694 - }
695 1070 /**
696 1071 * Register the bulk media actions.
697 1072 *
698 - * @param array $bulk_array The existing actions array.
699 - * @return array The array with the appended actions.
1073 + * @param array $bulk_array The existing actions array.
1074 + *
1075 + * @return array The array with the appended actions.
700 1076 */
701 1077 public function register_bulk_media_actions( $bulk_array ) {
702 1078
703 - $bulk_array['optimole_upload_image'] = __( 'Push Image to Optimole', 'optimole-wp' );
704 - $bulk_array['optimole_back_to_media'] = __( 'Restore image to media library', 'optimole-wp' );
1079 + $bulk_array['offload_images'] = __( 'Push Image to Optimole', 'optimole-wp' );
1080 + $bulk_array['rollback_images'] = __( 'Restore image to media library', 'optimole-wp' );
1081 +
705 1082 return $bulk_array;
706 -
707 1083 }
708 - /**
709 - * Register the possible notices for the media bulk actions.
710 - */
711 - public function bulk_action_notices() {
712 - if ( ! empty( $_REQUEST['optimole_offload_images_extra'] ) ) {
713 1084
714 - printf(
715 - '<div id="message" class="updated notice is-dismissible"><p>' .
716 - __( 'You can not offload more than 5 images at once from this menu.', 'optimole-wp' ) . '</p></div>'
717 - );
718 -
719 - }
720 - if ( ! empty( $_REQUEST['optimole_rollback_images_extra'] ) ) {
721 -
722 - printf(
723 - '<div id="message" class="updated notice is-dismissible"><p>' .
724 - __( 'You can not move back to library more than 5 images at once from this menu.', 'optimole-wp' ) . '</p></div>'
725 - );
726 -
727 - }
728 - if ( ! empty( $_REQUEST['optimole_offload_images_succes'] ) ) {
729 -
730 - printf(
731 - '<div id="message" class="updated notice is-dismissible"><p>' .
732 - _n(
733 - '%s image was moved on Optimole Cloud.',
734 - '%s images were moved on Optimole Cloud.',
735 - intval( $_REQUEST['optimole_offload_images_succes'] ),
736 - 'optimole-wp'
737 - ) . '</p></div>',
738 - intval( $_REQUEST['optimole_offload_images_succes'] )
739 - );
740 -
741 - }
742 - if ( ! empty( $_REQUEST['optimole_offload_images_failed'] ) ) {
743 - printf(
744 - '<div id="message" class="updated notice is-dismissible"><p>' .
745 - _n(
746 - '%s image failed while uploading to Optimole Cloud, please try again later.',
747 - '%s images failed while uploading to Optimole Cloud, please try again later',
748 - intval( $_REQUEST['optimole_offload_images_failed'] ) .
749 - 'optimole-wp'
750 - ) . '</p></div>',
751 - intval( $_REQUEST['optimole_offload_images_failed'] )
752 - );
753 -
754 - }
755 - if ( ! empty( $_REQUEST['optimole_back_to_media_success'] ) ) {
756 - printf(
757 - '<div id="message" class="updated notice is-dismissible"><p>' .
758 - _n(
759 - '%s image was stored back on media library.',
760 - '%s images were stored back on media library.',
761 - intval( $_REQUEST['optimole_back_to_media_success'] ),
762 - 'optimole-wp'
763 - ) . '</p></div>',
764 - intval( $_REQUEST['optimole_back_to_media_success'] )
765 - );
766 -
767 - }
768 - if ( ! empty( $_REQUEST['optimole_back_to_media_failed'] ) ) {
769 - printf(
770 - '<div id="message" class="updated notice is-dismissible"><p>' .
771 - _n(
772 - '%s image failed while restoring to media library, please try again later.',
773 - '%s images failed while restoring to media library, please try again later',
774 - intval( $_REQUEST['optimole_back_to_media_failed'] ) .
775 - 'optimole-wp'
776 - ) . '</p></div>',
777 - intval( $_REQUEST['optimole_back_to_media_failed'] )
778 - );
779 - }
780 - }
781 -
782 1085 /**
783 1086 * Send delete request to our servers and update the meta.
784 1087 *
785 1088 * @param string $original_url Original url of the image.
786 1089 * @param integer $post_id Image id inside db.
787 - * @param string $table_id Our cloud id for the image.
1090 + * @param string $image_id Our cloud id for the image.
788 1091 */
789 - public function delete_attachment_from_server( $original_url, $post_id, $table_id ) {
790 - $request = new Optml_Api();
791 - $delete_response = $request->call_upload_api( $original_url, 'true', $table_id );
1092 + public function delete_attachment_from_server( $original_url, $post_id, $image_id ) {
1093 + Optimole::offload()->deleteImage( $image_id );
792 1094
793 - delete_post_meta( $post_id, 'optimole_offload' );
794 - if ( is_wp_error( $delete_response ) || wp_remote_retrieve_response_code( $delete_response ) !== 200 ) {
795 - // should add some routine to retry delete once if delete fails
796 - }
1095 + delete_post_meta( $post_id, self::META_KEYS['offloaded'] );
1096 + delete_post_meta( $post_id, self::OM_OFFLOADED_FLAG );
797 1097 }
1098 +
798 1099 /**
799 1100 * Delete an image from our servers after it is removed from media.
800 1101 *
801 1102 * @param int $post_id The deleted post id.
@@ -801,15 +1102,24 @@
801 1102 * @param int $post_id The deleted post id.
802 1103 */
803 1104 public function delete_attachment_hook( $post_id ) {
804 1105 $file = wp_get_attachment_metadata( $post_id );
805 - if ( $file === false || ! isset( $file['file'] ) ) {
1106 + if ( $file === false ) {
806 1107 return;
807 1108 }
1109 +
1110 + // Skip if the image was imported from cloud library.
1111 + if ( $this->is_dam_imported_image( $post_id ) ) {
1112 + return;
1113 + }
1114 +
1115 + if ( ! $this->is_new_offloaded_attachment( $post_id ) && ! $this->is_legacy_offloaded_attachment( $post_id ) ) {
1116 + return;
1117 + }
1118 +
808 1119 $file = $file['file'];
809 - if ( self::is_uploaded_image( $file ) ) {
810 -
811 - $original_url = self::get_original_url( $post_id );
1120 + if ( self::is_uploaded_image( $file ) || $this->is_new_offloaded_attachment( $post_id ) ) {
1121 + $original_url = self::get_original_url( $post_id );
812 1122 if ( $original_url === false ) {
813 1123 return;
814 1124 }
815 1125 $table_id = [];
@@ -816,11 +1126,10 @@
816 1126
817 1127 preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $file, $table_id );
818 1128
819 1129 if ( ! isset( $table_id[1] ) ) {
820 - return;
1130 + return;
821 1131 }
822 -
823 1132 $this->delete_attachment_from_server( $original_url, $post_id, $table_id[1] );
824 1133 }
825 1134 }
826 1135
@@ -828,8 +1137,9 @@
828 1137 * Get optimized URL for an attachment image if it is uploaded to our servers.
829 1138 *
830 1139 * @param string $url The current url.
831 1140 * @param int $attachment_id The attachment image id.
1141 + *
832 1142 * @return string Optimole cdn URL.
833 1143 * @uses filter:wp_get_attachment_url
834 1144 */
835 1145 public function get_image_attachment_url( $url, $attachment_id ) {
@@ -835,66 +1145,109 @@
835 1145 public function get_image_attachment_url( $url, $attachment_id ) {
836 1146 if ( self::$return_original_url === true ) {
837 1147 return $url;
838 1148 }
839 - $meta = wp_get_attachment_metadata( $attachment_id );
840 - if ( ! isset( $meta['file'] ) ) {
1149 +
1150 + if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
1151 + $meta = wp_get_attachment_metadata( $attachment_id );
1152 + if ( ! isset( $meta['file'] ) ) {
1153 + return $url;
1154 + }
1155 +
1156 + // Skip DAM attachment filtering.
1157 + if ( $this->is_dam_imported_image( $attachment_id ) ) {
1158 + return $url;
1159 + }
1160 +
1161 + $file = $meta['file'];
1162 + if ( self::is_uploaded_image( $file ) ) {
1163 + return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $attachment_id . $file, $this->get_optimized_image_url( $url, 'auto', 'auto' ) );
1164 + } else {
1165 + // this is for the users that already offloaded the images before the other fixes
1166 + $local_file = get_attached_file( $attachment_id );
1167 + if ( ! file_exists( $local_file ) ) {
1168 + $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
1169 + if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1170 + foreach ( $duplicated_images as $id ) {
1171 + if ( ! empty( $id ) ) {
1172 + $duplicated_meta = wp_get_attachment_metadata( $id );
1173 + if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1174 + return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $id . $duplicated_meta['file'], $this->get_optimized_image_url( $url, 'auto', 'auto' ) );
1175 + }
1176 + }
1177 + }
1178 + }
1179 + }
1180 + }
841 1181 return $url;
842 1182 }
843 - $file = $meta['file'];
844 - if ( self::is_uploaded_image( $file ) ) {
845 - $optimized_url = ( new Optml_Image( $url, ['width' => 'auto', 'height' => 'auto', 'quality' => $this->settings->get_numeric_quality()], $this->settings->get( 'cache_buster' ) ) )->get_url();
846 - return str_replace( '/' . $url, '/' . self::KEYS['not_processed_flag'] . $attachment_id . $file, $optimized_url );
1183 +
1184 + if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
1185 + return $url;
847 1186 }
848 - return $url;
1187 +
1188 + return $this->get_new_offloaded_attachment_url( $url, $attachment_id );
849 1189 }
1190 +
850 1191 /**
851 1192 * Filter the requested image url.
852 1193 *
853 - * @param null $image The previous image value (null).
1194 + * @param bool|array $image The previous image value (null).
854 1195 * @param int $attachment_id The ID of the attachment.
855 - * @param string|array $size Requested size of image. Image size name, or array of width and height values (in that order).
1196 + * @param string|array $size Requested size of image. Image size name, or array of width and height values (in that order).
856 1197 *
857 - * @return array The image sizes and optimized url.
1198 + * @return bool|array The image sizes and optimized url.
858 1199 * @uses filter:image_downsize
859 1200 */
860 1201 public function generate_filter_downsize_urls( $image, $attachment_id, $size ) {
861 - if ( self::$return_original_url === true ) {
1202 + if ( $this->is_dam_imported_image( $attachment_id ) ) {
862 1203 return $image;
863 1204 }
864 - $sizes2crop = self::size_to_crop();
865 - if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) {
866 - return $image;
1205 +
1206 + if ( $this->is_legacy_offloaded_attachment( $attachment_id ) ) {
1207 + if ( self::$return_original_url === true ) {
1208 + return $image;
1209 + }
1210 +
1211 + $sizes2crop = self::size_to_crop();
1212 + if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) {
1213 + return $image;
1214 + }
1215 + $data = image_get_intermediate_size( $attachment_id, $size );
1216 + if ( false === $data || ! self::is_uploaded_image( $data['url'] ) ) {
1217 + return $image;
1218 + }
1219 + $resize = apply_filters( 'optml_default_crop', [] );
1220 + if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) {
1221 + $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] );
1222 + }
1223 + $id_filename = [];
1224 +
1225 + preg_match( '/\/(' . self::KEYS['not_processed_flag'] . '.*)/', $data['url'], $id_filename );
1226 + if ( ! isset( $id_filename[1] ) ) {
1227 + return $image;
1228 + }
1229 + $url = self::get_original_url( $attachment_id );
1230 +
1231 + return [
1232 + str_replace( $url, $id_filename[1], $this->get_optimized_image_url( $url, $data['width'], $data['height'], $resize ) ),
1233 + $data['width'],
1234 + $data['height'],
1235 + true,
1236 + ];
867 1237 }
868 - $data = image_get_intermediate_size( $attachment_id, $size );
869 - if ( ! isset( $data['url'] ) || ! isset( $data['width'] ) || ! isset( $data['height'] ) || ! self::is_uploaded_image( $data['url'] ) ) {
1238 +
1239 + if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
870 1240 return $image;
871 1241 }
872 - $resize = apply_filters( 'optml_default_crop', [] );
873 - if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) {
874 - $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] );
875 - }
876 - $id_filename = [];
877 1242
878 - preg_match( '/\/(' . self::KEYS['not_processed_flag'] . '.*)/', $data['url'], $id_filename );
879 - if ( ! isset( $id_filename[1] ) ) {
880 - return $image;
881 - }
882 - $url = self::get_original_url( $attachment_id );
883 - $optimized_url = ( new Optml_Image( $url, ['width' => $data['width'], 'height' => $data['height'], 'resize' => $resize, 'quality' => $this->settings->get_numeric_quality()], $this->settings->get( 'cache_buster' ) ) )->get_url();
884 - $optimized_url = str_replace( $url, $id_filename[1], $optimized_url );
885 - $image = [
886 - $optimized_url,
887 - $data['width'],
888 - $data['height'],
889 - true,
890 - ];
891 - return $image;
1243 + return $this->alter_attachment_image_src( $image, $attachment_id, $size, false );
892 1244 }
1245 +
893 1246 /**
894 1247 * Get image extension.
895 1248 *
896 - * @param string $path Image path.
1249 + * @param string $path Image path.
897 1250 *
898 1251 * @return string
899 1252 */
900 1253 private function get_ext( $path ) {
@@ -899,12 +1252,36 @@
899 1252 */
900 1253 private function get_ext( $path ) {
901 1254 return pathinfo( $path, PATHINFO_EXTENSION );
902 1255 }
1256 +
903 1257 /**
1258 + * Mark an image as having a retryable error.
1259 + *
1260 + * @param int $attachment_id The attachment ID.
1261 + * @param string $reason The reason for the error.
1262 + */
1263 + public static function mark_retryable_error( $attachment_id, $reason ) {
1264 + static $allowed_retries = 5;
1265 +
1266 + $retries = get_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, true );
1267 + $retries = empty( $retries ) ? 0 : (int) $retries;
1268 + if ( $retries >= $allowed_retries ) {
1269 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Reached the maximum number of retries.' );
1270 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1271 +
1272 + return;
1273 + }
1274 +
1275 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' ' . $reason . '. Marked for retry, retries done: ' . $retries );
1276 +
1277 + update_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER, ( $retries + 1 ) );
1278 + }
1279 +
1280 + /**
904 1281 * Update image meta with optimized cdn path.
905 1282 *
906 - * @param array $meta Meta information of the image.
1283 + * @param array $meta Meta information of the image.
907 1284 * @param int $attachment_id The image attachment ID.
908 1285 *
909 1286 * @return array
910 1287 * @uses filter:wp_generate_attachment_metadata
@@ -910,187 +1287,372 @@
910 1287 * @uses filter:wp_generate_attachment_metadata
911 1288 */
912 1289 public function generate_image_meta( $meta, $attachment_id ) {
913 1290
1291 + if ( $this->is_dam_imported_image( $attachment_id ) ) {
1292 + return $meta;
1293 + }
1294 +
1295 + if ( self::$instance->settings->is_offload_limit_reached() ) {
1296 + return $meta;
1297 + }
1298 +
1299 + if ( OPTML_DEBUG_MEDIA ) {
1300 + do_action( 'optml_log', 'called generate meta' );
1301 + }
1302 + // No meta, or image was already uploaded.
914 1303 if ( ! isset( $meta['file'] ) || ! isset( $meta['width'] ) || ! isset( $meta['height'] ) || self::is_uploaded_image( $meta['file'] ) ) {
915 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1304 + do_action( 'optml_log', 'invalid meta' );
1305 + do_action( 'optml_log', $meta );
1306 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1307 +
1308 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid meta.' );
1309 +
916 1310 return $meta;
917 1311 }
1312 + // Skip images based on filters.
918 1313 if ( false === Optml_Filters::should_do_image( $meta['file'], self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_FILENAME ] ) ) {
919 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1314 + do_action( 'optml_log', 'optimization filter' );
1315 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1316 +
920 1317 return $meta;
921 1318 }
922 - $original_url = self::get_original_url( $attachment_id );
1319 + $original_url = self::get_original_url( $attachment_id );
923 1320
1321 + // Could not find original URL.
924 1322 if ( $original_url === false ) {
925 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1323 + do_action( 'optml_log', 'error getting original url' );
1324 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1325 +
1326 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid original url.' );
1327 +
926 1328 return $meta;
927 1329 }
928 1330
929 - $local_file = get_attached_file( $attachment_id );
1331 + // We should strip the `-scaled` from the URL to not generate inconsistencies with automatically scaled images.
1332 + $original_url = $this->maybe_strip_scaled( $original_url );
1333 + $local_file = $this->maybe_strip_scaled( get_attached_file( $attachment_id ) );
1334 +
1335 + $extension = $this->get_ext( $local_file );
1336 + $content_type = Optml_Config::$image_extensions [ $extension ];
1337 + $temp = explode( '/', $local_file );
1338 + $file_name = end( $temp );
1339 + $no_ext_filename = str_replace( '.' . $extension, '', $file_name );
1340 + $original_name = $file_name;
1341 + if ( OPTML_DEBUG_MEDIA ) {
1342 + do_action( 'optml_log', 'file before replace' );
1343 + do_action( 'optml_log', $local_file );
1344 + }
1345 +
1346 + // check if the current filename is the last deduplicated filename
1347 + if ( ! empty( self::$last_deduplicated ) && strpos( $no_ext_filename, str_replace( '.' . $extension, '', self::$last_deduplicated ) ) !== false ) {
1348 + // replace the file with the original before deduplication to get the path where the image is uploaded
1349 + $local_file = str_replace( $file_name, self::$last_deduplicated, $local_file );
1350 + $original_name = self::$last_deduplicated;
1351 + self::$last_deduplicated = false;
1352 + }
1353 + if ( OPTML_DEBUG_MEDIA ) {
1354 + do_action( 'optml_log', 'file after replace' );
1355 + do_action( 'optml_log', $local_file );
1356 + }
930 1357 if ( ! file_exists( $local_file ) ) {
931 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1358 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1359 + do_action( 'optml_log', 'missing file' );
1360 + do_action( 'optml_log', $local_file );
1361 +
1362 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has missing file.' );
1363 +
932 1364 return $meta;
933 1365 }
934 - $extension = $this->get_ext( $local_file );
935 1366
936 1367 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
937 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1368 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1369 + do_action( 'optml_log', 'invalid extension' );
1370 + do_action( 'optml_log', $extension );
1371 +
1372 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid extension.' );
1373 +
938 1374 return $meta;
939 1375 }
940 1376 if ( false === Optml_Filters::should_do_extension( self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_EXT ], $extension ) ) {
941 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1377 + do_action( 'optml_log', 'extension filter' );
1378 + do_action( 'optml_log', $extension );
1379 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1380 +
942 1381 return $meta;
943 1382 }
944 1383
945 - $content_type = Optml_Config::$image_extensions [ $extension ];
946 - $temp = explode( '/', $local_file );
947 - $file_name = end( $temp );
1384 + $offload_manager = Optimole::offload();
1385 + $offload_usage = $offload_manager->getUsage();
948 1386
949 - $request = new Optml_Api();
950 - $generate_url_response = $request->call_upload_api( $original_url );
1387 + $current_run = self::get_process_meta();
1388 + $remaining = isset( $current_run['remaining'] ) ? absint( $current_run['remaining'] ) : 0;
951 1389
952 - if ( is_wp_error( $generate_url_response ) || wp_remote_retrieve_response_code( $generate_url_response ) !== 200 ) {
953 - if ( OPTML_DEBUG ) {
954 - do_action( 'optml_log', ' call to signed url error' );
1390 + if ( $remaining + $offload_usage->getCurrent() >= $offload_usage->getLimit() ) {
1391 + if ( OPTML_DEBUG_MEDIA ) {
1392 + do_action( 'optml_log', 'limit exceeded' );
1393 + do_action( 'optml_log', $offload_usage );
955 1394 }
956 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1395 +
1396 + self::$instance->settings->update( 'offload_limit_reached', 'enabled' );
1397 +
1398 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: offloading images would exceed limit.' );
1399 +
957 1400 return $meta;
958 1401 }
959 - $decoded_response = json_decode( $generate_url_response['body'], true );
960 1402
961 - if ( ! isset( $decoded_response['tableId'] ) || ! isset( $decoded_response['uploadUrl'] ) ) {
962 - if ( OPTML_DEBUG ) {
963 - do_action( 'optml_log', ' missing table id or upload url' );
1403 + try {
1404 + $image_id = $offload_manager->uploadImage( $local_file, $original_url );
1405 +
1406 + if ( OPTML_DEBUG_MEDIA ) {
1407 + do_action( 'optml_log', 'image id' );
1408 + do_action( 'optml_log', $image_id );
964 1409 }
965 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1410 +
1411 + // We clear the retry counter if we reach this point.
1412 + delete_post_meta( $attachment_id, self::RETRYABLE_META_COUNTER );
1413 + } catch ( InvalidArgumentException $exception ) {
1414 + if ( OPTML_DEBUG_MEDIA ) {
1415 + do_action( 'optml_log', 'invalid argument exception' );
1416 + do_action( 'optml_log', $exception );
1417 + }
1418 +
1419 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1420 +
1421 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' file is missing or unreadable.' );
1422 +
966 1423 return $meta;
967 - }
968 - $table_id = $decoded_response['tableId'];
969 - if ( OPTML_DEBUG ) {
970 - do_action( 'optml_log', ' table id' );
971 - do_action( 'optml_log', $table_id );
972 - }
973 - $upload_signed_url = $decoded_response['uploadUrl'];
974 - $image = file_get_contents( $local_file );
975 - if ( $image === false ) {
976 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1424 + } catch ( InvalidUploadApiResponseException $exception ) {
1425 + if ( OPTML_DEBUG_MEDIA ) {
1426 + do_action( 'optml_log', 'missing table id or upload url' );
1427 + do_action( 'optml_log', $exception );
1428 + }
1429 +
1430 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1431 +
1432 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has invalid table id or upload url.' );
1433 +
977 1434 return $meta;
978 - }
979 - if ( $upload_signed_url !== 'found_resource' ) {
1435 + } catch ( UploadFailedException $exception ) {
1436 + if ( OPTML_DEBUG_MEDIA ) {
1437 + do_action( 'optml_log', 'upload error' );
1438 + do_action( 'optml_log', $exception );
1439 + }
980 1440
981 - $request = new Optml_Api();
982 - $result = $request->upload_image( $upload_signed_url, $content_type, $image );
1441 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
983 1442
984 - if ( is_wp_error( $result ) || wp_remote_retrieve_response_code( $result ) !== 200 ) {
985 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
986 - return $meta;
1443 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has upload error.' );
1444 +
1445 + return $meta;
1446 + } catch ( UploadLimitException $exception ) {
1447 + if ( OPTML_DEBUG_MEDIA ) {
1448 + do_action( 'optml_log', 'limit exceeded' );
1449 + do_action( 'optml_log', $exception );
987 1450 }
988 - $file_size = filesize( $local_file );
989 - if ( $file_size === false ) {
990 - $file_size = 0;
1451 +
1452 + self::$instance->settings->update( 'offload_limit', $exception->getUsage()->getLimit() );
1453 + self::$instance->settings->update( 'offload_limit_reached', 'enabled' );
1454 +
1455 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: upload limit exceeded' );
1456 +
1457 + return $meta;
1458 + } catch ( UploadApiException $exception ) {
1459 + if ( OPTML_DEBUG_MEDIA ) {
1460 + do_action( 'optml_log', 'upload api error' );
1461 + do_action( 'optml_log', $exception );
991 1462 }
992 - $request = new Optml_Api();
993 - $result_update = $request->call_upload_api(
994 - $original_url,
995 - 'false',
996 - $table_id,
997 - 'success',
998 - 'false',
999 - $meta['width'],
1000 - $meta['height'],
1001 - $file_size
1002 - );
1003 - if ( is_wp_error( $result_update ) || wp_remote_retrieve_response_code( $result_update ) !== 200 ) {
1004 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1005 - return $meta;
1463 +
1464 + self::mark_retryable_error( $attachment_id, 'Error from upload api:' . $exception->getMessage() );
1465 +
1466 + return $meta;
1467 + } catch ( RuntimeException $exception ) {
1468 + if ( OPTML_DEBUG_MEDIA ) {
1469 + do_action( 'optml_log', 'runtime exception' );
1470 + do_action( 'optml_log', $exception );
1006 1471 }
1472 +
1473 + self::mark_retryable_error( $attachment_id, 'Unknown error from upload api: ' . $exception->getMessage() );
1474 +
1475 + return $meta;
1007 1476 }
1477 +
1008 1478 $url_to_append = $original_url;
1009 - $url_parts = parse_url( $original_url );
1479 + $url_parts = parse_url( $original_url );
1480 +
1010 1481 if ( isset( $url_parts['scheme'] ) && isset( $url_parts['host'] ) ) {
1011 1482 $url_to_append = $url_parts['scheme'] . '://' . $url_parts['host'] . '/' . $file_name;
1012 1483 }
1013 - $optimized_url = $this->get_media_optimized_url( $url_to_append, $table_id );
1014 - $request = new Optml_Api();
1015 - if ( $request->check_optimized_url( $optimized_url ) === false ) {
1016 - $request->call_upload_api( $original_url, 'true', $table_id );
1017 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
1484 +
1485 + $optimized_url = $this->get_media_optimized_url( $url_to_append, $image_id );
1486 +
1487 + if ( ( new Optml_Api() )->check_optimized_url( $optimized_url ) === false ) {
1488 + do_action( 'optml_log', 'optimization error' );
1489 + do_action( 'optml_log', $optimized_url );
1490 +
1491 + Optimole::offload()->deleteImage( $image_id );
1492 +
1493 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
1494 +
1495 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has optimization error.' );
1496 +
1018 1497 return $meta;
1019 1498 }
1020 - file_exists( $local_file ) && unlink( $local_file );
1021 - update_post_meta( $attachment_id, 'optimole_offload', 'true' );
1022 - $meta['file'] = '/' . self::KEYS['uploaded_flag'] . $table_id . '/' . $url_to_append;
1499 +
1500 + @unlink( $local_file );
1501 +
1502 + update_post_meta( $attachment_id, self::META_KEYS['offloaded'], 'true' );
1503 + update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true );
1504 +
1505 + $meta['file'] = '/' . self::KEYS['uploaded_flag'] . $image_id . '/' . $url_to_append;
1506 +
1023 1507 if ( isset( $meta['sizes'] ) ) {
1024 1508 foreach ( $meta['sizes'] as $key => $value ) {
1025 - $generated_image_size_path = str_replace( $file_name, $meta['sizes'][ $key ]['file'], $local_file );
1509 + $generated_image_size_path = str_replace( $original_name, $meta['sizes'][ $key ]['file'], $local_file );
1026 1510 file_exists( $generated_image_size_path ) && unlink( $generated_image_size_path );
1027 1511 $meta['sizes'][ $key ]['file'] = $file_name;
1028 1512 }
1029 1513 }
1514 +
1515 + // This is needed for scaled images.
1516 + // Otherwise, `-scaled` images will be left behind.
1517 + if ( isset( $meta['original_image'] ) ) {
1518 + $ext = $this->get_ext( $local_file );
1519 + $scaled_path = str_replace( '.' . $ext, '-scaled.' . $ext, $local_file );
1520 + file_exists( $scaled_path ) && unlink( $scaled_path );
1521 + }
1522 +
1523 + $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
1524 +
1525 + if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
1526 + foreach ( $duplicated_images as $duplicated_id ) {
1527 + $duplicated_meta = wp_get_attachment_metadata( $duplicated_id );
1528 + if ( isset( $duplicated_meta['file'] ) && ! self::is_uploaded_image( $duplicated_meta['file'] ) ) {
1529 + $duplicated_meta['file'] = $meta['file'];
1530 + if ( $duplicated_meta['sizes'] ) {
1531 + foreach ( $meta['sizes'] as $key => $value ) {
1532 + $duplicated_meta['sizes'][ $key ]['file'] = $file_name;
1533 + }
1534 + }
1535 + wp_update_attachment_metadata( $duplicated_id, $duplicated_meta );
1536 + update_post_meta( $duplicated_id, self::META_KEYS['offloaded'], 'true' );
1537 + update_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true );
1538 + }
1539 + }
1540 + }
1541 + if ( OPTML_DEBUG_MEDIA ) {
1542 + do_action( 'optml_log', 'success offload' );
1543 + }
1544 +
1545 + self::decrement_process_meta_remaining();
1546 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Image ID: ' . $attachment_id . ' has been offloaded.' );
1547 + $attachment_page_id = wp_get_post_parent_id( $attachment_id );
1548 +
1549 + if ( $attachment_page_id !== false && $attachment_page_id !== 0 ) {
1550 + self::$offload_update_post = true;
1551 + update_post_meta( $attachment_page_id, self::POST_OFFLOADED_FLAG, 'true' );
1552 + self::$offload_update_post = false;
1553 + }
1554 +
1030 1555 return $meta;
1031 1556 }
1032 1557
1033 - /** Get the args for wp query according to the scope.
1558 + /**
1559 + * Get the args for wp query according to the scope.
1034 1560 *
1035 1561 * @param int $batch Number of images to get.
1036 1562 * @param string $action The action for which to get the images.
1563 + *
1037 1564 * @return array|false The query options array or false if not passed a valid action.
1038 1565 */
1039 - public static function get_images_query_args( $batch, $action ) {
1566 + public static function get_images_or_pages_query_args( $batch, $action, $get_images = false ) {
1567 +
1040 1568 $args = [
1041 - 'post_type' => 'attachment',
1042 - 'post_mime_type' => [ 'image' ],
1043 - 'post_status' => 'inherit',
1044 1569 'posts_per_page' => $batch,
1045 1570 'fields' => 'ids',
1046 1571 'ignore_sticky_posts' => false,
1047 1572 'no_found_rows' => true,
1048 1573 ];
1049 - if ( $action === 'offload_images' ) {
1574 +
1575 + if ( $get_images === true ) {
1576 + $args['post_type'] = 'attachment';
1577 + $args['post_mime_type'] = 'image';
1578 + $args['post_status'] = 'inherit';
1579 +
1580 + // Offload args.
1581 + if ( $action === 'offload_images' ) {
1582 + $args['meta_query'] = [
1583 + 'relation' => 'AND',
1584 + [
1585 + 'key' => self::META_KEYS['offloaded'],
1586 + 'compare' => 'NOT EXISTS',
1587 + ],
1588 + [
1589 + 'key' => self::META_KEYS['offload_error'],
1590 + 'compare' => 'NOT EXISTS',
1591 + ],
1592 + ];
1593 +
1594 + return $args;
1595 + }
1596 +
1597 + // Rollback args.
1050 1598 $args['meta_query'] = [
1051 1599 'relation' => 'AND',
1052 1600 [
1053 - 'key' => 'optimole_offload',
1054 - 'compare' => 'NOT EXISTS',
1601 + 'key' => self::META_KEYS['offloaded'],
1602 + 'value' => 'true',
1603 + 'compare' => '=',
1055 1604 ],
1056 1605 [
1057 - 'key' => 'optimole_offload_error',
1606 + 'key' => self::META_KEYS['rollback_error'],
1058 1607 'compare' => 'NOT EXISTS',
1059 1608 ],
1060 - ];
1061 - return $args;
1062 - }
1063 - if ( $action === 'rollback_images' ) {
1064 - $args['meta_query'] = [
1065 - 'relation' => 'AND',
1066 1609 [
1067 - 'key' => 'optimole_offload',
1068 - 'value' => 'true',
1069 - 'compare' => '=',
1070 - ],
1071 - [
1072 - 'key' => 'optimole_rollback_error',
1610 + 'key' => Optml_Dam::OM_DAM_IMPORTED_FLAG,
1073 1611 'compare' => 'NOT EXISTS',
1074 1612 ],
1075 1613 ];
1614 +
1076 1615 return $args;
1077 1616 }
1078 - return false;
1617 +
1618 + $args = self::add_page_meta_query_args( $action, $args );
1619 + $post_types = array_filter(
1620 + get_post_types(),
1621 + function ( $post_type ) {
1622 + if ( $post_type === 'attachment' || $post_type === 'revision' ) {
1623 + return false;
1624 + }
1625 +
1626 + return true;
1627 + }
1628 + );
1629 +
1630 + $args ['post_type'] = array_values( $post_types );
1631 +
1632 + return $args;
1079 1633 }
1634 +
1080 1635 /**
1081 1636 * Query the database and upload images to our servers.
1082 1637 *
1083 1638 * @param int $batch Number of images to process in a batch.
1639 + *
1084 1640 * @return array Number of found images and number of successfully processed images.
1085 1641 */
1086 - public static function upload_images( $batch ) {
1087 - $args = self::get_images_query_args( $batch, 'offload_images' );
1088 - $attachments = new \WP_Query( $args );
1089 - $ids = $attachments->get_posts();
1090 - $media_offload = new Optml_Media_Offload();
1091 - $result = [ 'found_images' => count( $ids ) ];
1092 - $result['success_offload'] = $media_offload->upload_and_update_existing_images( $ids );
1642 + public function upload_images( $batch, $images = [] ) {
1643 + self::$instance->settings->update( 'offload_limit_reached', 'disabled' );
1644 +
1645 + if ( empty( $images ) || $images === 'none' ) {
1646 + $args = self::get_images_or_pages_query_args( $batch, 'offload_images', true );
1647 + $attachments = new \WP_Query( $args );
1648 + $ids = $attachments->get_posts();
1649 + } else {
1650 + $ids = array_slice( $images, 0, $batch );
1651 + }
1652 + $result = [ 'found_images' => count( $ids ) ];
1653 + $result['success_offload'] = $this->upload_and_update_existing_images( $ids );
1654 +
1093 1655 return $result;
1094 1656 }
1095 1657
1096 1658 /**
@@ -1096,27 +1658,1175 @@
1096 1658 /**
1097 1659 * Query the database and bring back image to media library.
1098 1660 *
1099 1661 * @param int $batch Number of images to process in a batch.
1662 + *
1100 1663 * @return array Number of found images and number of successfully processed images.
1101 1664 */
1102 - public static function rollback_images( $batch ) {
1103 - $args = self::get_images_query_args( $batch, 'rollback_images' );
1104 - $attachments = new \WP_Query( $args );
1105 - $ids = $attachments->get_posts();
1106 - $media_offload = new Optml_Media_Offload();
1107 - $result = [ 'found_images' => count( $ids ) ];
1108 - $result['success_rollback'] = $media_offload->rollback_and_update_images( $ids );
1665 + public function rollback_images( $batch, $images = [] ) {
1666 + if ( empty( $images ) || $images === 'none' ) {
1667 + $args = self::get_images_or_pages_query_args( $batch, 'rollback_images', true );
1668 + $attachments = new \WP_Query( $args );
1669 + $ids = $attachments->get_posts();
1670 + } else {
1671 + $ids = array_slice( $images, 0, $batch );
1672 + }
1673 + $result = [ 'found_images' => count( $ids ) ];
1674 + $result['success_rollback'] = $this->rollback_and_update_images( $ids );
1675 +
1109 1676 return $result;
1110 1677 }
1678 +
1111 1679 /**
1112 - * Calculate the number of images in media library.
1680 + * Update the post with the given id, the images will be updated by the filters we use.
1113 1681 *
1682 + * @param int $post_id The post id to update.
1683 + *
1684 + * @return bool Whether the update was succesful or not.
1685 + */
1686 + public function update_page( $post_id ) {
1687 + self::$offload_update_post = true;
1688 + $post_update = wp_update_post( [ 'ID' => $post_id ] );
1689 + self::$offload_update_post = false;
1690 + if ( $post_update === 0 ) {
1691 + return false;
1692 + }
1693 + do_action( 'optml_updated_post', $post_id );
1694 +
1695 + return true;
1696 + }
1697 +
1698 + /**
1699 + * Calculate the number of images in media library and the number of posts/pages.
1700 + *
1114 1701 * @param string $action The actions for which to get the number of images.
1702 + *
1115 1703 * @return int Number of images.
1116 1704 */
1117 - public static function number_of_library_images( $action ) {
1118 - $args = self::get_images_query_args( -1, $action );
1119 - $attachments = new \WP_Query( $args );
1120 - return $attachments->post_count;
1705 + public static function number_of_images_and_pages( $action ) {
1706 + $images_args = self::get_images_or_pages_query_args( - 1, $action, true );
1707 +
1708 + $images = new \WP_Query( $images_args );
1709 +
1710 + // With the new mechanism, when offloading images, we don't need to address pages anymore.
1711 + // Bail early with the number of images.
1712 + if ( $action === 'offload_images' ) {
1713 + return $images->post_count;
1714 + }
1715 +
1716 + $pages_args = self::get_images_or_pages_query_args( - 1, $action );
1717 + $pages = new \WP_Query( $pages_args );
1718 +
1719 + return $pages->post_count + $images->post_count;
1720 + }
1721 +
1722 + /**
1723 + * Calculate the number of images in media library and the number of posts/pages by IDs.
1724 + *
1725 + * @param string $action The actions for which to get the number of images.
1726 + *
1727 + * @return int Number of images.
1728 + */
1729 + public static function number_of_images_by_ids( $action, $ids ) {
1730 + $args = self::get_images_or_pages_query_args( - 1, $action, true );
1731 + $args['post__in'] = $ids;
1732 + $images = new \WP_Query( $args );
1733 +
1734 + return $images->post_count;
1735 + }
1736 +
1737 + /**
1738 + * Get pages that contain images by IDs.
1739 + *
1740 + * @param string $action The actions for which to get the number of images.
1741 + * @param array $images Image IDs.
1742 + * @param int $batch Batch count.
1743 + * @param int $page Page number.
1744 + */
1745 + public static function get_posts_by_image_ids( $action, $images = [], $batch = 10, $page = 1 ) {
1746 + if ( empty( $images ) ) {
1747 + return [];
1748 + }
1749 +
1750 + $transient_key = 'optml_images_' . md5( serialize( $images ) );
1751 + $transient = get_transient( $transient_key );
1752 +
1753 + if ( false !== $transient ) {
1754 + return array_slice( $transient, ( $page - 1 ) * $batch, $batch );
1755 + }
1756 +
1757 + global $wpdb;
1758 +
1759 + $image_urls = array_map(
1760 + function ( $image_id ) {
1761 + $meta = wp_get_attachment_metadata( $image_id );
1762 + $extension = Optml_Media_Offload::instance()->get_ext( $meta['file'] );
1763 +
1764 + return str_replace( '.' . $extension, '', $meta['file'] );
1765 + },
1766 + $images
1767 + );
1768 +
1769 + // Sanitize the image URLs for use in the SQL query.
1770 + $urls = array_map( 'esc_url_raw', $image_urls );
1771 +
1772 + // Initialize an empty string to hold the query.
1773 + $query = '';
1774 +
1775 + // Iterate through the array and add each URL to the query.
1776 + foreach ( $urls as $index => $url ) {
1777 + // If it's the first item, we don't need to add OR to the beginning.
1778 + if ( $index === 0 ) {
1779 + $query .= $wpdb->prepare( 'post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' );
1780 + } else {
1781 + $query .= $wpdb->prepare( ' OR post_content LIKE %s', '%' . $wpdb->esc_like( $url ) . '%' );
1782 + }
1783 + }
1784 +
1785 + // Get all the posts IDs by using LIMIT and offset in a loop.
1786 + $ids = [];
1787 + $offset = 0;
1788 + $limit = $batch;
1789 +
1790 + while ( true ) {
1791 + $posts = $wpdb->get_col(
1792 + $wpdb->prepare(
1793 + "SELECT ID FROM $wpdb->posts WHERE $query LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1794 + $limit,
1795 + $offset
1796 + )
1797 + );
1798 +
1799 + if ( empty( $posts ) ) {
1800 + break;
1801 + }
1802 +
1803 + $ids = array_merge( $ids, $posts );
1804 + $offset += $limit;
1805 + }
1806 +
1807 + set_transient( $transient_key, $ids, HOUR_IN_SECONDS );
1808 +
1809 + return array_slice( $ids, ( $page - 1 ) * $batch, $batch );
1810 + }
1811 +
1812 + /**
1813 + * Record process meta,
1814 + *
1815 + * @param int $count The number of images to process.
1816 + *
1817 + * @return void
1818 + */
1819 + public static function record_process_meta( $count ) {
1820 + $meta = get_option( 'optml_process_meta', [] );
1821 + $meta['count'] = $count;
1822 + $meta['remaining'] = $count;
1823 + $meta['start_time'] = time();
1824 + update_option( 'optml_process_meta', $meta );
1825 + }
1826 +
1827 + /**
1828 + * Update the process meta count.
1829 + *
1830 + * @return void
1831 + */
1832 + public static function decrement_process_meta_remaining() {
1833 + $meta = get_option( 'optml_process_meta', [] );
1834 +
1835 + if ( ! isset( $meta['remaining'] ) ) {
1836 + return;
1837 + }
1838 +
1839 + $meta['remaining'] = $meta['remaining'] - 1;
1840 + update_option( 'optml_process_meta', $meta );
1841 + }
1842 +
1843 + /**
1844 + * Get process meta,
1845 + *
1846 + * @return array
1847 + */
1848 + public static function get_process_meta() {
1849 + $res = [];
1850 + $meta = get_option( 'optml_process_meta', [] );
1851 + $res['time_passed'] = isset( $meta['start_time'] ) ? ( time() - $meta['start_time'] ) / 60 : 0;
1852 + $res['count'] = isset( $meta['count'] ) ? $meta['count'] : 0;
1853 + $res['remaining'] = isset( $meta['remaining'] ) ? $meta['remaining'] : $res['count'];
1854 +
1855 + return $res;
1856 + }
1857 +
1858 + /**
1859 + * Calculate the number of images in media library and the number of posts/pages.
1860 + *
1861 + * @param string $action The actions for which to get the number of images.
1862 + * @param bool $refresh Whether to refresh the cron or not.
1863 + *
1864 + * @return array Image count and Cron status.
1865 + */
1866 + public static function move_images( $action, $refresh ) {
1867 + $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status';
1868 + $count = 0;
1869 + $step = 0;
1870 + $batch = apply_filters( 'optimole_offload_batch', 20 ); // Reduce this to smaller if we have memory issues during testing.
1871 +
1872 + $count = Optml_Media_Offload::number_of_images_and_pages( $action );
1873 +
1874 + $possible_batch = ceil( $count / 10 );
1875 +
1876 + if ( $possible_batch < $batch ) {
1877 + $batch = $possible_batch;
1878 + }
1879 +
1880 + // If batch is less than 10, set it to 10.
1881 + if ( $batch < 10 ) {
1882 + $batch = 10;
1883 + }
1884 +
1885 + $in_progress = self::$instance->settings->get( $option ) !== 'disabled';
1886 +
1887 + if ( $count === 0 ) {
1888 + $in_progress = false;
1889 + }
1890 + $type = 'offload_images' === $action ? 'offload' : 'rollback';
1891 + self::$instance->settings->update( 'transfer_status', $action );
1892 + if ( false === $refresh ) {
1893 + // We check also the alternative action to avoid doing both in the same time and disable the running one.
1894 + $in_progress_b = self::$instance->settings->get( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status' ) !== 'disabled';
1895 + // We do this only if there is a mass action in progress, not individual ones.
1896 + if ( $in_progress_b ) {
1897 + // We stop the oposite action from going any further.
1898 + self::$instance->settings->update( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status', 'disabled' );
1899 + }
1900 + self::$instance->settings->update( 'offload_limit_reached', 'disabled' );
1901 + self::record_process_meta( $count );
1902 +
1903 + self::$instance->settings->update( $option, $in_progress ? 'enabled' : 'disabled' );
1904 + self::$instance->logger->add_log( $type, Optml_Logger::LOG_SEPARATOR );
1905 + self::$instance->logger->add_log( $type, 'Started with a total count of ' . intval( $count ) . '.' );
1906 +
1907 + if ( $in_progress !== true ) {
1908 + return [
1909 + 'count' => $count,
1910 + 'status' => $in_progress,
1911 + 'action' => $type,
1912 + ];
1913 + }
1914 +
1915 + // We acquire a lock to prevent multiple workers from running the same action concurrently.
1916 + $lock_token = self::acquire_transfer_lock( $action );
1917 +
1918 + if ( false !== $lock_token ) {
1919 + $total = ceil( $count / $batch );
1920 + self::schedule_action(
1921 + time(),
1922 + 'optml_start_processing_images',
1923 + [
1924 + $action,
1925 + $batch,
1926 + 1,
1927 + $total,
1928 + $step,
1929 + $lock_token,
1930 + ]
1931 + );
1932 + }
1933 + }
1934 +
1935 + $response = [
1936 + 'count' => $count,
1937 + 'action' => $type,
1938 + ];
1939 +
1940 + if ( $type === 'offload' ) {
1941 + $offload_limit_reached = self::$instance->settings->is_offload_limit_reached();
1942 + if ( $offload_limit_reached ) {
1943 + $in_progress = false;
1944 + self::$instance->settings->update( $option, 'disabled' );
1945 + }
1946 +
1947 + $response['reached_limit'] = self::$instance->settings->is_offload_limit_reached();
1948 + $response['offload_limit'] = self::$instance->settings->get( 'offload_limit' );
1949 + }
1950 +
1951 + $response['status'] = $in_progress;
1952 +
1953 + return $response;
1954 + }
1955 +
1956 + /**
1957 + * Schedule an action.
1958 + *
1959 + * @param int $time The time to schedule the action.
1960 + * @param string $hook The hook to schedule.
1961 + * @param array $args The arguments to pass to the hook.
1962 + *
1963 + * @return mixed
1964 + */
1965 + public static function schedule_action( $time, $hook, $args ) {
1966 + // We use AS if available to avoid issues with WP Cron.
1967 + if ( function_exists( 'as_schedule_single_action' ) ) {
1968 + return as_schedule_single_action( $time, $hook, $args );
1969 + } else {
1970 + return wp_schedule_single_event( $time, $hook, $args );
1971 + }
1972 + }
1973 +
1974 + /**
1975 + * Check if an action hook is scheduled.
1976 + *
1977 + * @param string $hook The hook to check.
1978 + *
1979 + * @return bool
1980 + */
1981 + public static function is_scheduled( $hook ) {
1982 + if ( function_exists( 'as_has_scheduled_action' ) ) {
1983 + return as_has_scheduled_action( $hook );
1984 + } elseif ( function_exists( 'as_next_scheduled_action' ) ) {
1985 + // For older versions of AS.
1986 + return as_next_scheduled_action( $hook ) !== false;
1987 + } else {
1988 + return wp_next_scheduled( $hook ) !== false;
1989 + }
1990 + }
1991 +
1992 + /**
1993 + * Attempt to acquire the transfer lock for a given action.
1994 + *
1995 + * @param string $action The transfer action ('offload_images'|'rollback_images').
1996 + *
1997 + * @return string|false The lock token on success, false if another worker already holds the lock.
1998 + */
1999 + public static function acquire_transfer_lock( $action ) {
2000 + $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2001 + if ( false !== $lock ) {
2002 + return false;
2003 + }
2004 +
2005 + $token = wp_generate_uuid4();
2006 +
2007 + set_transient(
2008 + self::TRANSFER_LOCK_TRANSIENT,
2009 + [
2010 + 'token' => $token,
2011 + 'action' => $action,
2012 + ],
2013 + self::TRANSFER_LOCK_TTL
2014 + );
2015 +
2016 + return $token;
2017 + }
2018 +
2019 + /**
2020 + * Renew the transfer lock if we still own it, extending its expiration.
2021 + *
2022 + * @param string $token The lock token this worker was given when it started the chain.
2023 + * @param string $action The transfer action currently being processed.
2024 + *
2025 + * @return bool True if we still own the lock and renewed it, false if ownership was lost.
2026 + */
2027 + public static function renew_transfer_lock( $token, $action ) {
2028 + $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2029 +
2030 + if ( ! is_array( $lock ) || ! isset( $lock['token'] ) || $lock['token'] !== $token ) {
2031 + return false;
2032 + }
2033 +
2034 + set_transient(
2035 + self::TRANSFER_LOCK_TRANSIENT,
2036 + [
2037 + 'token' => $token,
2038 + 'action' => $action,
2039 + ],
2040 + self::TRANSFER_LOCK_TTL
2041 + );
2042 +
2043 + return true;
2044 + }
2045 +
2046 + /**
2047 + * Release the transfer lock if we still own it, allowing another worker to acquire it.
2048 + *
2049 + * @param string $token The lock token to release.
2050 + *
2051 + * @return void
2052 + */
2053 + public static function release_transfer_lock( $token ) {
2054 + $lock = get_transient( self::TRANSFER_LOCK_TRANSIENT );
2055 +
2056 + if ( ! is_array( $lock ) || ! isset( $lock['token'] ) || $lock['token'] !== $token ) {
2057 + return;
2058 + }
2059 +
2060 + delete_transient( self::TRANSFER_LOCK_TRANSIENT );
2061 + }
2062 +
2063 + /**
2064 + * Start Processing Images by IDs
2065 + *
2066 + * @param string $action The action for which to get the number of images.
2067 + * @param int $id The images to process.
2068 + *
2069 + * @throws Exception If there is an error.
2070 + * @return void
2071 + */
2072 + public function move_single_image( $action, $id ) {
2073 + set_time_limit( 0 );
2074 +
2075 + // Only use the legacy offloaded attachments to query the pages that need to be updated.
2076 + // We can be confident that these IDs are already marked as offloaded.
2077 + $legacy_offloaded = ! $this->is_new_offloaded_attachment( $id );
2078 + $page_in = [];
2079 + if ( $legacy_offloaded ) {
2080 + $page_in = $action === 'offload_images' ? [] : Optml_Media_Offload::get_posts_by_image_ids( $action, [ $id ] );
2081 + }
2082 + // This will be 0 in the case of offloading now.
2083 + if ( $action === 'rollback_images' && 0 !== count( $page_in ) ) {
2084 + $page = 0;
2085 + do {
2086 + $to_update = Optml_Media_Offload::instance()->update_content( $page, $action, 100, $page_in );
2087 + if ( isset( $to_update['page'] ) ) {
2088 + if ( isset( $to_update['imagesToUpdate'] ) && count( $to_update['imagesToUpdate'] ) ) {
2089 + foreach ( $to_update['imagesToUpdate'] as $post_id => $images ) {
2090 + $images = array_intersect( $images, [ $id ] );
2091 + if ( empty( $images ) ) {
2092 + continue;
2093 + }
2094 + Optml_Media_Offload::instance()->rollback_and_update_images( $images );
2095 + Optml_Media_Offload::instance()->update_page( $post_id );
2096 + }
2097 + }
2098 + }
2099 + $page = $page + 1;
2100 + } while ( ! empty( $to_update['imagesToUpdate'] ) );
2101 +
2102 + } else {
2103 + $action === 'rollback_images' ?
2104 + Optml_Media_Offload::instance()->rollback_images( 1, [ $id ] ) :
2105 + Optml_Media_Offload::instance()->upload_images( 1, [ $id ] );
2106 + }
2107 + if ( empty( $page_in ) && $legacy_offloaded === false ) {
2108 + $meta = self::get_process_meta();
2109 + self::$instance->logger->add_log( $action, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' );
2110 + return;
2111 + }
2112 + }
2113 +
2114 + /**
2115 + * Start Processing Images
2116 + *
2117 + * @param string $action The action for which to get the number of images.
2118 + * @param int $batch The batch of images to process.
2119 + * @param int $page The page of images to process.
2120 + * @param int $total The total number of pages.
2121 + * @param int $step The current step.
2122 + * @param string $lock_token The transfer lock token owned by this processing chain.
2123 + *
2124 + * @return void
2125 + */
2126 + public function start_processing_images( $action, $batch, $page, $total, $step, $lock_token = '' ) {
2127 + $option = 'offload_images' === $action ? 'offloading_status' : 'rollback_status';
2128 + $type = 'offload_images' === $action ? 'offload' : 'rollback';
2129 +
2130 + if ( self::$instance->settings->get( $option ) === 'disabled' ) {
2131 + self::release_transfer_lock( $lock_token );
2132 + return;
2133 + }
2134 +
2135 + // If we don't own the lock anymore, stop processing.
2136 + if ( ! self::renew_transfer_lock( $lock_token, $action ) ) {
2137 + return;
2138 + }
2139 +
2140 + if ( $step > $total || 0 === $total ) {
2141 + $meta = self::get_process_meta();
2142 + self::$instance->logger->add_log( $type, 'Process finished with ' . $meta['count'] . ' items in ' . $meta['time_passed'] . ' minutes.' );
2143 +
2144 + self::$instance->settings->update( $option, 'disabled' );
2145 +
2146 + self::$instance->settings->update( 'show_offload_finish_notice', $type );
2147 +
2148 + // Transfer completed successfully: release the lock.
2149 + self::release_transfer_lock( $lock_token );
2150 +
2151 + return;
2152 + }
2153 +
2154 + set_time_limit( 0 );
2155 +
2156 + try {
2157 + $posts_to_update = $action === 'offload_images' ? [] : Optml_Media_Offload::instance()->update_content( $page, $action, $batch );
2158 +
2159 + // Kept for backward compatibility with old offloading mechanism where pages were modified.
2160 + if ( $action === 'rollback_images' && isset( $posts_to_update['page'] ) && $posts_to_update['page'] > $page ) {
2161 + $page = $posts_to_update['page'];
2162 + if ( isset( $posts_to_update['imagesToUpdate'] ) && count( $posts_to_update['imagesToUpdate'] ) ) {
2163 + foreach ( $posts_to_update['imagesToUpdate'] as $post_id => $images ) {
2164 + Optml_Media_Offload::instance()->rollback_and_update_images( $images );
2165 + Optml_Media_Offload::instance()->update_page( $post_id );
2166 + }
2167 + }
2168 + } else {
2169 + $action === 'rollback_images' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch );
2170 + }
2171 +
2172 + $step = $step + 1;
2173 +
2174 + self::schedule_action(
2175 + time(),
2176 + 'optml_start_processing_images',
2177 + [
2178 + $action,
2179 + $batch,
2180 + $page,
2181 + $total,
2182 + $step,
2183 + $lock_token,
2184 + ]
2185 + );
2186 + } catch ( Exception $e ) {
2187 + // Reschedule the cron to run again after a delay. Sometimes memory limit is exausted.
2188 + // This is a retryable error, so the lock is kept rather than released.
2189 + $delay_in_seconds = 10;
2190 + self::$instance->logger->add_log( $type, $e->getMessage() );
2191 +
2192 + self::schedule_action(
2193 + time() + $delay_in_seconds,
2194 + 'optml_start_processing_images',
2195 + [
2196 + $action,
2197 + $batch,
2198 + $page,
2199 + $total,
2200 + $step,
2201 + $lock_token,
2202 + ]
2203 + );
2204 + }
2205 + }
2206 +
2207 + /**
2208 + * Alter attachment image src for offloaded images.
2209 + *
2210 + * @param array|false $image {
2211 + * Array of image data.
2212 + *
2213 + * @type string $0 Image source URL.
2214 + * @type int $1 Image width in pixels.
2215 + * @type int $2 Image height in pixels.
2216 + * @type bool $3 Whether the image is a resized image.
2217 + * }
2218 + *
2219 + * @param int $attachment_id attachment id.
2220 + * @param string|int[] $size image size.
2221 + * @param bool $icon Whether the image should be treated as an icon.
2222 + *
2223 + * @return array $image.
2224 + */
2225 + public function alter_attachment_image_src( $image, $attachment_id, $size, $icon ) {
2226 + if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
2227 + return $image;
2228 + }
2229 + if ( isset( $image[0] ) ) {
2230 + $url = $image[0];
2231 + } else {
2232 + $url = get_post( $attachment_id );
2233 + $url = $url->guid;
2234 + }
2235 + $metadata = wp_get_attachment_metadata( $attachment_id );
2236 +
2237 + // Use the original size if the requested size is full.
2238 + if ( $size === 'full' || $this->is_attachment_edit_page( $attachment_id ) ) {
2239 + $image_url = $this->get_new_offloaded_attachment_url(
2240 + $url,
2241 + $attachment_id,
2242 + [
2243 + 'width' => $metadata['width'],
2244 + 'height' => $metadata['height'],
2245 + 'attachment_id' => $attachment_id,
2246 + ],
2247 + $metadata
2248 + );
2249 +
2250 + return [
2251 + $image_url,
2252 + $metadata['width'],
2253 + $metadata['height'],
2254 + false,
2255 + ];
2256 + }
2257 +
2258 + if ( doing_action( 'wp_insert_post_data' ) && wp_attachment_is( 'video', $attachment_id ) ) {
2259 + return $image;
2260 + }
2261 + $sizes = $this->size_to_dimension( $size, $metadata );
2262 + $image_url = $this->get_new_offloaded_attachment_url(
2263 + $url,
2264 + $attachment_id,
2265 + [
2266 + 'width' => $sizes['width'],
2267 + 'height' => $sizes['height'],
2268 + 'resize' => $sizes['resize'] ?? [],
2269 + 'attachment_id' => $attachment_id,
2270 + ],
2271 + $metadata
2272 + );
2273 +
2274 + return [
2275 + $image_url,
2276 + $sizes['width'],
2277 + $sizes['height'],
2278 + $size === 'full', // @phpstan-ignore-line
2279 + ];
2280 + }
2281 +
2282 + /**
2283 + * Needed for image sizes inside the editor.
2284 + *
2285 + * @param array $response Array of prepared attachment data. @see wp_prepare_attachment_for_js().
2286 + * @param WP_Post $attachment Attachment object.
2287 + * @param array|false $meta Array of attachment meta data, or false if there is none.
2288 + *
2289 + * @return array
2290 + */
2291 + public function alter_attachment_for_js( $response, $attachment, $meta ) {
2292 + if ( ! $this->is_new_offloaded_attachment( $attachment->ID ) ) {
2293 + return $response;
2294 + }
2295 +
2296 + $meta = [];
2297 + if ( isset( $response['width'] ) ) {
2298 + $meta['width'] = $response['width'];
2299 + }
2300 + if ( isset( $response['height'] ) ) {
2301 + $meta['height'] = $response['height'];
2302 + }
2303 + $sizes = Optml_App_Replacer::image_sizes();
2304 +
2305 + foreach ( $sizes as $size => $args ) {
2306 + if ( isset( $response['sizes'][ $size ] ) ) {
2307 + continue;
2308 + }
2309 + $args = $this->size_to_dimension( $size, $meta );
2310 + $response['sizes'][ $size ] = array_merge(
2311 + $args,
2312 + [
2313 + 'url' => $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $args ),
2314 + 'orientation' => ( $args['height'] > $args['width'] ) ? 'portrait' : 'landscape',
2315 + ]
2316 + );
2317 + }
2318 + $response['url'] = $this->get_new_offloaded_attachment_url( $response['url'], $attachment->ID, $meta );
2319 +
2320 + return $response;
2321 + }
2322 +
2323 + /**
2324 + * Alter attachment metadata.
2325 + *
2326 + * @param array $metadata The attachment metadata.
2327 + * @param int $id The attachment ID.
2328 + *
2329 + * @return array
2330 + */
2331 + public function alter_attachment_metadata( $metadata, $id ) {
2332 + if ( ! $this->is_new_offloaded_attachment( $id ) ) {
2333 + return $metadata;
2334 + }
2335 +
2336 + return $this->get_altered_metadata_for_remote_images( $metadata, $id );
2337 + }
2338 +
2339 + /**
2340 + * Get offloaded image attachment URL for new offloads.
2341 + *
2342 + * @param string $url The initial attachment URL.
2343 + * @param int $attachment_id The attachment ID.
2344 + * @param array $args The additional arguments.
2345 + * - width: The width of the image.
2346 + * - height: The height of the image.
2347 + * - crop: Whether to crop the image.
2348 + * @param array|null $attachment_metadata The attachment metadata.
2349 + * @return string
2350 + */
2351 + private function get_new_offloaded_attachment_url( $url, $attachment_id, $args = [], $attachment_metadata = null ) {
2352 + $process_flag = self::KEYS['not_processed_flag'] . $attachment_id;
2353 +
2354 + // Image might have already passed through this filter.
2355 + if ( strpos( $url, $process_flag ) !== false ) {
2356 + return $url;
2357 + }
2358 + if ( $attachment_metadata === null ) {
2359 + $meta = wp_get_attachment_metadata( $attachment_id );
2360 + } else {
2361 + $meta = $attachment_metadata;
2362 + }
2363 + if ( ! isset( $meta['file'] ) ) {
2364 + return $url;
2365 + }
2366 +
2367 + $default_args = [
2368 + 'width' => 'auto',
2369 + 'height' => 'auto',
2370 + 'resize' => apply_filters( 'optml_default_crop', [] ),
2371 + ];
2372 +
2373 + $args = wp_parse_args( $args, $default_args );
2374 + // If this is not cropped, we constrain the dimensions to the original image.
2375 + if ( empty( $args['resize'] ) && ! in_array( 'auto', [ $args['width'], $args['height'] ], true ) ) {
2376 + $dimensions = wp_constrain_dimensions( $meta['width'], $meta['height'], $args['width'], $args['height'] );
2377 + $args['width'] = $dimensions[0];
2378 + $args['height'] = $dimensions[1];
2379 + }
2380 +
2381 + $file = $meta['file'];
2382 + if ( self::is_uploaded_image( $file ) ) {
2383 + $optimized_url = $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] );
2384 +
2385 + return strpos( $optimized_url, $process_flag ) === false ? str_replace( '/' . ltrim( $file, '/' ), '/' . $process_flag . $file, $optimized_url ) : $optimized_url;
2386 + } else {
2387 + // this is for the users that already offloaded the images before the other fixes
2388 + $local_file = get_attached_file( $attachment_id );
2389 + if ( ! file_exists( $local_file ) ) {
2390 + $duplicated_images = apply_filters( 'optml_offload_duplicated_images', [], $attachment_id );
2391 + if ( is_array( $duplicated_images ) && ! empty( $duplicated_images ) ) {
2392 + foreach ( $duplicated_images as $id ) {
2393 + if ( ! empty( $id ) ) {
2394 + $duplicated_meta = wp_get_attachment_metadata( $id );
2395 + if ( isset( $duplicated_meta['file'] ) && self::is_uploaded_image( $duplicated_meta['file'] ) ) {
2396 + return $this->get_optimized_image_url( $this->get_offloaded_attachment_url( $attachment_id, $url ), $args['width'], $args['height'], $args['resize'] );
2397 + }
2398 + }
2399 + }
2400 + }
2401 + }
2402 + }
2403 + return $url;
2404 + }
2405 +
2406 + /**
2407 + * Replace the URLs in the editor content with the offloaded ones.
2408 + *
2409 + * @param string $content The incoming content.
2410 + *
2411 + * @return string
2412 + */
2413 + public function replace_urls_in_editor_content( $content ) {
2414 + $raw_extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content );
2415 +
2416 + if ( empty( $raw_extracted ) ) {
2417 + return $content;
2418 + }
2419 +
2420 + $to_replace = [];
2421 + foreach ( $raw_extracted as $url ) {
2422 + $attachment = $this->get_local_attachement_id_from_url( $url );
2423 +
2424 + // No local attachment.
2425 + if ( $attachment['attachment_id'] === 0 ) {
2426 + if ( $this->can_replace_url( $url ) ) {
2427 + $to_replace[ $url ] = $this->get_optimized_image_url( $url, 'auto', 'auto' );
2428 + }
2429 + continue;
2430 + }
2431 +
2432 + $attachment_id = $attachment['attachment_id'];
2433 +
2434 + // Not offloaded.
2435 + if ( ! $this->is_new_offloaded_attachment( $attachment_id ) ) {
2436 + continue;
2437 + }
2438 +
2439 + // Get W/H from url.
2440 + $size = $this->parse_dimensions_from_filename( $url );
2441 + $width = $size[0] !== false ? $size[0] : 'auto';
2442 + $height = $size[1] !== false ? $size[1] : 'auto';
2443 +
2444 + // Handle resize.
2445 + $sizes2crop = self::size_to_crop();
2446 + $resize = apply_filters( 'optml_default_crop', [] );
2447 + $sizes = image_get_intermediate_size( $attachment_id, $size );
2448 + if ( false !== $sizes ) {
2449 + if ( isset( $sizes2crop[ $width . $height ] ) ) {
2450 + $resize = $this->to_optml_crop( $sizes2crop[ $width . $height ] );
2451 + }
2452 + }
2453 +
2454 + // Build the optimized URL.
2455 + $optimized_url = $this->get_optimized_image_url( self::KEYS['not_processed_flag'] . $attachment_id . '/' . ltrim( $this->get_offloaded_attachment_url( $attachment_id, $url ), '/' ), $width, $height, $resize );
2456 +
2457 + // Drop any image size from the URL.
2458 + $optimized_url = str_replace( '-' . $width . 'x' . $height, '', $optimized_url );
2459 +
2460 + $to_replace[ $url ] = $optimized_url;
2461 + }
2462 +
2463 + return str_replace( array_keys( $to_replace ), array_values( $to_replace ), $content );
2464 + }
2465 +
2466 + /**
2467 + * Replaces the post content URLs to use Offloaded ones on editor fetch.
2468 + *
2469 + * @param \WP_REST_Response $response The response object.
2470 + * @param \WP_Post $post The post object.
2471 + * @param \WP_REST_Request $request The request object.
2472 + *
2473 + * @return \WP_REST_Response
2474 + */
2475 + public function pre_filter_rest_content( \WP_REST_Response $response, \WP_Post $post, \WP_REST_Request $request ) {
2476 + $context = $request->get_param( 'context' );
2477 +
2478 + if ( $context !== 'edit' ) {
2479 + return $response;
2480 + }
2481 +
2482 + $data = $response->get_data();
2483 +
2484 + // Actually replace all URLs.
2485 + $data['content']['raw'] = $this->replace_urls_in_editor_content( $data['content']['raw'] );
2486 +
2487 + $response->set_data( $data );
2488 +
2489 + return $response;
2490 + }
2491 +
2492 + /**
2493 + * Legacy function to be used for WordPress versions under 6.0.0.
2494 + *
2495 + * @param array $post_data Slashed, sanitized, processed post data.
2496 + * @param array $postarr Slashed sanitized post data.
2497 + * @param array $unsanitized_postarr Un-sanitized post data.
2498 + *
2499 + * @return array
2500 + */
2501 + public function legacy_filter_saved_data( $post_data, $postarr, $unsanitized_postarr ) {
2502 + return $this->filter_saved_data( $post_data, $postarr, $unsanitized_postarr, true );
2503 + }
2504 +
2505 + /**
2506 + * Filter post content to use local attachments when saving offloaded images.
2507 + *
2508 + * @param array $post_data Slashed, sanitized, processed post data.
2509 + * @param array $postarr Slashed sanitized post data.
2510 + * @param array $unsanitized_postarr Un-sanitized post data.
2511 + * @param bool $update Whether this is an existing post being updated or not.
2512 + *
2513 + * @return array
2514 + */
2515 + public function filter_saved_data( $post_data, $postarr, $unsanitized_postarr, $update ) {
2516 + if ( $postarr['post_status'] === 'trash' ) {
2517 + return $post_data;
2518 + }
2519 +
2520 + $content = $post_data['post_content'];
2521 +
2522 + $extracted = Optml_Main::instance()->manager->extract_urls_from_content( $content );
2523 + $replace = [];
2524 +
2525 + foreach ( $extracted as $idx => $url ) {
2526 + $id = self::get_attachment_id_from_url( $url );
2527 +
2528 + if ( $id === false ) {
2529 + continue;
2530 + }
2531 +
2532 + $id = (int) $id;
2533 +
2534 + if ( $this->is_legacy_offloaded_attachment( $id ) ) {
2535 + continue;
2536 + }
2537 +
2538 + $original = self::get_original_url( $id );
2539 +
2540 + if ( $original === false ) {
2541 + continue;
2542 + }
2543 +
2544 + $replace[ $url ] = $original;
2545 +
2546 + $size = $this->parse_dimension_from_optimized_url( $url );
2547 +
2548 + if ( $size[0] === false || $size[1] === false ) {
2549 + continue;
2550 + }
2551 + if ( $size[0] === 'auto' || $size[1] === 'auto' ) {
2552 + continue;
2553 + }
2554 +
2555 + $extension = $this->get_ext( $url );
2556 + $metadata = wp_get_attachment_metadata( $id );
2557 +
2558 + // Is this the full URL.
2559 + if ( $metadata['width'] === (int) $size[0] && $metadata['height'] === (int) $size[1] ) {
2560 + continue;
2561 + }
2562 +
2563 + $size_crop_map = self::size_to_crop();
2564 +
2565 + $crop = false;
2566 +
2567 + if ( isset( $size_crop_map[ $size[0] . $size[1] ] ) ) {
2568 + $crop = $size_crop_map[ $size[0] . $size[1] ];
2569 + }
2570 +
2571 + if ( $crop ) {
2572 + $width = $size[0];
2573 + $height = $size[1];
2574 + } else {
2575 + // In case of an image size, we need to calculate the new dimensions for the proper file path.
2576 + $constrained = wp_constrain_dimensions( $metadata['width'], $metadata['height'], (int) $size[0], (int) $size[1] );
2577 +
2578 + $width = $constrained[0];
2579 + $height = $constrained[1];
2580 + }
2581 + $replace[ $url ] = $this->maybe_strip_scaled( $replace[ $url ] );
2582 +
2583 + $suffix = sprintf( '-%sx%s.%s', $width, $height, $extension );
2584 +
2585 + $replace[ $url ] = str_replace( '.' . $extension, $suffix, $replace[ $url ] );
2586 + }
2587 +
2588 + $post_data['post_content'] = str_replace( array_keys( $replace ), array_values( $replace ), $content );
2589 +
2590 + return $post_data;
2591 + }
2592 +
2593 + /**
2594 + * Alter the image size for the image widget.
2595 + *
2596 + * @param string $html the attachment image HTML string.
2597 + * @param array $settings Control settings.
2598 + * @param string $image_size_key Optional. Settings key for image size.
2599 + * Default is `image`.
2600 + * @param string $image_key Optional. Settings key for image. Default
2601 + * is null. If not defined uses image size key
2602 + * as the image key.
2603 + *
2604 + * @return string
2605 + */
2606 + public function alter_elementor_image_size( $html, $settings, $image_size_key, $image_key ) {
2607 + if ( ! isset( $settings['image'] ) ) {
2608 + return $html;
2609 + }
2610 +
2611 + $image = $settings['image'];
2612 +
2613 + if ( ! isset( $image['id'] ) ) {
2614 + return $html;
2615 + }
2616 +
2617 + if ( ! $this->is_new_offloaded_attachment( $image['id'] ) ) {
2618 + return $html;
2619 + }
2620 +
2621 + if ( ! isset( $settings['image_size'] ) ) {
2622 + return $html;
2623 + }
2624 +
2625 + if ( $settings['image_size'] === 'custom' ) {
2626 + if ( ! isset( $settings['image_custom_dimension'] ) ) {
2627 + return $html;
2628 + }
2629 +
2630 + $custom_dimensions = $settings['image_custom_dimension'];
2631 +
2632 + if ( ! isset( $custom_dimensions['width'] ) || ! isset( $custom_dimensions['height'] ) ) {
2633 + return $html;
2634 + }
2635 +
2636 + $new_args = [
2637 + 'width' => $custom_dimensions['width'],
2638 + 'height' => $custom_dimensions['height'],
2639 + 'resize' => $this->to_optml_crop( true ),
2640 + ];
2641 + $new_url = $this->get_new_offloaded_attachment_url( $image['url'], $image['id'], $new_args );
2642 +
2643 + return str_replace( $image['url'], $new_url, $html );
2644 + }
2645 +
2646 + return $html;
2647 + }
2648 +
2649 + /**
2650 + * Adds new actions for new offloads.
2651 + *
2652 + * @return void
2653 + */
2654 + public function add_new_actions() {
2655 + add_filter( 'wp_prepare_attachment_for_js', [ self::$instance, 'alter_attachment_for_js' ], 999, 3 );
2656 + add_filter( 'wp_get_attachment_metadata', [ self::$instance, 'alter_attachment_metadata' ], 10, 2 );
2657 + add_filter( 'wp_get_attachment_image_src', [ self::$instance, 'alter_attachment_image_src' ], 10, 4 );
2658 +
2659 + // Needed for rendering beaver builder css properly.
2660 + add_filter( 'fl_builder_render_css', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 );
2661 +
2662 + // Filter saved data on insert to use local attachments.
2663 + // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter.
2664 + $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' );
2665 + if ( $below_6_0_0 ) {
2666 + add_filter( 'wp_insert_post_data', [ self::$instance, 'legacy_filter_saved_data' ], 10, 3 );
2667 + } else {
2668 + add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_saved_data' ], 10, 4 );
2669 + }
2670 +
2671 + // Filter loaded data in the editors to use local attachments.
2672 + add_filter( 'content_edit_pre', [ self::$instance, 'replace_urls_in_editor_content' ], 10, 1 );
2673 +
2674 + add_action(
2675 + 'init',
2676 + function () {
2677 + $types = get_post_types_by_support( 'editor' );
2678 +
2679 + foreach ( $types as $type ) {
2680 +
2681 + $post_type = get_post_type_object( $type );
2682 +
2683 + if ( property_exists( $post_type, 'show_in_rest' ) && true === $post_type->show_in_rest ) {
2684 + add_filter( 'rest_prepare_' . $type, [ self::$instance, 'pre_filter_rest_content' ], 10, 3 );
2685 + }
2686 + }
2687 + },
2688 + PHP_INT_MAX
2689 + );
2690 +
2691 + add_filter( 'get_attached_file', [ $this, 'alter_attached_file_response' ], 10, 2 );
2692 + add_filter(
2693 + 'elementor/image_size/get_attachment_image_html',
2694 + [
2695 + $this,
2696 + 'alter_elementor_image_size',
2697 + ],
2698 + 10,
2699 + 4
2700 + );
2701 + }
2702 +
2703 + /**
2704 + * Elementor checks if the file exists before requesting a specific image size.
2705 + *
2706 + * Needed because otherwise there won't be any width/height on the `img` tags, breaking lazyload.
2707 + *
2708 + * Also needed because some
2709 + *
2710 + * @param string $file The file path.
2711 + * @param int $id The attachment ID.
2712 + *
2713 + * @return bool|string
2714 + */
2715 + public function alter_attached_file_response( $file, $id ) {
2716 + if ( ! $this->is_new_offloaded_attachment( $id ) ) {
2717 + return $file;
2718 + }
2719 +
2720 + $metadata = wp_get_attachment_metadata( $id );
2721 +
2722 + if ( isset( $metadata['file'] ) ) {
2723 + $uploads = wp_get_upload_dir();
2724 +
2725 + return $uploads['basedir'] . '/' . $metadata['file'];
2726 + }
2727 +
2728 + return true;
2729 + }
2730 +
2731 + /**
2732 + * Maybe strip the `-scaled` from the URL.
2733 + *
2734 + * @param string $url The url.
2735 + *
2736 + * @return string
2737 + */
2738 + public function maybe_strip_scaled( $url ) {
2739 + $ext = $this->get_ext( $url );
2740 +
2741 + return str_replace( '-scaled.' . $ext, '.' . $ext, $url );
2742 + }
2743 +
2744 + /**
2745 + * Is it a PHPUnit test run.
2746 + *
2747 + * @return bool
2748 + */
2749 + public static function is_phpunit_test() {
2750 + return defined( 'OPTML_PHPUNIT_TESTING' ) && OPTML_PHPUNIT_TESTING === true;
2751 + }
2752 +
2753 + /**
2754 + * Get offloaded image attachment URL based on the given attachment ID and URL.
2755 + *
2756 + * @param mixed $attachment_id The attachment ID.
2757 + * @param string $url The attachment URL.
2758 + *
2759 + * @return string
2760 + */
2761 + private function get_offloaded_attachment_url( $attachment_id, $url ) {
2762 + if ( ! $this->settings->is_offload_enabled() || ! is_numeric( $attachment_id ) ) {
2763 + return $url;
2764 + } elseif ( empty( $attachment_id ) && strpos( $url, self::KEYS['not_processed_flag'] ) !== false ) {
2765 + $attachment_id = (int) self::get_attachment_id_from_url( $url );
2766 + } elseif ( empty( $attachment_id ) ) {
2767 + $attachment_id = $this->attachment_url_to_post_id( $url );
2768 + }
2769 +
2770 + if ( $attachment_id > 0 || ! empty( get_post_meta( $attachment_id, self::OM_OFFLOADED_FLAG, true ) ) ) {
2771 + $url = wp_get_attachment_metadata( $attachment_id )['file'];
2772 + }
2773 +
2774 + return $url;
2775 + }
2776 +
2777 + /**
2778 + * Cleanup the offload errors meta.
2779 + *
2780 + * @param string $meta_key The meta key to delete. Defaults to the offload error key.
2781 + *
2782 + * @return int|bool Number of rows affected/selected or false on error.
2783 + */
2784 + public static function clear_offload_errors_meta( $meta_key = '' ) {
2785 + global $wpdb;
2786 +
2787 + if ( empty( $meta_key ) ) {
2788 + $meta_key = self::META_KEYS['offload_error'];
2789 + }
2790 +
2791 + // Collect the affected attachments before the bulk delete so their object
2792 + // caches can be invalidated. A raw DELETE bypasses the meta/query caches,
2793 + // which would otherwise leave stale WP_Query results for subsequent queries.
2794 + $post_ids = $wpdb->get_col(
2795 + $wpdb->prepare(
2796 + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s",
2797 + $meta_key
2798 + )
2799 + );
2800 +
2801 + $result = $wpdb->query(
2802 + $wpdb->prepare(
2803 + "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
2804 + $meta_key
2805 + )
2806 + );
2807 +
2808 + foreach ( $post_ids as $post_id ) {
2809 + wp_cache_delete( (int) $post_id, 'post_meta' );
2810 + }
2811 +
2812 + // Bump the posts last_changed so cached WP_Query results (which are keyed
2813 + // on it) are recomputed on the next query. The raw DELETE above does not
2814 + // touch the object cache, so without this the retried rollback/offload
2815 + // query could return a stale set that still excludes the cleared posts.
2816 + wp_cache_set( 'last_changed', microtime(), 'posts' );
2817 +
2818 + return $result;
2819 + }
2820 +
2821 + /**
2822 + * Cleanup the rollback errors meta.
2823 + *
2824 + * Used when the user retries the rollback process so previously errored
2825 + * attachments are considered again for restore.
2826 + *
2827 + * @return int|bool Number of rows affected/selected or false on error.
2828 + */
2829 + public static function clear_rollback_errors_meta() {
2830 + return self::clear_offload_errors_meta( self::META_KEYS['rollback_error'] );
1121 2831 }
1122 2832 }