get_error_message() ); } $public_url = Options::get_credential( 'public_url' ); $result = $client->test_connection( $public_url ); foreach ( $result['steps'] as $step ) { if ( $step['ok'] ) { WP_CLI::log( sprintf( '[OK] %s: %s', $step['step'], $step['message'] ) ); } else { WP_CLI::warning( sprintf( '[FAIL] %s: %s', $step['step'], $step['message'] ) ); } } if ( $result['ok'] ) { WP_CLI::success( sprintf( 'Connection test passed. Bucket: %s | Public URL: %s', $client->get_bucket(), $public_url ) ); } else { WP_CLI::error( 'Connection test failed. Review the steps above.' ); } } // ------------------------------------------------------------------------- // offload // ------------------------------------------------------------------------- /** * Offloads attachments to Cloudflare R2. * * Uploads attachment files to the configured R2 bucket. Use --all to * process every attachment, or --ids to target specific ones. * * ## OPTIONS * * [--all] * : Process all attachments not yet offloaded. * * [--ids=] * : Comma-separated list of attachment IDs. * * [--force] * : Re-upload even if already offloaded. * * [--mime=] * : Filter attachments by MIME type (e.g. image/jpeg). * * [--dry-run] * : Show what would happen without uploading. * * ## EXAMPLES * * wp r2clouder offload --all * wp r2clouder offload --ids=42,99,108 * wp r2clouder offload --all --mime=image/jpeg --dry-run * wp r2clouder offload --all --force * * @subcommand offload * * @param array $args Positional arguments (unused). * @param array $assoc_args Associative arguments. * @return void */ public function offload( $args, $assoc_args ) { $force = Utils\get_flag_value( $assoc_args, 'force', false ); $dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false ); $ids = $this->get_attachment_ids( $assoc_args, 'offload', ! $force ); if ( $dry_run ) { WP_CLI::log( 'Dry run enabled. No files will be uploaded.' ); } $offloader = new Offloader(); $progress = Utils\make_progress_bar( 'Offloading attachments', count( $ids ) ); $uploaded = 0; $skipped = 0; $failed = 0; $bytes = 0; foreach ( $ids as $id ) { if ( $dry_run ) { $is_offloaded = Offload_Meta::is_offloaded( $id ); if ( $is_offloaded && ! $force ) { WP_CLI::log( sprintf( 'Skip #%d (already offloaded)', $id ) ); ++$skipped; } elseif ( ! Offloader::is_extension_allowed( $id ) ) { WP_CLI::log( sprintf( 'Skip #%d (extension_filtered)', $id ) ); ++$skipped; } else { WP_CLI::log( sprintf( 'Would offload #%d%s', $id, $force && $is_offloaded ? ' (force)' : '' ) ); ++$uploaded; } $progress->tick(); continue; } $result = $offloader->offload( $id, array( 'force' => $force ) ); if ( is_wp_error( $result ) && 'r2clouder_extension_filtered' === $result->get_error_code() ) { WP_CLI::log( sprintf( 'Skip #%d (extension_filtered): %s', $id, $result->get_error_message() ) ); ++$skipped; $progress->tick(); continue; } if ( is_wp_error( $result ) ) { WP_CLI::warning( sprintf( '#%d: %s', $id, $result->get_error_message() ) ); ++$failed; $progress->tick(); continue; } if ( 0 === $result['uploaded'] ) { ++$skipped; } else { $uploaded += $result['uploaded']; $bytes += $result['bytes']; } $progress->tick(); } $progress->finish(); WP_CLI::log( '' ); WP_CLI::log( sprintf( 'Uploaded: %d | Skipped: %d | Failed: %d | Bytes: %s', $uploaded, $skipped, $failed, size_format( $bytes ) ) ); if ( $failed > 0 ) { WP_CLI::warning( sprintf( '%d attachment(s) failed.', $failed ) ); } else { WP_CLI::success( 'Offload complete.' ); } } // ------------------------------------------------------------------------- // pullback // ------------------------------------------------------------------------- /** * Downloads offloaded files back to local storage. * * Pulls every variant of the specified attachments from R2 back to the * local uploads directory and removes the offload record so WordPress * serves the local files again. * * ## OPTIONS * * [--all] * : Pull back all offloaded attachments. * * [--ids=] * : Comma-separated list of attachment IDs. * * ## EXAMPLES * * wp r2clouder pullback --ids=42,99 * wp r2clouder pullback --all * * @subcommand pullback * * @param array $args Positional arguments (unused). * @param array $assoc_args Associative arguments. * @return void */ public function pullback( $args, $assoc_args ) { $ids = $this->get_attachment_ids_offloaded( $assoc_args ); $progress = Utils\make_progress_bar( 'Pulling back attachments', count( $ids ) ); $pulled = 0; $failed = 0; $already = 0; foreach ( $ids as $id ) { if ( ! Offload_Meta::is_offloaded( $id ) ) { WP_CLI::log( sprintf( '#%d: not offloaded, skipping.', $id ) ); ++$already; $progress->tick(); continue; } $job = new Job(); $job->attachment_id = $id; $job->job_type = Job::TYPE_PULLBACK; $result = Lifecycle::handle_pullback( $job ); if ( is_wp_error( $result ) ) { WP_CLI::warning( sprintf( '#%d: %s', $id, $result->get_error_message() ) ); ++$failed; } else { ++$pulled; } $progress->tick(); } $progress->finish(); WP_CLI::log( '' ); WP_CLI::log( sprintf( 'Pulled back: %d | Skipped: %d | Failed: %d', $pulled, $already, $failed ) ); if ( $failed > 0 ) { WP_CLI::warning( sprintf( '%d attachment(s) failed.', $failed ) ); } else { WP_CLI::success( 'Pull-back complete.' ); } } // ------------------------------------------------------------------------- // verify // ------------------------------------------------------------------------- /** * Verifies that offloaded objects exist in the R2 bucket. * * For each offloaded attachment, checks that every recorded object key * actually exists in the bucket. When --fix is passed, missing objects * are re-uploaded from local files when available, or the stale entries * are pruned. * * ## OPTIONS * * [--all] * : Verify all offloaded attachments. * * [--ids=] * : Comma-separated list of attachment IDs. * * [--fix] * : Attempt to repair mismatches. * * ## EXAMPLES * * wp r2clouder verify --all * wp r2clouder verify --ids=42 --fix * * @subcommand verify * * @param array $args Positional arguments (unused). * @param array $assoc_args Associative arguments. * @return void */ public function verify( $args, $assoc_args ) { $fix = Utils\get_flag_value( $assoc_args, 'fix', false ); $ids = $this->get_attachment_ids_offloaded( $assoc_args ); $progress = Utils\make_progress_bar( 'Verifying attachments', count( $ids ) ); $total_ok = 0; $total_miss = 0; $total_fixed = 0; $total_errors = 0; $skipped = 0; foreach ( $ids as $id ) { $result = Lifecycle::verify_attachment( $id, $fix ); if ( is_wp_error( $result ) ) { WP_CLI::log( sprintf( '#%d: %s', $id, $result->get_error_message() ) ); ++$skipped; $progress->tick(); continue; } $total_ok += $result['verified']; $total_miss += $result['missing']; $total_fixed += $result['fixed']; if ( ! empty( $result['errors'] ) ) { foreach ( $result['errors'] as $err ) { WP_CLI::warning( sprintf( '#%d: %s', $id, $err ) ); } $total_errors += count( $result['errors'] ); } $progress->tick(); } $progress->finish(); WP_CLI::log( '' ); WP_CLI::log( sprintf( 'Verified: %d | Missing: %d | Fixed: %d | Errors: %d | Skipped: %d', $total_ok, $total_miss, $total_fixed, $total_errors, $skipped ) ); if ( $total_miss > 0 && ! $fix ) { WP_CLI::warning( 'Missing objects detected. Run with --fix to attempt repair.' ); } elseif ( $total_errors > 0 ) { WP_CLI::warning( 'Verification completed with errors.' ); } else { WP_CLI::success( 'Verification complete.' ); } } // ------------------------------------------------------------------------- // orphans // ------------------------------------------------------------------------- /** * Finds unreferenced objects in the R2 bucket. * * Lists objects in the bucket that are not recorded in any attachment's * offload meta. These may be left over from deleted attachments whose * delete jobs failed. Objects are never auto-deleted; this command * reports only. * * ## OPTIONS * * [--format=] * : Output format. * --- * default: table * options: * - table * - csv * - json * --- * * [--prefix=] * : Only scan objects with this key prefix. * * ## EXAMPLES * * wp r2clouder orphans * wp r2clouder orphans --format=csv * wp r2clouder orphans --prefix=2026/09/ * * @subcommand orphans * * @param array $args Positional arguments (unused). * @param array $assoc_args Associative arguments. * @return void */ public function orphans( $args, $assoc_args ) { $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); $prefix = Utils\get_flag_value( $assoc_args, 'prefix', '' ); $client = Storage::client(); if ( is_wp_error( $client ) ) { WP_CLI::error( $client->get_error_message() ); } WP_CLI::log( 'Listing objects in bucket...' ); $bucket_keys = array(); $token = ''; do { $list_args = array( 'max_keys' => 1000 ); if ( '' !== $prefix ) { $list_args['prefix'] = $prefix; } if ( '' !== $token ) { $list_args['continuation_token'] = $token; } $page = $client->list_objects( $list_args ); if ( is_wp_error( $page ) ) { WP_CLI::error( $page->get_error_message() ); } foreach ( $page['objects'] as $obj ) { $bucket_keys[ $obj['key'] ] = array( 'bytes' => $obj['bytes'], 'last_modified' => $obj['last_modified'], ); } $token = $page['truncated'] ? $page['next_token'] : ''; } while ( '' !== $token ); WP_CLI::log( sprintf( 'Found %d objects in bucket.', count( $bucket_keys ) ) ); if ( empty( $bucket_keys ) ) { WP_CLI::success( 'Bucket is empty. No orphans.' ); return; } WP_CLI::log( 'Collecting known keys from offload meta...' ); $known_keys = $this->collect_all_offload_keys(); WP_CLI::log( sprintf( 'Found %d known keys in offload meta.', count( $known_keys ) ) ); $orphans = array(); foreach ( $bucket_keys as $key => $info ) { if ( ! isset( $known_keys[ $key ] ) ) { $orphans[] = array( 'key' => $key, 'bytes' => $info['bytes'], 'size' => size_format( $info['bytes'] ), 'last_modified' => $info['last_modified'], ); } } if ( empty( $orphans ) ) { WP_CLI::success( 'No orphaned objects found.' ); return; } WP_CLI::log( '' ); Utils\format_items( $format, $orphans, array( 'key', 'size', 'last_modified' ) ); WP_CLI::log( '' ); WP_CLI::warning( sprintf( '%d orphaned object(s) found (%s total). These are reported only and were not deleted.', count( $orphans ), size_format( array_sum( array_column( $orphans, 'bytes' ) ) ) ) ); } // ------------------------------------------------------------------------- // queue // ------------------------------------------------------------------------- /** * Manages the R2Clouder job queue. * * ## OPTIONS * * * : The queue action to perform. * --- * options: * - status * - work * - retry * - clear * --- * * [--limit=] * : Maximum jobs to process (for 'work' action). Default 50. * * [--id=] * : Retry a specific failed job (for 'retry' action). * * [--all] * : Retry all failed jobs (for 'retry' action). * * [--status=] * : Status to clear: 'done' or 'failed' (for 'clear' action). * --- * options: * - done * - failed * --- * * ## EXAMPLES * * wp r2clouder queue status * wp r2clouder queue work --limit=100 * wp r2clouder queue retry --id=42 * wp r2clouder queue retry --all * wp r2clouder queue clear --status=done * wp r2clouder queue clear --status=failed * * @subcommand queue * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function queue( $args, $assoc_args ) { $action = isset( $args[0] ) ? $args[0] : ''; switch ( $action ) { case 'status': $this->queue_status(); break; case 'work': $this->queue_work( $assoc_args ); break; case 'retry': $this->queue_retry( $assoc_args ); break; case 'clear': $this->queue_clear( $assoc_args ); break; default: WP_CLI::error( 'Unknown queue action. Use: status, work, retry, or clear.' ); } } /** * Prints queue status counts. * * @return void */ private function queue_status() { $counts = Queue::counts(); $total = array_sum( $counts ); WP_CLI::log( 'Queue status:' ); WP_CLI::log( '' ); foreach ( $counts as $status => $count ) { WP_CLI::log( sprintf( ' %-10s %d', $status, $count ) ); } WP_CLI::log( sprintf( ' %-10s %d', 'total', $total ) ); } /** * Runs the queue worker synchronously. * * @param array $assoc_args Associative arguments. * @return void */ private function queue_work( array $assoc_args ) { $limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 50; WP_CLI::log( sprintf( 'Running queue worker (limit: %d)...', $limit ) ); $stats = Worker::run( array( 'max_jobs' => $limit, 'use_lock' => false, 'time_limit' => 300, ) ); WP_CLI::log( '' ); WP_CLI::log( sprintf( 'Claimed: %d | Done: %d | Retrying: %d | Failed: %d', $stats['claimed'], $stats['done'], $stats['retrying'], $stats['failed'] ) ); WP_CLI::log( sprintf( 'Stopped: %s | Elapsed: %.1fs', $stats['stopped'], $stats['elapsed'] ) ); if ( $stats['failed'] > 0 ) { WP_CLI::warning( sprintf( '%d job(s) failed.', $stats['failed'] ) ); } else { WP_CLI::success( 'Queue run complete.' ); } } /** * Retries failed jobs. * * @param array $assoc_args Associative arguments. * @return void */ private function queue_retry( array $assoc_args ) { $retry_all = Utils\get_flag_value( $assoc_args, 'all', false ); $retry_id = isset( $assoc_args['id'] ) ? (int) $assoc_args['id'] : 0; if ( ! $retry_all && $retry_id <= 0 ) { WP_CLI::error( 'Specify --id= or --all to retry failed jobs.' ); } if ( $retry_all ) { $affected = Queue::retry(); WP_CLI::success( sprintf( '%d failed job(s) moved back to pending.', $affected ) ); } else { $affected = Queue::retry( array( $retry_id ) ); if ( $affected > 0 ) { WP_CLI::success( sprintf( 'Job #%d moved back to pending.', $retry_id ) ); } else { WP_CLI::warning( sprintf( 'Job #%d was not found or is not in failed status.', $retry_id ) ); } } } /** * Clears jobs by status. * * @param array $assoc_args Associative arguments. * @return void */ private function queue_clear( array $assoc_args ) { $status = isset( $assoc_args['status'] ) ? $assoc_args['status'] : ''; if ( ! in_array( $status, array( 'done', 'failed' ), true ) ) { WP_CLI::error( 'Specify --status=done or --status=failed.' ); } $deleted = Queue::clear( $status ); WP_CLI::success( sprintf( 'Cleared %d %s job(s).', $deleted, $status ) ); } // ------------------------------------------------------------------------- // purge // ------------------------------------------------------------------------- /** * Purges CDN cache for offloaded media URLs. * * Pass one or more URLs directly, or use --attachment-id to purge all * URLs belonging to a specific attachment. * * ## OPTIONS * * [...] * : One or more URLs to purge. * * [--attachment-id=] * : Purge all URLs for this attachment. * * ## EXAMPLES * * wp r2clouder purge https://cdn.example.com/2026/09/photo.jpg * wp r2clouder purge --attachment-id=42 * * @subcommand purge * * @param array $args Positional arguments (URLs). * @param array $assoc_args Associative arguments. * @return void */ public function purge( $args, $assoc_args ) { $attachment_id = isset( $assoc_args['attachment-id'] ) ? (int) $assoc_args['attachment-id'] : 0; $urls = $args; if ( $attachment_id > 0 ) { $data = Offload_Meta::get( $attachment_id ); if ( null === $data ) { WP_CLI::error( sprintf( 'Attachment #%d has no offload record.', $attachment_id ) ); } $base = isset( $data['base'] ) ? untrailingslashit( (string) $data['base'] ) : ''; $keys = Offload_Meta::keys( $data ); if ( empty( $keys ) || '' === $base ) { WP_CLI::error( sprintf( 'No URLs could be built for attachment #%d.', $attachment_id ) ); } foreach ( $keys as $key ) { $urls[] = $base . '/' . ltrim( $key, '/' ); } } if ( empty( $urls ) ) { WP_CLI::error( 'Provide one or more URLs or use --attachment-id.' ); } $urls = array_values( array_unique( $urls ) ); WP_CLI::log( sprintf( 'Purging %d URL(s)...', count( $urls ) ) ); $result = Purger::purge_urls( $urls ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); } WP_CLI::success( sprintf( 'Purged %d URL(s).', count( $urls ) ) ); } // ------------------------------------------------------------------------- // settings // ------------------------------------------------------------------------- /** * Manages R2Clouder settings. * * ## OPTIONS * * * : The settings action to perform. * --- * options: * - get * - set * - export * - import * --- * * [] * : Setting key (for 'get' and 'set' actions). * * [] * : Setting value (for 'set' action). * * [--format=] * : Output format for 'get' action. * --- * default: table * options: * - table * - json * --- * * ## EXAMPLES * * wp r2clouder settings get * wp r2clouder settings get webp_quality --format=json * wp r2clouder settings set webp_quality 85 * wp r2clouder settings set delete_local true * wp r2clouder settings export > devsyed-media-offloader-settings.json * wp r2clouder settings import devsyed-media-offloader-settings.json * * @subcommand settings * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function settings( $args, $assoc_args ) { $action = isset( $args[0] ) ? $args[0] : ''; switch ( $action ) { case 'get': $this->settings_get( $args, $assoc_args ); break; case 'set': $this->settings_set( $args ); break; case 'export': $this->settings_export( $assoc_args ); break; case 'import': $this->settings_import( $args ); break; default: WP_CLI::error( 'Unknown settings action. Use: get, set, export, or import.' ); } } /** * Prints settings. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ private function settings_get( array $args, array $assoc_args ) { $format = Utils\get_flag_value( $assoc_args, 'format', 'table' ); $settings = Options::get_settings(); $key = isset( $args[1] ) ? $args[1] : ''; if ( '' !== $key ) { if ( ! array_key_exists( $key, $settings ) ) { WP_CLI::error( sprintf( 'Unknown setting: %s', $key ) ); } $value = $settings[ $key ]; if ( 'json' === $format ) { WP_CLI::log( wp_json_encode( array( $key => $value ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); } else { WP_CLI::log( sprintf( '%s: %s', $key, $this->format_value( $value ) ) ); } return; } if ( 'json' === $format ) { WP_CLI::log( wp_json_encode( $settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); return; } $rows = array(); foreach ( $settings as $k => $v ) { $rows[] = array( 'key' => $k, 'value' => $this->format_value( $v ), ); } Utils\format_items( 'table', $rows, array( 'key', 'value' ) ); } /** * Updates a single setting. * * @param array $args Positional arguments. * @return void */ private function settings_set( array $args ) { if ( ! isset( $args[1] ) || ! isset( $args[2] ) ) { WP_CLI::error( 'Usage: wp r2clouder settings set ' ); } $key = $args[1]; $value = $args[2]; $defaults = Options::defaults(); if ( ! array_key_exists( $key, $defaults ) ) { WP_CLI::error( sprintf( 'Unknown setting: %s', $key ) ); } $value = $this->cast_value( $value, $defaults[ $key ] ); $settings = Admin\Admin::sanitize_settings( array( $key => $value ) ); $value = $settings[ $key ]; if ( ! Options::update_settings( $settings ) && array_replace( $settings, Options::get_settings() ) !== $settings ) { WP_CLI::error( 'Could not save settings.' ); } WP_CLI::success( sprintf( 'Updated %s to %s.', $key, $this->format_value( $value ) ) ); } /** * Exports settings and masked credentials to JSON. * * @param array $assoc_args Associative arguments. * @return void */ private function settings_export( array $assoc_args ) { if ( isset( $assoc_args['file'] ) ) { WP_CLI::error( 'Settings export writes to standard output. Use shell redirection to save it to a file.' ); } $data = array( 'settings' => Options::get_settings(), 'credentials' => $this->mask_credentials( Options::get_credentials() ), ); $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); if ( false === $json ) { WP_CLI::error( 'Could not encode settings as JSON.' ); } WP_CLI::log( $json ); } /** * Imports settings from a JSON file. * * @param array $args Positional arguments. * @return void */ private function settings_import( array $args ) { if ( ! isset( $args[1] ) || '' === $args[1] ) { WP_CLI::error( 'Usage: wp r2clouder settings import ' ); } $file = $args[1]; if ( ! is_readable( $file ) ) { WP_CLI::error( sprintf( 'File not readable: %s', $file ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- CLI tool reading an import file. $contents = file_get_contents( $file ); if ( false === $contents ) { WP_CLI::error( sprintf( 'Could not read %s.', $file ) ); } $data = json_decode( $contents, true ); if ( ! is_array( $data ) ) { WP_CLI::error( 'Invalid JSON in the import file.' ); } if ( isset( $data['settings'] ) && is_array( $data['settings'] ) ) { $settings = Admin\Admin::sanitize_settings( $data['settings'] ); if ( ! Options::update_settings( $settings ) && array_replace( $settings, Options::get_settings() ) !== $settings ) { WP_CLI::error( 'Could not save imported settings.' ); } WP_CLI::log( 'Settings imported.' ); } if ( isset( $data['credentials'] ) && is_array( $data['credentials'] ) ) { // Remove masked values so we only import real credential data. $credentials = array_filter( $data['credentials'], static function ( $val ) { return is_string( $val ) && false === strpos( $val, '*' ); } ); if ( ! empty( $credentials ) ) { $credentials = Admin\Admin::sanitize_credentials( $credentials ); if ( ! Options::update_credentials( $credentials ) && array_diff_assoc( $credentials, Options::get_credentials() ) ) { WP_CLI::error( 'Could not save imported credentials.' ); } WP_CLI::log( 'Credentials imported (non-masked values only).' ); } } WP_CLI::success( 'Import complete.' ); } // ------------------------------------------------------------------------- // Attachment query helpers // ------------------------------------------------------------------------- /** * Resolves attachment IDs from --all or --ids flags. * * @param array $assoc_args Associative arguments. * @param string $context Context label for error messages. * @param bool $exclude_offloaded Exclude already-offloaded attachments. * @return int[] Attachment IDs in ascending order. */ private function get_attachment_ids( array $assoc_args, $context = 'command', $exclude_offloaded = false ) { $ids_flag = isset( $assoc_args['ids'] ) ? $assoc_args['ids'] : ''; $all = Utils\get_flag_value( $assoc_args, 'all', false ); $mime = isset( $assoc_args['mime'] ) ? $assoc_args['mime'] : ''; if ( '' !== $ids_flag ) { $ids = array_values( array_filter( array_map( 'absint', explode( ',', $ids_flag ) ) ) ); if ( empty( $ids ) ) { WP_CLI::error( 'No valid attachment IDs in --ids.' ); } sort( $ids ); return $ids; } if ( ! $all ) { WP_CLI::error( sprintf( 'Specify --all or --ids for the %s command.', $context ) ); } return $this->query_all_attachments( $mime, $exclude_offloaded ); } /** * Resolves attachment IDs for commands that operate on offloaded attachments. * * @param array $assoc_args Associative arguments. * @return int[] Attachment IDs. */ private function get_attachment_ids_offloaded( array $assoc_args ) { $ids_flag = isset( $assoc_args['ids'] ) ? $assoc_args['ids'] : ''; $all = Utils\get_flag_value( $assoc_args, 'all', false ); if ( '' !== $ids_flag ) { $ids = array_values( array_filter( array_map( 'absint', explode( ',', $ids_flag ) ) ) ); if ( empty( $ids ) ) { WP_CLI::error( 'No valid attachment IDs in --ids.' ); } sort( $ids ); return $ids; } if ( ! $all ) { WP_CLI::error( 'Specify --all or --ids.' ); } return $this->query_offloaded_attachment_ids(); } /** * Queries all attachment IDs in batches using cursor-based pagination. * * @param string $mime MIME type filter. * @param bool $exclude_offloaded Exclude already-offloaded. * @return int[] */ private function query_all_attachments( $mime = '', $exclude_offloaded = false ) { $ids = array(); $last_id = 0; do { $query_args = array( 'post_type' => 'attachment', 'post_status' => 'any', 'posts_per_page' => self::QUERY_BATCH, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, ); if ( $last_id > 0 ) { // Cursor: only posts with ID > last_id. // WP_Query does not have a native cursor, so we use a where filter. add_filter( 'posts_where', $cursor_filter = static function ( $where ) use ( $last_id ) { global $wpdb; $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $last_id ); return $where; } ); } if ( '' !== $mime ) { $query_args['post_mime_type'] = $mime; } if ( $exclude_offloaded ) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Required to filter already-offloaded attachments. $query_args['meta_query'] = array( array( 'key' => Offload_Meta::META_KEY, 'compare' => 'NOT EXISTS', ), ); } $query = new \WP_Query( $query_args ); $batch_ids = $query->posts; if ( isset( $cursor_filter ) ) { remove_filter( 'posts_where', $cursor_filter ); unset( $cursor_filter ); } if ( empty( $batch_ids ) ) { break; } $batch_ids = array_map( 'intval', $batch_ids ); $batch_size = count( $batch_ids ); $ids = array_merge( $ids, $batch_ids ); $last_id = end( $batch_ids ); } while ( $batch_size >= self::QUERY_BATCH ); if ( empty( $ids ) ) { WP_CLI::error( 'No attachments found matching the criteria.' ); } WP_CLI::log( sprintf( 'Found %d attachment(s).', count( $ids ) ) ); return $ids; } /** * Queries all attachment IDs that have offload meta. * * @return int[] */ private function query_offloaded_attachment_ids() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CLI command; needs live data. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s ORDER BY post_id ASC", Offload_Meta::META_KEY ) ); $ids = array_map( 'intval', $ids ); if ( empty( $ids ) ) { WP_CLI::error( 'No offloaded attachments found.' ); } WP_CLI::log( sprintf( 'Found %d offloaded attachment(s).', count( $ids ) ) ); return $ids; } /** * Collects all object keys from every attachment's offload meta. * * @return array Keys as array keys for O(1) lookup. */ private function collect_all_offload_keys() { global $wpdb; $known = array(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CLI command scanning all meta; must be live. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s", Offload_Meta::META_KEY ), ARRAY_A ); if ( ! is_array( $rows ) ) { return $known; } foreach ( $rows as $row ) { $data = maybe_unserialize( $row['meta_value'] ); $data = Offload_Meta::normalize( $data ); if ( null === $data ) { continue; } foreach ( Offload_Meta::keys( $data, true ) as $key ) { $known[ $key ] = true; } } return $known; } // ------------------------------------------------------------------------- // Formatting helpers // ------------------------------------------------------------------------- /** * Formats a settings value for display. * * @param mixed $value Setting value. * @return string */ private function format_value( $value ) { if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } if ( is_array( $value ) ) { return wp_json_encode( $value ); } return (string) $value; } /** * Casts a string input to match the type of the default value. * * @param string $input User input. * @param mixed $default_value Default value whose type guides the cast. * @return mixed */ private function cast_value( $input, $default_value ) { if ( is_bool( $default_value ) ) { return in_array( strtolower( $input ), array( 'true', '1', 'yes', 'on' ), true ); } if ( is_int( $default_value ) ) { return (int) $input; } if ( is_array( $default_value ) ) { $decoded = json_decode( $input, true ); return is_array( $decoded ) ? $decoded : $default_value; } return (string) $input; } // ------------------------------------------------------------------------- // migrate // ------------------------------------------------------------------------- /** * Moves all media to another bucket, then switches the site over. * * The provider copies the objects; no file bytes pass through this server. * New uploads stay local while the copy runs. The source bucket is never * changed. Provider charges land on your own accounts. * * ## OPTIONS * * * : start, status or cancel. * * [--provider=] * : Target provider, r2 or s3. * * [--bucket=] * : Target bucket. * * [--account-id=] * : Target Cloudflare account id (r2). * * [--region=] * : Target AWS region (s3). * * [--access-key=] * : Target access key. * * [--secret-key=] * : Target secret key. * * [--public-url=] * : Target delivery URL. * * [--role-arn=] * : IAM role ARN for DataSync (r2 to s3). * * [--cf-token=] * : Cloudflare API token with R2 write access (s3 to r2). * * [--yes] * : Confirm that provider charges apply. * * ## EXAMPLES * * wp r2clouder migrate start --provider=s3 --region=eu-west-1 --bucket=media --access-key=… --secret-key=… --public-url=https://media.example.com --role-arn=arn:aws:iam::123456789012:role/datasync --yes * wp r2clouder migrate status * wp r2clouder migrate cancel * * @subcommand migrate * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function migrate( $args, $assoc_args ) { $action = $args[0]; if ( 'status' === $action ) { WP_CLI::print_value( Migration::public_view(), array( 'format' => 'json' ) ); return; } if ( 'cancel' === $action ) { Runner::cancel(); WP_CLI::success( 'Migration cancelled. The source bucket was not changed.' ); return; } if ( 'start' !== $action ) { WP_CLI::error( 'Action must be start, status or cancel.' ); } if ( ! Utils\get_flag_value( $assoc_args, 'yes', false ) ) { WP_CLI::error( 'Pass --yes to confirm that provider charges apply to your own accounts.' ); } $target = array(); foreach ( array( 'provider', 'bucket', 'account_id', 'region', 'access_key', 'secret_key', 'public_url', 'role_arn', 'cf_token' ) as $key ) { $target[ $key ] = (string) Utils\get_flag_value( $assoc_args, str_replace( '_', '-', $key ), '' ); } $created = Migration::create( $target ); if ( is_wp_error( $created ) ) { WP_CLI::error( $created->get_error_message() ); } $started = Runner::start(); if ( is_wp_error( $started ) ) { WP_CLI::error( $started->get_error_message() ); } WP_CLI::success( 'Migration started. Check progress with `wp r2clouder migrate status`.' ); } /** * Masks credential values for export. * * @param array $credentials Credentials. * @return array */ private function mask_credentials( array $credentials ) { $masked = array(); foreach ( $credentials as $key => $value ) { if ( '' === $value ) { $masked[ $key ] = ''; continue; } if ( in_array( $key, array( 'public_url', 'bucket' ), true ) ) { $masked[ $key ] = $value; continue; } $masked[ $key ] = '***'; } return $masked; } }