diff --git a/Tests/Fixtures/WP/class-wp-cli.php b/Tests/Fixtures/WP/class-wp-cli.php new file mode 100644 index 000000000..a3b591848 --- /dev/null +++ b/Tests/Fixtures/WP/class-wp-cli.php @@ -0,0 +1,95 @@ +once() + ->andReturn( BulkEmptyStub::class ); + + $result = ( new Bulk() )->run_restore( 'wp' ); + + $this->assertSame( + [ + 'success' => false, + 'message' => 'no-images', + 'restored' => 0, + 'errors' => 0, + 'total' => 0, + ], + $result + ); + } + + /** + * Test: all media restore successfully — restored count equals total, errors is 0. + */ + public function testCountsAllRestoredWhenNoErrors(): void { + Filters\expectApplied( 'imagify_bulk_class_name' ) + ->once() + ->andReturn( BulkWithThreeIdsStub::class ); + + Functions\when( 'imagify_get_optimization_process' )->justReturn( new BulkProcessStub( true ) ); + Functions\when( 'is_wp_error' )->justReturn( false ); + + $result = ( new Bulk() )->run_restore( 'wp' ); + + $this->assertSame( + [ + 'success' => true, + 'message' => 'success', + 'restored' => 3, + 'errors' => 0, + 'total' => 3, + ], + $result + ); + } + + /** + * Test: all media fail to restore — errors count equals total, restored is 0. + */ + public function testCountsAllErrorsWhenAllRestoresFail(): void { + Filters\expectApplied( 'imagify_bulk_class_name' ) + ->once() + ->andReturn( BulkWithThreeIdsStub::class ); + + $wp_error = new WP_Error( 'restore_failed', 'Could not restore.' ); + + Functions\when( 'imagify_get_optimization_process' )->justReturn( new BulkProcessStub( $wp_error ) ); + Functions\when( 'is_wp_error' )->alias( + function ( $thing ) { + return $thing instanceof WP_Error; + } + ); + + $result = ( new Bulk() )->run_restore( 'wp' ); + + $this->assertSame( + [ + 'success' => true, + 'message' => 'success', + 'restored' => 0, + 'errors' => 3, + 'total' => 3, + ], + $result + ); + } + + /** + * Test: mixed results — restored and errors are counted independently. + */ + public function testCountsMixedRestoredAndErrors(): void { + Filters\expectApplied( 'imagify_bulk_class_name' ) + ->once() + ->andReturn( BulkWithThreeIdsStub::class ); + + $wp_error = new WP_Error( 'restore_failed', 'Could not restore.' ); + + // imagify_get_optimization_process is called once per media ID. + // Return a failing process for media ID 1, success for 2 and 3. + Functions\when( 'imagify_get_optimization_process' )->alias( + function ( $media_id ) use ( $wp_error ) { + return new BulkProcessStub( 1 === $media_id ? $wp_error : true ); + } + ); + + Functions\when( 'is_wp_error' )->alias( + function ( $thing ) { + return $thing instanceof WP_Error; + } + ); + + $result = ( new Bulk() )->run_restore( 'wp' ); + + $this->assertSame( + [ + 'success' => true, + 'message' => 'success', + 'restored' => 2, + 'errors' => 1, + 'total' => 3, + ], + $result + ); + } +} + +/** + * Stub bulk: no eligible media. + */ +class BulkEmptyStub implements BulkInterface { + + /** + * {@inheritdoc} + */ + public function get_unoptimized_media_ids( $optimization_level ) { + return []; + } + + /** + * {@inheritdoc} + */ + public function get_optimized_media_ids(): array { + return []; + } + + /** + * {@inheritdoc} + */ + public function get_optimized_media_ids_without_format( $format ) { + return [ + 'ids' => [], + 'errors' => [ + 'no_file_path' => [], + 'no_backup' => [], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function has_optimized_media_without_nextgen() { + return 0; + } + + /** + * {@inheritdoc} + */ + public function get_context_data() { + return []; + } +} + +/** + * Stub optimization process that returns a fixed result on restore(). + */ +class BulkProcessStub { + + /** + * The value to return from restore(). + * + * @var mixed + */ + private $result; + + /** + * @param mixed $result The value restore() should return. + */ + public function __construct( $result ) { + $this->result = $result; + } + + /** + * Stub restore. + * + * @return mixed + */ + public function restore() { + return $this->result; + } +} + +/** + * Stub bulk: three eligible media IDs. + */ +class BulkWithThreeIdsStub implements BulkInterface { + + /** + * {@inheritdoc} + */ + public function get_unoptimized_media_ids( $optimization_level ) { + return []; + } + + /** + * {@inheritdoc} + */ + public function get_optimized_media_ids(): array { + return [ 1, 2, 3 ]; + } + + /** + * {@inheritdoc} + */ + public function get_optimized_media_ids_without_format( $format ) { + return [ + 'ids' => [], + 'errors' => [ + 'no_file_path' => [], + 'no_backup' => [], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function has_optimized_media_without_nextgen() { + return 0; + } + + /** + * {@inheritdoc} + */ + public function get_context_data() { + return []; + } +} diff --git a/Tests/Unit/classes/CLI/RestoreCommandTest.php b/Tests/Unit/classes/CLI/RestoreCommandTest.php new file mode 100644 index 000000000..7a6b2d549 --- /dev/null +++ b/Tests/Unit/classes/CLI/RestoreCommandTest.php @@ -0,0 +1,260 @@ +command = new RestoreCommand(); + } + + /** + * Tears down the test fixture. + */ + protected function tearDown(): void { + $this->resetPropertyValue( 'instance', Bulk::class ); + parent::tearDown(); + } + + /** + * Injects a stub into Bulk::$instance with controlled run_restore() outcomes. + * + * @param array $result_map Keyed by internal context string (e.g. 'wp', 'custom-folders'). + */ + private function injectBulkStub( array $result_map ): void { + $stub = new class( $result_map ) { + /** + * Map of context => result array. + * + * @var array + */ + private $map; + + /** + * Constructs the stub. + * + * @param array $map Map of context => result. + */ + public function __construct( array $map ) { + $this->map = $map; + } + + /** + * Returns a controlled restore result for the given context. + * + * @param string $context The bulk context. + * + * @return array + */ + public function run_restore( string $context ): array { + return $this->map[ $context ] ?? [ + 'success' => false, + 'message' => 'no-images', + 'restored' => 0, + 'errors' => 0, + 'total' => 0, + ]; + } + }; + + $this->setPropertyValue( 'instance', Bulk::class, $stub ); + } + + /** + * Test: get_name() returns the correct WP-CLI command name. + * + * @covers \Imagify\CLI\RestoreCommand::get_command_name + */ + public function testGetName(): void { + $this->assertSame( 'imagify restore', $this->command->get_name() ); + } + + /** + * Test: get_synopsis() returns a single optional repeating positional 'contexts' argument. + * + * @covers \Imagify\CLI\RestoreCommand::get_synopsis + */ + public function testGetSynopsisHasOptionalRepeatingContextsArgument(): void { + $synopsis = $this->command->get_synopsis(); + + $this->assertCount( 1, $synopsis ); + $this->assertSame( 'positional', $synopsis[0]['type'] ); + $this->assertSame( 'contexts', $synopsis[0]['name'] ); + $this->assertTrue( $synopsis[0]['optional'] ); + $this->assertTrue( $synopsis[0]['repeating'] ); + } + + /** + * Test: no arguments defaults to restoring both 'wp' and 'custom-folders' and emits success. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testDefaultsToAllContextsWhenNoArgumentsGiven(): void { + $this->injectBulkStub( + [ + 'wp' => [ + 'success' => true, + 'message' => 'success', + 'restored' => 3, + 'errors' => 0, + 'total' => 3, + ], + 'custom-folders' => [ + 'success' => true, + 'message' => 'success', + 'restored' => 2, + 'errors' => 0, + 'total' => 2, + ], + ] + ); + + ( $this->command )( [], [] ); + + $this->assertNotEmpty( \WP_CLI::$success_messages ); + $this->assertStringContainsString( '5', \WP_CLI::$success_messages[0] ); + $this->assertEmpty( \WP_CLI::$warning_messages ); + $this->assertEmpty( \WP_CLI::$error_messages ); + } + + /** + * Test: 'library' argument maps to the internal 'wp' context. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testLibraryArgumentMapsToWpContext(): void { + $this->injectBulkStub( + [ + 'wp' => [ + 'success' => true, + 'message' => 'success', + 'restored' => 1, + 'errors' => 0, + 'total' => 1, + ], + ] + ); + + ( $this->command )( [ 'library' ], [] ); + + $this->assertNotEmpty( \WP_CLI::$success_messages ); + $this->assertStringContainsString( '1', \WP_CLI::$success_messages[0] ); + $this->assertEmpty( \WP_CLI::$warning_messages ); + } + + /** + * Test: 'custom-folders' argument is passed through to Bulk::run_restore() as-is. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testCustomFoldersArgumentPassedThrough(): void { + $this->injectBulkStub( + [ + 'custom-folders' => [ + 'success' => true, + 'message' => 'success', + 'restored' => 2, + 'errors' => 0, + 'total' => 2, + ], + ] + ); + + ( $this->command )( [ 'custom-folders' ], [] ); + + $this->assertNotEmpty( \WP_CLI::$success_messages ); + $this->assertEmpty( \WP_CLI::$warning_messages ); + } + + /** + * Test: when no optimized media exist a warning is emitted and no success message. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testEmitsWarningWhenNoMediaToRestore(): void { + $this->injectBulkStub( + [ + 'wp' => [ + 'success' => false, + 'message' => 'no-images', + 'restored' => 0, + 'errors' => 0, + 'total' => 0, + ], + 'custom-folders' => [ + 'success' => false, + 'message' => 'no-images', + 'restored' => 0, + 'errors' => 0, + 'total' => 0, + ], + ] + ); + + ( $this->command )( [], [] ); + + $this->assertNotEmpty( \WP_CLI::$warning_messages ); + $this->assertEmpty( \WP_CLI::$success_messages ); + } + + /** + * Test: an invalid context emits an error and aborts without success. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testEmitsErrorForInvalidContext(): void { + ( $this->command )( [ 'invalid-context' ], [] ); + + $this->assertNotEmpty( \WP_CLI::$error_messages ); + $this->assertStringContainsString( 'invalid-context', \WP_CLI::$error_messages[0] ); + $this->assertEmpty( \WP_CLI::$success_messages ); + } + + /** + * Test: when some restores fail a warning is used instead of success. + * + * @covers \Imagify\CLI\RestoreCommand::__invoke + */ + public function testEmitsWarningWhenRestoreHasPartialErrors(): void { + $this->injectBulkStub( + [ + 'wp' => [ + 'success' => true, + 'message' => 'success', + 'restored' => 2, + 'errors' => 1, + 'total' => 3, + ], + ] + ); + + ( $this->command )( [ 'library' ], [] ); + + $this->assertNotEmpty( \WP_CLI::$warning_messages ); + $this->assertEmpty( \WP_CLI::$success_messages ); + } +} diff --git a/Tests/Unit/phpunit.xml.dist b/Tests/Unit/phpunit.xml.dist index 31a151185..f1ffb0697 100644 --- a/Tests/Unit/phpunit.xml.dist +++ b/Tests/Unit/phpunit.xml.dist @@ -3,6 +3,7 @@ ../../inc + ../../classes diff --git a/classes/Bulk/Bulk.php b/classes/Bulk/Bulk.php index f24aa9c0f..397e74ac8 100644 --- a/classes/Bulk/Bulk.php +++ b/classes/Bulk/Bulk.php @@ -219,6 +219,60 @@ public function run_optimize( string $context, int $optimization_level ) { ]; } + /** + * Runs the bulk restore for a given context. + * + * Restores all optimized media to their original state synchronously. + * Does not consume API quota since restore is a local file operation. + * + * @since 2.3 + * + * @param string $context Current context (WP/Custom folders). + * + * @return array { + * @type bool $success Whether any media was found to restore. + * @type string $message Status message. + * @type int $restored Number of media successfully restored. + * @type int $errors Number of media that failed to restore. + * @type int $total Total number of media processed. + * } + */ + public function run_restore( string $context ) { + $media_ids = $this->get_bulk_instance( $context )->get_optimized_media_ids(); + + if ( empty( $media_ids ) ) { + return [ + 'success' => false, + 'message' => 'no-images', + 'restored' => 0, + 'errors' => 0, + 'total' => 0, + ]; + } + + $restored = 0; + $errors = 0; + + foreach ( $media_ids as $media_id ) { + $result = imagify_get_optimization_process( $media_id, $context )->restore(); + + if ( is_wp_error( $result ) ) { + ++$errors; + continue; + } + + ++$restored; + } + + return [ + 'success' => true, + 'message' => 'success', + 'restored' => $restored, + 'errors' => $errors, + 'total' => count( $media_ids ), + ]; + } + /** * Runs the next-gen generation * diff --git a/classes/Bulk/BulkInterface.php b/classes/Bulk/BulkInterface.php index b599f6b32..938f90a6f 100644 --- a/classes/Bulk/BulkInterface.php +++ b/classes/Bulk/BulkInterface.php @@ -17,6 +17,15 @@ interface BulkInterface { */ public function get_unoptimized_media_ids( $optimization_level ); + /** + * Get ids of all optimized media that have a backup file available for restore. + * + * @since 2.3 + * + * @return int[] A flat list of media IDs. + */ + public function get_optimized_media_ids(): array; + /** * Get ids of all optimized media without Next gen versions. * diff --git a/classes/Bulk/CustomFolders.php b/classes/Bulk/CustomFolders.php index 19081e7d9..fa07ce600 100644 --- a/classes/Bulk/CustomFolders.php +++ b/classes/Bulk/CustomFolders.php @@ -78,6 +78,64 @@ public function get_unoptimized_media_ids( $optimization_level ) { return $files; } + /** + * Get all optimized file IDs that have a backup file available for restore. + * + * @return array A list of file IDs. + */ + public function get_optimized_media_ids(): array { + global $wpdb; + + $this->set_no_time_limit(); + + $files_table = Imagify_Files_DB::get_instance()->get_table_name(); + $folders_table = Imagify_Folders_DB::get_instance()->get_table_name(); + $files = $wpdb->get_results( + $wpdb->prepare( // WPCS: unprepared SQL ok. + " + SELECT fi.file_id, fi.path + FROM $files_table AS fi + INNER JOIN $folders_table AS fo + ON ( fi.folder_id = fo.folder_id ) + WHERE + fi.status IN ( 'success', 'already_optimized' ) + ORDER BY fi.file_id DESC + LIMIT 0, %d", + imagify_get_unoptimized_attachment_limit() + ) + ); + + $wpdb->flush(); + unset( $files_table, $folders_table ); + + if ( ! $files ) { + return []; + } + + $data = []; + + foreach ( $files as $file ) { + $file_id = absint( $file->file_id ); + + if ( empty( $file->path ) ) { + // Problem. + continue; + } + + $file_path = Imagify_Files_Scan::remove_placeholder( $file->path ); + $backup_path = Imagify_Custom_Folders::get_file_backup_path( $file_path ); + + if ( ! $this->filesystem->exists( $backup_path ) ) { + // No backup, cannot restore. + continue; + } + + $data[] = $file_id; + } + + return $data; + } + /** * Get ids of all optimized media without Next gen versions. * diff --git a/classes/Bulk/Noop.php b/classes/Bulk/Noop.php index 971967c65..5cd15147a 100644 --- a/classes/Bulk/Noop.php +++ b/classes/Bulk/Noop.php @@ -19,6 +19,17 @@ public function get_unoptimized_media_ids( $optimization_level ) { return []; } + /** + * Get all optimized media ids that can be restored. + * + * @since 2.3 + * + * @return array A list of optimized media IDs with backup files available. + */ + public function get_optimized_media_ids(): array { + return []; + } + /** * * Get ids of all optimized media without Next gen versions. * diff --git a/classes/Bulk/WP.php b/classes/Bulk/WP.php index afc1ffe07..f28014e4c 100644 --- a/classes/Bulk/WP.php +++ b/classes/Bulk/WP.php @@ -171,6 +171,87 @@ public function get_unoptimized_media_ids( $optimization_level ) { return $data; } + /** + * Get all optimized media IDs that have a backup file available for restore. + * + * @return array A list of media IDs. + */ + public function get_optimized_media_ids(): array { + global $wpdb; + + $this->set_no_time_limit(); + + $mime_types = Imagify_DB::get_mime_types(); + $statuses = Imagify_DB::get_post_statuses(); + $nodata_join = Imagify_DB::get_required_wp_metadata_join_clause(); + $nodata_where = Imagify_DB::get_required_wp_metadata_where_clause( + [ + 'prepared' => true, + ] + ); + $ids = $wpdb->get_col( + $wpdb->prepare( // WPCS: unprepared SQL ok. + " + SELECT DISTINCT p.ID + FROM $wpdb->posts AS p + $nodata_join + INNER JOIN $wpdb->postmeta AS mt1 + ON ( p.ID = mt1.post_id AND mt1.meta_key = '_imagify_status' ) + WHERE + p.post_mime_type IN ( $mime_types ) + AND mt1.meta_value IN ( 'success', 'already_optimized' ) + AND p.post_type = 'attachment' + AND p.post_status IN ( $statuses ) + $nodata_where + ORDER BY p.ID DESC + LIMIT 0, %d", + imagify_get_unoptimized_attachment_limit() + ) + ); + + $wpdb->flush(); + unset( $mime_types, $statuses ); + $ids = array_filter( array_map( 'absint', $ids ) ); + + if ( ! $ids ) { + return []; + } + + $metas = Imagify_DB::get_metas( + [ + // Get attachments filename. + 'filenames' => '_wp_attached_file', + ], + $ids + ); + + $data = []; + + foreach ( $ids as $id ) { + if ( empty( $metas['filenames'][ $id ] ) ) { + // Problem. + continue; + } + + $file_path = get_imagify_attached_file( $metas['filenames'][ $id ] ); + + if ( ! $file_path ) { + continue; + } + + $attachment_backup_path = get_imagify_attachment_backup_path( $file_path ); + + if ( ! $this->filesystem->exists( $attachment_backup_path ) ) { + // No backup, cannot restore. + continue; + } + + $data[] = $id; + } + + return $data; + } + /** * Get ids of all optimized media without Next gen versions. * diff --git a/classes/CLI/RestoreCommand.php b/classes/CLI/RestoreCommand.php new file mode 100644 index 000000000..5715494c5 --- /dev/null +++ b/classes/CLI/RestoreCommand.php @@ -0,0 +1,150 @@ + + */ + private const CONTEXT_MAP = [ + 'library' => 'wp', + 'custom-folders' => 'custom-folders', + ]; + + /** + * Executes the command. + * + * @param array $arguments Positional argument. + * @param array $options Optional arguments. + */ + public function __invoke( $arguments, $options ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + if ( empty( $arguments ) ) { + $arguments = array_keys( self::CONTEXT_MAP ); + } + + $contexts = []; + + foreach ( $arguments as $arg ) { + if ( ! isset( self::CONTEXT_MAP[ $arg ] ) ) { + \WP_CLI::error( + sprintf( + 'Invalid context: "%s". Valid values are: %s', + $arg, + implode( ', ', array_keys( self::CONTEXT_MAP ) ) + ) + ); + return; + } + + $contexts[] = self::CONTEXT_MAP[ $arg ]; + } + + $total_restored = 0; + $total_errors = 0; + + foreach ( $contexts as $index => $context ) { + $label = $arguments[ $index ]; + + \WP_CLI::log( sprintf( 'Restoring optimized media for: %s', $label ) ); + + $result = Bulk::get_instance()->run_restore( $context ); + + if ( ! $result['success'] ) { + \WP_CLI::warning( sprintf( 'No optimized media to restore for: %s', $label ) ); + continue; + } + + $total_restored += $result['restored']; + $total_errors += $result['errors']; + + \WP_CLI::log( + sprintf( + '%s: %d restored, %d errors out of %d total.', + ucfirst( $label ), + $result['restored'], + $result['errors'], + $result['total'] + ) + ); + } + + if ( 0 === $total_restored && 0 === $total_errors ) { + \WP_CLI::warning( 'No optimized media found to restore.' ); + return; + } + + if ( $total_errors > 0 ) { + \WP_CLI::warning( + sprintf( + 'Restore completed with errors: %d restored, %d failed.', + $total_restored, + $total_errors + ) + ); + return; + } + + \WP_CLI::success( + sprintf( + 'Restore completed: %d media restored successfully.', + $total_restored + ) + ); + } + + /** + * {@inheritdoc} + */ + protected function get_command_name(): string { + return 'restore'; + } + + /** + * {@inheritdoc} + */ + public function get_description(): string { + return 'Restore all optimized media to their original state'; + } + + /** + * {@inheritdoc} + */ + public function get_synopsis(): array { + return [ + [ + 'type' => 'positional', + 'name' => 'contexts', + 'description' => 'The context(s) to restore. Possible values are library and custom-folders. Defaults to all if omitted.', + 'optional' => true, + 'repeating' => true, + ], + ]; + } +} diff --git a/classes/Plugin.php b/classes/Plugin.php index 1f8f8cd6b..a7c442c5f 100644 --- a/classes/Plugin.php +++ b/classes/Plugin.php @@ -5,7 +5,7 @@ use Imagify\Admin\AdminBar; use Imagify\Bulk\Bulk; -use Imagify\CLI\{BulkOptimizeCommand, GenerateMissingNextgenCommand}; +use Imagify\CLI\{BulkOptimizeCommand, GenerateMissingNextgenCommand, RestoreCommand}; use Imagify\Dependencies\League\Container\Container; use Imagify\Dependencies\League\Container\ServiceProvider\ServiceProviderInterface; use Imagify\EventManagement\{EventManager, SubscriberInterface}; @@ -132,6 +132,7 @@ class_alias( '\\Imagify\\Traits\\InstanceGetterTrait', '\\Imagify\\Traits\\FakeS add_action( 'init', [ $this, 'maybe_activate' ] ); imagify_add_command( new BulkOptimizeCommand() ); + imagify_add_command( new RestoreCommand() ); imagify_add_command( new GenerateMissingNextgenCommand() ); foreach ( $providers as $service_provider ) { diff --git a/docs/api/wp-cli.md b/docs/api/wp-cli.md new file mode 100644 index 000000000..f702bd8fc --- /dev/null +++ b/docs/api/wp-cli.md @@ -0,0 +1,122 @@ +# WP-CLI Commands + +Imagify registers WP-CLI subcommands under the `imagify` root command. +All commands are registered in `classes/Plugin.php` via `imagify_add_command()`. + +--- + +## `wp imagify bulk-optimize ` + +Enqueues asynchronous ActionScheduler jobs to optimize all unoptimized images +for one or more bulk contexts. + +**Class:** `Imagify\CLI\BulkOptimizeCommand` + +### Arguments + +| Name | Required | Repeating | Description | +|---|---|---|---| +| `contexts` | yes | yes | One or more contexts to run. Valid values: `wp`, `custom-folders`. | + +### Options + +| Option | Default | Description | +|---|---|---| +| `--optimization-level` | account default | `0` (normal), `1` (aggressive), `2` (ultra). | + +### Behaviour + +Delegates to `Imagify\Bulk\Bulk::run_optimize()`, which calls +`get_unoptimized_media_ids()` on the matching `AbstractBulk` subclass and +enqueues one `imagify_optimize_media` ActionScheduler action per ID. + +A warning is emitted via `WP_CLI::warning()` if a context has no images to optimize. + +--- + +## `wp imagify restore [...]` + +Synchronously restores all optimized images back to their original files +for one or more bulk contexts. + +**Class:** `Imagify\CLI\RestoreCommand` + +**Since:** 2.3 (issue #922) + +### Arguments + +| Name | Required | Repeating | Description | +|---|---|---|---| +| `contexts` | no | yes | One or more contexts to restore. Valid values: `library`, `custom-folders`. Defaults to both if omitted. | + +`library` is an alias for the WordPress media library (`wp` context). + +### Examples + +```bash +# Restore all optimized images (library + custom folders). +wp imagify restore + +# Restore only the WordPress media library. +wp imagify restore library + +# Restore only custom folders. +wp imagify restore custom-folders + +# Restore both contexts explicitly. +wp imagify restore library custom-folders +``` + +### Behaviour + +1. For each context, calls `Imagify\Bulk\Bulk::run_restore( $context )`. +2. `run_restore()` calls `get_optimized_media_ids()` on the matching `AbstractBulk` + subclass. Only images that have a backup file on disk are included. +3. For each eligible image, calls `imagify_get_optimization_process( $media_id, $context )->restore()` **synchronously** (no ActionScheduler queue). +4. Prints per-context counts (restored / errors / total) and a final success or warning message. + +A `WP_CLI::warning()` is emitted if: +- A context has no eligible images. +- The overall restore completed but some images failed. + +`WP_CLI::success()` is emitted only when all processed images were restored without errors. + +### Return structure from `Bulk::run_restore()` + +| Key | Type | Description | +|---|---|---| +| `success` | bool | `false` if no eligible images were found; `true` otherwise. | +| `message` | string | `no-images` or `success`. | +| `restored` | int | Number of images successfully restored. | +| `errors` | int | Number of images that failed to restore. | +| `total` | int | Total number of images processed. | + +--- + +## Context resolution + +Both bulk commands resolve the concrete `AbstractBulk` subclass through the +`imagify_bulk_class_name` filter (applied via `wpm_apply_filters_typed()`). +The default mapping is: + +| Context | Class | +|---|---| +| `wp` | `Imagify\Bulk\WP` | +| `custom-folders` | `Imagify\Bulk\CustomFolders` | + +--- + +## `get_optimized_media_ids()` — context implementations + +`BulkInterface::get_optimized_media_ids(): array` returns a flat list of media IDs +(integers) that are eligible for restoration: + +- **`Imagify\Bulk\WP`** — queries `wp_posts` INNER JOIN `wp_postmeta` for + attachments with `_imagify_status` IN `('success', 'already_optimized')`, then + filters to only those with an existing backup file (`get_imagify_attachment_backup_path()`). +- **`Imagify\Bulk\CustomFolders`** — queries `imagify_files` INNER JOIN + `imagify_folders` for files with `status IN ('success', 'already_optimized')`, + then filters to only those with an existing backup file + (`Imagify_Custom_Folders::get_file_backup_path()`). +- **`Imagify\Bulk\Noop`** and **`Imagify\ThirdParty\NGG\Bulk\NGG`** — return `[]` + (restore is not supported for these contexts). diff --git a/inc/3rd-party/nextgen-gallery/classes/Bulk/NGG.php b/inc/3rd-party/nextgen-gallery/classes/Bulk/NGG.php index 33ff54e3f..3a0aa58b8 100644 --- a/inc/3rd-party/nextgen-gallery/classes/Bulk/NGG.php +++ b/inc/3rd-party/nextgen-gallery/classes/Bulk/NGG.php @@ -102,6 +102,17 @@ public function get_unoptimized_media_ids( $optimization_level ) { return $data; } + /** + * Get all optimized media ids that can be restored. + * + * @since 2.3 + * + * @return array A list of optimized media IDs with backup files available. + */ + public function get_optimized_media_ids(): array { + return []; + } + /** * Get ids of all optimized media without next-gen versions. *