This commit is contained in:
Javier Casares 2026-03-02 11:22:59 +00:00
commit 5cdf7f48e5
29 changed files with 6526 additions and 0 deletions

View file

@ -0,0 +1,342 @@
<?php
/**
* Wizard Step 4 — Content Relations Migration.
*
* Processes post and term relation groups in batches via AJAX.
* State (offsets, totals, completion) is persisted in Wizard_State step 4 data.
*
* @package Robotstxt_Mlp2_Mlp5
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class Robotstxt_Mlp2_Mlp5_Step_Content_Relations
*/
class Robotstxt_Mlp2_Mlp5_Step_Content_Relations {
/**
* Number of groups to process per AJAX batch.
*
* @var int
*/
const BATCH_SIZE = 50;
/**
* Run one batch of content relations.
*
* Reads the current offset from wizard state, processes BATCH_SIZE groups,
* updates state, and returns a progress snapshot.
*
* @return array Progress snapshot (see get_progress()).
*/
public static function run_batch(): array {
$state = Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 4 );
// Initialise totals on first call.
if ( empty( $state ) ) {
$state = array(
'post_total' => Robotstxt_Mlp2_Mlp5_Reader_Content_Relations::count_groups( 'post' ),
'term_total' => Robotstxt_Mlp2_Mlp5_Reader_Content_Relations::count_groups( 'term' ),
'post_offset' => 0,
'term_offset' => 0,
'post_done' => false,
'term_done' => false,
);
}
// Process posts first, then terms.
if ( ! $state['post_done'] ) {
$groups = Robotstxt_Mlp2_Mlp5_Reader_Content_Relations::get_groups_batch( 'post', (int) $state['post_offset'], self::BATCH_SIZE );
$state['post_offset'] += count( $groups );
self::process_groups( $groups );
if ( empty( $groups ) || (int) $state['post_offset'] >= (int) $state['post_total'] ) {
$state['post_done'] = true;
}
} elseif ( ! $state['term_done'] ) {
$groups = Robotstxt_Mlp2_Mlp5_Reader_Content_Relations::get_groups_batch( 'term', (int) $state['term_offset'], self::BATCH_SIZE );
$state['term_offset'] += count( $groups );
self::process_groups( $groups );
if ( empty( $groups ) || (int) $state['term_offset'] >= (int) $state['term_total'] ) {
$state['term_done'] = true;
}
}
Robotstxt_Mlp2_Mlp5_Wizard_State::set_step_data( 4, $state );
return self::build_progress( $state );
}
/**
* Return the current progress snapshot without running any work.
*
* @return array {
* @type int $total Total groups to process.
* @type int $processed Groups processed so far.
* @type int $migrated Successfully written to MLP5.
* @type int $skipped Skipped (orphaned, already exists, etc.).
* @type int $errors Failed writes.
* @type bool $done True when all groups (post + term) have been processed.
* }
*/
public static function get_progress(): array {
$state = Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 4 );
if ( empty( $state ) ) {
return array(
'total' => 0,
'processed' => 0,
'migrated' => 0,
'skipped' => 0,
'errors' => 0,
'done' => false,
);
}
return self::build_progress( $state );
}
/**
* Render the Step 4 UI.
*/
public static function render(): void {
$progress = self::get_progress();
$done = $progress['done'];
$nonce = wp_create_nonce( Robotstxt_Mlp2_Mlp5_Ajax::NONCE_ACTION );
?>
<h2><?php esc_html_e( 'Step 4 — Content Relations', 'robotstxt-mlp2-mlp5' ); ?></h2>
<p><?php esc_html_e( 'This step migrates all post and term relationships in batches. On large sites this can take several minutes. You can pause and resume at any time.', 'robotstxt-mlp2-mlp5' ); ?></p>
<div id="mlp-progress-wrap" style="max-width:500px;margin-bottom:1.5em;">
<p id="mlp-progress-label">
<?php
printf(
/* translators: 1: processed count, 2: total count */
esc_html__( 'Processed %1$s of %2$s groups', 'robotstxt-mlp2-mlp5' ),
'<strong id="mlp-processed">' . esc_html( number_format_i18n( $progress['processed'] ) ) . '</strong>',
'<strong id="mlp-total">' . esc_html( number_format_i18n( $progress['total'] ) ) . '</strong>'
);
?>
</p>
<progress id="mlp-progress-bar" max="<?php echo esc_attr( (string) max( 1, $progress['total'] ) ); ?>" value="<?php echo esc_attr( (string) $progress['processed'] ); ?>" style="width:100%;height:20px;"></progress>
<table class="widefat striped" style="border:0;margin-top:.75em;">
<tbody>
<tr><td><?php esc_html_e( 'Migrated', 'robotstxt-mlp2-mlp5' ); ?></td><td id="mlp-migrated"><?php echo esc_html( number_format_i18n( $progress['migrated'] ) ); ?></td></tr>
<tr><td><?php esc_html_e( 'Skipped', 'robotstxt-mlp2-mlp5' ); ?></td><td id="mlp-skipped"><?php echo esc_html( number_format_i18n( $progress['skipped'] ) ); ?></td></tr>
<tr><td><?php esc_html_e( 'Errors', 'robotstxt-mlp2-mlp5' ); ?></td><td id="mlp-errors"><?php echo esc_html( number_format_i18n( $progress['errors'] ) ); ?></td></tr>
</tbody>
</table>
</div>
<?php if ( $done ) : ?>
<p style="color:#46b450;font-weight:600;"><?php esc_html_e( 'Content relations migration complete.', 'robotstxt-mlp2-mlp5' ); ?></p>
<?php else : ?>
<p>
<button id="mlp-btn-start" class="button button-primary"><?php echo $progress['processed'] > 0 ? esc_html__( 'Resume', 'robotstxt-mlp2-mlp5' ) : esc_html__( 'Start', 'robotstxt-mlp2-mlp5' ); ?></button>
<button id="mlp-btn-pause" class="button" style="display:none;"><?php esc_html_e( 'Pause', 'robotstxt-mlp2-mlp5' ); ?></button>
<span id="mlp-status" style="margin-left:.75em;"></span>
</p>
<?php endif; ?>
<?php if ( $done ) : ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'mlp_migrator_step_4', 'mlp_migrator_nonce' ); ?>
<input type="hidden" name="action" value="mlp_migrator_step" />
<input type="hidden" name="mlp_migrator_step_action" value="4" />
<p><button type="submit" class="button button-primary"><?php esc_html_e( 'Next: Verification', 'robotstxt-mlp2-mlp5' ); ?></button></p>
</form>
<?php endif; ?>
<script>
(function() {
var ajaxUrl = <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>;
var nonce = <?php echo wp_json_encode( $nonce ); ?>;
var running = false;
function updateUI( data ) {
document.getElementById('mlp-processed').textContent = data.processed;
document.getElementById('mlp-total').textContent = data.total;
document.getElementById('mlp-migrated').textContent = data.migrated;
document.getElementById('mlp-skipped').textContent = data.skipped;
document.getElementById('mlp-errors').textContent = data.errors;
var bar = document.getElementById('mlp-progress-bar');
if ( bar ) { bar.max = Math.max(1, data.total); bar.value = data.processed; }
}
function runBatch() {
if ( ! running ) { return; }
var body = new URLSearchParams({ action: 'mlp_migrator_run_batch', nonce: nonce });
fetch( ajaxUrl, { method: 'POST', body: body, credentials: 'same-origin' } )
.then( function(r) { return r.json(); } )
.then( function(res) {
if ( ! res.success ) {
document.getElementById('mlp-status').textContent = res.data.message || '<?php echo esc_js( __( 'Error during migration.', 'robotstxt-mlp2-mlp5' ) ); ?>';
running = false;
document.getElementById('mlp-btn-start').style.display = '';
document.getElementById('mlp-btn-pause').style.display = 'none';
return;
}
updateUI( res.data );
if ( res.data.done ) {
running = false;
document.getElementById('mlp-status').textContent = '<?php echo esc_js( __( 'Done! Reload the page to continue.', 'robotstxt-mlp2-mlp5' ) ); ?>';
document.getElementById('mlp-btn-start').style.display = 'none';
document.getElementById('mlp-btn-pause').style.display = 'none';
setTimeout( function() { window.location.reload(); }, 1500 );
} else {
runBatch();
}
} )
.catch( function() {
document.getElementById('mlp-status').textContent = '<?php echo esc_js( __( 'Network error. Click Resume to continue.', 'robotstxt-mlp2-mlp5' ) ); ?>';
running = false;
document.getElementById('mlp-btn-start').style.display = '';
document.getElementById('mlp-btn-pause').style.display = 'none';
} );
}
var btnStart = document.getElementById('mlp-btn-start');
var btnPause = document.getElementById('mlp-btn-pause');
if ( btnStart ) {
btnStart.addEventListener( 'click', function(e) {
e.preventDefault();
running = true;
btnStart.style.display = 'none';
btnPause.style.display = '';
document.getElementById('mlp-status').textContent = '<?php echo esc_js( __( 'Running…', 'robotstxt-mlp2-mlp5' ) ); ?>';
runBatch();
} );
}
if ( btnPause ) {
btnPause.addEventListener( 'click', function(e) {
e.preventDefault();
running = false;
btnPause.style.display = 'none';
btnStart.style.display = '';
document.getElementById('mlp-status').textContent = '<?php echo esc_js( __( 'Paused.', 'robotstxt-mlp2-mlp5' ) ); ?>';
} );
}
})();
</script>
<?php
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Process a set of groups and log the results.
*
* @param array[] $groups Groups from Reader_Content_Relations::get_groups_batch().
*/
private static function process_groups( array $groups ): void {
foreach ( $groups as $group ) {
$type = $group['type'];
$members = $group['members'];
// Idempotency: skip if the first member is already in MLP5.
$first = $members[0];
if ( Robotstxt_Mlp2_Mlp5_Writer_Content_Relations::pair_exists_in_mlp5( $first['site_id'], $first['element_id'] ) ) {
Robotstxt_Mlp2_Mlp5_Logger::log(
'skipped',
'post_relation' === $type || 'post' === $type ? 'post_relation' : 'term_relation',
$first['site_id'],
$first['element_id'],
__( 'Relation already exists in MLP5 (idempotent skip).', 'robotstxt-mlp2-mlp5' )
);
continue;
}
// Validate members: skip those where site or content no longer exists.
$valid_members = array();
foreach ( $members as $m ) {
if ( ! get_site( $m['site_id'] ) ) {
continue;
}
switch_to_blog( $m['site_id'] );
if ( 'term' === $type ) {
$exists = (bool) get_term( $m['element_id'] );
} else {
$exists = (bool) get_post( $m['element_id'] );
}
restore_current_blog();
if ( $exists ) {
$valid_members[] = $m;
}
}
if ( count( $valid_members ) < 2 ) {
Robotstxt_Mlp2_Mlp5_Logger::log(
'skipped',
'term' === $type ? 'term_relation' : 'post_relation',
$first['site_id'],
$first['element_id'],
__( 'Fewer than 2 valid members after orphan check. Skipped.', 'robotstxt-mlp2-mlp5' )
);
continue;
}
// Write to MLP5.
$context = 'term' === $type ? 'term_relation' : 'post_relation';
$written = Robotstxt_Mlp2_Mlp5_Writer_Content_Relations::create_group( $type, $valid_members );
if ( $written ) {
Robotstxt_Mlp2_Mlp5_Logger::log( 'success', $context, $first['site_id'], $first['element_id'], sprintf( /* translators: %d: member count */ __( 'Group migrated with %d members.', 'robotstxt-mlp2-mlp5' ), count( $valid_members ) ) );
} else {
Robotstxt_Mlp2_Mlp5_Logger::log( 'error', $context, $first['site_id'], $first['element_id'], __( 'Failed to write group to MLP5.', 'robotstxt-mlp2-mlp5' ) );
}
}
}
/**
* Build a progress snapshot from a state array.
*
* @param array $state Step 4 state array.
* @return array
*/
private static function build_progress( array $state ): array {
$post_total = (int) ( $state['post_total'] ?? 0 );
$term_total = (int) ( $state['term_total'] ?? 0 );
$post_offset = (int) ( $state['post_offset'] ?? 0 );
$term_offset = (int) ( $state['term_offset'] ?? 0 );
$post_done = ! empty( $state['post_done'] );
$term_done = ! empty( $state['term_done'] );
$migrated = Robotstxt_Mlp2_Mlp5_Logger::count( array( 'type' => 'success' ) );
$skipped = Robotstxt_Mlp2_Mlp5_Logger::count( array( 'type' => 'skipped' ) );
$errors = Robotstxt_Mlp2_Mlp5_Logger::count( array( 'type' => 'error' ) );
// Subtract site_relation log entries (those belong to step 3).
$migrated -= Robotstxt_Mlp2_Mlp5_Logger::count(
array(
'type' => 'success',
'context' => 'site_relation',
)
);
$skipped -= Robotstxt_Mlp2_Mlp5_Logger::count(
array(
'type' => 'skipped',
'context' => 'site_relation',
)
);
$errors -= Robotstxt_Mlp2_Mlp5_Logger::count(
array(
'type' => 'error',
'context' => 'site_relation',
)
);
return array(
'total' => $post_total + $term_total,
'processed' => $post_offset + $term_offset,
'migrated' => max( 0, $migrated ),
'skipped' => max( 0, $skipped ),
'errors' => max( 0, $errors ),
'done' => $post_done && $term_done,
);
}
}