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.
*