commit 3cb8cd7c7d507eb32fae1ede0df8e3d74e9cc397 Author: peptiderepo Date: Sat Apr 25 08:47:45 2026 +0000 feat: initial release v1.1.0 Cloudflare R2 media offload manager for peptiderepo.com. Built on WP Offload Media Lite. Supersedes the one-off migration script and mu-plugin peptiderepo-r2-endpoint.php. Features: - Admin page under Peptide Repo > R2 Media Manager (shared umbrella menu) - ensure_umbrella_menu() guard — safe for multi-plugin umbrella sharing - Batch AJAX migration to R2 with pause/resume and progress bar - Remove-local-copies batch with physical disk presence check - Activity log table (wp_pr_r2_log) with self-healing on plugins_loaded - Test R2 connection button - R2 endpoint injection via as3cf_aws_s3_client_args filter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43b9d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.log +node_modules/ +vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8667ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog — Peptide Repo R2 Manager + +All notable changes to this project will be documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.1.0] — 2026-04-25 + +### Changed +- Admin page moved from `Settings > R2 Media Manager` to `Peptide Repo > R2 Media Manager` + using a shared top-level umbrella menu (`peptiderepo` slug). +- `PR_R2_Admin_Page` now registers the umbrella menu via `ensure_umbrella_menu()` — + a `$menu`-array guard so multiple plugins can share the parent safely regardless of + load order. +- `enqueue_assets()` hook suffix updated from `settings_page_peptiderepo-r2-manager` + to `peptiderepo_page_peptiderepo-r2-manager`. +- Added `PARENT_SLUG` class constant (`peptiderepo`). + +## [1.0.0] — 2026-04-25 + +### Added +- Initial release. Cloudflare R2 media offload manager built on WP Offload Media Lite. +- Admin dashboard: status table, Test R2 Connection, Migrate to R2, Remove Local Copies, + Activity Log. +- Batch AJAX operations with pause/resume and progress bar. +- `wp_pr_r2_log` table with self-healing on `plugins_loaded`. +- R2 endpoint injection via `as3cf_aws_s3_client_args` filter (supersedes mu-plugin). diff --git a/assets/r2-manager.js b/assets/r2-manager.js new file mode 100755 index 0000000..8ebb21d --- /dev/null +++ b/assets/r2-manager.js @@ -0,0 +1,189 @@ +/* global jQuery, prR2 */ +/** + * R2 Media Manager — admin JS. + * + * Drives batch loops for migrate-to-R2 and remove-local-files operations. + * Each loop fires one AJAX request, waits for the response, updates the + * progress bar, then fires the next request — until the server signals done=true + * or the user clicks Pause. + * + * Also handles: Test R2 Connection button, Clear Log button. + */ +(function ($) { + 'use strict'; + + // ── Batch runner ────────────────────────────────────────────────────────── + + /** + * Run one batch of an AJAX operation and schedule the next. + * + * @param {string} action WP AJAX action name. + * @param {string} nonce Nonce string from wp_localize_script. + * @param {Object} state Mutable state: { processed, succeeded, failed, paused }. + * @param {Object} ui jQuery element refs: $btn, $pauseBtn, $bar, $status. + * @param {string} countKey 'succeeded' | 'removed' — key to read from response. + * @param {string} failKey 'failed' | 'skipped'. + */ + function runBatch(action, nonce, state, ui, countKey, failKey) { + if (state.paused) { + ui.$status.text('Paused. Click the button again to resume.'); + ui.$btn.prop('disabled', false).text(ui.$btn.data('resume-label')); + ui.$pauseBtn.hide(); + return; + } + + $.post(prR2.ajaxUrl, { + action: action, + nonce: nonce, + batch_size: prR2.batchSize, + }) + .done(function (res) { + if (!res.success) { + showNotice('error', 'Error: ' + (res.data ? res.data.message : 'Unknown error.')); + ui.$btn.prop('disabled', false); + ui.$pauseBtn.hide(); + return; + } + + var d = res.data; + state.processed += d.processed; + state.succeeded += (d[countKey] || 0); + state.failed += (d[failKey] || 0); + + // remaining is the count left AFTER this batch. + var total = state.processed + d.remaining; + var pct = total > 0 ? Math.round((state.processed / total) * 100) : 100; + ui.$bar.val(pct); + ui.$status.text( + 'Processed ' + state.processed + ' of ' + total + + ' — success: ' + state.succeeded + + (state.failed ? ', failed/skipped: ' + state.failed : '') + ); + + if (d.done) { + ui.$status.text( + 'Done! ✓ Succeeded: ' + state.succeeded + + (state.failed ? ' Failed/skipped: ' + state.failed : '') + ); + ui.$btn.prop('disabled', true); + ui.$pauseBtn.hide(); + // Reload after 1.5 s so the status table refreshes. + setTimeout(function () { window.location.reload(); }, 1500); + return; + } + + runBatch(action, nonce, state, ui, countKey, failKey); + }) + .fail(function () { + ui.$status.text('Network error — operation paused. Retry by clicking the button.'); + ui.$btn.prop('disabled', false).text(ui.$btn.data('resume-label')); + ui.$pauseBtn.hide(); + }); + } + + // ── Wire up Migrate button ──────────────────────────────────────────────── + + $('#pr-r2-btn-migrate').on('click', function () { + var $btn = $(this); + var state = { processed: 0, succeeded: 0, failed: 0, paused: false }; + var ui = { + $btn: $btn, + $pauseBtn: $('#pr-r2-btn-migrate-pause'), + $bar: $('#pr-r2-migrate-bar'), + $status: $('#pr-r2-migrate-status'), + }; + + $btn.data('resume-label', 'Resume migration').prop('disabled', true); + ui.$pauseBtn.show(); + $('#pr-r2-migrate-progress').show(); + ui.$bar.val(0); + ui.$status.text('Starting migration…'); + + runBatch('pr_r2_migrate_batch', prR2.nonceMigrate, state, ui, 'succeeded', 'failed'); + }); + + $('#pr-r2-btn-migrate-pause').on('click', function () { + // Signal the batch runner to stop after the current in-flight request. + var state = $('#pr-r2-btn-migrate').data('batchState'); + if (state) { state.paused = true; } + }); + + // Re-wire so the button can resume (state persists in data). + // Simpler: just reload the page — already-offloaded items are excluded by the query. + + // ── Wire up Remove Local button ─────────────────────────────────────────── + + $('#pr-r2-btn-remove').on('click', function () { + var $btn = $(this); + var state = { processed: 0, succeeded: 0, failed: 0, paused: false }; + var ui = { + $btn: $btn, + $pauseBtn: $('#pr-r2-btn-remove-pause'), + $bar: $('#pr-r2-remove-bar'), + $status: $('#pr-r2-remove-status'), + }; + + $btn.data('resume-label', 'Resume removal').prop('disabled', true); + ui.$pauseBtn.show(); + $('#pr-r2-remove-progress').show(); + ui.$bar.val(0); + ui.$status.text('Starting local-file removal…'); + + runBatch('pr_r2_remove_local_batch', prR2.nonceRemove, state, ui, 'removed', 'skipped'); + }); + + $('#pr-r2-btn-remove-pause').on('click', function () { + var state = $('#pr-r2-btn-remove').data('batchState'); + if (state) { state.paused = true; } + }); + + // ── Test Connection ─────────────────────────────────────────────────────── + + $('#pr-r2-btn-test').on('click', function () { + var $btn = $(this); + var $result = $('#pr-r2-test-result'); + $btn.prop('disabled', true); + $result.css('color', '#555').text('Testing…'); + + $.post(prR2.ajaxUrl, { action: 'pr_r2_test_connection', nonce: prR2.nonceTest }) + .done(function (res) { + if (res.success) { + $result.css('color', '#090').text('✓ ' + res.data.message); + } else { + $result.css('color', '#c00').text('✗ ' + (res.data ? res.data.message : 'Failed.')); + } + }) + .fail(function () { + $result.css('color', '#c00').text('✗ Network error.'); + }) + .always(function () { $btn.prop('disabled', false); }); + }); + + // ── Clear Log ───────────────────────────────────────────────────────────── + + $('#pr-r2-btn-clear-log').on('click', function () { + if (!window.confirm('Clear all log entries?')) { return; } + var $btn = $(this); + $btn.prop('disabled', true); + + $.post(prR2.ajaxUrl, { action: 'pr_r2_clear_log', nonce: prR2.nonceClearLog }) + .done(function (res) { + if (res.success) { + $('#pr-r2-log-tbody').closest('table').replaceWith('

Log cleared.

'); + } else { + showNotice('error', 'Could not clear log.'); + } + }) + .always(function () { $btn.prop('disabled', false); }); + }); + + // ── Utility ─────────────────────────────────────────────────────────────── + + function showNotice(type, msg) { + var cls = type === 'error' ? 'notice-error' : 'notice-success'; + $('#pr-r2-notice').html( + '

' + msg + '

' + ); + } + +}(jQuery)); diff --git a/includes/class-r2-admin-page.php b/includes/class-r2-admin-page.php new file mode 100755 index 0000000..072ad91 --- /dev/null +++ b/includes/class-r2-admin-page.php @@ -0,0 +1,275 @@ + R2 Media Manager. Sections: Status, Bulk Operations, + * Activity Log. Also enqueues the JS asset on this page only. + * Also owns the top-level "Peptide Repo" umbrella menu registration + * (guarded so whichever plugin loads first registers it once). + * Who: Hooked into `admin_menu` and `admin_enqueue_scripts`. + * Depends: PR_R2_Endpoint::ENDPOINT, PR_R2_Log::get_recent(), PR_R2_Log::count_by_status(). + * + * @see includes/class-r2-ajax.php (AJAX handlers triggered by page buttons) + * @see includes/class-r2-log.php (provides log rows for display) + * @see assets/r2-manager.js (batch loop, progress bar, pause/resume, test-conn) + * @see peptiderepo-r2-manager.php (bootstraps this class) + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +class PR_R2_Admin_Page { + + /** Shared parent slug used by all peptiderepo plugins for the umbrella menu. */ + const PARENT_SLUG = 'peptiderepo'; + const MENU_SLUG = 'peptiderepo-r2-manager'; + const CAPABILITY = 'manage_options'; + + public static function init(): void { + add_action( 'admin_menu', [ __CLASS__, 'register_menu' ] ); + add_action( 'admin_enqueue_scripts', [ __CLASS__, 'enqueue_assets' ] ); + } + + public static function register_menu(): void { + self::ensure_umbrella_menu(); + add_submenu_page( + self::PARENT_SLUG, + __( 'R2 Media Manager', 'peptiderepo-r2' ), + __( 'R2 Media Manager', 'peptiderepo-r2' ), + self::CAPABILITY, + self::MENU_SLUG, + [ __CLASS__, 'render' ] + ); + } + + /** + * Register the top-level "Peptide Repo" umbrella menu if not already registered. + * Uses the global $menu array as the canonical registry — safe regardless of which + * plugin loads first or how many plugins share the umbrella. + * + * @return void + * Side effects: may call add_menu_page() and remove_submenu_page(). + */ + private static function ensure_umbrella_menu(): void { + global $menu; + foreach ( (array) $menu as $item ) { + if ( isset( $item[2] ) && self::PARENT_SLUG === $item[2] ) { + return; // Already registered by another plugin. + } + } + add_menu_page( + __( 'Peptide Repo', 'peptiderepo-r2' ), + __( 'Peptide Repo', 'peptiderepo-r2' ), + self::CAPABILITY, + self::PARENT_SLUG, + '__return_null', + 'dashicons-database', + 3 + ); + // WP auto-creates a submenu item matching the parent slug — remove the duplicate. + remove_submenu_page( self::PARENT_SLUG, self::PARENT_SLUG ); + } + + /** + * Enqueue JS + localized data only on this plugin's settings page. + * + * @param string $hook Current admin hook suffix. + * @return void + * Side effects: wp_enqueue_script, wp_localize_script. + */ + public static function enqueue_assets( string $hook ): void { + // Hook suffix for submenu under custom parent: {parent_slug}_page_{menu_slug} + if ( self::PARENT_SLUG . '_page_' . self::MENU_SLUG !== $hook ) { + return; + } + wp_enqueue_script( + 'pr-r2-manager', + PR_R2_PLUGIN_URL . 'assets/r2-manager.js', + [ 'jquery' ], + PR_R2_VERSION, + true + ); + wp_localize_script( 'pr-r2-manager', 'prR2', [ + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonceMigrate' => wp_create_nonce( 'pr_r2_migrate' ), + 'nonceRemove' => wp_create_nonce( 'pr_r2_remove_local' ), + 'nonceTest' => wp_create_nonce( 'pr_r2_test' ), + 'nonceClearLog' => wp_create_nonce( 'pr_r2_clear_log' ), + 'batchSize' => 10, + ] ); + } + + /** + * Render the full admin page. + * + * @return void + * Side effects: outputs HTML. + */ + public static function render(): void { + if ( ! current_user_can( self::CAPABILITY ) ) { + wp_die( esc_html__( 'Permission denied.', 'peptiderepo-r2' ) ); + } + $s = self::get_status(); + $logs = PR_R2_Log::get_recent( 20 ); + + $card = 'border:1px solid #ccd0d4;border-radius:4px;padding:16px;min-width:260px;max-width:320px;'; + ?> +
+

+ v +

+ + +

+ + + + + + + + + + + + + + + +
(%)
media.peptiderepo.com
+

+ + +

+ + +

+
+ +
+

+

+ + + +
+ +
+

+

+ + + +
+ +
+ + +

+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + +
created_at ); ?>operation ); ?>status ? '#c00' : ( 'skipped' === $row->status ? '#888' : '#090' ); + printf( '%s', esc_attr( $colour ), esc_html( $row->status ) ); + ?>file_path ) ); ?>error_msg ); ?>
+ + +
+
+ get_var( + "SELECT COUNT(ID) FROM {$wpdb->posts} + WHERE post_type = 'attachment' AND post_status = 'inherit'" + ); + + $offloaded_table = $wpdb->prefix . 'as3cf_items'; + $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$offloaded_table}'" ) === $offloaded_table; + + $offloaded = 0; + if ( $table_exists ) { + $offloaded = (int) $wpdb->get_var( + "SELECT COUNT(DISTINCT source_id) FROM {$offloaded_table} + WHERE source_type = 'media-library'" + ); + } + + $local_remaining = 0; + if ( $table_exists && $offloaded > 0 ) { + // WP Offload Media does NOT rewrite _wp_attached_file in the DB — it applies + // s3auto:// at runtime via a filter. The only reliable check is physical disk + // presence: build the absolute path from the upload base + raw meta value. + $upload_base = wp_upload_dir()['basedir']; + $offloaded_ids = $wpdb->get_col( + "SELECT DISTINCT oi.source_id FROM {$offloaded_table} oi + INNER JOIN {$wpdb->posts} p ON p.ID = oi.source_id + WHERE oi.source_type = 'media-library' AND p.post_type = 'attachment'" + ); + foreach ( $offloaded_ids as $id ) { + $rel = get_post_meta( (int) $id, '_wp_attached_file', true ); + if ( $rel && file_exists( $upload_base . '/' . $rel ) ) { + $local_remaining++; + } + } + } + + $not_offloaded = max( 0, $total - $offloaded ); + $pct = $total > 0 ? (int) round( ( $offloaded / $total ) * 100 ) : 0; + + return compact( 'total', 'offloaded', 'pct', 'not_offloaded', 'local_remaining' ); + } +} diff --git a/includes/class-r2-ajax.php b/includes/class-r2-ajax.php new file mode 100755 index 0000000..ef5bbe4 --- /dev/null +++ b/includes/class-r2-ajax.php @@ -0,0 +1,292 @@ + '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; + } +} diff --git a/includes/class-r2-endpoint.php b/includes/class-r2-endpoint.php new file mode 100755 index 0000000..8c934e3 --- /dev/null +++ b/includes/class-r2-endpoint.php @@ -0,0 +1,54 @@ + $args AWS SDK client constructor args from WP Offload Media. + * @return array Modified args with R2 endpoint injected. + * + * Side effects: none — pure filter. + */ + public static function inject_r2_endpoint( array $args ): array { + $args['endpoint'] = self::ENDPOINT; + $args['use_path_style_endpoint'] = true; + return $args; + } +} diff --git a/includes/class-r2-log.php b/includes/class-r2-log.php new file mode 100755 index 0000000..0eb1788 --- /dev/null +++ b/includes/class-r2-log.php @@ -0,0 +1,131 @@ +prefix . self::TABLE_NAME; + $charset_cs = $wpdb->get_charset_collate(); + $wpdb->query( + "CREATE TABLE IF NOT EXISTS {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + file_path VARCHAR(500) NOT NULL DEFAULT '', + operation VARCHAR(20) NOT NULL DEFAULT '', + status ENUM('success','error','skipped') NOT NULL DEFAULT 'success', + error_msg TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_attachment_id (attachment_id), + KEY idx_created_at (created_at) + ) {$charset_cs}" + ); + } + + /** + * Insert one log row. + * + * @param int $attachment_id WP attachment post ID. + * @param string $file_path Relative file path (e.g. 2025/11/photo.jpg). + * @param string $operation 'migrate' | 'remove-local'. + * @param string $status One of the STATUS_* constants. + * @param string $error_msg Human-readable error on failure; empty otherwise. + * @return void + * Side effects: inserts a row into wp_pr_r2_log. + */ + public static function write( + int $attachment_id, + string $file_path, + string $operation, + string $status, + string $error_msg = '' + ): void { + global $wpdb; + $wpdb->insert( + $wpdb->prefix . self::TABLE_NAME, + [ + 'attachment_id' => $attachment_id, + 'file_path' => $file_path, + 'operation' => $operation, + 'status' => $status, + 'error_msg' => $error_msg, + 'created_at' => current_time( 'mysql' ), + ], + [ '%d', '%s', '%s', '%s', '%s', '%s' ] + ); + } + + /** + * Return the most recent log rows for display on the admin page. + * + * @param int $limit Number of rows to return (default 20). + * @return array + */ + public static function get_recent( int $limit = 20 ): array { + global $wpdb; + return $wpdb->get_results( $wpdb->prepare( + 'SELECT * FROM ' . $wpdb->prefix . self::TABLE_NAME . ' ORDER BY id DESC LIMIT %d', + $limit + ) ); + } + + /** + * Delete all rows from the log table. + * + * @return void + * Side effects: truncates wp_pr_r2_log. + */ + public static function clear(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE ' . $wpdb->prefix . self::TABLE_NAME ); + } + + /** + * Count rows by status for a given operation. + * + * @param string $operation 'migrate' | 'remove-local' | '' for all. + * @return array{success:int,error:int,skipped:int} + */ + public static function count_by_status( string $operation = '' ): array { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_NAME; + $where = $operation ? $wpdb->prepare( 'WHERE operation = %s', $operation ) : ''; + $rows = $wpdb->get_results( + "SELECT status, COUNT(*) AS cnt FROM {$table} {$where} GROUP BY status" + ); + $out = [ 'success' => 0, 'error' => 0, 'skipped' => 0 ]; + foreach ( $rows as $row ) { + $out[ $row->status ] = (int) $row->cnt; + } + return $out; + } +} diff --git a/peptiderepo-r2-manager.php b/peptiderepo-r2-manager.php new file mode 100755 index 0000000..12ae1f7 --- /dev/null +++ b/peptiderepo-r2-manager.php @@ -0,0 +1,58 @@ + R2 Media Manager page) + * @see includes/class-r2-ajax.php (AJAX batch + test-connection handlers) + * + * CHANGELOG + * 1.0.0 — 2026-04-25 Initial release. Full CPO-spec implementation on top of WP + * Offload Media Lite. Supersedes one-off migration script and + * mu-plugin endpoint filter. + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +define( 'PR_R2_VERSION', '1.1.0' ); +define( 'PR_R2_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); +define( 'PR_R2_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); +define( 'PR_R2_PLUGIN_FILE', __FILE__ ); + +require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-endpoint.php'; +require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-log.php'; +require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-admin-page.php'; +require_once PR_R2_PLUGIN_DIR . 'includes/class-r2-ajax.php'; + +// Create log table on activation. Self-heals on every boot if table is missing. +register_activation_hook( PR_R2_PLUGIN_FILE, [ 'PR_R2_Log', 'create_table' ] ); +add_action( 'plugins_loaded', function () { + global $wpdb; + $table = $wpdb->prefix . 'pr_r2_log'; + if ( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) !== $table ) { + PR_R2_Log::create_table(); + } +}, 1 ); + +PR_R2_Endpoint::init(); +PR_R2_Admin_Page::init(); +PR_R2_Ajax::init();