uploader = new CloudAttachmentUploader($cloud_provider); } catch (\Exception $e) { \WP_CLI::error('Failed to initialize cloud provider: ' . $e->getMessage()); } } /** * Offload media attachments to cloud storage. * * This command allows you to offload WordPress media attachments to your configured * cloud storage provider (S3, Cloudflare R2, DigitalOcean Spaces, MinIO, or Wasabi). * * ## OPTIONS * * [] * : Attachment ID(s) to offload. Can be a single ID or comma-separated list. * --- * examples: * - 123 (single attachment) * - 123,456,789 (multiple attachments) * --- * * [--limit=] * : Maximum number of attachments to process. Only applies when no specific IDs are provided. * Use this to process media in smaller batches to avoid timeouts or memory issues. * --- * default: 0 (no limit) * examples: * - 50 (process up to 50 files) * - 100 (process up to 100 files) * --- * * [--skip-failed] * : Skip attachments that have previously failed offloading. Useful for processing * only clean attachments without retrying known problematic files. * * ## EXAMPLES * * # Offload all unoffloaded media attachments * wp advmo offload * * # Offload a specific attachment by ID * wp advmo offload 123 * * # Offload multiple specific attachments * wp advmo offload 123,456,789 * * # Offload up to 100 most recent attachments * wp advmo offload --limit=100 * * # Offload all attachments, skipping any with previous errors * wp advmo offload --skip-failed * * # Combine flags: offload up to 50 attachments, skip failed ones * wp advmo offload --limit=50 --skip-failed * * # Retry specific failed attachments * wp advmo offload 123,456 * * ## WHAT THIS COMMAND DOES * * 1. **Validates** cloud storage configuration * 2. **Identifies** eligible attachments based on your criteria * 3. **Uploads** original files and all image sizes to cloud storage * 4. **Updates** attachment metadata to mark as offloaded * 5. **Applies** retention policy (keeps/removes local files as configured) * 6. **Reports** detailed success/failure statistics * * ## BEFORE RUNNING * * - Ensure your cloud storage provider is properly configured * - Test with a small batch first: `wp advmo offload --limit=5` * - Consider running during low-traffic periods for large batches * - Check available disk space if using "retain local files" policy * * ## OUTPUT INFORMATION * * The command provides real-time progress indicators and detailed results: * * - **Progress**: "Processing 1/100 attachments..." * - **Success**: "Successfully offloaded 95 attachment(s)" * - **Warnings**: "Attachment ID 999 not found, skipping" * - **Errors**: "Failed to offload attachment ID 123: [error details]" * - **Summary**: "95 successful, 3 failed, 2 skipped out of 100 total" * * ## TROUBLESHOOTING * * If attachments fail to offload: * 1. Check error logs in WordPress admin > Media Offloader > Media Overview * 2. Verify cloud storage credentials and permissions * 3. Ensure files exist on disk and are readable * 4. Try offloading individual files to isolate issues * 5. Check server memory/timeout limits for large batches * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments (flags). */ public function __invoke($args, $assoc_args) { $this->initializeUploader(); $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : 0; $skip_failed = isset($assoc_args['skip-failed']); // Parse attachment IDs from arguments $attachment_ids = $this->parseAttachmentIds($args); if (!empty($attachment_ids)) { $this->offloadSpecificAttachments($attachment_ids, $skip_failed); } else { $this->offloadAllAttachments($limit, $skip_failed); } } /** * Parse attachment IDs from command arguments. * * @param array $args Command arguments. * @return array Array of attachment IDs or empty array. */ private function parseAttachmentIds($args) { if (empty($args[0])) { return []; } $ids_string = $args[0]; // Handle comma-separated list if (strpos($ids_string, ',') !== false) { $ids = explode(',', $ids_string); return array_map('intval', array_filter(array_map('trim', $ids))); } // Handle single ID $id = intval($ids_string); return $id > 0 ? [$id] : []; } /** * Offload specific attachments by ID. * * @param array $attachment_ids Array of attachment IDs. * @param bool $skip_failed Whether to skip failed attachments. */ private function offloadSpecificAttachments($attachment_ids, $skip_failed) { $total = count($attachment_ids); \WP_CLI::log("Processing {$total} specific attachment(s)..."); $successful = 0; $failed = 0; $skipped = 0; foreach ($attachment_ids as $index => $attachment_id) { \WP_CLI::log("Processing attachment ID {$attachment_id}..."); // Validate attachment exists if (!$this->attachmentExists($attachment_id)) { \WP_CLI::warning("Attachment ID {$attachment_id} not found, skipping."); $skipped++; continue; } // Skip if already offloaded if ($this->is_offloaded($attachment_id)) { \WP_CLI::log("Attachment ID {$attachment_id} already offloaded, skipping."); $skipped++; continue; } // Skip failed if flag is set if ($skip_failed && $this->hasErrors($attachment_id)) { \WP_CLI::log("Attachment ID {$attachment_id} has previous errors, skipping."); $skipped++; continue; } // Attempt offload if ($this->processAttachment($attachment_id)) { \WP_CLI::success("Successfully offloaded attachment ID {$attachment_id}"); $successful++; } else { $failed++; $errors = get_post_meta($attachment_id, 'advmo_error_log', true); $error_message = is_array($errors) ? implode('; ', $errors) : $errors; \WP_CLI::error("Failed to offload attachment ID {$attachment_id}: {$error_message}", false); } } $this->displayResults($successful, $failed, $skipped, $total); } /** * Offload all eligible attachments. * * @param int $limit Maximum number of attachments to process. * @param bool $skip_failed Whether to skip failed attachments. */ private function offloadAllAttachments($limit, $skip_failed) { $attachment_ids = $this->getEligibleAttachments($limit, $skip_failed); $total = count($attachment_ids); if ($total === 0) { \WP_CLI::success('No eligible attachments found for offloading.'); return; } \WP_CLI::log("Found {$total} eligible attachment(s) for offloading..."); $successful = 0; $failed = 0; $skipped = 0; foreach ($attachment_ids as $index => $attachment_id) { $current = $index + 1; \WP_CLI::log("Processing attachment ID {$attachment_id} ({$current}/{$total})..."); // Double-check eligibility (in case of concurrent modifications) if ($this->is_offloaded($attachment_id)) { \WP_CLI::log("Attachment ID {$attachment_id} already offloaded, skipping."); $skipped++; continue; } if ($this->processAttachment($attachment_id)) { \WP_CLI::success("Successfully offloaded attachment ID {$attachment_id}"); $successful++; } else { $failed++; $errors = get_post_meta($attachment_id, 'advmo_error_log', true); $error_message = is_array($errors) ? implode('; ', $errors) : $errors; \WP_CLI::error("Failed to offload attachment ID {$attachment_id}: {$error_message}", false); } } $this->displayResults($successful, $failed, $skipped, $total); } /** * Get eligible attachments for offloading. * * @param int $limit Maximum number to retrieve. * @param bool $skip_failed Whether to skip failed attachments. * @return array Array of attachment IDs. */ private function getEligibleAttachments($limit, $skip_failed) { global $wpdb; // Sanitize limit to ensure it's a positive integer $limit = absint($limit); if ($skip_failed) { // Exclude attachments with errors if ($limit > 0) { $query = $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = 'advmo_offloaded' LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = 'advmo_error_log' WHERE p.post_type = 'attachment' AND (pm1.meta_value IS NULL OR pm1.meta_value = '') AND pm2.meta_id IS NULL ORDER BY p.post_date ASC LIMIT %d", $limit ); } else { // No user-supplied values, so no need for prepare() // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are from $wpdb $query = "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = 'advmo_offloaded' LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = 'advmo_error_log' WHERE p.post_type = 'attachment' AND (pm1.meta_value IS NULL OR pm1.meta_value = '') AND pm2.meta_id IS NULL ORDER BY p.post_date ASC"; } } else { // Include all non-offloaded attachments if ($limit > 0) { $query = $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = 'advmo_offloaded' WHERE p.post_type = 'attachment' AND (pm.meta_value IS NULL OR pm.meta_value = '') ORDER BY p.post_date ASC LIMIT %d", $limit ); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are from $wpdb $query = "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = 'advmo_offloaded' WHERE p.post_type = 'attachment' AND (pm.meta_value IS NULL OR pm.meta_value = '') ORDER BY p.post_date ASC"; } } return $wpdb->get_col($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Values are prepared when present; other query parts are trusted WordPress table names and literals. } /** * Process a single attachment for offloading. * * @param int $attachment_id Attachment ID. * @return bool True if successful, false otherwise. */ private function processAttachment($attachment_id) { try { return $this->uploader->uploadExistingAttachment($attachment_id); } catch (\Exception $e) { // Log the error to attachment meta update_post_meta($attachment_id, 'advmo_error_log', $e->getMessage()); return false; } } /** * Check if an attachment exists. * * @param int $attachment_id Attachment ID. * @return bool True if exists, false otherwise. */ private function attachmentExists($attachment_id) { $post = get_post($attachment_id); return $post && $post->post_type === 'attachment'; } /** * Check if attachment has errors. * * @param int $attachment_id Attachment ID. * @return bool True if has errors, false otherwise. */ private function hasErrors($attachment_id) { $errors = get_post_meta($attachment_id, 'advmo_error_log', true); return !empty($errors); } /** * Display operation results. * * @param int $successful Number of successful operations. * @param int $failed Number of failed operations. * @param int $skipped Number of skipped operations. * @param int $total Total number of operations attempted. */ private function displayResults($successful, $failed, $skipped, $total) { if ($successful > 0) { \WP_CLI::success("Successfully offloaded {$successful} attachment(s)."); } if ($failed > 0) { \WP_CLI::warning("{$failed} attachment(s) failed to offload."); } if ($skipped > 0) { \WP_CLI::log("{$skipped} attachment(s) were skipped."); } \WP_CLI::log("Summary: {$successful} successful, {$failed} failed, {$skipped} skipped out of {$total} total."); if ($failed > 0) { \WP_CLI::log('Check attachment error logs or use the Media Overview page for detailed error information.'); } } }