'WP Offload Media Lite is not active.' ] ); } $batch_size = min( 50, max( 1, (int) ( $_POST['batch_size'] ?? self::BATCH_SIZE ) ) ); $ids = self::get_not_offloaded_ids( $batch_size ); if ( empty( $ids ) ) { wp_send_json_success( [ 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'remaining' => 0, 'done' => true ] ); } $upload_handler = $as3cf->get_item_handler( 'upload' ); $succeeded = 0; $failed = 0; foreach ( $ids as $id ) { $id = (int) $id; $file_path = get_attached_file( $id ) ?: ''; try { $item = \DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item::create_from_source_id( $id ); if ( ! $item ) { PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_SKIP, 'create_from_source_id returned null' ); $failed++; continue; } $result = $upload_handler->handle( $item ); if ( is_wp_error( $result ) ) { PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_ERROR, $result->get_error_message() ); $failed++; } else { PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_OK ); $succeeded++; } } catch ( \Exception $e ) { PR_R2_Log::write( $id, $file_path, 'migrate', PR_R2_Log::STATUS_ERROR, $e->getMessage() ); $failed++; } } $remaining = self::count_not_offloaded(); wp_send_json_success( [ 'processed' => count( $ids ), 'succeeded' => $succeeded, 'failed' => $failed, 'remaining' => $remaining, 'done' => $remaining === 0, ] ); } // ── Remove-local batch ───────────────────────────────────────────────── /** * Delete local file copies for one batch of already-offloaded attachments. * * POST params: nonce, batch_size (optional int, capped at 50). * Returns JSON: { processed, removed, skipped, remaining, done }. * * Side effects: deletes local files from Hostinger disk, writes log rows. */ public static function handle_remove_local_batch(): void { self::verify_nonce( 'pr_r2_remove_local' ); global $as3cf; if ( ! $as3cf ) { wp_send_json_error( [ 'message' => 'WP Offload Media Lite is not active.' ] ); } $batch_size = min( 50, max( 1, (int) ( $_POST['batch_size'] ?? self::BATCH_SIZE ) ) ); $ids = self::get_local_remaining_ids( $batch_size ); if ( empty( $ids ) ) { wp_send_json_success( [ 'processed' => 0, 'removed' => 0, 'skipped' => 0, 'remaining' => 0, 'done' => true ] ); } $remove_handler = $as3cf->get_item_handler( 'remove-local' ); $removed = 0; $skipped = 0; foreach ( $ids as $id ) { $id = (int) $id; $file_path = get_attached_file( $id ) ?: ''; try { $item = \DeliciousBrains\WP_Offload_Media\Items\Media_Library_Item::get_by_source_id( $id ); if ( ! $item ) { PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_SKIP, 'no as3cf_items row' ); $skipped++; continue; } $result = $remove_handler->handle( $item ); if ( is_wp_error( $result ) ) { PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_ERROR, $result->get_error_message() ); $skipped++; } else { PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_OK ); $removed++; } } catch ( \Exception $e ) { PR_R2_Log::write( $id, $file_path, 'remove-local', PR_R2_Log::STATUS_ERROR, $e->getMessage() ); $skipped++; } } $remaining = self::count_local_remaining(); wp_send_json_success( [ 'processed' => count( $ids ), 'removed' => $removed, 'skipped' => $skipped, 'remaining' => $remaining, 'done' => $remaining === 0, ] ); } // ── Test R2 connection ───────────────────────────────────────────────── /** * Verify the R2 bucket is reachable via a read-only ListObjects call. * * POST params: nonce. * Returns JSON: success { message } | error { message }. * * Side effects: one S3 ListObjectsV2 API call. */ public static function handle_test_connection(): void { self::verify_nonce( 'pr_r2_test' ); global $as3cf; if ( ! $as3cf ) { wp_send_json_error( [ 'message' => 'WP Offload Media Lite is not active.' ] ); } try { $provider = $as3cf->get_storage_provider(); $client = $provider->get_client(); $bucket = $as3cf->get_setting( 'bucket' ); $client->list_objects( [ 'Bucket' => $bucket, 'MaxKeys' => 1 ] ); wp_send_json_success( [ 'message' => "R2 connection OK — bucket '{$bucket}' is accessible." ] ); } catch ( \Exception $e ) { wp_send_json_error( [ 'message' => 'Connection failed: ' . $e->getMessage() ] ); } } // ── Clear log ────────────────────────────────────────────────────────── /** * Truncate the wp_pr_r2_log table. * * POST params: nonce. * Returns JSON: success { message }. * * Side effects: deletes all rows from wp_pr_r2_log. */ public static function handle_clear_log(): void { self::verify_nonce( 'pr_r2_clear_log' ); PR_R2_Log::clear(); wp_send_json_success( [ 'message' => 'Log cleared.' ] ); } // ── Private helpers ──────────────────────────────────────────────────── /** * Verify nonce and capability; send JSON error and die on failure. * * @param string $action Nonce action string. * @return void */ private static function verify_nonce( string $action ): void { if ( ! current_user_can( PR_R2_Admin_Page::CAPABILITY ) ) { wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 ); } if ( ! check_ajax_referer( $action, 'nonce', false ) ) { wp_send_json_error( [ 'message' => 'Invalid or expired nonce.' ], 403 ); } } /** * Return up to $limit attachment IDs with no row in as3cf_items. * Always queries from the start — processed items drop out of the result set. * * @param int $limit * @return int[] */ private static function get_not_offloaded_ids( int $limit ): array { global $wpdb; $table = $wpdb->prefix . 'as3cf_items'; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$table} oi ON oi.source_id = p.ID AND oi.source_type = 'media-library' WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND oi.id IS NULL LIMIT %d", $limit ) ) ); } /** @return int */ private static function count_not_offloaded(): int { global $wpdb; $table = $wpdb->prefix . 'as3cf_items'; return (int) $wpdb->get_var( "SELECT COUNT(p.ID) FROM {$wpdb->posts} p LEFT JOIN {$table} oi ON oi.source_id = p.ID AND oi.source_type = 'media-library' WHERE p.post_type = 'attachment' AND p.post_status = 'inherit' AND oi.id IS NULL" ); } /** * Return up to $limit attachment IDs that are offloaded but have local copies. * Checks physical disk presence — WP Offload Media does not rewrite * _wp_attached_file in the DB; s3auto:// is applied at runtime via filter only. * * @param int $limit * @return int[] */ private static function get_local_remaining_ids( int $limit ): array { global $wpdb; $table = $wpdb->prefix . 'as3cf_items'; $upload_base = wp_upload_dir()['basedir']; $all_ids = $wpdb->get_col( "SELECT DISTINCT oi.source_id FROM {$table} oi INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'" ); $result = []; foreach ( $all_ids as $id ) { if ( count( $result ) >= $limit ) { break; } $rel = get_post_meta( (int) $id, '_wp_attached_file', true ); if ( $rel && file_exists( $upload_base . '/' . $rel ) ) { $result[] = (int) $id; } } return $result; } /** @return int */ private static function count_local_remaining(): int { global $wpdb; $table = $wpdb->prefix . 'as3cf_items'; $upload_base = wp_upload_dir()['basedir']; $all_ids = $wpdb->get_col( "SELECT DISTINCT oi.source_id FROM {$table} oi INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'" ); $count = 0; foreach ( $all_ids as $id ) { $rel = get_post_meta( (int) $id, '_wp_attached_file', true ); if ( $rel && file_exists( $upload_base . '/' . $rel ) ) { $count++; } } return $count; } }