diff --git a/docs/hooks/index.md b/docs/hooks/index.md index a978adfb..a1fe8c72 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -6,6 +6,8 @@ The plugin provides several hooks to let you extend or modify its behavior. - [updated_tiny_postmeta](updated_tiny_postmeta.md) — Triggered when tinify meta data has been updated - [tiny_image_after_compression](tiny_image_after_compression.md) — Triggered after successful optimization. +- [tiny_image_size_before_compression](tiny_image_size_before_compression.md) — Triggered before optimizing an image size. + ## Filters diff --git a/docs/hooks/tiny_image_size_before_compression.md b/docs/hooks/tiny_image_size_before_compression.md new file mode 100644 index 00000000..514b1c65 --- /dev/null +++ b/docs/hooks/tiny_image_size_before_compression.md @@ -0,0 +1,23 @@ +# tiny_image_size_before_compression + +Action that is done before compressing an single image size. + +**Location:** `src/class-tiny-image.php` +**Since:** 3.7.0 + +## Arguments + +1. `int $attachment_id` - The attachment ID. +2. `int|string $size_name` - The image size name. 0 for the original. +3. `string $filepath` - The file path to the image being compressed. + +## Example + +```php +add_filter( + 'tiny_image_size_before_compression', + function ( $attachment_id, $size_name, $filename ) { + // notify system of compression + } +); +``` diff --git a/src/class-tiny-compress.php b/src/class-tiny-compress.php index d17f55c9..236e8880 100644 --- a/src/class-tiny-compress.php +++ b/src/class-tiny-compress.php @@ -96,7 +96,7 @@ public function get_status() { /** * Compresses a single file * - * @param [type] $file + * @param string $file path to file * @param array $resize_opts * @param array $preserve_opts * @param array{ string } conversion options diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index a586f430..f82b4403 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -221,6 +221,23 @@ public function compress() { if ( ! $size->is_duplicate() ) { $size->add_tiny_meta_start(); $this->update_tiny_post_meta(); + + /** + * Fires before an image size is sent for compression. + * + * @since 3.6.8 + * + * @param int $attachment_id The attachment ID. + * @param int|string $size_name The image size name. 0 for the original. + * @param string $filepath The file path to the image being compressed. + */ + do_action( + 'tiny_image_size_before_compression', + $this->id, + $size_name, + $size->filename + ); + $resize = $this->settings->get_resize_options( $size_name ); $preserve = $this->settings->get_preserve_options( $size_name ); Tiny_Logger::debug( diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index 2236332d..8484c034 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -67,6 +67,13 @@ public function init() { add_action( 'delete_attachment', $this->get_method( 'clean_attachment' ), 10, 2 ); + add_action( + 'tiny_image_size_before_compression', + $this->get_method( 'backup_image_size' ), + 10, + 3 + ); + load_plugin_textdomain( self::NAME, false, @@ -861,6 +868,43 @@ public function clean_attachment( $post_id ) { $tiny_image->delete_converted_image(); } + /** + * Creates a backup of an image size before compression. + * + * Hooked to the `tiny_image_size_before_compression` action. Only creates + * a backup for the original image size when the backup setting is enabled. + * The backup is stored under {upload_dir}/tinify_backup/, preserving the + * original path structure relative to the uploads base directory. + * + * @since 3.6.8 + * + * @param int $image_id The attachment ID. + * @param int|string $size_name The image size name. 0 for the original. + * @param string $filepath The file path to the image to be backed up. + * @return bool return true on backup created + */ + public function backup_image_size( $image_id, $size_name, $filepath ) { + if ( ! Tiny_Image::is_original( $size_name ) ) { + return false; + } + + if ( ! $this->settings->get_backup_enabled() ) { + return false; + } + + $upload_dir = wp_upload_dir(); + $upload_base = trailingslashit( $upload_dir['basedir'] ); + $relative_path = ltrim( str_replace( $upload_base, '', $filepath ), '/' ); + $backup_file = $upload_base . 'tinify_backup/' . $relative_path; + $backup_dir = dirname( $backup_file ); + + if ( ! wp_mkdir_p( $backup_dir ) ) { + return false; + } + + return copy( $filepath, $backup_file ); + } + public static function request_review() { $review_url = 'https://wordpress.org/support/plugin/tiny-compress-images/reviews/#new-post'; diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 290b8b0d..fa5bbe49 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -123,6 +123,9 @@ public function admin_init() { $field = self::get_prefixed_name( 'resize_original' ); register_setting( 'tinify', $field ); + $field = self::get_prefixed_name( 'backup' ); + register_setting( 'tinify', $field ); + $field = self::get_prefixed_name( 'preserve_data' ); register_setting( 'tinify', $field ); @@ -305,6 +308,16 @@ public function new_plugin_install() { return ! $compression_timing; } + public function get_backup_enabled() { + $sizes = $this->get_sizes(); + if ( ! $sizes[ Tiny_Image::ORIGINAL ]['tinify'] ) { + return false; + } + + $setting = get_option( self::get_prefixed_name( 'backup' ) ); + return isset( $setting['enabled'] ) && 'on' === $setting['enabled']; + } + public function get_resize_enabled() { /* This only applies if the original is being resized. */ $sizes = $this->get_sizes(); @@ -343,6 +356,12 @@ public function get_preserve_enabled( $name ) { return isset( $setting[ $name ] ) && 'on' === $setting[ $name ]; } + /** + * Retrieves the preserve options for the original image + * + * @param string - size name + * @return false|array false if size is not original, otherwise array of preserved keys + */ public function get_preserve_options( $size_name ) { if ( ! Tiny_Image::is_original( $size_name ) ) { return false; diff --git a/src/views/settings-original-image.php b/src/views/settings-original-image.php index 261dd8d9..872055a3 100644 --- a/src/views/settings-original-image.php +++ b/src/views/settings-original-image.php @@ -68,6 +68,28 @@ +

+ get_backup_enabled(); + ?> + /> + +

+ render_preserve_input( 'creation', diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index ff1b2c0c..c9821d38 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -282,6 +282,14 @@ public function createImage($file_size, $path, $name) ->at($dir); } + /** + * Creates images on the virtual disk for testing + * @param null|array $sizes Array of size => bytes to create, file will be named $name-$size.png + * @param int $original_size Bytes of image + * @param string $path Path to image + * @param string $name Name of the image + * @return void + */ public function createImages($sizes = null, $original_size = 12345, $path = '14/01', $name = 'test') { vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs); @@ -309,6 +317,13 @@ public function createImagesFromJSON($virtual_images) } } + /** + * creates image meta data for testing + * + * @param string $path directory of the file in UPLOAD_DIR + * @param string $name name of the file without extension + * @return array object containing metadata + */ public function getTestMetadata($path = '14/01', $name = 'test') { $metadata = array( @@ -338,7 +353,7 @@ public function getTestMetadata($path = '14/01', $name = 'test') */ public static function assertHook($hookname, $expected_args = null) { - $hooks = array('add_action', 'add_filter'); + $hooks = array('add_action', 'add_filter', 'do_action', 'apply_filters'); $found = false; foreach ($hooks as $method) { @@ -401,7 +416,7 @@ public function current_time() */ public function wp_mkdir_p($dir) { - mkdir($dir, 0755, true); + return mkdir($dir, 0755, true) || is_dir($dir); } /** diff --git a/test/unit/TinyImageTest.php b/test/unit/TinyImageTest.php index f70f3b4a..03b8bd3e 100644 --- a/test/unit/TinyImageTest.php +++ b/test/unit/TinyImageTest.php @@ -306,4 +306,5 @@ public function test_conversion_same_mimetype() // second call should be only with image/webp because first call was a image/webp $this->assertEquals(array('image/webp'), $compress_calls[1]['convert_to']); } + } diff --git a/test/unit/TinyPluginTest.php b/test/unit/TinyPluginTest.php index f99cd7e2..05523e46 100644 --- a/test/unit/TinyPluginTest.php +++ b/test/unit/TinyPluginTest.php @@ -3,7 +3,9 @@ require_once dirname(__FILE__) . '/TinyTestCase.php'; use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\content\LargeFileContent; + +use function PHPUnit\Framework\assertFalse; +use function PHPUnit\Framework\assertTrue; class Tiny_Plugin_Test extends Tiny_TestCase { @@ -495,4 +497,38 @@ public function test_conversion_enabled_and_not_filtered() WordPressStubs::assertHook('template_redirect', array($tiny_picture, 'on_template_redirect')); } + + public function test_init_adds_backup_image_size_action() { + $tiny_plugin = new Tiny_Plugin(); + $tiny_plugin->init(); + + // assert that backup is hooked into `tiny_image_size_before_compression` + WordPressStubs::assertHook('tiny_image_size_before_compression', array($tiny_plugin, 'backup_image_size')); + } + + public function test_will_copy_original_file_on_backup() { + $this->wp->createImage( 37857, '2026/04', 'testfile.png' ); + $og_file_path = $this->vfs->url() . '/wp-content/uploads/2026/04/testfile.png'; + $expected_backup = $this->vfs->url() . '/wp-content/uploads/tinify_backup/2026/04/testfile.png'; + + $tiny_plugin = new Tiny_Plugin(); + + $ref = new \ReflectionClass($tiny_plugin); + $settings_prop = $ref->getProperty('settings'); + $settings_prop->setAccessible(true); + $mock_settings = $this->createMock(Tiny_Settings::class); + $mock_settings->method('get_backup_enabled')->willReturn(true); + $settings_prop->setValue($tiny_plugin, $mock_settings); + + $tiny_plugin->backup_image_size(1, 0, $og_file_path); + + assertTrue(file_exists($expected_backup), 'expected backup to be created'); + } + + public function test_when_not_original_will_not_backup() { + $tiny_plugin = new Tiny_Plugin(); + $created = $tiny_plugin->backup_image_size(1, 'thumbnail', 'filepath'); + + assertFalse($created, 'expected backup not te be created'); + } } diff --git a/test/unit/TinySettingsAdminTest.php b/test/unit/TinySettingsAdminTest.php index 3de0d81d..3feb1b1c 100644 --- a/test/unit/TinySettingsAdminTest.php +++ b/test/unit/TinySettingsAdminTest.php @@ -18,6 +18,7 @@ public function test_admin_init_should_register_keys() { array( 'tinify', 'tinypng_compression_timing' ), array( 'tinify', 'tinypng_sizes' ), array( 'tinify', 'tinypng_resize_original' ), + array( 'tinify', 'tinypng_backup' ), array( 'tinify', 'tinypng_preserve_data' ), array( 'tinify', 'tinypng_convert_format' ), array( 'tinify', 'tinypng_logging_enabled' ), diff --git a/test/unit/TinyTestCase.php b/test/unit/TinyTestCase.php index 4838a404..c64f22d6 100644 --- a/test/unit/TinyTestCase.php +++ b/test/unit/TinyTestCase.php @@ -40,6 +40,11 @@ public static function client_supported() { } abstract class Tiny_TestCase extends TestCase { + /** + * WordPress stubs + * + * @var \WordPressStubs + */ protected $wp; protected $vfs;