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
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules/
|
||||
vendor/
|
||||
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
@@ -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).
|
||||
189
assets/r2-manager.js
Executable file
189
assets/r2-manager.js
Executable file
@@ -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('<p>Log cleared.</p>');
|
||||
} 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(
|
||||
'<div class="notice ' + cls + ' is-dismissible"><p>' + msg + '</p></div>'
|
||||
);
|
||||
}
|
||||
|
||||
}(jQuery));
|
||||
275
includes/class-r2-admin-page.php
Executable file
275
includes/class-r2-admin-page.php
Executable file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
/**
|
||||
* What: Registers and renders the "R2 Media Manager" settings page under
|
||||
* Peptide Repo > 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;';
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'R2 Media Manager', 'peptiderepo-r2' ); ?>
|
||||
<span style="font-size:13px;font-weight:normal;color:#666;margin-left:8px;">v<?php echo esc_html( PR_R2_VERSION ); ?></span>
|
||||
</h1>
|
||||
|
||||
<?php /* ── STATUS TABLE ───────────────────────────────────────────── */ ?>
|
||||
<h2><?php esc_html_e( 'Status', 'peptiderepo-r2' ); ?></h2>
|
||||
<table class="widefat striped" style="max-width:520px;margin-bottom:8px;">
|
||||
<tbody>
|
||||
<tr><td><strong><?php esc_html_e( 'Total attachments', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><?php echo esc_html( $s['total'] ); ?></td></tr>
|
||||
<tr><td><strong><?php esc_html_e( 'Offloaded to R2', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><?php echo esc_html( $s['offloaded'] ); ?> (<?php echo esc_html( $s['pct'] ); ?>%)</td></tr>
|
||||
<tr><td><strong><?php esc_html_e( 'Not yet offloaded', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><?php echo esc_html( $s['not_offloaded'] ); ?></td></tr>
|
||||
<tr><td><strong><?php esc_html_e( 'Local copies remaining', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><?php echo esc_html( $s['local_remaining'] ); ?></td></tr>
|
||||
<tr><td><strong><?php esc_html_e( 'R2 endpoint', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><code style="font-size:11px;"><?php echo esc_html( PR_R2_Endpoint::ENDPOINT ); ?></code></td></tr>
|
||||
<tr><td><strong><?php esc_html_e( 'Delivery domain', 'peptiderepo-r2' ); ?></strong></td>
|
||||
<td><code>media.peptiderepo.com</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button id="pr-r2-btn-test" class="button"><?php esc_html_e( 'Test R2 Connection', 'peptiderepo-r2' ); ?></button>
|
||||
<span id="pr-r2-test-result" style="margin-left:10px;font-style:italic;"></span>
|
||||
</p>
|
||||
|
||||
<?php /* ── BULK OPERATIONS ────────────────────────────────────────── */ ?>
|
||||
<h2><?php esc_html_e( 'Bulk Operations', 'peptiderepo-r2' ); ?></h2>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:24px;">
|
||||
|
||||
<div style="<?php echo esc_attr( $card ); ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( 'Migrate to R2', 'peptiderepo-r2' ); ?></h3>
|
||||
<p><?php esc_html_e( 'Upload local files not yet on R2. Safe to run multiple times.', 'peptiderepo-r2' ); ?></p>
|
||||
<button id="pr-r2-btn-migrate" class="button button-primary"
|
||||
<?php disabled( $s['not_offloaded'], 0 ); ?>>
|
||||
<?php
|
||||
echo $s['not_offloaded'] > 0
|
||||
? esc_html( sprintf( __( 'Migrate %d files →', 'peptiderepo-r2' ), $s['not_offloaded'] ) )
|
||||
: esc_html__( 'All files migrated ✓', 'peptiderepo-r2' );
|
||||
?>
|
||||
</button>
|
||||
<button id="pr-r2-btn-migrate-pause" class="button" style="display:none;margin-left:6px;"><?php esc_html_e( 'Pause', 'peptiderepo-r2' ); ?></button>
|
||||
<div id="pr-r2-migrate-progress" style="margin-top:10px;display:none;">
|
||||
<progress id="pr-r2-migrate-bar" value="0" max="100" style="width:100%;height:18px;"></progress>
|
||||
<p id="pr-r2-migrate-status" style="margin:4px 0 0;font-size:12px;color:#555;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="<?php echo esc_attr( $card ); ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( 'Remove Local Copies', 'peptiderepo-r2' ); ?></h3>
|
||||
<p><?php esc_html_e( 'Delete local copies for files already on R2. Frees Hostinger disk. Irreversible.', 'peptiderepo-r2' ); ?></p>
|
||||
<button id="pr-r2-btn-remove" class="button button-secondary"
|
||||
<?php disabled( $s['local_remaining'], 0 ); ?>>
|
||||
<?php
|
||||
echo $s['local_remaining'] > 0
|
||||
? esc_html( sprintf( __( 'Remove %d local copies', 'peptiderepo-r2' ), $s['local_remaining'] ) )
|
||||
: esc_html__( 'No local copies remaining ✓', 'peptiderepo-r2' );
|
||||
?>
|
||||
</button>
|
||||
<button id="pr-r2-btn-remove-pause" class="button" style="display:none;margin-left:6px;"><?php esc_html_e( 'Pause', 'peptiderepo-r2' ); ?></button>
|
||||
<div id="pr-r2-remove-progress" style="margin-top:10px;display:none;">
|
||||
<progress id="pr-r2-remove-bar" value="0" max="100" style="width:100%;height:18px;"></progress>
|
||||
<p id="pr-r2-remove-status" style="margin:4px 0 0;font-size:12px;color:#555;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php /* ── ACTIVITY LOG ─────────────────────────────────────────────── */ ?>
|
||||
<h2><?php esc_html_e( 'Activity Log', 'peptiderepo-r2' ); ?>
|
||||
<button id="pr-r2-btn-clear-log" class="button" style="font-size:12px;margin-left:10px;"><?php esc_html_e( 'Clear log', 'peptiderepo-r2' ); ?></button>
|
||||
</h2>
|
||||
<?php if ( empty( $logs ) ) : ?>
|
||||
<p><?php esc_html_e( 'No log entries yet. Run a migration to populate.', 'peptiderepo-r2' ); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="widefat striped" style="font-size:12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Time', 'peptiderepo-r2' ); ?></th>
|
||||
<th><?php esc_html_e( 'Operation', 'peptiderepo-r2' ); ?></th>
|
||||
<th><?php esc_html_e( 'Status', 'peptiderepo-r2' ); ?></th>
|
||||
<th><?php esc_html_e( 'File', 'peptiderepo-r2' ); ?></th>
|
||||
<th><?php esc_html_e( 'Error', 'peptiderepo-r2' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pr-r2-log-tbody">
|
||||
<?php foreach ( $logs as $row ) : ?>
|
||||
<tr>
|
||||
<td><?php echo esc_html( $row->created_at ); ?></td>
|
||||
<td><?php echo esc_html( $row->operation ); ?></td>
|
||||
<td><?php
|
||||
$colour = 'error' === $row->status ? '#c00' : ( 'skipped' === $row->status ? '#888' : '#090' );
|
||||
printf( '<span style="color:%s;font-weight:bold;">%s</span>', esc_attr( $colour ), esc_html( $row->status ) );
|
||||
?></td>
|
||||
<td style="word-break:break-all;"><?php echo esc_html( basename( $row->file_path ) ); ?></td>
|
||||
<td><?php echo esc_html( $row->error_msg ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="pr-r2-notice" style="margin-top:16px;"></div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current offload status counts.
|
||||
* Public — also consumed by PR_R2_Ajax::handle_get_status().
|
||||
*
|
||||
* @return array{total:int,offloaded:int,pct:int,not_offloaded:int,local_remaining:int}
|
||||
*/
|
||||
public static function get_status(): array {
|
||||
global $wpdb;
|
||||
|
||||
$total = (int) $wpdb->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' );
|
||||
}
|
||||
}
|
||||
292
includes/class-r2-ajax.php
Executable file
292
includes/class-r2-ajax.php
Executable file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
/**
|
||||
* What: AJAX handlers for: batch migrate-to-R2, batch remove-local-files,
|
||||
* test R2 connection, and clear log. All actions are admin-only and
|
||||
* nonce-protected.
|
||||
* Who: wp_ajax_ hooks registered by PR_R2_Ajax::init(). Triggered by the
|
||||
* JS in assets/r2-manager.js via jQuery.post().
|
||||
* Depends: global $as3cf (WP Offload Media Lite), PR_R2_Log, PR_R2_Admin_Page.
|
||||
*
|
||||
* @see includes/class-r2-admin-page.php (renders the buttons that trigger these actions)
|
||||
* @see includes/class-r2-log.php (receives log writes after each file operation)
|
||||
* @see assets/r2-manager.js (drives the batch loop + progress bar)
|
||||
*
|
||||
* AJAX actions:
|
||||
* pr_r2_migrate_batch — upload a batch of unprocessed attachments to R2
|
||||
* pr_r2_remove_local_batch — delete local copies for a batch of offloaded attachments
|
||||
* pr_r2_test_connection — verify the R2 bucket is reachable
|
||||
* pr_r2_clear_log — truncate wp_pr_r2_log
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PR_R2_Ajax {
|
||||
|
||||
/** Attachments processed per AJAX call. 10 keeps each call well under 30s. */
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
public static function init(): void {
|
||||
add_action( 'wp_ajax_pr_r2_migrate_batch', [ __CLASS__, 'handle_migrate_batch' ] );
|
||||
add_action( 'wp_ajax_pr_r2_remove_local_batch', [ __CLASS__, 'handle_remove_local_batch' ] );
|
||||
add_action( 'wp_ajax_pr_r2_test_connection', [ __CLASS__, 'handle_test_connection' ] );
|
||||
add_action( 'wp_ajax_pr_r2_clear_log', [ __CLASS__, 'handle_clear_log' ] );
|
||||
}
|
||||
|
||||
// ── Migrate batch ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload one batch of not-yet-offloaded attachments to R2.
|
||||
*
|
||||
* POST params: nonce, batch_size (optional int, capped at 50).
|
||||
* Returns JSON: { processed, succeeded, failed, remaining, done }.
|
||||
*
|
||||
* Side effects: uploads files to R2, writes as3cf_items rows, writes log rows.
|
||||
*/
|
||||
public static function handle_migrate_batch(): void {
|
||||
self::verify_nonce( 'pr_r2_migrate' );
|
||||
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_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;
|
||||
}
|
||||
}
|
||||
54
includes/class-r2-endpoint.php
Executable file
54
includes/class-r2-endpoint.php
Executable file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/**
|
||||
* What: Injects the Cloudflare R2 endpoint into WP Offload Media's AWS SDK client.
|
||||
* Supersedes wp-content/mu-plugins/peptiderepo-r2-endpoint.php — that
|
||||
* mu-plugin should be deleted once this plugin is active.
|
||||
* Who: Registered via `as3cf_aws_s3_client_args` filter; fires whenever WP
|
||||
* Offload Media initialises its S3 client (uploads, deletes, URL signing).
|
||||
* Depends: WP Offload Media Lite (amazon-s3-and-cloudfront).
|
||||
*
|
||||
* @see peptiderepo-r2-manager.php (bootstraps this class)
|
||||
* @see https://deliciousbrains.com/wp-offload-media/doc/custom-s3-compatible-storage/
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PR_R2_Endpoint {
|
||||
|
||||
/**
|
||||
* Cloudflare R2 account-level S3-compatible endpoint.
|
||||
* Using the account endpoint (not the public URL) so the AWS SDK can reach
|
||||
* the bucket via path-style access regardless of custom domain routing.
|
||||
*/
|
||||
const ENDPOINT = 'https://a16f4baa82a1074c47f696e7cb6d5995.r2.cloudflarestorage.com';
|
||||
|
||||
/**
|
||||
* Register the endpoint-injection filter.
|
||||
* Called once from the plugin bootstrap.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
add_filter( 'as3cf_aws_s3_client_args', [ __CLASS__, 'inject_r2_endpoint' ], 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the AWS SDK S3 client endpoint to Cloudflare R2.
|
||||
*
|
||||
* Uses path-style endpoint so the bucket name appears in the URL path
|
||||
* (required with the account-level R2 endpoint, which doesn't support
|
||||
* virtual-hosted-style bucket subdomains).
|
||||
*
|
||||
* @param array<string,mixed> $args AWS SDK client constructor args from WP Offload Media.
|
||||
* @return array<string,mixed> 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;
|
||||
}
|
||||
}
|
||||
131
includes/class-r2-log.php
Executable file
131
includes/class-r2-log.php
Executable file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* What: Manages the wp_pr_r2_log table used to track every offload and
|
||||
* remove-local operation: success, error, or skipped. Surfaces the
|
||||
* most recent entries on the admin page for visibility.
|
||||
* Who: PR_R2_Log::create_table() called on plugin activation.
|
||||
* PR_R2_Log::write() called by PR_R2_Ajax after each file operation.
|
||||
* PR_R2_Log::get_recent() called by PR_R2_Admin_Page::render().
|
||||
* Depends: WordPress $wpdb global.
|
||||
*
|
||||
* @see includes/class-r2-ajax.php (writes log rows after each operation)
|
||||
* @see includes/class-r2-admin-page.php (reads log rows for display)
|
||||
* @see peptiderepo-r2-manager.php (register_activation_hook + self-heal)
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class PR_R2_Log {
|
||||
|
||||
const TABLE_NAME = 'pr_r2_log';
|
||||
const STATUS_OK = 'success';
|
||||
const STATUS_ERROR = 'error';
|
||||
const STATUS_SKIP = 'skipped';
|
||||
|
||||
/**
|
||||
* Create the log table. Uses CREATE TABLE IF NOT EXISTS for idempotency.
|
||||
* Safe to call repeatedly — no-op if the table already exists.
|
||||
*
|
||||
* @return void
|
||||
* Side effects: may CREATE wp_pr_r2_log in the WordPress database.
|
||||
*/
|
||||
public static function create_table(): void {
|
||||
global $wpdb;
|
||||
$table = $wpdb->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<int,object>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
58
peptiderepo-r2-manager.php
Executable file
58
peptiderepo-r2-manager.php
Executable file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Peptide Repo R2 Manager
|
||||
* Plugin URI: https://peptiderepo.com
|
||||
* Description: Cloudflare R2 media offload manager — admin dashboard for bulk
|
||||
* migration, remove-local operations, connection testing, and error
|
||||
* logging. Supersedes mu-plugin peptiderepo-r2-endpoint.php.
|
||||
* Version: 1.1.0
|
||||
* Author: peptiderepo
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: peptiderepo-r2
|
||||
*
|
||||
* What: Admin plugin for managing Cloudflare R2 media offloading on top of
|
||||
* WP Offload Media Lite (amazon-s3-and-cloudfront). Provides the bulk
|
||||
* migration UI, error log, test-connection, and R2 endpoint injection
|
||||
* that the Lite tier omits.
|
||||
* Who: Loaded by WordPress on every request (active plugin).
|
||||
* Depends: WP Offload Media Lite active; AS3CF_SETTINGS defined in wp-config.php.
|
||||
*
|
||||
* @see wp-config.php (AS3CF_SETTINGS constant with R2 credentials)
|
||||
* @see includes/class-r2-endpoint.php (R2 endpoint injection — replaces mu-plugin)
|
||||
* @see includes/class-r2-log.php (DB log table management)
|
||||
* @see includes/class-r2-admin-page.php (Settings > 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();
|
||||
Reference in New Issue
Block a user