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 +2317 -492 3.0.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 = false;
238 - $width = false;
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.
@@ -276,9 +535,45 @@
276 535 }
277 536
278 537 return $attachment_id;
279 538 }
539 +
280 540 /**
541 + * Get attachment id from url
542 + *
543 + * @param string $url The optimized url .
544 + *
545 + * @return false|mixed The attachment id .
546 + */
547 + public static function get_attachment_id_from_url( $url ) {
548 + preg_match( '/\/' . Optml_Media_Offload::KEYS['not_processed_flag'] . '([^\/]*)\//', $url, $attachment_id );
549 +
550 + return isset( $attachment_id[1] ) ? $attachment_id[1] : false;
551 + }
552 +
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 + /**
281 576 * Filter out the urls that are saved to our servers when saving to the DB.
282 577 *
283 578 * @param array $data The post data array to save.
284 579 *
@@ -287,22 +582,50 @@
287 582 */
288 583 public function filter_uploaded_images( $data ) {
289 584
290 585 $content = trim( wp_unslash( $data['post_content'] ) );
291 - $images = Optml_Manager::parse_images_from_html( $content );
586 + if ( OPTML_DEBUG_MEDIA ) {
587 + do_action( 'optml_log', 'content to update' );
588 + do_action( 'optml_log', $content );
589 + }
590 + $images = Optml_Manager::instance()->extract_urls_from_content( $content );
292 591 if ( ! isset( $images[0] ) ) {
293 592 return $data;
294 593 }
295 - foreach ( $images[0] as $index => $tag ) {
296 - $url = $images['img_url'][ $index ];
297 - $attachment_id = $this->get_id_from_tag( $tag );
298 - if ( false === $attachment_id || ! wp_attachment_is_image( $attachment_id ) ) {
594 + if ( OPTML_DEBUG_MEDIA ) {
595 + do_action( 'optml_log', 'images to update' );
596 + do_action( 'optml_log', $images );
597 + }
598 + foreach ( $images as $url ) {
599 + $is_original_uploaded = self::is_uploaded_image( $url );
600 + $attachment_id = false;
601 + $size = 'thumbnail';
602 + if ( $is_original_uploaded ) {
603 + $found_size = $this->parse_dimension_from_optimized_url( $url );
604 + if ( $found_size[0] !== 'auto' && $found_size[1] !== 'auto' ) {
605 + $size = $found_size;
606 + }
607 + $attachment_id = self::get_attachment_id_from_url( $url );
608 + } else {
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'];
612 + }
613 +
614 + if ( OPTML_DEBUG_MEDIA ) {
615 + do_action( 'optml_log', 'image id and found size' );
616 + do_action( 'optml_log', $attachment_id );
617 + do_action( 'optml_log', $size );
618 + }
619 + if ( false === $attachment_id || ! $this->is_legacy_offloaded_attachment( $attachment_id ) || ! wp_attachment_is_image( $attachment_id ) ) {
299 620 continue;
300 621 }
301 - $is_original_uploaded = self::is_uploaded_image( $url );
302 - $size = $is_original_uploaded ? $this->parse_dimension_from_optimized_url( $url ) : $this->parse_dimensions_from_filename( $url );
622 + $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
623 + if ( OPTML_DEBUG_MEDIA ) {
624 + do_action( 'optml_log', ' image url to replace with ' );
625 + do_action( 'optml_log', $optimized_url );
626 + }
303 627
304 - $optimized_url = wp_get_attachment_image_src( $attachment_id, $size );
305 628 if ( ! isset( $optimized_url[0] ) ) {
306 629 continue;
307 630 }
308 631 if ( $is_original_uploaded === self::is_uploaded_image( $optimized_url[0] ) ) {
@@ -310,94 +633,198 @@
310 633 }
311 634 $content = str_replace( $url, $optimized_url[0], $content );
312 635 }
313 636 $data['post_content'] = wp_slash( $content );
637 +
314 638 return $data;
315 639 }
640 +
316 641 /**
317 - * 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.
318 643 *
319 - * @param int $attachment_id The attachment id of the image to init an update.
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.
320 648 */
321 - public function update_content( $attachment_id ) {
322 - $content = new \WP_Query( [ 's' => 'wp-image-' . $attachment_id, 'fields' => 'ids', 'posts_per_page' => 100 ] );
323 - if ( ! empty( $content->found_posts ) ) {
324 - $content_posts = array_unique( $content->get_posts() );
325 - foreach ( $content_posts as $content_id ) {
326 - wp_update_post( [ 'ID' => $content_id ] ); // existing filters should take care of providing optimized urls
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 );
327 671 }
328 672 }
673 +
674 + return apply_filters( 'optml_content_images_to_update', $found_images, $content );
329 675 }
676 +
330 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 ) {
689 + do_action( 'optml_log', ' updating_content ' );
690 + }
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,
711 + 'update_post_term_cache' => false,
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 + }
740 + }
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 + }
750 + }
751 + }
752 + ++$page;
753 + }
754 + $result['page'] = $page;
755 + $result['imagesToUpdate'] = $images_to_update;
756 +
757 + return $result;
758 + }
759 +
760 + /**
331 761 * Add inline action to push to our servers.
332 762 *
333 763 * @param array $actions All actions.
334 - * @param \WP_Post $post The current post image object.
764 + * @param \WP_Post $post The current post image object.
335 765 *
336 766 * @return array
337 767 */
338 768 public function add_inline_media_action( $actions, $post ) {
339 - $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'];
340 774 if ( wp_check_filetype( $file, Optml_Config::$all_extensions )['ext'] === false || ! current_user_can( 'delete_post', $post->ID ) ) {
341 775 return $actions;
342 776 }
343 - if ( ! self::is_uploaded_image( $file ) ) {
344 - $upload_action_url = add_query_arg(
345 - [
346 - 'action' => 'optimole_upload_image',
347 - 'media[]' => $post->ID,
348 - '_wpnonce' => wp_create_nonce( 'bulk-media' ),
349 - ],
350 - 'upload.php'
351 - );
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 + );
352 788
353 - $actions['optimole_upload_image'] = sprintf(
354 - '<a href="%s" aria-label="%s">%s</a>',
355 - $upload_action_url,
356 - esc_attr__( 'Offload to Optimole', 'optimole-wp' ),
357 - esc_html__( 'Offload to Optimole', 'optimole-wp' )
358 - );
359 - }
360 - if ( self::is_uploaded_image( $file ) ) {
361 - $rollback_action_url = add_query_arg(
362 - [
363 - 'action' => 'optimole_back_to_media',
364 - 'media[]' => $post->ID,
365 - '_wpnonce' => wp_create_nonce( 'bulk-media' ),
366 - ],
367 - 'upload.php'
368 - );
369 - $actions['optimole_back_to_media'] = sprintf(
370 - '<a href="%s" aria-label="%s">%s</a>',
371 - $rollback_action_url,
372 - esc_attr__( 'Restore image to media library', 'optimole-wp' ),
373 - esc_html__( 'Restore image to media library', 'optimole-wp' )
374 - );
375 - }
376 789 return $actions;
377 790 }
791 +
378 792 /**
379 793 * Upload images to our servers and update inside pages.
380 794 *
381 795 * @param array $image_ids The id of the attachments for the selected images.
796 + *
382 797 * @return int The number of successfully processed images.
383 798 */
384 799 public function upload_and_update_existing_images( $image_ids ) {
385 800 $success_up = 0;
801 + if ( OPTML_DEBUG_MEDIA ) {
802 + do_action( 'optml_log', ' images to upload ' );
803 + do_action( 'optml_log', $image_ids );
804 + }
386 805 foreach ( $image_ids as $id ) {
387 806 if ( self::is_uploaded_image( wp_get_attachment_metadata( $id )['file'] ) ) {
388 - $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;
389 811 continue;
390 812 }
391 813
392 814 $meta = $this->generate_image_meta( wp_get_attachment_metadata( $id ), $id );
393 815 if ( isset( $meta['file'] ) && self::is_uploaded_image( $meta['file'] ) ) {
394 -
395 - $success_up ++;
816 + ++$success_up;
396 817 wp_update_attachment_metadata( $id, $meta );
397 - $this->update_content( $id );
398 818 }
399 819 }
820 + if ( $success_up > 0 ) {
821 + if ( OPTML_DEBUG_MEDIA ) {
822 + do_action( 'optml_log', ' call post update, succesful images: ' );
823 + do_action( 'optml_log', $success_up );
824 + }
825 + }
826 +
400 827 return $success_up;
401 828 }
402 829
403 830 /**
@@ -403,60 +830,90 @@
403 830 /**
404 831 * Return the original url of an image attachment.
405 832 *
406 833 * @param integer $post_id Image attachment id.
407 - * @return string The original url of the image.
834 + *
835 + * @return string|bool The original url of the image.
408 836 */
409 837 public static function get_original_url( $post_id ) {
410 838 self::$return_original_url = true;
411 - $original_url = wp_get_attachment_url( $post_id );
839 + $original_url = wp_get_attachment_url( $post_id );
412 840 self::$return_original_url = false;
841 +
413 842 return $original_url;
414 843 }
844 +
415 845 /**
416 846 * Bring images back to media library and update inside pages.
417 847 *
418 848 * @param array $image_ids The id of the attachments for the selected images.
849 + *
419 850 * @return int The number of successfully processed images.
420 851 */
421 852 public function rollback_and_update_images( $image_ids ) {
422 853 $success_back = 0;
854 + if ( OPTML_DEBUG_MEDIA ) {
855 + do_action( 'optml_log', ' images to rollback ' );
856 + do_action( 'optml_log', $image_ids );
857 + }
858 +
423 859 foreach ( $image_ids as $id ) {
860 + // Skip DAM attachment filtering.
861 + if ( $this->is_dam_imported_image( $id ) ) {
862 + continue;
863 + }
424 864 $current_meta = wp_get_attachment_metadata( $id );
425 865 if ( ! isset( $current_meta['file'] ) || ! self::is_uploaded_image( $current_meta['file'] ) ) {
426 - delete_post_meta( $id, 'optimole_offload' );
427 - $success_back++;
866 + delete_post_meta( $id, self::META_KEYS['offloaded'] );
867 + delete_post_meta( $id, self::OM_OFFLOADED_FLAG );
868 + ++$success_back;
428 869 continue;
429 870 }
430 - $table_id = [];
431 - $filename = pathinfo( $current_meta['file'], PATHINFO_BASENAME );
432 - preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $current_meta['file'], $table_id );
433 - 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 ) {
434 878 continue;
435 879 }
436 - $table_id = $table_id[1];
437 - $request = new Optml_Api();
438 - $get_response = $request->call_upload_api( '', 'false', $table_id, 'false', 'true' );
439 880
440 - if ( is_wp_error( $get_response ) || wp_remote_retrieve_response_code( $get_response ) !== 200 ) {
441 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
881 + if ( OPTML_DEBUG_MEDIA ) {
882 + do_action( 'optml_log', ' image cloud id ' );
883 + do_action( 'optml_log', $image_id );
884 + }
885 +
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 ) {
891 + do_action( 'optml_log', ' error get url' );
892 + }
893 +
894 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error getting URL.' );
895 +
442 896 continue;
443 897 }
444 898
445 - $get_url = json_decode( $get_response['body'], true )['getUrl'];
446 -
447 899 if ( ! function_exists( 'download_url' ) ) {
448 900 include_once ABSPATH . 'wp-admin/includes/file.php';
449 901 }
450 902 if ( ! function_exists( 'download_url' ) ) {
451 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
903 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
452 904 continue;
453 905 }
454 906 $timeout_seconds = 60;
455 - $temp_file = download_url( $get_url, $timeout_seconds );
907 + $temp_file = download_url( $image_url, $timeout_seconds );
456 908
457 909 if ( is_wp_error( $temp_file ) ) {
458 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
910 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
911 + if ( OPTML_DEBUG_MEDIA ) {
912 + do_action( 'optml_log', ' download_url error ' );
913 + }
914 +
915 + self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_ROLLBACK, 'Image ID: ' . $id . ' has error downloading URL.' );
459 916 continue;
460 917 }
461 918
462 919 $extension = $this->get_ext( $filename );
@@ -461,9 +918,15 @@
461 918
462 919 $extension = $this->get_ext( $filename );
463 920
464 921 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
465 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
922 + if ( OPTML_DEBUG_MEDIA ) {
923 + do_action( 'optml_log', ' image has invalid extension' );
924 + do_action( 'optml_log', $extension );
925 + }
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.' );
466 929 continue;
467 930 }
468 931
469 932 $type = Optml_Config::$image_extensions [ $extension ];
@@ -476,12 +939,12 @@
476 939 ];
477 940
478 941 $overrides = [
479 942 // do not expect the default form data from normal uploads
480 - 'test_form' => false,
943 + 'test_form' => false,
481 944
482 945 // Setting this to false lets WordPress allow empty files, not recommended.
483 - 'test_size' => true,
946 + 'test_size' => true,
484 947
485 948 // A properly uploaded file will pass this test. There should be no reason to override this one.
486 949 'test_upload' => true,
487 950 ];
@@ -489,16 +952,21 @@
489 952 if ( ! function_exists( 'wp_handle_sideload' ) ) {
490 953 include_once ABSPATH . '/wp-admin/includes/file.php';
491 954 }
492 955 if ( ! function_exists( 'wp_handle_sideload' ) ) {
493 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
956 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
494 957 continue;
495 958 }
496 959
497 960 // Move the temporary file into the uploads directory.
498 - $results = wp_handle_sideload( $file, $overrides );
961 + $results = wp_handle_sideload( $file, $overrides, get_the_date( 'Y/m', $id ) );
499 962 if ( ! empty( $results['error'] ) ) {
500 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
963 + if ( OPTML_DEBUG_MEDIA ) {
964 + do_action( 'optml_log', ' wp_handle_sideload error' );
965 + }
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.' );
501 969 continue;
502 970 }
503 971
504 972 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
@@ -504,23 +972,23 @@
504 972 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
505 973 include_once ABSPATH . '/wp-admin/includes/image.php';
506 974 }
507 975 if ( ! function_exists( 'wp_create_image_subsizes' ) ) {
508 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
976 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
509 977 continue;
510 978 }
511 - wp_create_image_subsizes( $results['file'], $id );
979 + $original_meta = wp_create_image_subsizes( $results['file'], $id );
512 980 if ( $type === 'image/svg+xml' ) {
513 981 if ( ! function_exists( 'wp_get_attachment_metadata' ) || ! function_exists( 'wp_update_attachment_metadata' ) ) {
514 982 include_once ABSPATH . '/wp-admin/includes/post.php';
515 983 }
516 984 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
517 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
985 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
518 986 continue;
519 987 }
520 988 $meta = wp_get_attachment_metadata( $id );
521 989 if ( ! isset( $meta['file'] ) ) {
522 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
990 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
523 991 continue;
524 992 }
525 993 $meta['file'] = $results['file'];
526 994 wp_update_attachment_metadata( $id, $meta );
@@ -529,20 +997,50 @@
529 997 if ( ! function_exists( 'update_attached_file' ) ) {
530 998 include_once ABSPATH . '/wp-admin/includes/post.php';
531 999 }
532 1000 if ( ! function_exists( 'update_attached_file' ) ) {
533 - update_post_meta( $id, 'optimole_rollback_error', 'true' );
1001 + update_post_meta( $id, self::META_KEYS['rollback_error'], 'true' );
534 1002 continue;
535 1003 }
536 1004 update_attached_file( $id, $results['file'] );
537 - $success_back++;
538 - $this->update_content( $id );
539 - $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 );
540 1030 if ( $original_url === false ) {
541 1031 continue;
542 1032 }
543 - $this->delete_attachment_from_server( $original_url, $id, $table_id );
1033 + $this->delete_attachment_from_server( $original_url, $id, $image_id );
544 1034 }
1035 +
1036 + if ( $success_back > 0 ) {
1037 + if ( OPTML_DEBUG_MEDIA ) {
1038 + do_action( 'optml_log', ' call update post, success rollback' );
1039 + do_action( 'optml_log', $success_back );
1040 + }
1041 + }
1042 +
545 1043 return $success_back;
546 1044 }
547 1045
548 1046 /**
@@ -547,150 +1045,58 @@
547 1045
548 1046 /**
549 1047 * Handle the bulk actions.
550 1048 *
551 - * @param string $redirect The current url from the media library.
552 - * @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.
553 1051 * @param array $image_ids The id of the attachments for the selected images.
1052 + *
554 1053 * @return string The url with the correspondent query args for the executed actions.
555 1054 */
556 1055 public function bulk_action_handler( $redirect, $doaction, $image_ids ) {
1056 +
557 1057 if ( empty( $image_ids ) ) {
558 1058 return $redirect;
559 1059 }
560 - $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 );
561 1060
562 - if ( $doaction === 'optimole_upload_image' ) {
563 - if ( count( $image_ids ) > 5 ) {
564 - $redirect = add_query_arg( 'optimole_offload_images_extra', 'true', $redirect );
565 - $image_ids = array_slice( $image_ids, 0, 5, true );
566 - }
567 - $success_up = $this->upload_and_update_existing_images( $image_ids );
568 - $redirect = add_query_arg( 'optimole_offload_images_succes', $success_up, $redirect );
569 - $failed_up = count( $image_ids ) - $success_up;
570 - if ( $failed_up > 0 ) {
571 - $redirect = add_query_arg( 'optimole_offload_images_failed', $failed_up, $redirect );
572 - }
573 - }
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 );
574 1066
575 - if ( $doaction === 'optimole_back_to_media' ) {
576 - if ( count( $image_ids ) > 5 ) {
577 - $redirect = add_query_arg( 'optimole_rollback_images_extra', 'true', $redirect );
578 - $image_ids = array_slice( $image_ids, 0, 5, true );
579 - }
580 - $success_back = $this->rollback_and_update_images( $image_ids );
581 - $failed_down = count( $image_ids ) - $success_back;
582 - if ( $failed_down > 0 ) {
583 - $redirect = add_query_arg( 'optimole_back_to_media_failed', $failed_down, $redirect );
584 - }
585 - $redirect = add_query_arg( 'optimole_back_to_media_success', $success_back, $redirect );
586 - }
587 1067 return $redirect;
1068 + }
588 1069
589 - }
590 1070 /**
591 1071 * Register the bulk media actions.
592 1072 *
593 - * @param array $bulk_array The existing actions array.
594 - * @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.
595 1076 */
596 1077 public function register_bulk_media_actions( $bulk_array ) {
597 1078
598 - $bulk_array['optimole_upload_image'] = __( 'Push Image to Optimole', 'optimole-wp' );
599 - $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 +
600 1082 return $bulk_array;
601 -
602 1083 }
603 - /**
604 - * Register the possible notices for the media bulk actions.
605 - */
606 - public function bulk_action_notices() {
607 - if ( ! empty( $_REQUEST['optimole_offload_images_extra'] ) ) {
608 1084
609 - printf(
610 - '<div id="message" class="updated notice is-dismissible"><p>' .
611 - __( 'You can not offload more than 5 images at once from this menu.', 'optimole-wp' ) . '</p></div>'
612 - );
613 -
614 - }
615 - if ( ! empty( $_REQUEST['optimole_rollback_images_extra'] ) ) {
616 -
617 - printf(
618 - '<div id="message" class="updated notice is-dismissible"><p>' .
619 - __( 'You can not move back to library more than 5 images at once from this menu.', 'optimole-wp' ) . '</p></div>'
620 - );
621 -
622 - }
623 - if ( ! empty( $_REQUEST['optimole_offload_images_succes'] ) ) {
624 -
625 - printf(
626 - '<div id="message" class="updated notice is-dismissible"><p>' .
627 - _n(
628 - '%s image was stored on our server.',
629 - '%s images were stored on our servers.',
630 - intval( $_REQUEST['optimole_offload_images_succes'] ),
631 - 'optimole-wp'
632 - ) . '</p></div>',
633 - intval( $_REQUEST['optimole_offload_images_succes'] )
634 - );
635 -
636 - }
637 - if ( ! empty( $_REQUEST['optimole_offload_images_failed'] ) ) {
638 - printf(
639 - '<div id="message" class="updated notice is-dismissible"><p>' .
640 - _n(
641 - '%s image failed while uploading to our servers, please try again later.',
642 - '%s images failed while uploading to our servers, please try again later',
643 - intval( $_REQUEST['optimole_offload_images_failed'] ) .
644 - 'optimole-wp'
645 - ) . '</p></div>',
646 - intval( $_REQUEST['optimole_offload_images_failed'] )
647 - );
648 -
649 - }
650 - if ( ! empty( $_REQUEST['optimole_back_to_media_success'] ) ) {
651 - printf(
652 - '<div id="message" class="updated notice is-dismissible"><p>' .
653 - _n(
654 - '%s image was stored back on media library.',
655 - '%s images were stored back on media library.',
656 - intval( $_REQUEST['optimole_back_to_media_success'] ),
657 - 'optimole-wp'
658 - ) . '</p></div>',
659 - intval( $_REQUEST['optimole_back_to_media_success'] )
660 - );
661 -
662 - }
663 - if ( ! empty( $_REQUEST['optimole_back_to_media_failed'] ) ) {
664 - printf(
665 - '<div id="message" class="updated notice is-dismissible"><p>' .
666 - _n(
667 - '%s image failed while restoring to media library, please try again later.',
668 - '%s images failed while restoring to media library, please try again later',
669 - intval( $_REQUEST['optimole_back_to_media_failed'] ) .
670 - 'optimole-wp'
671 - ) . '</p></div>',
672 - intval( $_REQUEST['optimole_back_to_media_failed'] )
673 - );
674 - }
675 - }
676 -
677 1085 /**
678 1086 * Send delete request to our servers and update the meta.
679 1087 *
680 1088 * @param string $original_url Original url of the image.
681 1089 * @param integer $post_id Image id inside db.
682 - * @param string $table_id Our cloud id for the image.
1090 + * @param string $image_id Our cloud id for the image.
683 1091 */
684 - public function delete_attachment_from_server( $original_url, $post_id, $table_id ) {
685 - $request = new Optml_Api();
686 - $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 );
687 1094
688 - delete_post_meta( $post_id, 'optimole_offload' );
689 - if ( is_wp_error( $delete_response ) || wp_remote_retrieve_response_code( $delete_response ) !== 200 ) {
690 - // should add some routine to retry delete once if delete fails
691 - }
1095 + delete_post_meta( $post_id, self::META_KEYS['offloaded'] );
1096 + delete_post_meta( $post_id, self::OM_OFFLOADED_FLAG );
692 1097 }
1098 +
693 1099 /**
694 1100 * Delete an image from our servers after it is removed from media.
695 1101 *
696 1102 * @param int $post_id The deleted post id.
@@ -696,15 +1102,24 @@
696 1102 * @param int $post_id The deleted post id.
697 1103 */
698 1104 public function delete_attachment_hook( $post_id ) {
699 1105 $file = wp_get_attachment_metadata( $post_id );
700 - if ( $file === false || ! isset( $file['file'] ) ) {
1106 + if ( $file === false ) {
701 1107 return;
702 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 +
703 1119 $file = $file['file'];
704 - if ( self::is_uploaded_image( $file ) ) {
705 -
706 - $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 );
707 1122 if ( $original_url === false ) {
708 1123 return;
709 1124 }
710 1125 $table_id = [];
@@ -711,11 +1126,10 @@
711 1126
712 1127 preg_match( '/\/' . self::KEYS['uploaded_flag'] . '([^\/]*)\//', $file, $table_id );
713 1128
714 1129 if ( ! isset( $table_id[1] ) ) {
715 - return;
1130 + return;
716 1131 }
717 -
718 1132 $this->delete_attachment_from_server( $original_url, $post_id, $table_id[1] );
719 1133 }
720 1134 }
721 1135
@@ -723,8 +1137,9 @@
723 1137 * Get optimized URL for an attachment image if it is uploaded to our servers.
724 1138 *
725 1139 * @param string $url The current url.
726 1140 * @param int $attachment_id The attachment image id.
1141 + *
727 1142 * @return string Optimole cdn URL.
728 1143 * @uses filter:wp_get_attachment_url
729 1144 */
730 1145 public function get_image_attachment_url( $url, $attachment_id ) {
@@ -730,66 +1145,109 @@
730 1145 public function get_image_attachment_url( $url, $attachment_id ) {
731 1146 if ( self::$return_original_url === true ) {
732 1147 return $url;
733 1148 }
734 - $meta = wp_get_attachment_metadata( $attachment_id );
735 - 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 + }
736 1181 return $url;
737 1182 }
738 - $file = $meta['file'];
739 - if ( self::is_uploaded_image( $file ) ) {
740 - $optimized_url = ( new Optml_Image( $url, ['width' => 'auto', 'height' => 'auto', 'quality' => $this->settings->get_numeric_quality()], $this->settings->get( 'cache_buster' ) ) )->get_url();
741 - 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;
742 1186 }
743 - return $url;
1187 +
1188 + return $this->get_new_offloaded_attachment_url( $url, $attachment_id );
744 1189 }
1190 +
745 1191 /**
746 1192 * Filter the requested image url.
747 1193 *
748 - * @param null $image The previous image value (null).
1194 + * @param bool|array $image The previous image value (null).
749 1195 * @param int $attachment_id The ID of the attachment.
750 - * @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).
751 1197 *
752 - * @return array The image sizes and optimized url.
1198 + * @return bool|array The image sizes and optimized url.
753 1199 * @uses filter:image_downsize
754 1200 */
755 1201 public function generate_filter_downsize_urls( $image, $attachment_id, $size ) {
756 - if ( self::$return_original_url === true ) {
1202 + if ( $this->is_dam_imported_image( $attachment_id ) ) {
757 1203 return $image;
758 1204 }
759 - $sizes2crop = self::size_to_crop();
760 - if ( wp_attachment_is( 'video', $attachment_id ) && doing_action( 'wp_insert_post_data' ) ) {
761 - 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 + ];
762 1237 }
763 - $data = image_get_intermediate_size( $attachment_id, $size );
764 - 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 ) ) {
765 1240 return $image;
766 1241 }
767 - $resize = apply_filters( 'optml_default_crop', [] );
768 - if ( isset( $sizes2crop[ $data['width'] . $data['height'] ] ) ) {
769 - $resize = $this->to_optml_crop( $sizes2crop[ $data['width'] . $data['height'] ] );
770 - }
771 - $id_filename = [];
772 1242
773 - preg_match( '/\/(' . self::KEYS['not_processed_flag'] . '.*)/', $data['url'], $id_filename );
774 - if ( ! isset( $id_filename[1] ) ) {
775 - return $image;
776 - }
777 - $url = self::get_original_url( $attachment_id );
778 - $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();
779 - $optimized_url = str_replace( $url, $id_filename[1], $optimized_url );
780 - $image = [
781 - $optimized_url,
782 - $data['width'],
783 - $data['height'],
784 - true,
785 - ];
786 - return $image;
1243 + return $this->alter_attachment_image_src( $image, $attachment_id, $size, false );
787 1244 }
1245 +
788 1246 /**
789 1247 * Get image extension.
790 1248 *
791 - * @param string $path Image path.
1249 + * @param string $path Image path.
792 1250 *
793 1251 * @return string
794 1252 */
795 1253 private function get_ext( $path ) {
@@ -794,12 +1252,36 @@
794 1252 */
795 1253 private function get_ext( $path ) {
796 1254 return pathinfo( $path, PATHINFO_EXTENSION );
797 1255 }
1256 +
798 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 + /**
799 1281 * Update image meta with optimized cdn path.
800 1282 *
801 - * @param array $meta Meta information of the image.
1283 + * @param array $meta Meta information of the image.
802 1284 * @param int $attachment_id The image attachment ID.
803 1285 *
804 1286 * @return array
805 1287 * @uses filter:wp_generate_attachment_metadata
@@ -805,177 +1287,372 @@
805 1287 * @uses filter:wp_generate_attachment_metadata
806 1288 */
807 1289 public function generate_image_meta( $meta, $attachment_id ) {
808 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.
809 1303 if ( ! isset( $meta['file'] ) || ! isset( $meta['width'] ) || ! isset( $meta['height'] ) || self::is_uploaded_image( $meta['file'] ) ) {
810 - 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 +
811 1310 return $meta;
812 1311 }
1312 + // Skip images based on filters.
813 1313 if ( false === Optml_Filters::should_do_image( $meta['file'], self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_FILENAME ] ) ) {
814 - 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 +
815 1317 return $meta;
816 1318 }
817 - $original_url = self::get_original_url( $attachment_id );
1319 + $original_url = self::get_original_url( $attachment_id );
818 1320
1321 + // Could not find original URL.
819 1322 if ( $original_url === false ) {
820 - 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 +
821 1328 return $meta;
822 1329 }
823 1330
824 - $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 + }
825 1357 if ( ! file_exists( $local_file ) ) {
826 - 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 +
827 1364 return $meta;
828 1365 }
829 - $extension = $this->get_ext( $local_file );
830 1366
831 1367 if ( ! isset( Optml_Config::$image_extensions [ $extension ] ) ) {
832 - 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 +
833 1374 return $meta;
834 1375 }
835 1376 if ( false === Optml_Filters::should_do_extension( self::$filters[ Optml_Settings::FILTER_TYPE_OPTIMIZE ][ Optml_Settings::FILTER_EXT ], $extension ) ) {
836 - 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 +
837 1381 return $meta;
838 1382 }
839 1383
840 - $content_type = Optml_Config::$image_extensions [ $extension ];
841 - $temp = explode( '/', $local_file );
842 - $file_name = end( $temp );
1384 + $offload_manager = Optimole::offload();
1385 + $offload_usage = $offload_manager->getUsage();
843 1386
844 - $request = new Optml_Api();
845 - $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;
846 1389
847 - if ( is_wp_error( $generate_url_response ) || wp_remote_retrieve_response_code( $generate_url_response ) !== 200 ) {
848 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
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 );
1394 + }
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 +
849 1400 return $meta;
850 1401 }
851 - $decoded_response = json_decode( $generate_url_response['body'], true );
852 1402
853 - if ( ! isset( $decoded_response['tableId'] ) || ! isset( $decoded_response['uploadUrl'] ) ) {
854 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
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 );
1409 + }
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 +
855 1423 return $meta;
856 - }
857 - $table_id = $decoded_response['tableId'];
858 - $upload_signed_url = $decoded_response['uploadUrl'];
859 - $image = file_get_contents( $local_file );
860 - if ( $image === false ) {
861 - 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 +
862 1434 return $meta;
863 - }
864 - 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 + }
865 1440
866 - $request = new Optml_Api();
867 - $result = $request->upload_image( $upload_signed_url, $content_type, $image );
1441 + update_post_meta( $attachment_id, self::META_KEYS['offload_error'], 'true' );
868 1442
869 - if ( is_wp_error( $result ) || wp_remote_retrieve_response_code( $result ) !== 200 ) {
870 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
871 - 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 );
872 1450 }
873 - $file_size = filesize( $local_file );
874 - if ( $file_size === false ) {
875 - $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 );
876 1462 }
877 - $request = new Optml_Api();
878 - $result_update = $request->call_upload_api(
879 - $original_url,
880 - 'false',
881 - $table_id,
882 - 'success',
883 - 'false',
884 - $meta['width'],
885 - $meta['height'],
886 - $file_size
887 - );
888 - if ( is_wp_error( $result_update ) || wp_remote_retrieve_response_code( $result_update ) !== 200 ) {
889 - update_post_meta( $attachment_id, 'optimole_offload_error', 'true' );
890 - 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 );
891 1471 }
1472 +
1473 + self::mark_retryable_error( $attachment_id, 'Unknown error from upload api: ' . $exception->getMessage() );
1474 +
1475 + return $meta;
892 1476 }
1477 +
893 1478 $url_to_append = $original_url;
894 - $url_parts = parse_url( $original_url );
1479 + $url_parts = parse_url( $original_url );
1480 +
895 1481 if ( isset( $url_parts['scheme'] ) && isset( $url_parts['host'] ) ) {
896 1482 $url_to_append = $url_parts['scheme'] . '://' . $url_parts['host'] . '/' . $file_name;
897 1483 }
898 - $optimized_url = $this->get_media_optimized_url( $url_to_append, $table_id );
899 - $request = new Optml_Api();
900 - if ( $request->check_optimized_url( $optimized_url ) === false ) {
901 - $request->call_upload_api( $original_url, 'true', $table_id );
902 - 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 +
903 1497 return $meta;
904 1498 }
905 - file_exists( $local_file ) && unlink( $local_file );
906 - update_post_meta( $attachment_id, 'optimole_offload', 'true' );
907 - $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 +
908 1507 if ( isset( $meta['sizes'] ) ) {
909 1508 foreach ( $meta['sizes'] as $key => $value ) {
910 - $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 );
911 1510 file_exists( $generated_image_size_path ) && unlink( $generated_image_size_path );
912 1511 $meta['sizes'][ $key ]['file'] = $file_name;
913 1512 }
914 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 +
915 1555 return $meta;
916 1556 }
917 1557
918 - /** Get the args for wp query according to the scope.
1558 + /**
1559 + * Get the args for wp query according to the scope.
919 1560 *
920 1561 * @param int $batch Number of images to get.
921 1562 * @param string $action The action for which to get the images.
1563 + *
922 1564 * @return array|false The query options array or false if not passed a valid action.
923 1565 */
924 - 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 +
925 1568 $args = [
926 - 'post_type' => 'attachment',
927 - 'post_mime_type' => [ 'image' ],
928 - 'post_status' => 'inherit',
929 1569 'posts_per_page' => $batch,
930 1570 'fields' => 'ids',
931 1571 'ignore_sticky_posts' => false,
932 1572 'no_found_rows' => true,
933 1573 ];
934 - 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.
935 1598 $args['meta_query'] = [
936 1599 'relation' => 'AND',
937 1600 [
938 - 'key' => 'optimole_offload',
939 - 'compare' => 'NOT EXISTS',
1601 + 'key' => self::META_KEYS['offloaded'],
1602 + 'value' => 'true',
1603 + 'compare' => '=',
940 1604 ],
941 1605 [
942 - 'key' => 'optimole_offload_error',
1606 + 'key' => self::META_KEYS['rollback_error'],
943 1607 'compare' => 'NOT EXISTS',
944 1608 ],
945 - ];
946 - return $args;
947 - }
948 - if ( $action === 'rollback_images' ) {
949 - $args['meta_query'] = [
950 - 'relation' => 'AND',
951 1609 [
952 - 'key' => 'optimole_offload',
953 - 'value' => 'true',
954 - 'compare' => '=',
955 - ],
956 - [
957 - 'key' => 'optimole_rollback_error',
1610 + 'key' => Optml_Dam::OM_DAM_IMPORTED_FLAG,
958 1611 'compare' => 'NOT EXISTS',
959 1612 ],
960 1613 ];
1614 +
961 1615 return $args;
962 1616 }
963 - 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;
964 1633 }
1634 +
965 1635 /**
966 1636 * Query the database and upload images to our servers.
967 1637 *
968 1638 * @param int $batch Number of images to process in a batch.
1639 + *
969 1640 * @return array Number of found images and number of successfully processed images.
970 1641 */
971 - public static function upload_images( $batch ) {
972 - $args = self::get_images_query_args( $batch, 'offload_images' );
973 - $attachments = new \WP_Query( $args );
974 - $ids = $attachments->get_posts();
975 - $media_offload = new Optml_Media_Offload();
976 - $result = [ 'found_images' => count( $ids ) ];
977 - $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 +
978 1655 return $result;
979 1656 }
980 1657
981 1658 /**
@@ -981,27 +1658,1175 @@
981 1658 /**
982 1659 * Query the database and bring back image to media library.
983 1660 *
984 1661 * @param int $batch Number of images to process in a batch.
1662 + *
985 1663 * @return array Number of found images and number of successfully processed images.
986 1664 */
987 - public static function rollback_images( $batch ) {
988 - $args = self::get_images_query_args( $batch, 'rollback_images' );
989 - $attachments = new \WP_Query( $args );
990 - $ids = $attachments->get_posts();
991 - $media_offload = new Optml_Media_Offload();
992 - $result = [ 'found_images' => count( $ids ) ];
993 - $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 +
994 1676 return $result;
995 1677 }
1678 +
996 1679 /**
997 - * 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.
998 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 + *
999 1701 * @param string $action The actions for which to get the number of images.
1702 + *
1000 1703 * @return int Number of images.
1001 1704 */
1002 - public static function number_of_library_images( $action ) {
1003 - $args = self::get_images_query_args( -1, $action );
1004 - $attachments = new \WP_Query( $args );
1005 - 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'] );
1006 2831 }
1007 2832 }