v1.0.0
This commit is contained in:
parent
8caa140991
commit
5cdf7f48e5
29 changed files with 6526 additions and 0 deletions
583
includes/class-robotstxt-mlp2-mlp5-admin-page.php
Normal file
583
includes/class-robotstxt-mlp2-mlp5-admin-page.php
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<?php
|
||||
/**
|
||||
* Network Admin page.
|
||||
*
|
||||
* Registers the plugin settings page under Network Admin > Settings and
|
||||
* orchestrates the full migration wizard. Handles all POST actions for:
|
||||
* - Table rename / restore (preparation)
|
||||
* - Wizard step navigation (steps 1–6)
|
||||
* - Wizard reset
|
||||
* - CSV log export
|
||||
*
|
||||
* Network Admin only. Uses exclusively:
|
||||
* network_admin_menu, network_admin_init.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Admin_Page
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Admin_Page {
|
||||
|
||||
/**
|
||||
* Menu slug for the plugin settings page.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const MENU_SLUG = 'robotstxt-mlp2-mlp5';
|
||||
|
||||
/**
|
||||
* Required capability to access the plugin page.
|
||||
*
|
||||
* The manage_network capability is the standard for Network Admin settings.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CAPABILITY = 'manage_network';
|
||||
|
||||
/**
|
||||
* Register the Network Admin page and all POST/GET handlers.
|
||||
*/
|
||||
public static function init(): void {
|
||||
add_action( 'network_admin_menu', array( static::class, 'register_menu' ) );
|
||||
add_action( 'admin_post_mlp_migrator_rename_tables', array( static::class, 'handle_rename_action' ) );
|
||||
add_action( 'admin_post_mlp_migrator_restore_tables', array( static::class, 'handle_restore_action' ) );
|
||||
add_action( 'admin_post_mlp_migrator_step', array( static::class, 'handle_step_actions' ) );
|
||||
add_action( 'admin_post_mlp_migrator_reset_wizard', array( static::class, 'handle_reset_action' ) );
|
||||
add_action( 'admin_post_mlp_migrator_export_log', array( 'Robotstxt_Mlp2_Mlp5_Step_Verification', 'maybe_export_csv' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the settings page under Network Admin > Settings.
|
||||
*/
|
||||
public static function register_menu(): void {
|
||||
add_submenu_page(
|
||||
'settings.php',
|
||||
__( 'MLP2 to MLP5 Migrator', 'robotstxt-mlp2-mlp5' ),
|
||||
__( 'MLP Migrator', 'robotstxt-mlp2-mlp5' ),
|
||||
self::CAPABILITY,
|
||||
self::MENU_SLUG,
|
||||
array( static::class, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL to this plugin's Network Admin page.
|
||||
*
|
||||
* @param array $extra Optional extra query args.
|
||||
* @return string Absolute URL.
|
||||
*/
|
||||
public static function page_url( array $extra = array() ): string {
|
||||
$args = array_merge( array( 'page' => self::MENU_SLUG ), $extra );
|
||||
return network_admin_url( 'settings.php?' . http_build_query( $args ) );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST handlers (preparation)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle the "Rename MLP2 tables" POST action.
|
||||
*/
|
||||
public static function handle_rename_action(): void {
|
||||
check_admin_referer( 'mlp_migrator_rename', 'mlp_migrator_nonce' );
|
||||
if ( ! current_user_can( self::CAPABILITY ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
$result = Robotstxt_Mlp2_Mlp5_Table_Preparer::rename_to_backup();
|
||||
$args = array( 'renamed' => count( $result['renamed'] ) );
|
||||
if ( ! empty( $result['errors'] ) ) {
|
||||
$args['rename_errors'] = count( $result['errors'] );
|
||||
}
|
||||
wp_safe_redirect( self::page_url( $args ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "Restore original table names" POST action.
|
||||
*/
|
||||
public static function handle_restore_action(): void {
|
||||
check_admin_referer( 'mlp_migrator_restore', 'mlp_migrator_nonce' );
|
||||
if ( ! current_user_can( self::CAPABILITY ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
$result = Robotstxt_Mlp2_Mlp5_Table_Preparer::restore_from_backup();
|
||||
$args = array( 'restored' => count( $result['restored'] ) );
|
||||
if ( ! empty( $result['errors'] ) ) {
|
||||
$args['restore_errors'] = count( $result['errors'] );
|
||||
}
|
||||
wp_safe_redirect( self::page_url( $args ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST handlers (wizard steps)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Route all wizard step POST actions.
|
||||
*/
|
||||
public static function handle_step_actions(): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
if ( ! isset( $_POST['mlp_migrator_step_action'] ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! current_user_can( self::CAPABILITY ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$action = sanitize_key( wp_unslash( $_POST['mlp_migrator_step_action'] ) );
|
||||
|
||||
switch ( $action ) {
|
||||
case '1':
|
||||
self::handle_step_1_next();
|
||||
break;
|
||||
case '2_run':
|
||||
self::handle_step_2_run();
|
||||
break;
|
||||
case '2':
|
||||
self::handle_step_next( 2, 3, 'mlp_migrator_step_2' );
|
||||
break;
|
||||
case '3_run':
|
||||
self::handle_step_3_run();
|
||||
break;
|
||||
case '3':
|
||||
self::handle_step_next( 3, 4, 'mlp_migrator_step_3' );
|
||||
break;
|
||||
case '4':
|
||||
self::handle_step_next( 4, 5, 'mlp_migrator_step_4' );
|
||||
break;
|
||||
case '5':
|
||||
self::handle_step_next( 5, 6, 'mlp_migrator_step_5' );
|
||||
break;
|
||||
case '6_cleanup':
|
||||
self::handle_step_6_cleanup();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Next" from Step 1 (Preflight).
|
||||
*/
|
||||
private static function handle_step_1_next(): void {
|
||||
check_admin_referer( 'mlp_migrator_step_1', 'mlp_migrator_nonce' );
|
||||
$checks = Robotstxt_Mlp2_Mlp5_Step_Preflight::run();
|
||||
if ( $checks['can_proceed'] ) {
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::mark_step_complete( 1 );
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::set_step( 2 );
|
||||
}
|
||||
wp_safe_redirect( self::page_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Run Analysis" from Step 2.
|
||||
*/
|
||||
private static function handle_step_2_run(): void {
|
||||
check_admin_referer( 'mlp_migrator_analysis', 'mlp_migrator_nonce' );
|
||||
$result = Robotstxt_Mlp2_Mlp5_Step_Analysis::run();
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::set_step_data( 2, $result );
|
||||
wp_safe_redirect( self::page_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Start" from Step 3 (Site Relations).
|
||||
*/
|
||||
private static function handle_step_3_run(): void {
|
||||
check_admin_referer( 'mlp_migrator_site_relations', 'mlp_migrator_nonce' );
|
||||
$result = Robotstxt_Mlp2_Mlp5_Step_Site_Relations::run();
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::set_step_data( 3, $result );
|
||||
wp_safe_redirect( self::page_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Next" for a standard step (advance to next, mark complete).
|
||||
*
|
||||
* @param int $current_step Current step number.
|
||||
* @param int $next_step Next step number.
|
||||
* @param string $nonce_action Nonce action string.
|
||||
*/
|
||||
private static function handle_step_next( int $current_step, int $next_step, string $nonce_action ): void {
|
||||
check_admin_referer( $nonce_action, 'mlp_migrator_nonce' );
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::mark_step_complete( $current_step );
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::set_step( $next_step );
|
||||
wp_safe_redirect( self::page_url() );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the cleanup POST from Step 6.
|
||||
*/
|
||||
private static function handle_step_6_cleanup(): void {
|
||||
check_admin_referer( 'mlp_migrator_cleanup', 'mlp_migrator_nonce' );
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$confirm = isset( $_POST['cleanup_confirm'] ) ? wp_unslash( $_POST['cleanup_confirm'] ) : '';
|
||||
if ( 'DELETE' !== $confirm ) {
|
||||
wp_safe_redirect( self::page_url( array( 'cleanup_error' => '1' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
$deactivate = ! empty( $_POST['cleanup_deactivate'] );
|
||||
$drop_tables = ! empty( $_POST['cleanup_drop_tables'] ) && $deactivate;
|
||||
$sitemeta = ! empty( $_POST['cleanup_sitemeta'] );
|
||||
|
||||
if ( $deactivate ) {
|
||||
Robotstxt_Mlp2_Mlp5_Step_Cleanup::deactivate_mlp2();
|
||||
}
|
||||
if ( $drop_tables ) {
|
||||
Robotstxt_Mlp2_Mlp5_Step_Cleanup::drop_backup_tables();
|
||||
}
|
||||
if ( $sitemeta ) {
|
||||
Robotstxt_Mlp2_Mlp5_Step_Cleanup::remove_mlp2_sitemeta();
|
||||
}
|
||||
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::mark_step_complete( 6 );
|
||||
wp_safe_redirect( self::page_url( array( 'cleanup_done' => '1' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the wizard reset POST action.
|
||||
*/
|
||||
public static function handle_reset_action(): void {
|
||||
check_admin_referer( 'mlp_migrator_reset', 'mlp_migrator_nonce' );
|
||||
if ( ! current_user_can( self::CAPABILITY ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to perform this action.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
Robotstxt_Mlp2_Mlp5_Wizard_State::reset();
|
||||
Robotstxt_Mlp2_Mlp5_Logger::clear();
|
||||
wp_safe_redirect( self::page_url( array( 'reset' => '1' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render the full page.
|
||||
*/
|
||||
public static function render(): void {
|
||||
if ( ! current_user_can( self::CAPABILITY ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
|
||||
$has_original = Robotstxt_Mlp2_Mlp5_Table_Preparer::has_original_tables();
|
||||
$has_backup = Robotstxt_Mlp2_Mlp5_Table_Preparer::has_backup_tables();
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$mlp5_active = $mlp5->is_active();
|
||||
|
||||
// Once MLP5 is active its own tables match the mlp_* pattern.
|
||||
// They are not leftover MLP2 tables, so suppress the "must be renamed" signal.
|
||||
if ( $mlp5_active ) {
|
||||
$has_original = false;
|
||||
}
|
||||
|
||||
// Migration is complete when wizard step 6 is marked done.
|
||||
$wizard_done = Robotstxt_Mlp2_Mlp5_Wizard_State::is_step_complete( Robotstxt_Mlp2_Mlp5_Wizard_State::TOTAL_STEPS );
|
||||
$ready = ( $has_backup && $mlp5_active ) || ( $wizard_done && $mlp5_active );
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'MLP2 to MLP5 Migrator', 'robotstxt-mlp2-mlp5' ); ?></h1>
|
||||
|
||||
<?php self::render_feedback_notices(); ?>
|
||||
|
||||
<?php if ( $ready ) : ?>
|
||||
<?php self::render_wizard( $has_original, $has_backup, $mlp5_active, $mlp5 ); ?>
|
||||
<?php else : ?>
|
||||
<?php self::render_state_banner( $has_original, $has_backup, $mlp5_active ); ?>
|
||||
<?php self::render_status_card( $has_original, $has_backup, $mlp5_active, $mlp5 ); ?>
|
||||
<?php self::render_action_forms( $has_original, $has_backup, $mlp5_active ); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the full wizard when the system is ready (State C).
|
||||
*
|
||||
* @param bool $has_original Whether original MLP2 tables exist.
|
||||
* @param bool $has_backup Whether backup MLP2 tables exist.
|
||||
* @param bool $mlp5_active Whether MLP5 is network-active.
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp5 $mlp5 MLP5 detector instance.
|
||||
*/
|
||||
private static function render_wizard(
|
||||
bool $has_original,
|
||||
bool $has_backup,
|
||||
bool $mlp5_active,
|
||||
Robotstxt_Mlp2_Mlp5_Detector_Mlp5 $mlp5
|
||||
): void {
|
||||
$current_step = Robotstxt_Mlp2_Mlp5_Wizard_State::get_current_step();
|
||||
$step_labels = array(
|
||||
1 => __( 'Preflight', 'robotstxt-mlp2-mlp5' ),
|
||||
2 => __( 'Analysis', 'robotstxt-mlp2-mlp5' ),
|
||||
3 => __( 'Site Relations', 'robotstxt-mlp2-mlp5' ),
|
||||
4 => __( 'Content Relations', 'robotstxt-mlp2-mlp5' ),
|
||||
5 => __( 'Verification', 'robotstxt-mlp2-mlp5' ),
|
||||
6 => __( 'Cleanup', 'robotstxt-mlp2-mlp5' ),
|
||||
);
|
||||
?>
|
||||
<div style="background:#fff;border:1px solid #c3c4c7;padding:1.5em;max-width:780px;">
|
||||
|
||||
<?php /* Step indicator */ ?>
|
||||
<ol style="display:flex;list-style:none;margin:0 0 1.5em;padding:0;gap:0;">
|
||||
<?php for ( $i = 1; $i <= Robotstxt_Mlp2_Mlp5_Wizard_State::TOTAL_STEPS; $i++ ) : ?>
|
||||
<?php
|
||||
$is_done = Robotstxt_Mlp2_Mlp5_Wizard_State::is_step_complete( $i );
|
||||
$is_current = ( $i === $current_step );
|
||||
if ( $is_current ) {
|
||||
$bg = '#2271b1';
|
||||
$color = '#fff';
|
||||
} elseif ( $is_done ) {
|
||||
$bg = '#00a32a';
|
||||
$color = '#fff';
|
||||
} else {
|
||||
$bg = '#f0f0f1';
|
||||
$color = '#50575e';
|
||||
}
|
||||
?>
|
||||
<li style="flex:1;text-align:center;padding:.5em .25em;background:<?php echo esc_attr( $bg ); ?>;color:<?php echo esc_attr( $color ); ?>;font-size:.85em;font-weight:<?php echo $is_current ? '700' : '400'; ?>;border-right:2px solid #fff;">
|
||||
<?php echo esc_html( $i . '. ' . $step_labels[ $i ] ); ?>
|
||||
<?php echo $is_done ? ' ✓' : ''; ?>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
</ol>
|
||||
|
||||
<?php /* Step content */ ?>
|
||||
<div style="min-height:200px;">
|
||||
<?php
|
||||
switch ( $current_step ) {
|
||||
case 1:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Preflight::render();
|
||||
break;
|
||||
case 2:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Analysis::render();
|
||||
break;
|
||||
case 3:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Site_Relations::render();
|
||||
break;
|
||||
case 4:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Content_Relations::render();
|
||||
break;
|
||||
case 5:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Verification::render();
|
||||
break;
|
||||
case 6:
|
||||
Robotstxt_Mlp2_Mlp5_Step_Cleanup::render();
|
||||
break;
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php /* Reset disclosure */ ?>
|
||||
<details style="margin-top:2em;border-top:1px solid #e2e2e2;padding-top:1em;">
|
||||
<summary style="cursor:pointer;color:#646970;"><?php esc_html_e( 'Reset migration (start over)', 'robotstxt-mlp2-mlp5' ); ?></summary>
|
||||
<div style="margin-top:.75em;padding:.75em;border:1px solid #dcdcde;background:#fff8f8;">
|
||||
<p><?php esc_html_e( 'This will reset the wizard to Step 1 and clear the migration log. It does not modify MLP5 data.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_reset', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_reset_wizard" />
|
||||
<button type="submit" class="button button-secondary"><?php esc_html_e( 'Reset wizard', 'robotstxt-mlp2-mlp5' ); ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<?php /* Status sidebar (compact, always visible) */ ?>
|
||||
<div style="margin-top:1em;max-width:780px;">
|
||||
<details>
|
||||
<summary style="cursor:pointer;color:#646970;"><?php esc_html_e( 'System status', 'robotstxt-mlp2-mlp5' ); ?></summary>
|
||||
<div style="margin-top:.5em;">
|
||||
<?php self::render_status_card( $has_original, $has_backup, $mlp5_active, $mlp5 ); ?>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared UI helpers (used in both preparation and wizard views)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Show feedback notices after redirects.
|
||||
*/
|
||||
private static function render_feedback_notices(): void {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET['renamed'] ) ) {
|
||||
$count = (int) $_GET['renamed'];
|
||||
/* translators: %d: number of renamed tables */
|
||||
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html( sprintf( _n( '%d table renamed successfully.', '%d tables renamed successfully.', $count, 'robotstxt-mlp2-mlp5' ), $count ) ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders
|
||||
}
|
||||
if ( isset( $_GET['rename_errors'] ) ) {
|
||||
$count = (int) $_GET['rename_errors'];
|
||||
/* translators: %d: number of tables that could not be renamed */
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( sprintf( _n( '%d table could not be renamed. Check database permissions.', '%d tables could not be renamed. Check database permissions.', $count, 'robotstxt-mlp2-mlp5' ), $count ) ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders
|
||||
}
|
||||
if ( isset( $_GET['restored'] ) ) {
|
||||
$count = (int) $_GET['restored'];
|
||||
/* translators: %d: number of restored tables */
|
||||
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html( sprintf( _n( '%d table restored to original name.', '%d tables restored to original names.', $count, 'robotstxt-mlp2-mlp5' ), $count ) ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders
|
||||
}
|
||||
if ( isset( $_GET['restore_errors'] ) ) {
|
||||
$count = (int) $_GET['restore_errors'];
|
||||
/* translators: %d: number of tables that could not be restored */
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( sprintf( _n( '%d table could not be restored. Check database permissions.', '%d tables could not be restored. Check database permissions.', $count, 'robotstxt-mlp2-mlp5' ), $count ) ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders
|
||||
}
|
||||
if ( isset( $_GET['reset'] ) ) {
|
||||
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html__( 'Wizard reset. You can start the migration from Step 1.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
if ( isset( $_GET['cleanup_done'] ) ) {
|
||||
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html__( 'Cleanup completed.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
if ( isset( $_GET['cleanup_error'] ) ) {
|
||||
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html__( 'Cleanup confirmation invalid. Type DELETE exactly.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a banner communicating the overall system state (preparation view only).
|
||||
*
|
||||
* @param bool $has_original Whether original MLP2 tables exist.
|
||||
* @param bool $has_backup Whether backup MLP2 tables exist.
|
||||
* @param bool $mlp5_active Whether MLP5 is network-active.
|
||||
*/
|
||||
private static function render_state_banner( bool $has_original, bool $has_backup, bool $mlp5_active ): void {
|
||||
if ( $has_original && ! $has_backup ) {
|
||||
$color = '#d63638';
|
||||
$msg = __( 'Action required: MLP2 tables must be renamed before MultilingualPress 5 can be activated.', 'robotstxt-mlp2-mlp5' );
|
||||
} elseif ( $has_backup && ! $mlp5_active ) {
|
||||
$color = '#dba617';
|
||||
$msg = __( 'MLP2 tables renamed. Now go to Network Admin > Plugins and activate MultilingualPress 5.', 'robotstxt-mlp2-mlp5' );
|
||||
} elseif ( $has_original && $has_backup ) {
|
||||
$color = '#dba617';
|
||||
$msg = __( 'Warning: both original and backup MLP2 tables were found. Investigate before proceeding.', 'robotstxt-mlp2-mlp5' );
|
||||
} else {
|
||||
$color = '#d63638';
|
||||
$msg = __( 'No MLP2 tables found. Migration cannot proceed. Restore your database from a backup.', 'robotstxt-mlp2-mlp5' );
|
||||
}
|
||||
printf(
|
||||
'<div style="border-left:4px solid %s;padding:.75em 1em;margin:1em 0;background:#fff;max-width:640px;"><strong>%s</strong></div>',
|
||||
esc_attr( $color ),
|
||||
wp_kses_post( $msg )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system status card.
|
||||
*
|
||||
* @param bool $has_original Whether original MLP2 tables exist.
|
||||
* @param bool $has_backup Whether backup MLP2 tables exist.
|
||||
* @param bool $mlp5_active Whether MLP5 is network-active.
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp5 $mlp5 MLP5 detector instance.
|
||||
*/
|
||||
private static function render_status_card(
|
||||
bool $has_original,
|
||||
bool $has_backup,
|
||||
bool $mlp5_active,
|
||||
Robotstxt_Mlp2_Mlp5_Detector_Mlp5 $mlp5
|
||||
): void {
|
||||
$orig_count = count( Robotstxt_Mlp2_Mlp5_Table_Preparer::get_original_tables() );
|
||||
$backup_count = count( Robotstxt_Mlp2_Mlp5_Table_Preparer::get_backup_tables() );
|
||||
?>
|
||||
<div class="card" style="max-width:640px;margin-top:1em;">
|
||||
<h2 style="margin-top:0;"><?php esc_html_e( 'System Status', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
<table class="widefat striped" style="border:0;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'MLP2 tables (original names)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $has_original ) : ?>
|
||||
<span style="color:#d63638;">● <?php echo esc_html( sprintf( /* translators: %d: number of tables */ _n( '%d table — must be renamed', '%d tables — must be renamed', $orig_count, 'robotstxt-mlp2-mlp5' ), $orig_count ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#46b450;">✓ <?php esc_html_e( 'None', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'MLP2 tables (backup copies)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $has_backup ) : ?>
|
||||
<span style="color:#46b450;">✓ <?php echo esc_html( sprintf( /* translators: %d: number of backup tables */ _n( '%d backup table ready', '%d backup tables ready', $backup_count, 'robotstxt-mlp2-mlp5' ), $backup_count ) ); // phpcs:ignore WordPress.WP.I18n.MismatchedPlaceholders ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#888;">— <?php esc_html_e( 'Not yet renamed', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'MultilingualPress 5 (network-active)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $mlp5_active ) : ?>
|
||||
<span style="color:#46b450;">✓ <?php echo esc_html( sprintf( /* translators: %s: version */ __( 'Active (v%s)', 'robotstxt-mlp2-mlp5' ), $mlp5->get_version() ) ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#888;">— <?php esc_html_e( 'Not active', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render action forms for the preparation view.
|
||||
*
|
||||
* @param bool $has_original Whether original MLP2 tables exist.
|
||||
* @param bool $has_backup Whether backup MLP2 tables exist.
|
||||
* @param bool $mlp5_active Whether MLP5 is network-active.
|
||||
*/
|
||||
private static function render_action_forms( bool $has_original, bool $has_backup, bool $mlp5_active ): void {
|
||||
if ( $has_original ) {
|
||||
?>
|
||||
<div style="max-width:640px;margin-top:1.5em;">
|
||||
<h2><?php esc_html_e( 'Step 1 — Rename MLP2 Tables', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
<p><?php esc_html_e( 'MultilingualPress 5 cannot activate while the original MLP2 table names are present. Click below to rename them to temporary backup copies ({prefix}mlp2bak_*). This is safe and fully reversible.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_rename', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_rename_tables" />
|
||||
<p><button type="submit" class="button button-primary"><?php esc_html_e( 'Rename MLP2 tables (prepare for MLP5 activation)', 'robotstxt-mlp2-mlp5' ); ?></button></p>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
if ( $has_backup && ! $mlp5_active ) {
|
||||
?>
|
||||
<div style="max-width:640px;margin-top:1.5em;">
|
||||
<h2><?php esc_html_e( 'Step 2 — Activate MultilingualPress 5', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
<p><?php esc_html_e( 'MLP2 tables have been renamed. MultilingualPress 5 can now be activated without the old-version compatibility error.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
<p><a href="<?php echo esc_url( network_admin_url( 'plugins.php' ) ); ?>" class="button button-primary"><?php esc_html_e( 'Go to Network Admin › Plugins', 'robotstxt-mlp2-mlp5' ); ?></a></p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
if ( $has_backup ) {
|
||||
?>
|
||||
<div style="max-width:640px;margin-top:2em;">
|
||||
<details>
|
||||
<summary style="cursor:pointer;color:#646970;"><?php esc_html_e( 'Emergency: restore original MLP2 table names', 'robotstxt-mlp2-mlp5' ); ?></summary>
|
||||
<div style="margin-top:.75em;padding:.75em;border:1px solid #dcdcde;background:#fff8f8;">
|
||||
<p><?php esc_html_e( 'This renames backup tables back to their original MLP2 names. Only use this before MLP5 has been activated.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_restore', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_restore_tables" />
|
||||
<button type="submit" class="button button-secondary"><?php esc_html_e( 'Restore original table names', 'robotstxt-mlp2-mlp5' ); ?></button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
}
|
||||
56
includes/class-robotstxt-mlp2-mlp5-ajax.php
Normal file
56
includes/class-robotstxt-mlp2-mlp5-ajax.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
/**
|
||||
* AJAX handler registry.
|
||||
*
|
||||
* All AJAX actions for the migration wizard are registered here.
|
||||
* Every handler verifies a nonce and the manage_network capability
|
||||
* before performing any work.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Ajax
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Ajax {
|
||||
|
||||
/**
|
||||
* Nonce action used for all batch AJAX requests.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const NONCE_ACTION = 'mlp_migrator_batch';
|
||||
|
||||
/**
|
||||
* Register all AJAX hooks. Called once on plugins_loaded.
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wp_ajax_mlp_migrator_run_batch', array( static::class, 'action_run_batch' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: run one content-relations migration batch.
|
||||
*
|
||||
* Verifies nonce and capability, delegates to Step_Content_Relations,
|
||||
* and returns a JSON progress snapshot.
|
||||
*/
|
||||
public static function action_run_batch(): void {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$raw_nonce = isset( $_POST['nonce'] ) ? wp_unslash( $_POST['nonce'] ) : '';
|
||||
if ( ! wp_verify_nonce( (string) $raw_nonce, self::NONCE_ACTION ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Invalid nonce.' ), 403 );
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'manage_network' ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
|
||||
}
|
||||
|
||||
$progress = Robotstxt_Mlp2_Mlp5_Step_Content_Relations::run_batch();
|
||||
|
||||
wp_send_json_success( $progress );
|
||||
}
|
||||
}
|
||||
64
includes/class-robotstxt-mlp2-mlp5-db-upgrader.php
Normal file
64
includes/class-robotstxt-mlp2-mlp5-db-upgrader.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
/**
|
||||
* Database schema upgrader.
|
||||
*
|
||||
* Compares the installed DB schema version with the current version constant
|
||||
* and runs any pending upgrade routines in sequence.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Db_Upgrader
|
||||
*
|
||||
* How to add a new schema version:
|
||||
* 1. Increment ROBOTSTXT_MLP2_MLP5_DB_VERSION in robotstxt-mlp2-mlp5.php (e.g. '1.1').
|
||||
* 2. Add a private static method upgrade_to_1_1() with the schema changes.
|
||||
* 3. Add a version_compare block for '1.1' inside maybe_upgrade().
|
||||
* 4. Update changelog.txt and CHANGELOG.md.
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Db_Upgrader {
|
||||
|
||||
/**
|
||||
* Run any pending DB upgrade routines.
|
||||
*
|
||||
* Reads the installed schema version from sitemeta and compares it with
|
||||
* the ROBOTSTXT_MLP2_MLP5_DB_VERSION constant. If the installed version
|
||||
* is lower, all intermediate upgrade methods are executed in order and
|
||||
* the stored version is updated to the current value.
|
||||
*
|
||||
* Safe to call on every request — exits immediately when no upgrade is
|
||||
* needed (one cached sitemeta read).
|
||||
*/
|
||||
public static function maybe_upgrade(): void {
|
||||
$installed = (string) get_site_option( Robotstxt_Mlp2_Mlp5_Logger::DB_VERSION_KEY, '0' );
|
||||
$current = ROBOTSTXT_MLP2_MLP5_DB_VERSION;
|
||||
|
||||
if ( version_compare( $installed, $current, '>=' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute upgrade routines in ascending version order.
|
||||
if ( version_compare( $installed, '1.0', '<' ) ) {
|
||||
self::upgrade_to_1_0();
|
||||
}
|
||||
|
||||
// Future version upgrade blocks go here, following the same pattern above.
|
||||
|
||||
update_site_option( Robotstxt_Mlp2_Mlp5_Logger::DB_VERSION_KEY, $current );
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade DB schema to version 1.0.
|
||||
*
|
||||
* Creates the migration log table for the first time. dbDelta() is
|
||||
* idempotent and handles both initial creation and incremental ALTER safely.
|
||||
*/
|
||||
private static function upgrade_to_1_0(): void {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::create_table();
|
||||
}
|
||||
}
|
||||
282
includes/class-robotstxt-mlp2-mlp5-detector-mlp2.php
Normal file
282
includes/class-robotstxt-mlp2-mlp5-detector-mlp2.php
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<?php
|
||||
/**
|
||||
* MultilingualPress 2 detection service.
|
||||
*
|
||||
* Detects whether MultilingualPress 2 is installed (active or inactive),
|
||||
* reads its plugin metadata, and identifies its database tables and schema.
|
||||
*
|
||||
* Note: MLP2 does not need to be active for migration. Only its database
|
||||
* tables need to exist. See docs/ROADMAP.md — Background section.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Detector_Mlp2
|
||||
*
|
||||
* Instantiate once to detect MLP2; all detection results are cached in
|
||||
* instance properties to avoid repeated file or database reads.
|
||||
*
|
||||
* Usage:
|
||||
* $mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
* if ( $mlp2->get_site_relations_table() !== '' ) { ... }
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Detector_Mlp2 {
|
||||
|
||||
/**
|
||||
* Known plugin basename for MultilingualPress 2 (WordPress.org slug).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PLUGIN_BASENAME = 'multilingual-press/multilingual-press.php';
|
||||
|
||||
/**
|
||||
* Resolved MLP2 plugin basename, or empty string if not found on disk.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $plugin_file;
|
||||
|
||||
/**
|
||||
* Plugin header data from get_plugin_data(), or empty array if unavailable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private array $plugin_data;
|
||||
|
||||
/**
|
||||
* Constructor. Locates the MLP2 plugin file and loads its header data.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->plugin_file = $this->find_plugin_file();
|
||||
$this->plugin_data = $this->plugin_file ? $this->load_plugin_data() : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether MultilingualPress 2 is currently network-active.
|
||||
*
|
||||
* Informational only. MLP2 may be deactivated or uninstalled while its
|
||||
* database tables still exist. Table presence is the migration gate, not
|
||||
* plugin activity.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_active(): bool {
|
||||
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
return '' !== $this->plugin_file && is_plugin_active_for_network( $this->plugin_file );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MLP2 version string, or empty string if unavailable.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_version(): string {
|
||||
return isset( $this->plugin_data['Version'] ) ? (string) $this->plugin_data['Version'] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the resolved MLP2 plugin basename, or empty string if not on disk.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_plugin_file(): string {
|
||||
return $this->plugin_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given table exists in the database.
|
||||
*
|
||||
* @param string $table Full table name (including prefix).
|
||||
* @return bool
|
||||
*/
|
||||
public function table_exists( string $table ): bool {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full MLP2 site relations table name, or empty string if absent.
|
||||
*
|
||||
* Checks the original name first. If the original has been renamed by the
|
||||
* Table_Preparer, returns the backup name instead so that later phases can
|
||||
* read the data regardless of whether preparation has run.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_site_relations_table(): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->base_prefix . 'mlp_site_relations';
|
||||
$backup = Robotstxt_Mlp2_Mlp5_Table_Preparer::backup_name( $table );
|
||||
|
||||
// Check backup first: after preparation the original name belongs to MLP5.
|
||||
if ( '' !== $backup && $this->table_exists( $backup ) ) {
|
||||
return $backup;
|
||||
}
|
||||
|
||||
// Fall back to original only when no backup tables exist (preparation not done yet).
|
||||
if ( ! Robotstxt_Mlp2_Mlp5_Table_Preparer::has_backup_tables() && $this->table_exists( $table ) ) {
|
||||
return $table;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full MLP2 content relations table name, or empty string if absent.
|
||||
*
|
||||
* MLP2 uses `mlp_relationships` in older minor versions,
|
||||
* `mlp_content_relations` in newer ones, and `multilingual_linked` in some
|
||||
* installations (table name lacks the mlp_ prefix and is never renamed by
|
||||
* the Table_Preparer). All three are checked. If the original table has been
|
||||
* renamed by the Table_Preparer, its backup name is returned.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_content_relations_table(): string {
|
||||
global $wpdb;
|
||||
|
||||
// After preparation, original names belong to MLP5; only look at backups.
|
||||
$preparation_done = Robotstxt_Mlp2_Mlp5_Table_Preparer::has_backup_tables();
|
||||
|
||||
foreach ( array( 'mlp_relationships', 'mlp_content_relations' ) as $candidate ) {
|
||||
$table = $wpdb->base_prefix . $candidate;
|
||||
$backup = Robotstxt_Mlp2_Mlp5_Table_Preparer::backup_name( $table );
|
||||
|
||||
if ( '' !== $backup && $this->table_exists( $backup ) ) {
|
||||
return $backup;
|
||||
}
|
||||
|
||||
// Fall back to original only when preparation has not run yet.
|
||||
if ( ! $preparation_done && $this->table_exists( $table ) ) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
// Some MLP2 installations store content relations in `multilingual_linked`
|
||||
// (no mlp_ prefix). This table is never touched by Table_Preparer, so it
|
||||
// remains accessible regardless of whether preparation has run.
|
||||
$linked = $wpdb->base_prefix . 'multilingual_linked';
|
||||
if ( $this->table_exists( $linked ) ) {
|
||||
return $linked;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all tables in the database that match the MLP table prefix pattern.
|
||||
*
|
||||
* This may include tables belonging to MLP5 if it uses the same prefix.
|
||||
* Use get_site_relations_table() and get_content_relations_table() for
|
||||
* precise MLP2-specific detection.
|
||||
*
|
||||
* @return string[] Array of full table names.
|
||||
*/
|
||||
public function get_all_tables(): array {
|
||||
global $wpdb;
|
||||
|
||||
$like = $wpdb->esc_like( $wpdb->base_prefix . 'mlp_' ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
|
||||
|
||||
return is_array( $tables ) ? $tables : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return column definitions for a table.
|
||||
*
|
||||
* @param string $table Full table name (must start with the DB base prefix).
|
||||
* @return array Associative array keyed by column name (Field), or empty array
|
||||
* if the table does not exist or the name fails validation.
|
||||
*/
|
||||
public function describe_table( string $table ): array {
|
||||
global $wpdb;
|
||||
|
||||
// Validate: table name must start with the base prefix.
|
||||
if ( ! str_starts_with( $table, $wpdb->base_prefix ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
if ( ! $this->table_exists( $table ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$columns = $wpdb->get_results( "SHOW COLUMNS FROM {$table}", ARRAY_A );
|
||||
|
||||
if ( empty( $columns ) || ! is_array( $columns ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$result = array();
|
||||
foreach ( $columns as $column ) {
|
||||
if ( isset( $column['Field'] ) ) {
|
||||
$result[ $column['Field'] ] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the MLP2 plugin file on disk.
|
||||
*
|
||||
* Checks the known canonical basename first. If not found, scans all
|
||||
* installed plugins for a plugin named "MultilingualPress" with a
|
||||
* version below 3.0.0 (which marks the MLP3+ series).
|
||||
*
|
||||
* @return string Plugin basename, or empty string if not found.
|
||||
*/
|
||||
private function find_plugin_file(): string {
|
||||
if ( file_exists( WP_PLUGIN_DIR . '/' . self::PLUGIN_BASENAME ) ) {
|
||||
return self::PLUGIN_BASENAME;
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'get_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
foreach ( get_plugins() as $basename => $data ) {
|
||||
if (
|
||||
isset( $data['Name'], $data['Version'] ) &&
|
||||
'MultilingualPress' === $data['Name'] &&
|
||||
version_compare( (string) $data['Version'], '3.0.0', '<' )
|
||||
) {
|
||||
return $basename;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin header data from the MLP2 main file.
|
||||
*
|
||||
* @return array Plugin data from get_plugin_data(), or empty array on failure.
|
||||
*/
|
||||
private function load_plugin_data(): array {
|
||||
if ( ! function_exists( 'get_plugin_data' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
$path = WP_PLUGIN_DIR . '/' . $this->plugin_file;
|
||||
|
||||
if ( ! file_exists( $path ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return get_plugin_data( $path, false, false );
|
||||
}
|
||||
}
|
||||
218
includes/class-robotstxt-mlp2-mlp5-detector-mlp5.php
Normal file
218
includes/class-robotstxt-mlp2-mlp5-detector-mlp5.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
/**
|
||||
* MultilingualPress 5 detection service.
|
||||
*
|
||||
* Detects whether MultilingualPress 5 is installed (active or inactive),
|
||||
* reads its plugin metadata, and identifies its database tables.
|
||||
*
|
||||
* MLP5 must be network-active for migration to proceed. Unlike MLP2,
|
||||
* the plugin itself needs to be running so its API is available.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Detector_Mlp5
|
||||
*
|
||||
* Instantiate once to detect MLP5; all detection results are cached in
|
||||
* instance properties to avoid repeated file or database reads.
|
||||
*
|
||||
* Usage:
|
||||
* $mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
* if ( $mlp5->is_active() ) { ... }
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Detector_Mlp5 {
|
||||
|
||||
/**
|
||||
* Known plugin basename for MultilingualPress 5 (commercial, Inpsyde).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PLUGIN_BASENAME = 'multilingualpress/multilingualpress.php';
|
||||
|
||||
/**
|
||||
* Resolved MLP5 plugin basename, or empty string if not found on disk.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $plugin_file;
|
||||
|
||||
/**
|
||||
* Plugin header data from get_plugin_data(), or empty array if unavailable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private array $plugin_data;
|
||||
|
||||
/**
|
||||
* Constructor. Locates the MLP5 plugin file and loads its header data.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->plugin_file = $this->find_plugin_file();
|
||||
$this->plugin_data = $this->plugin_file ? $this->load_plugin_data() : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether MultilingualPress 5 is currently network-active.
|
||||
*
|
||||
* MLP5 must be active for migration to proceed; its API tables and
|
||||
* internal state are only available when the plugin is running.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_active(): bool {
|
||||
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
return '' !== $this->plugin_file && is_plugin_active_for_network( $this->plugin_file );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MLP5 version string, or empty string if unavailable.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_version(): string {
|
||||
return isset( $this->plugin_data['Version'] ) ? (string) $this->plugin_data['Version'] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the resolved MLP5 plugin basename, or empty string if not on disk.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_plugin_file(): string {
|
||||
return $this->plugin_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MLP5 site relations table name, or empty string if absent.
|
||||
*
|
||||
* MLP5 uses the same mlp_site_relations table name as MLP2.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_site_relations_table(): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->base_prefix . 'mlp_site_relations';
|
||||
|
||||
return $this->table_exists( $table ) ? $table : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MLP5 relationship groups table name, or empty string if absent.
|
||||
*
|
||||
* MLP5 stores one row per group of related content here (id, type).
|
||||
* The content_relations table references this table via relationship_id.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_relationships_table(): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->base_prefix . 'mlp_relationships';
|
||||
|
||||
return $this->table_exists( $table ) ? $table : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the MLP5 content relations table name, or empty string if absent.
|
||||
*
|
||||
* MLP5 uses mlp_content_relations (the newer name introduced in MLP2
|
||||
* late versions and carried forward).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_content_relations_table(): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->base_prefix . 'mlp_content_relations';
|
||||
|
||||
return $this->table_exists( $table ) ? $table : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all tables in the database that match the MLP table prefix pattern.
|
||||
*
|
||||
* @return string[] Array of full table names.
|
||||
*/
|
||||
public function get_all_tables(): array {
|
||||
global $wpdb;
|
||||
|
||||
$like = $wpdb->esc_like( $wpdb->base_prefix . 'mlp_' ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
|
||||
|
||||
return is_array( $tables ) ? $tables : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given table exists in the database.
|
||||
*
|
||||
* @param string $table Full table name (including prefix).
|
||||
* @return bool
|
||||
*/
|
||||
private function table_exists( string $table ): bool {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the MLP5 plugin file on disk.
|
||||
*
|
||||
* Checks the known canonical basename first. If not found, scans all
|
||||
* installed plugins for a plugin named "MultilingualPress" with a
|
||||
* version of 3.0.0 or higher (MLP3+ series includes MLP5).
|
||||
*
|
||||
* @return string Plugin basename, or empty string if not found.
|
||||
*/
|
||||
private function find_plugin_file(): string {
|
||||
if ( file_exists( WP_PLUGIN_DIR . '/' . self::PLUGIN_BASENAME ) ) {
|
||||
return self::PLUGIN_BASENAME;
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'get_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
foreach ( get_plugins() as $basename => $data ) {
|
||||
if (
|
||||
isset( $data['Name'], $data['Version'] ) &&
|
||||
'MultilingualPress' === $data['Name'] &&
|
||||
version_compare( (string) $data['Version'], '3.0.0', '>=' )
|
||||
) {
|
||||
return $basename;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin header data from the MLP5 main file.
|
||||
*
|
||||
* @return array Plugin data from get_plugin_data(), or empty array on failure.
|
||||
*/
|
||||
private function load_plugin_data(): array {
|
||||
if ( ! function_exists( 'get_plugin_data' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
$path = WP_PLUGIN_DIR . '/' . $this->plugin_file;
|
||||
|
||||
if ( ! file_exists( $path ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return get_plugin_data( $path, false, false );
|
||||
}
|
||||
}
|
||||
290
includes/class-robotstxt-mlp2-mlp5-logger.php
Normal file
290
includes/class-robotstxt-mlp2-mlp5-logger.php
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<?php
|
||||
/**
|
||||
* Migration log manager.
|
||||
*
|
||||
* Handles creation, insertion, retrieval, truncation, and CSV export
|
||||
* of the migration log table.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Logger
|
||||
*
|
||||
* All queries in this class are intentionally direct — the log table is
|
||||
* write-once per operation and must never be read from an object cache.
|
||||
* Per-line DirectDatabaseQuery suppressions are applied throughout.
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Logger {
|
||||
|
||||
/**
|
||||
* Sitemeta key used to track the installed DB schema version.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const DB_VERSION_KEY = 'mlp_migrator_db_version';
|
||||
|
||||
/**
|
||||
* Return the fully qualified log table name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->base_prefix . 'mlp_migrator_log';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or update) the log table via dbDelta().
|
||||
*
|
||||
* Safe to call multiple times — dbDelta() is idempotent and only alters
|
||||
* the table when the schema has changed. The DB upgrader is responsible
|
||||
* for calling this method and for updating the stored schema version.
|
||||
*/
|
||||
public static function create_table(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table_name();
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
// Note: dbDelta() requires two spaces before PRIMARY KEY.
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
created_at datetime NOT NULL,
|
||||
type varchar(20) NOT NULL,
|
||||
context varchar(50) NOT NULL,
|
||||
source_id bigint(20) unsigned DEFAULT NULL,
|
||||
target_id bigint(20) unsigned DEFAULT NULL,
|
||||
message text NOT NULL,
|
||||
metadata longtext DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY type (type),
|
||||
KEY context (context)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the log table and remove the schema version option.
|
||||
*
|
||||
* Only called from uninstall.php when the user has explicitly opted in
|
||||
* to data deletion via the plugin settings.
|
||||
*/
|
||||
public static function drop_table(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table_name();
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
|
||||
|
||||
delete_site_option( self::DB_VERSION_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a log entry.
|
||||
*
|
||||
* @param string $type Entry type: 'success', 'skipped', or 'error'.
|
||||
* @param string $context Operation context: 'site_relation', 'post_relation', 'term_relation', or 'cleanup'.
|
||||
* @param int|null $source_id Source content or site ID, or null if not applicable.
|
||||
* @param int|null $target_id Target content or site ID, or null if not applicable.
|
||||
* @param string $message Human-readable description of the operation result.
|
||||
* @param array $metadata Optional additional structured data (stored as JSON).
|
||||
*
|
||||
* @return int Inserted row ID, or 0 on failure.
|
||||
*/
|
||||
public static function log(
|
||||
string $type,
|
||||
string $context,
|
||||
?int $source_id,
|
||||
?int $target_id,
|
||||
string $message,
|
||||
array $metadata = array()
|
||||
): int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
$inserted = $wpdb->insert(
|
||||
self::table_name(),
|
||||
array(
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
'type' => $type,
|
||||
'context' => $context,
|
||||
'source_id' => $source_id,
|
||||
'target_id' => $target_id,
|
||||
'message' => $message,
|
||||
'metadata' => empty( $metadata ) ? null : wp_json_encode( $metadata ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%d', '%d', '%s', '%s' )
|
||||
);
|
||||
|
||||
return $inserted ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve log entries.
|
||||
*
|
||||
* @param array $args {
|
||||
* Optional. Query arguments.
|
||||
*
|
||||
* @type string $type Filter by type: 'success', 'skipped', or 'error'.
|
||||
* @type string $context Filter by context: 'site_relation', 'post_relation', 'term_relation', or 'cleanup'.
|
||||
* @type int $limit Maximum number of rows to return. Default 50.
|
||||
* @type int $offset Row offset for pagination. Default 0.
|
||||
* @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
|
||||
* }
|
||||
*
|
||||
* @return array Array of row objects, or an empty array.
|
||||
*/
|
||||
public static function get_entries( array $args = array() ): array {
|
||||
global $wpdb;
|
||||
|
||||
$args = wp_parse_args(
|
||||
$args,
|
||||
array(
|
||||
'type' => '',
|
||||
'context' => '',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
'order' => 'DESC',
|
||||
)
|
||||
);
|
||||
|
||||
$table = self::table_name();
|
||||
$order = 'ASC' === strtoupper( (string) $args['order'] ) ? 'ASC' : 'DESC';
|
||||
$where = '';
|
||||
$values = array();
|
||||
|
||||
if ( ! empty( $args['type'] ) ) {
|
||||
$where .= ' AND type = %s';
|
||||
$values[] = $args['type'];
|
||||
}
|
||||
|
||||
if ( ! empty( $args['context'] ) ) {
|
||||
$where .= ' AND context = %s';
|
||||
$values[] = $args['context'];
|
||||
}
|
||||
|
||||
$values[] = (int) $args['limit'];
|
||||
$values[] = (int) $args['offset'];
|
||||
|
||||
// Table name and ORDER BY direction are hardcoded — not from user input.
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
return (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE 1=1{$where} ORDER BY id {$order} LIMIT %d OFFSET %d",
|
||||
$values
|
||||
)
|
||||
);
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
/**
|
||||
* Count log entries.
|
||||
*
|
||||
* Accepts the same filter arguments as get_entries() except limit, offset, and order.
|
||||
*
|
||||
* @param array $args Optional. 'type' and/or 'context' filter keys.
|
||||
*
|
||||
* @return int Total count of matching entries.
|
||||
*/
|
||||
public static function count( array $args = array() ): int {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table_name();
|
||||
$where = '';
|
||||
$values = array();
|
||||
|
||||
if ( ! empty( $args['type'] ) ) {
|
||||
$where .= ' AND type = %s';
|
||||
$values[] = $args['type'];
|
||||
}
|
||||
|
||||
if ( ! empty( $args['context'] ) ) {
|
||||
$where .= ' AND context = %s';
|
||||
$values[] = $args['context'];
|
||||
}
|
||||
|
||||
if ( empty( $values ) ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE 1=1{$where}", $values )
|
||||
);
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate the log table.
|
||||
*
|
||||
* Removes all rows without dropping the table structure. Used when
|
||||
* restarting a migration from scratch.
|
||||
*/
|
||||
public static function clear(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table_name();
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query( "TRUNCATE TABLE {$table}" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the full log as a CSV string.
|
||||
*
|
||||
* Columns: id, created_at, type, context, source_id, target_id, message, metadata.
|
||||
* Rows are ordered by id ASC.
|
||||
*
|
||||
* @return string CSV content with a header row, or an empty string on failure.
|
||||
*/
|
||||
public static function export_csv(): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table_name();
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY id ASC", ARRAY_A );
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
|
||||
$buffer = fopen( 'php://temp', 'r+' );
|
||||
|
||||
if ( false === $buffer ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
fputcsv( $buffer, array( 'id', 'created_at', 'type', 'context', 'source_id', 'target_id', 'message', 'metadata' ) );
|
||||
|
||||
if ( ! empty( $rows ) ) {
|
||||
foreach ( $rows as $row ) {
|
||||
fputcsv(
|
||||
$buffer,
|
||||
array(
|
||||
$row['id'],
|
||||
$row['created_at'],
|
||||
$row['type'],
|
||||
$row['context'],
|
||||
$row['source_id'] ?? '',
|
||||
$row['target_id'] ?? '',
|
||||
$row['message'],
|
||||
$row['metadata'] ?? '',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rewind( $buffer );
|
||||
$csv = stream_get_contents( $buffer );
|
||||
fclose( $buffer ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
|
||||
|
||||
return is_string( $csv ) ? $csv : '';
|
||||
}
|
||||
}
|
||||
197
includes/class-robotstxt-mlp2-mlp5-reader-content-relations.php
Normal file
197
includes/class-robotstxt-mlp2-mlp5-reader-content-relations.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
/**
|
||||
* MLP2 content relations reader.
|
||||
*
|
||||
* Reads post and term relation data from the MLP2 backup content_relations
|
||||
* table. Relations are grouped by (ml_source_blogid, ml_source_elementid)
|
||||
* so that each group can be written as a single MLP5 relationship.
|
||||
*
|
||||
* MLP2 backup table schema (mlp2bak_content_relations or mlp2bak_relationships):
|
||||
* ml_source_blogid INT -- source site ID
|
||||
* ml_source_elementid INT -- source element (post/term) ID
|
||||
* ml_blogid INT -- target site ID
|
||||
* ml_elementid INT -- target element ID
|
||||
* ml_type VARCHAR -- 'post' or 'term'
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Reader_Content_Relations
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Reader_Content_Relations {
|
||||
|
||||
/**
|
||||
* Count distinct source groups for a given type.
|
||||
*
|
||||
* @param string $type 'post' or 'term'.
|
||||
* @return int
|
||||
*/
|
||||
public static function count_groups( string $type ): int {
|
||||
global $wpdb;
|
||||
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
$table = $mlp2->get_content_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
list( $src_blog, $src_elem ) = self::source_columns( $mlp2, $table );
|
||||
if ( '' === $src_blog ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$type_safe = ( 'term' === $type ) ? 'term' : 'post';
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT {$src_blog}, {$src_elem}) FROM `{$table}` WHERE ml_type = %s",
|
||||
$type_safe
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a batch of distinct source groups, with all their translation members.
|
||||
*
|
||||
* Each returned element is:
|
||||
* [
|
||||
* 'type' => string, // 'post' or 'term'
|
||||
* 'members' => [
|
||||
* ['site_id' => int, 'element_id' => int],
|
||||
* ...
|
||||
* ]
|
||||
* ]
|
||||
*
|
||||
* The source element itself is always included as a member.
|
||||
*
|
||||
* @param string $type 'post' or 'term'.
|
||||
* @param int $offset Offset into the distinct source list.
|
||||
* @param int $limit Number of groups to return.
|
||||
* @return array[]
|
||||
*/
|
||||
public static function get_groups_batch( string $type, int $offset, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
$table = $mlp2->get_content_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
list( $src_blog, $src_elem ) = self::source_columns( $mlp2, $table );
|
||||
if ( '' === $src_blog ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$type_safe = ( 'term' === $type ) ? 'term' : 'post';
|
||||
|
||||
// Fetch a page of distinct sources.
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$sources = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT {$src_blog} AS source_blog, {$src_elem} AS source_elem FROM `{$table}` WHERE ml_type = %s ORDER BY {$src_blog}, {$src_elem} LIMIT %d OFFSET %d",
|
||||
$type_safe,
|
||||
$limit,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( empty( $sources ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$groups = array();
|
||||
|
||||
foreach ( $sources as $source ) {
|
||||
$src_blog_id = (int) $source['source_blog'];
|
||||
$src_elem_id = (int) $source['source_elem'];
|
||||
|
||||
// Fetch all translations for this source.
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$translations = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT ml_blogid, ml_elementid FROM `{$table}` WHERE ml_type = %s AND {$src_blog} = %d AND {$src_elem} = %d",
|
||||
$type_safe,
|
||||
$src_blog_id,
|
||||
$src_elem_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
// Build members: source + all translations (deduplicated).
|
||||
$seen = array();
|
||||
$members = array();
|
||||
|
||||
$source_key = $src_blog_id . ':' . $src_elem_id;
|
||||
$seen[ $source_key ] = true;
|
||||
$members[] = array(
|
||||
'site_id' => $src_blog_id,
|
||||
'element_id' => $src_elem_id,
|
||||
);
|
||||
|
||||
foreach ( (array) $translations as $t ) {
|
||||
$key = ( (int) $t['ml_blogid'] ) . ':' . ( (int) $t['ml_elementid'] );
|
||||
if ( isset( $seen[ $key ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$seen[ $key ] = true;
|
||||
$members[] = array(
|
||||
'site_id' => (int) $t['ml_blogid'],
|
||||
'element_id' => (int) $t['ml_elementid'],
|
||||
);
|
||||
}
|
||||
|
||||
if ( count( $members ) >= 2 ) {
|
||||
$groups[] = array(
|
||||
'type' => $type_safe,
|
||||
'members' => $members,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the source-blog and source-element column names.
|
||||
*
|
||||
* Returns ['', ''] when columns cannot be determined.
|
||||
*
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2 MLP2 detector.
|
||||
* @param string $table Content relations table name.
|
||||
* @return string[] [ $source_blog_col, $source_element_col ]
|
||||
*/
|
||||
private static function source_columns( Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2, string $table ): array {
|
||||
$cols = $mlp2->describe_table( $table );
|
||||
|
||||
$blog_col = '';
|
||||
foreach ( array( 'ml_source_blogid', 'ml_blogid' ) as $c ) {
|
||||
if ( isset( $cols[ $c ] ) ) {
|
||||
$blog_col = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$elem_col = '';
|
||||
foreach ( array( 'ml_source_elementid', 'ml_elementid' ) as $c ) {
|
||||
if ( isset( $cols[ $c ] ) ) {
|
||||
$elem_col = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array( $blog_col, $elem_col );
|
||||
}
|
||||
}
|
||||
58
includes/class-robotstxt-mlp2-mlp5-reader-site-relations.php
Normal file
58
includes/class-robotstxt-mlp2-mlp5-reader-site-relations.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
/**
|
||||
* MLP2 site relations reader.
|
||||
*
|
||||
* Reads all site connection rows from the MLP2 backup site_relations table.
|
||||
*
|
||||
* MLP2 site_relations schema:
|
||||
* site_1 INT UNSIGNED -- lower site ID
|
||||
* site_2 INT UNSIGNED -- higher site ID
|
||||
* PRIMARY KEY (site_1, site_2)
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Reader_Site_Relations
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Reader_Site_Relations {
|
||||
|
||||
/**
|
||||
* Return all MLP2 site relation rows as an array of associative arrays.
|
||||
*
|
||||
* Each element has keys: site_1 (int), site_2 (int).
|
||||
*
|
||||
* @return array[]
|
||||
*/
|
||||
public static function get_all(): array {
|
||||
global $wpdb;
|
||||
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
$table = $mlp2->get_site_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results( "SELECT site_1, site_2 FROM `{$table}`", ARRAY_A );
|
||||
|
||||
if ( ! is_array( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$result = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$result[] = array(
|
||||
'site_1' => (int) $row['site_1'],
|
||||
'site_2' => (int) $row['site_2'],
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
390
includes/class-robotstxt-mlp2-mlp5-step-analysis.php
Normal file
390
includes/class-robotstxt-mlp2-mlp5-step-analysis.php
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
<?php
|
||||
/**
|
||||
* Wizard Step 2 — Analysis.
|
||||
*
|
||||
* Scans MLP2 backup tables and MLP5 tables to count records, detect
|
||||
* orphaned content, and validate schema compatibility before migration.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Step_Analysis
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Step_Analysis {
|
||||
|
||||
/**
|
||||
* Run the full analysis and return a structured result.
|
||||
*
|
||||
* @return array {
|
||||
* @type int $site_relations_count MLP2 site relation rows.
|
||||
* @type int $post_groups_count Distinct MLP2 post-relation groups (by source).
|
||||
* @type int $term_groups_count Distinct MLP2 term-relation groups (by source).
|
||||
* @type int[] $orphaned_site_ids Site IDs referenced in MLP2 that no longer exist.
|
||||
* @type int $orphaned_posts_count Count of orphaned post references (sampled).
|
||||
* @type int $orphaned_terms_count Count of orphaned term references (sampled).
|
||||
* @type int $duplicate_relations Count of duplicate content relation rows.
|
||||
* @type bool $mlp5_schema_ok True if MLP5 tables have expected columns.
|
||||
* @type string $mlp5_schema_error Human-readable schema error, or empty string.
|
||||
* @type string $mlp2_content_table Backup content relations table name.
|
||||
* @type string $mlp2_site_table Backup site relations table name.
|
||||
* @type string $mlp5_content_table MLP5 content relations table name.
|
||||
* @type string $mlp5_site_table MLP5 site relations table name.
|
||||
* @type string $mlp5_relationships_table MLP5 relationships group table name.
|
||||
* }
|
||||
*/
|
||||
public static function run(): array {
|
||||
global $wpdb;
|
||||
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
|
||||
$mlp2_site_table = $mlp2->get_site_relations_table();
|
||||
$mlp2_content_table = $mlp2->get_content_relations_table();
|
||||
$mlp5_site_table = $mlp5->get_site_relations_table();
|
||||
$mlp5_content_table = $mlp5->get_content_relations_table();
|
||||
$mlp5_rel_table = $mlp5->get_relationships_table();
|
||||
|
||||
// --- Counts ---
|
||||
$site_count = '';
|
||||
if ( '' !== $mlp2_site_table ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$site_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$mlp2_site_table}`" );
|
||||
}
|
||||
|
||||
$post_count = 0;
|
||||
$term_count = 0;
|
||||
if ( '' !== $mlp2_content_table ) {
|
||||
$col = self::source_blog_col( $mlp2, $mlp2_content_table );
|
||||
$id = self::source_element_col( $mlp2, $mlp2_content_table );
|
||||
|
||||
if ( '' !== $col && '' !== $id ) {
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$post_count = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT {$col}, {$id}) FROM `{$mlp2_content_table}` WHERE ml_type = 'post'" );
|
||||
$term_count = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT {$col}, {$id}) FROM `{$mlp2_content_table}` WHERE ml_type = 'term'" );
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
|
||||
// --- Orphaned sites ---
|
||||
$orphaned_sites = self::detect_orphaned_sites( $mlp2_site_table );
|
||||
|
||||
// --- Orphaned posts/terms (sampled) ---
|
||||
$orphaned_posts = self::detect_orphaned_content( $mlp2_content_table, 'post', 100 );
|
||||
$orphaned_terms = self::detect_orphaned_content( $mlp2_content_table, 'term', 100 );
|
||||
|
||||
// --- Duplicates ---
|
||||
$duplicates = self::count_duplicate_relations( $mlp2_content_table );
|
||||
|
||||
// --- MLP5 schema validation ---
|
||||
list( $schema_ok, $schema_error ) = self::validate_mlp5_schema( $mlp5_site_table, $mlp5_content_table, $mlp5_rel_table, $mlp2, $mlp2_site_table, $mlp2_content_table );
|
||||
|
||||
return array(
|
||||
'site_relations_count' => (int) $site_count,
|
||||
'post_groups_count' => $post_count,
|
||||
'term_groups_count' => $term_count,
|
||||
'orphaned_site_ids' => $orphaned_sites,
|
||||
'orphaned_posts_count' => $orphaned_posts,
|
||||
'orphaned_terms_count' => $orphaned_terms,
|
||||
'duplicate_relations' => $duplicates,
|
||||
'mlp5_schema_ok' => $schema_ok,
|
||||
'mlp5_schema_error' => $schema_error,
|
||||
'mlp2_content_table' => $mlp2_content_table,
|
||||
'mlp2_site_table' => $mlp2_site_table,
|
||||
'mlp5_content_table' => $mlp5_content_table,
|
||||
'mlp5_site_table' => $mlp5_site_table,
|
||||
'mlp5_relationships_table' => $mlp5_rel_table,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Step 2 UI.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$data = Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 2 );
|
||||
$ran = ! empty( $data );
|
||||
$ran_ok = $ran && ! empty( $data['mlp5_schema_ok'] );
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Step 2 — Analysis', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
|
||||
<?php if ( ! $ran ) : ?>
|
||||
<p><?php esc_html_e( 'Click "Run Analysis" to scan the MLP2 backup database and validate MLP5 schema compatibility before migrating.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_analysis', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="2_run" />
|
||||
<p><button type="submit" class="button button-primary"><?php esc_html_e( 'Run Analysis', 'robotstxt-mlp2-mlp5' ); ?></button></p>
|
||||
</form>
|
||||
<?php else : ?>
|
||||
|
||||
<?php if ( ! $data['mlp5_schema_ok'] ) : ?>
|
||||
<div class="notice notice-error inline" style="margin:0 0 1em;">
|
||||
<p><strong><?php esc_html_e( 'MLP5 schema incompatibility detected:', 'robotstxt-mlp2-mlp5' ); ?></strong> <?php echo esc_html( $data['mlp5_schema_error'] ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<table class="widefat striped" style="border:0;margin-bottom:1.5em;">
|
||||
<thead><tr><th><?php esc_html_e( 'Category', 'robotstxt-mlp2-mlp5' ); ?></th><th><?php esc_html_e( 'Records found', 'robotstxt-mlp2-mlp5' ); ?></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><?php esc_html_e( 'Site relations', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['site_relations_count'] ) ); ?></td></tr>
|
||||
<tr><td><?php esc_html_e( 'Post relation groups', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['post_groups_count'] ) ); ?></td></tr>
|
||||
<tr><td><?php esc_html_e( 'Term relation groups', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['term_groups_count'] ) ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ( ! empty( $data['orphaned_site_ids'] ) || $data['orphaned_posts_count'] > 0 || $data['orphaned_terms_count'] > 0 || $data['duplicate_relations'] > 0 ) : ?>
|
||||
<h3><?php esc_html_e( 'Conflicts (will be skipped and logged)', 'robotstxt-mlp2-mlp5' ); ?></h3>
|
||||
<table class="widefat striped" style="border:0;margin-bottom:1.5em;">
|
||||
<tbody>
|
||||
<?php if ( ! empty( $data['orphaned_site_ids'] ) ) : ?>
|
||||
<tr><td><?php esc_html_e( 'Orphaned sites', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( implode( ', ', array_map( 'intval', $data['orphaned_site_ids'] ) ) ); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php if ( $data['orphaned_posts_count'] > 0 ) : ?>
|
||||
<tr><td><?php esc_html_e( 'Orphaned post references', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['orphaned_posts_count'] ) ); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php if ( $data['orphaned_terms_count'] > 0 ) : ?>
|
||||
<tr><td><?php esc_html_e( 'Orphaned term references', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['orphaned_terms_count'] ) ); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php if ( $data['duplicate_relations'] > 0 ) : ?>
|
||||
<tr><td><?php esc_html_e( 'Duplicate relation rows', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) $data['duplicate_relations'] ) ); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;">
|
||||
<?php wp_nonce_field( 'mlp_migrator_analysis', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="2_run" />
|
||||
<button type="submit" class="button button-secondary"><?php esc_html_e( 'Re-run Analysis', 'robotstxt-mlp2-mlp5' ); ?></button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;">
|
||||
<?php wp_nonce_field( 'mlp_migrator_step_2', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="2" />
|
||||
<button type="submit" class="button button-primary"<?php echo $ran_ok ? '' : ' disabled'; ?>><?php esc_html_e( 'Next: Migrate Site Relations', 'robotstxt-mlp2-mlp5' ); ?></button>
|
||||
</form>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect orphaned site IDs in the MLP2 site relations table.
|
||||
*
|
||||
* @param string $table MLP2 backup site relations table name.
|
||||
* @return int[] Site IDs that no longer exist in the network.
|
||||
*/
|
||||
private static function detect_orphaned_sites( string $table ): array {
|
||||
if ( '' === $table ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results( "SELECT site_1, site_2 FROM `{$table}`", ARRAY_A );
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$ids = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$ids[] = (int) $row['site_1'];
|
||||
$ids[] = (int) $row['site_2'];
|
||||
}
|
||||
$ids = array_unique( $ids );
|
||||
|
||||
$existing = get_sites(
|
||||
array(
|
||||
'site__in' => $ids,
|
||||
'fields' => 'ids',
|
||||
'number' => count( $ids ),
|
||||
)
|
||||
);
|
||||
$existing = array_map( 'intval', $existing );
|
||||
|
||||
return array_values( array_diff( $ids, $existing ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample up to $sample content relation rows and count orphaned elements.
|
||||
*
|
||||
* @param string $table MLP2 backup content relations table name.
|
||||
* @param string $type 'post' or 'term'.
|
||||
* @param int $sample Maximum rows to inspect.
|
||||
* @return int Count of orphaned references found in the sample.
|
||||
*/
|
||||
private static function detect_orphaned_content( string $table, string $type, int $sample ): int {
|
||||
if ( '' === $table ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$type_safe = ( 'term' === $type ) ? 'term' : 'post';
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT ml_blogid, ml_elementid FROM `{$table}` WHERE ml_type = %s LIMIT %d",
|
||||
$type_safe,
|
||||
$sample
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$orphans = 0;
|
||||
foreach ( $rows as $row ) {
|
||||
$site_id = (int) $row['ml_blogid'];
|
||||
$element_id = (int) $row['ml_elementid'];
|
||||
|
||||
if ( ! get_site( $site_id ) ) {
|
||||
++$orphans;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch_to_blog( $site_id );
|
||||
if ( 'term' === $type_safe ) {
|
||||
$exists = (bool) get_term( $element_id );
|
||||
} else {
|
||||
$exists = (bool) get_post( $element_id );
|
||||
}
|
||||
restore_current_blog();
|
||||
|
||||
if ( ! $exists ) {
|
||||
++$orphans;
|
||||
}
|
||||
}
|
||||
|
||||
return $orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count duplicate rows in the content relations table.
|
||||
*
|
||||
* @param string $table MLP2 backup content relations table name.
|
||||
* @return int Count of duplicate rows.
|
||||
*/
|
||||
private static function count_duplicate_relations( string $table ): int {
|
||||
if ( '' === $table ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$distinct = (int) $wpdb->get_var( "SELECT COUNT(*) FROM (SELECT DISTINCT ml_source_blogid, ml_source_elementid, ml_blogid, ml_elementid, ml_type FROM `{$table}`) AS d" );
|
||||
|
||||
return max( 0, $total - $distinct );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that MLP5 tables have the expected columns.
|
||||
*
|
||||
* @param string $mlp5_site MLP5 site relations table.
|
||||
* @param string $mlp5_content MLP5 content relations table.
|
||||
* @param string $mlp5_rel MLP5 relationships table.
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2 MLP2 detector.
|
||||
* @param string $mlp2_site MLP2 backup site relations table.
|
||||
* @param string $mlp2_content MLP2 backup content relations table.
|
||||
* @return array [ bool $ok, string $error_message ]
|
||||
*/
|
||||
private static function validate_mlp5_schema(
|
||||
string $mlp5_site,
|
||||
string $mlp5_content,
|
||||
string $mlp5_rel,
|
||||
Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2,
|
||||
string $mlp2_site,
|
||||
string $mlp2_content
|
||||
): array {
|
||||
if ( '' === $mlp5_site || '' === $mlp5_content ) {
|
||||
return array( false, __( 'MLP5 tables not found. Ensure MultilingualPress 5 is active and has been initialized.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
|
||||
// Site relations: expect site_1 and site_2 columns.
|
||||
$site_cols = $mlp2->describe_table( $mlp5_site );
|
||||
if ( ! isset( $site_cols['site_1'], $site_cols['site_2'] ) ) {
|
||||
return array( false, sprintf( /* translators: %s: table name */ __( 'Table %s does not have expected columns (site_1, site_2).', 'robotstxt-mlp2-mlp5' ), $mlp5_site ) );
|
||||
}
|
||||
|
||||
// Content relations: expect relationship_id, site_id, content_id.
|
||||
$content_cols = $mlp2->describe_table( $mlp5_content );
|
||||
if ( ! isset( $content_cols['relationship_id'], $content_cols['site_id'], $content_cols['content_id'] ) ) {
|
||||
return array( false, sprintf( /* translators: %s: table name */ __( 'Table %s does not have expected columns (relationship_id, site_id, content_id).', 'robotstxt-mlp2-mlp5' ), $mlp5_content ) );
|
||||
}
|
||||
|
||||
// Relationships group table: expect id and type columns.
|
||||
if ( '' !== $mlp5_rel ) {
|
||||
$rel_cols = $mlp2->describe_table( $mlp5_rel );
|
||||
if ( ! isset( $rel_cols['id'], $rel_cols['type'] ) ) {
|
||||
return array( false, sprintf( /* translators: %s: table name */ __( 'Table %s does not have expected columns (id, type).', 'robotstxt-mlp2-mlp5' ), $mlp5_rel ) );
|
||||
}
|
||||
}
|
||||
|
||||
// MLP2 backup: check the content table has the known columns.
|
||||
if ( '' !== $mlp2_content ) {
|
||||
$mlp2_cols = $mlp2->describe_table( $mlp2_content );
|
||||
$required = array( 'ml_blogid', 'ml_elementid', 'ml_type' );
|
||||
foreach ( $required as $col ) {
|
||||
if ( ! isset( $mlp2_cols[ $col ] ) ) {
|
||||
return array( false, sprintf( /* translators: 1: column name, 2: table name */ __( 'MLP2 backup table %2$s is missing expected column "%1$s". Schema may differ from expected.', 'robotstxt-mlp2-mlp5' ), $col, $mlp2_content ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array( true, '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the source-blog column name for the MLP2 content relations table.
|
||||
*
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2 MLP2 detector.
|
||||
* @param string $table Content relations table name.
|
||||
* @return string Column name or empty string.
|
||||
*/
|
||||
private static function source_blog_col( Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2, string $table ): string {
|
||||
$cols = $mlp2->describe_table( $table );
|
||||
foreach ( array( 'ml_source_blogid', 'ml_blogid' ) as $candidate ) {
|
||||
if ( isset( $cols[ $candidate ] ) ) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the source-element column name for the MLP2 content relations table.
|
||||
*
|
||||
* @param Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2 MLP2 detector.
|
||||
* @param string $table Content relations table name.
|
||||
* @return string Column name or empty string.
|
||||
*/
|
||||
private static function source_element_col( Robotstxt_Mlp2_Mlp5_Detector_Mlp2 $mlp2, string $table ): string {
|
||||
$cols = $mlp2->describe_table( $table );
|
||||
foreach ( array( 'ml_source_elementid', 'ml_elementid' ) as $candidate ) {
|
||||
if ( isset( $cols[ $candidate ] ) ) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
194
includes/class-robotstxt-mlp2-mlp5-step-cleanup.php
Normal file
194
includes/class-robotstxt-mlp2-mlp5-step-cleanup.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
/**
|
||||
* Wizard Step 6 — Optional Cleanup.
|
||||
*
|
||||
* Offers three independent cleanup actions:
|
||||
* 1. Deactivate MultilingualPress 2 (if still active).
|
||||
* 2. Drop all MLP2 backup tables ({prefix}mlp2bak_*).
|
||||
* 3. Remove MLP2-related entries from wp_sitemeta.
|
||||
*
|
||||
* All actions require typing "DELETE" in a confirmation field and
|
||||
* verifying a nonce. Actions are logged in the migration log.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Step_Cleanup
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Step_Cleanup {
|
||||
|
||||
/**
|
||||
* Deactivate MultilingualPress 2 if it is currently network-active.
|
||||
*
|
||||
* @return string 'success', 'skipped', or 'error'.
|
||||
*/
|
||||
public static function deactivate_mlp2(): string {
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
|
||||
if ( ! $mlp2->is_active() ) {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'skipped', 'cleanup', null, null, __( 'MLP2 deactivation: plugin is already inactive.', 'robotstxt-mlp2-mlp5' ) );
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'deactivate_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
|
||||
deactivate_plugins( $mlp2->get_plugin_file(), true );
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'success', 'cleanup', null, null, __( 'MLP2 plugin deactivated network-wide.', 'robotstxt-mlp2-mlp5' ) );
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all MLP2 backup tables ({prefix}mlp2bak_*).
|
||||
*
|
||||
* Each table drop is logged individually.
|
||||
*
|
||||
* @return array { @type int $dropped, @type int $errors }
|
||||
*/
|
||||
public static function drop_backup_tables(): array {
|
||||
global $wpdb;
|
||||
|
||||
$tables = Robotstxt_Mlp2_Mlp5_Table_Preparer::get_backup_tables();
|
||||
$dropped = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ( $tables as $table ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
|
||||
if ( false === $result ) {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'error', 'cleanup', null, null, sprintf( /* translators: %s: table name */ __( 'Failed to drop backup table: %s', 'robotstxt-mlp2-mlp5' ), $table ) );
|
||||
++$errors;
|
||||
} else {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'success', 'cleanup', null, null, sprintf( /* translators: %s: table name */ __( 'Backup table dropped: %s', 'robotstxt-mlp2-mlp5' ), $table ) );
|
||||
++$dropped;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'dropped' => $dropped,
|
||||
'errors' => $errors,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete MLP2-related entries from wp_sitemeta.
|
||||
*
|
||||
* Removes all sitemeta keys whose name starts with 'mlp_' (MLP2 prefix).
|
||||
*
|
||||
* @return int Number of keys deleted.
|
||||
*/
|
||||
public static function remove_mlp2_sitemeta(): int {
|
||||
global $wpdb;
|
||||
|
||||
$like = $wpdb->esc_like( 'mlp_' ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$keys = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s AND site_id = %d",
|
||||
$like,
|
||||
get_current_network_id()
|
||||
)
|
||||
);
|
||||
|
||||
$deleted = 0;
|
||||
foreach ( (array) $keys as $key ) {
|
||||
if ( delete_site_option( $key ) ) {
|
||||
++$deleted;
|
||||
}
|
||||
}
|
||||
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'success', 'cleanup', null, null, sprintf( /* translators: %d: key count */ __( 'Removed %d MLP2 sitemeta entries.', 'robotstxt-mlp2-mlp5' ), $deleted ) );
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Step 6 UI.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$backup_tables = Robotstxt_Mlp2_Mlp5_Table_Preparer::get_backup_tables();
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Step 6 — Cleanup (Optional)', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
|
||||
<div class="notice notice-success inline" style="margin:0 0 1.5em;">
|
||||
<p><strong><?php esc_html_e( 'Migration complete!', 'robotstxt-mlp2-mlp5' ); ?></strong> <?php esc_html_e( 'MultilingualPress 5 is now configured with your migrated data. Verify that everything works before performing the optional cleanup below.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
</div>
|
||||
|
||||
<p><?php esc_html_e( 'The cleanup actions below are optional and irreversible. MultilingualPress 5 will work correctly whether or not you perform them.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_cleanup', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="6_cleanup" />
|
||||
|
||||
<table class="widefat" style="border:0;max-width:600px;margin-bottom:1.5em;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:36px;"><input type="checkbox" id="cleanup_deactivate" name="cleanup_deactivate" value="1" /></td>
|
||||
<td><label for="cleanup_deactivate">
|
||||
<?php if ( $mlp2->is_active() ) : ?>
|
||||
<strong><?php esc_html_e( 'Deactivate MultilingualPress 2 (network-wide)', 'robotstxt-mlp2-mlp5' ); ?></strong>
|
||||
<?php else : ?>
|
||||
<strong><?php esc_html_e( 'Deactivate MultilingualPress 2', 'robotstxt-mlp2-mlp5' ); ?></strong>
|
||||
<em style="color:#888;"> — <?php esc_html_e( 'already inactive', 'robotstxt-mlp2-mlp5' ); ?></em>
|
||||
<?php endif; ?>
|
||||
</label></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" id="cleanup_drop_tables" name="cleanup_drop_tables" value="1" disabled /></td>
|
||||
<td><label for="cleanup_drop_tables">
|
||||
<strong><?php esc_html_e( 'Delete MLP2 backup database tables', 'robotstxt-mlp2-mlp5' ); ?></strong>
|
||||
<span style="color:#888;"> (<?php echo esc_html( sprintf( /* translators: %d: table count */ _n( '%d table', '%d tables', count( $backup_tables ), 'robotstxt-mlp2-mlp5' ), count( $backup_tables ) ) ); ?>: <code><?php echo esc_html( implode( ', ', $backup_tables ) ); ?></code>)</span>
|
||||
<br><em style="color:#d63638;"><?php esc_html_e( 'Irreversible. Only do this after verifying the migration in MLP5.', 'robotstxt-mlp2-mlp5' ); ?></em>
|
||||
</label></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="checkbox" id="cleanup_sitemeta" name="cleanup_sitemeta" value="1" /></td>
|
||||
<td><label for="cleanup_sitemeta">
|
||||
<strong><?php esc_html_e( 'Delete MLP2 network settings from wp_sitemeta', 'robotstxt-mlp2-mlp5' ); ?></strong>
|
||||
</label></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<label for="cleanup_confirm"><strong><?php esc_html_e( 'Type DELETE to confirm:', 'robotstxt-mlp2-mlp5' ); ?></strong></label><br>
|
||||
<input type="text" id="cleanup_confirm" name="cleanup_confirm" value="" placeholder="DELETE" autocomplete="off" style="font-family:monospace;width:160px;margin-top:.25em;" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<button type="submit" id="cleanup_submit" class="button button-primary" disabled>
|
||||
<?php esc_html_e( 'Proceed with cleanup', 'robotstxt-mlp2-mlp5' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var chkDeactivate = document.getElementById('cleanup_deactivate');
|
||||
var chkTables = document.getElementById('cleanup_drop_tables');
|
||||
var inputConfirm = document.getElementById('cleanup_confirm');
|
||||
var btnSubmit = document.getElementById('cleanup_submit');
|
||||
|
||||
function update() {
|
||||
chkTables.disabled = ! chkDeactivate.checked;
|
||||
if ( ! chkDeactivate.checked ) { chkTables.checked = false; }
|
||||
btnSubmit.disabled = inputConfirm.value !== 'DELETE';
|
||||
}
|
||||
|
||||
chkDeactivate.addEventListener('change', update);
|
||||
inputConfirm.addEventListener('input', update);
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
342
includes/class-robotstxt-mlp2-mlp5-step-content-relations.php
Normal file
342
includes/class-robotstxt-mlp2-mlp5-step-content-relations.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
includes/class-robotstxt-mlp2-mlp5-step-preflight.php
Normal file
142
includes/class-robotstxt-mlp2-mlp5-step-preflight.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
/**
|
||||
* Wizard Step 1 — Preflight checks.
|
||||
*
|
||||
* Verifies that the system is ready to run the migration:
|
||||
* - MLP2 backup tables exist (preparation step was done)
|
||||
* - MultilingualPress 5 is network-active
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Step_Preflight
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Step_Preflight {
|
||||
|
||||
/**
|
||||
* Run all preflight checks and return a structured result.
|
||||
*
|
||||
* @return array {
|
||||
* @type bool $mlp2_tables_renamed Backup tables exist (rename step was done).
|
||||
* @type bool $mlp2_original_present Original MLP2 tables still exist (rename not done).
|
||||
* @type string[] $mlp2_backup_tables List of backup table names found.
|
||||
* @type bool $mlp2_active MLP2 plugin is network-active (informational).
|
||||
* @type string $mlp2_version MLP2 plugin version string, or empty.
|
||||
* @type bool $mlp5_active MLP5 plugin is network-active.
|
||||
* @type string $mlp5_version MLP5 plugin version string, or empty.
|
||||
* @type bool $can_proceed True when migration may start.
|
||||
* @type string[] $blocking_issues Human-readable blocking problem descriptions.
|
||||
* }
|
||||
*/
|
||||
public static function run(): array {
|
||||
$mlp2 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp2();
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$backup_tables = Robotstxt_Mlp2_Mlp5_Table_Preparer::get_backup_tables();
|
||||
$issues = array();
|
||||
|
||||
$tables_renamed = count( $backup_tables ) > 0;
|
||||
$original_present = Robotstxt_Mlp2_Mlp5_Table_Preparer::has_original_tables();
|
||||
$mlp5_active = $mlp5->is_active();
|
||||
|
||||
if ( ! $tables_renamed ) {
|
||||
if ( $original_present ) {
|
||||
$issues[] = __( 'MLP2 tables have not been renamed yet. Go back to the plugin status page and click "Rename MLP2 tables".', 'robotstxt-mlp2-mlp5' );
|
||||
} else {
|
||||
$issues[] = __( 'No MLP2 tables found (neither original nor backup). Restore your database from a backup before proceeding.', 'robotstxt-mlp2-mlp5' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $mlp5_active ) {
|
||||
$issues[] = __( 'MultilingualPress 5 is not network-active. Activate it before running the migration.', 'robotstxt-mlp2-mlp5' );
|
||||
}
|
||||
|
||||
return array(
|
||||
'mlp2_tables_renamed' => $tables_renamed,
|
||||
'mlp2_original_present' => $original_present,
|
||||
'mlp2_backup_tables' => $backup_tables,
|
||||
'mlp2_active' => $mlp2->is_active(),
|
||||
'mlp2_version' => $mlp2->get_version(),
|
||||
'mlp5_active' => $mlp5_active,
|
||||
'mlp5_version' => $mlp5->get_version(),
|
||||
'can_proceed' => $tables_renamed && $mlp5_active,
|
||||
'blocking_issues' => $issues,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Step 1 UI.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$checks = self::run();
|
||||
$can_proceed = $checks['can_proceed'];
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Step 1 — Preflight Checks', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
|
||||
<table class="widefat striped" style="border:0;margin-bottom:1.5em;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:60%"><?php esc_html_e( 'MLP2 backup tables (preparation done)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $checks['mlp2_tables_renamed'] ) : ?>
|
||||
<span style="color:#46b450;">✓ <?php echo esc_html( sprintf( /* translators: %d: table count */ _n( '%d table ready', '%d tables ready', count( $checks['mlp2_backup_tables'] ), 'robotstxt-mlp2-mlp5' ), count( $checks['mlp2_backup_tables'] ) ) ); ?></span>
|
||||
<?php elseif ( $checks['mlp2_original_present'] ) : ?>
|
||||
<span style="color:#d63638;">✗ <?php esc_html_e( 'Original tables still present — rename step not done', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#d63638;">✗ <?php esc_html_e( 'No MLP2 tables found', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'MLP2 plugin (active — informational only)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $checks['mlp2_active'] ) : ?>
|
||||
<span style="color:#dba617;">⚠ <?php esc_html_e( 'Active — consider deactivating before proceeding', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#46b450;">✓ <?php esc_html_e( 'Inactive (expected)', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'MultilingualPress 5 (network-active)', 'robotstxt-mlp2-mlp5' ); ?></td>
|
||||
<td>
|
||||
<?php if ( $checks['mlp5_active'] ) : ?>
|
||||
<span style="color:#46b450;">✓ <?php echo esc_html( sprintf( /* translators: %s: version */ __( 'Active (v%s)', 'robotstxt-mlp2-mlp5' ), $checks['mlp5_version'] ) ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color:#d63638;">✗ <?php esc_html_e( 'Not active', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ( ! empty( $checks['blocking_issues'] ) ) : ?>
|
||||
<div class="notice notice-error inline" style="margin:0 0 1em;">
|
||||
<ul style="margin:.5em 0 .5em 1em;list-style:disc;">
|
||||
<?php foreach ( $checks['blocking_issues'] as $issue ) : ?>
|
||||
<li><?php echo esc_html( $issue ); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_step_1', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="1" />
|
||||
<p>
|
||||
<button type="submit" class="button button-primary"<?php echo $can_proceed ? '' : ' disabled'; ?>>
|
||||
<?php esc_html_e( 'Next: Run Analysis', 'robotstxt-mlp2-mlp5' ); ?>
|
||||
</button>
|
||||
<?php if ( ! $can_proceed ) : ?>
|
||||
<span style="margin-left:.75em;color:#d63638;"><?php esc_html_e( 'Resolve the issues above before continuing.', 'robotstxt-mlp2-mlp5' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
113
includes/class-robotstxt-mlp2-mlp5-step-site-relations.php
Normal file
113
includes/class-robotstxt-mlp2-mlp5-step-site-relations.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
/**
|
||||
* Wizard Step 3 — Site Relations Migration.
|
||||
*
|
||||
* Reads all MLP2 backup site relations and writes them to MLP5,
|
||||
* skipping orphaned or already-migrated pairs and logging all results.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Step_Site_Relations
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Step_Site_Relations {
|
||||
|
||||
/**
|
||||
* Run the site relations migration.
|
||||
*
|
||||
* @return array {
|
||||
* @type int $migrated Number of relations successfully written.
|
||||
* @type int $skipped Number of relations skipped.
|
||||
* @type int $errors Number of relations that failed.
|
||||
* }
|
||||
*/
|
||||
public static function run(): array {
|
||||
$rows = Robotstxt_Mlp2_Mlp5_Reader_Site_Relations::get_all();
|
||||
$orphaned_site_ids = array_map( 'intval', (array) ( Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 2 )['orphaned_site_ids'] ?? array() ) );
|
||||
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$site_1 = $row['site_1'];
|
||||
$site_2 = $row['site_2'];
|
||||
|
||||
// Skip orphaned sites.
|
||||
if ( in_array( $site_1, $orphaned_site_ids, true ) || in_array( $site_2, $orphaned_site_ids, true ) ) {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'skipped', 'site_relation', $site_1, $site_2, __( 'One or both sites no longer exist in the network.', 'robotstxt-mlp2-mlp5' ) );
|
||||
++$skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already exists in MLP5.
|
||||
if ( Robotstxt_Mlp2_Mlp5_Writer_Site_Relations::relation_exists( $site_1, $site_2 ) ) {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'skipped', 'site_relation', $site_1, $site_2, __( 'Relation already exists in MLP5 (idempotent skip).', 'robotstxt-mlp2-mlp5' ) );
|
||||
++$skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write.
|
||||
if ( Robotstxt_Mlp2_Mlp5_Writer_Site_Relations::write_relation( $site_1, $site_2 ) ) {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'success', 'site_relation', $site_1, $site_2, __( 'Site relation migrated successfully.', 'robotstxt-mlp2-mlp5' ) );
|
||||
++$migrated;
|
||||
} else {
|
||||
Robotstxt_Mlp2_Mlp5_Logger::log( 'error', 'site_relation', $site_1, $site_2, __( 'Failed to write site relation to MLP5.', 'robotstxt-mlp2-mlp5' ) );
|
||||
++$errors;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'migrated' => $migrated,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Step 3 UI.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$data = Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 3 );
|
||||
$ran = ! empty( $data );
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Step 3 — Site Relations', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
<p><?php esc_html_e( 'This step migrates site connections and language assignments from MLP2 to MLP5. It usually completes in seconds.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
|
||||
<?php if ( $ran ) : ?>
|
||||
<table class="widefat striped" style="border:0;max-width:400px;margin-bottom:1.5em;">
|
||||
<tbody>
|
||||
<tr><td><?php esc_html_e( 'Migrated', 'robotstxt-mlp2-mlp5' ); ?></td><td style="color:#46b450;font-weight:600;"><?php echo esc_html( number_format_i18n( (int) ( $data['migrated'] ?? 0 ) ) ); ?></td></tr>
|
||||
<tr><td><?php esc_html_e( 'Skipped', 'robotstxt-mlp2-mlp5' ); ?></td><td><?php echo esc_html( number_format_i18n( (int) ( $data['skipped'] ?? 0 ) ) ); ?></td></tr>
|
||||
<tr><td><?php esc_html_e( 'Errors', 'robotstxt-mlp2-mlp5' ); ?></td><td style="<?php echo (int) ( $data['errors'] ?? 0 ) > 0 ? 'color:#d63638;font-weight:600;' : ''; ?>"><?php echo esc_html( number_format_i18n( (int) ( $data['errors'] ?? 0 ) ) ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;">
|
||||
<?php wp_nonce_field( 'mlp_migrator_site_relations', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="3_run" />
|
||||
<button type="submit" class="button button-<?php echo $ran ? 'secondary' : 'primary'; ?>">
|
||||
<?php echo $ran ? esc_html__( 'Re-run Site Relations', 'robotstxt-mlp2-mlp5' ) : esc_html__( 'Start Site Relations Migration', 'robotstxt-mlp2-mlp5' ); ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php if ( $ran ) : ?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;">
|
||||
<?php wp_nonce_field( 'mlp_migrator_step_3', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="3" />
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e( 'Next: Content Relations', 'robotstxt-mlp2-mlp5' ); ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
154
includes/class-robotstxt-mlp2-mlp5-step-verification.php
Normal file
154
includes/class-robotstxt-mlp2-mlp5-step-verification.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
/**
|
||||
* Wizard Step 5 — Verification.
|
||||
*
|
||||
* Compares expected totals (from the Analysis step) with actual log counts
|
||||
* and provides a downloadable CSV export of the full migration log.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Step_Verification
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Step_Verification {
|
||||
|
||||
/**
|
||||
* Build the verification summary.
|
||||
*
|
||||
* @return array[] Rows keyed by context with expected/migrated/skipped/errors.
|
||||
*/
|
||||
public static function get_summary(): array {
|
||||
$analysis = Robotstxt_Mlp2_Mlp5_Wizard_State::get_step_data( 2 );
|
||||
|
||||
$contexts = array(
|
||||
'site_relation' => array(
|
||||
'label' => __( 'Site Relations', 'robotstxt-mlp2-mlp5' ),
|
||||
'expected' => (int) ( $analysis['site_relations_count'] ?? 0 ),
|
||||
),
|
||||
'post_relation' => array(
|
||||
'label' => __( 'Post Relations', 'robotstxt-mlp2-mlp5' ),
|
||||
'expected' => (int) ( $analysis['post_groups_count'] ?? 0 ),
|
||||
),
|
||||
'term_relation' => array(
|
||||
'label' => __( 'Term Relations', 'robotstxt-mlp2-mlp5' ),
|
||||
'expected' => (int) ( $analysis['term_groups_count'] ?? 0 ),
|
||||
),
|
||||
);
|
||||
|
||||
$summary = array();
|
||||
foreach ( $contexts as $context => $meta ) {
|
||||
$summary[ $context ] = array(
|
||||
'label' => $meta['label'],
|
||||
'expected' => $meta['expected'],
|
||||
'migrated' => Robotstxt_Mlp2_Mlp5_Logger::count(
|
||||
array(
|
||||
'type' => 'success',
|
||||
'context' => $context,
|
||||
)
|
||||
),
|
||||
'skipped' => Robotstxt_Mlp2_Mlp5_Logger::count(
|
||||
array(
|
||||
'type' => 'skipped',
|
||||
'context' => $context,
|
||||
)
|
||||
),
|
||||
'errors' => Robotstxt_Mlp2_Mlp5_Logger::count(
|
||||
array(
|
||||
'type' => 'error',
|
||||
'context' => $context,
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the CSV export request.
|
||||
*
|
||||
* Must be called on network_admin_init when the export GET param is present.
|
||||
*/
|
||||
public static function maybe_export_csv(): void {
|
||||
check_admin_referer( 'mlp_migrator_export_log', 'mlp_export_nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_network' ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to export the log.', 'robotstxt-mlp2-mlp5' ) );
|
||||
}
|
||||
|
||||
$csv = Robotstxt_Mlp2_Mlp5_Logger::export_csv();
|
||||
|
||||
header( 'Content-Type: text/csv; charset=UTF-8' );
|
||||
header( 'Content-Disposition: attachment; filename="mlp-migration-log.csv"' );
|
||||
header( 'Pragma: no-cache' );
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $csv;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Step 5 UI.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$summary = self::get_summary();
|
||||
$export_url = wp_nonce_url(
|
||||
admin_url( 'admin-post.php?action=mlp_migrator_export_log' ),
|
||||
'mlp_migrator_export_log',
|
||||
'mlp_export_nonce'
|
||||
);
|
||||
?>
|
||||
<h2><?php esc_html_e( 'Step 5 — Verification', 'robotstxt-mlp2-mlp5' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Compare expected totals (from the Analysis step) with actual migration results. Download the full log before proceeding to cleanup.', 'robotstxt-mlp2-mlp5' ); ?></p>
|
||||
|
||||
<table class="widefat striped" style="border:0;margin-bottom:1.5em;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Category', 'robotstxt-mlp2-mlp5' ); ?></th>
|
||||
<th><?php esc_html_e( 'Expected', 'robotstxt-mlp2-mlp5' ); ?></th>
|
||||
<th><?php esc_html_e( 'Migrated', 'robotstxt-mlp2-mlp5' ); ?></th>
|
||||
<th><?php esc_html_e( 'Skipped', 'robotstxt-mlp2-mlp5' ); ?></th>
|
||||
<th><?php esc_html_e( 'Errors', 'robotstxt-mlp2-mlp5' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $summary as $row ) : ?>
|
||||
<?php
|
||||
$color = '#46b450'; // Green.
|
||||
if ( (int) $row['errors'] > 0 ) {
|
||||
$color = '#dba617'; // Amber.
|
||||
}
|
||||
if ( 0 === (int) $row['migrated'] && (int) $row['expected'] > 0 ) {
|
||||
$color = '#d63638'; // Red.
|
||||
}
|
||||
?>
|
||||
<tr style="border-left:4px solid <?php echo esc_attr( $color ); ?>;">
|
||||
<td><?php echo esc_html( $row['label'] ); ?></td>
|
||||
<td><?php echo esc_html( number_format_i18n( (int) $row['expected'] ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format_i18n( (int) $row['migrated'] ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format_i18n( (int) $row['skipped'] ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format_i18n( (int) $row['errors'] ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<a href="<?php echo esc_url( $export_url ); ?>" class="button button-secondary">
|
||||
<?php esc_html_e( 'Download log (CSV)', 'robotstxt-mlp2-mlp5' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
|
||||
<?php wp_nonce_field( 'mlp_migrator_step_5', 'mlp_migrator_nonce' ); ?>
|
||||
<input type="hidden" name="action" value="mlp_migrator_step" />
|
||||
<input type="hidden" name="mlp_migrator_step_action" value="5" />
|
||||
<p><button type="submit" class="button button-primary"><?php esc_html_e( 'Next: Cleanup', 'robotstxt-mlp2-mlp5' ); ?></button></p>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
250
includes/class-robotstxt-mlp2-mlp5-table-preparer.php
Normal file
250
includes/class-robotstxt-mlp2-mlp5-table-preparer.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
/**
|
||||
* Table preparation service.
|
||||
*
|
||||
* MultilingualPress 5 refuses to network-activate when it detects tables
|
||||
* with the original MLP2 prefix ({base_prefix}mlp_*). This class renames
|
||||
* those tables to a backup prefix ({base_prefix}mlp2bak_*) so that MLP5
|
||||
* can activate cleanly. The migration wizard then reads from the renamed
|
||||
* backup tables.
|
||||
*
|
||||
* Original name pattern : {base_prefix}mlp_{suffix}
|
||||
* Backup name pattern : {base_prefix}mlp2bak_{suffix}
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Table_Preparer
|
||||
*
|
||||
* All methods are static. No instantiation required.
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Table_Preparer {
|
||||
|
||||
/**
|
||||
* Table name fragment (after base_prefix) that identifies original MLP2 tables.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const ORIGINAL_FRAGMENT = 'mlp_';
|
||||
|
||||
/**
|
||||
* Table name fragment (after base_prefix) used for renamed backup copies.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BACKUP_FRAGMENT = 'mlp2bak_';
|
||||
|
||||
/**
|
||||
* Table suffixes (after base_prefix + ORIGINAL_FRAGMENT) that belong to
|
||||
* this plugin and must never be renamed to backup.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
const PLUGIN_TABLE_SUFFIXES = array( 'migrator_log' );
|
||||
|
||||
/**
|
||||
* Return the backup table name for a given original MLP2 table.
|
||||
*
|
||||
* @param string $original_table Full original table name (including base_prefix).
|
||||
* @return string Backup table name, or empty string if not a valid MLP2 table.
|
||||
*/
|
||||
public static function backup_name( string $original_table ): string {
|
||||
global $wpdb;
|
||||
|
||||
$orig_prefix = $wpdb->base_prefix . self::ORIGINAL_FRAGMENT;
|
||||
|
||||
if ( ! str_starts_with( $original_table, $orig_prefix ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $wpdb->base_prefix . self::BACKUP_FRAGMENT . substr( $original_table, strlen( $orig_prefix ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the original table name for a given backup table.
|
||||
*
|
||||
* @param string $backup_table Full backup table name (including base_prefix).
|
||||
* @return string Original table name, or empty string if not a backup table.
|
||||
*/
|
||||
public static function original_name( string $backup_table ): string {
|
||||
global $wpdb;
|
||||
|
||||
$bak_prefix = $wpdb->base_prefix . self::BACKUP_FRAGMENT;
|
||||
|
||||
if ( ! str_starts_with( $backup_table, $bak_prefix ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $wpdb->base_prefix . self::ORIGINAL_FRAGMENT . substr( $backup_table, strlen( $bak_prefix ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all tables in the database with the original MLP2 prefix.
|
||||
*
|
||||
* @return string[] Array of full table names.
|
||||
*/
|
||||
public static function get_original_tables(): array {
|
||||
global $wpdb;
|
||||
|
||||
$like = $wpdb->esc_like( $wpdb->base_prefix . self::ORIGINAL_FRAGMENT ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
|
||||
|
||||
return is_array( $tables ) ? $tables : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all tables in the database with the backup prefix.
|
||||
*
|
||||
* @return string[] Array of full table names.
|
||||
*/
|
||||
public static function get_backup_tables(): array {
|
||||
global $wpdb;
|
||||
|
||||
$like = $wpdb->esc_like( $wpdb->base_prefix . self::BACKUP_FRAGMENT ) . '%';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) );
|
||||
|
||||
return is_array( $tables ) ? $tables : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any original MLP2 tables exist in the database.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function has_original_tables(): bool {
|
||||
return count( self::get_original_tables() ) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any backup (renamed) MLP2 tables exist in the database.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function has_backup_tables(): bool {
|
||||
return count( self::get_backup_tables() ) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename all original MLP2 tables to their backup equivalents.
|
||||
*
|
||||
* Tables that already have a backup copy are skipped. Tables that cannot
|
||||
* be converted to a valid backup name are recorded as errors.
|
||||
*
|
||||
* @return array {
|
||||
* @type string[] $renamed Tables successfully renamed.
|
||||
* @type string[] $skipped Tables skipped (backup name already exists).
|
||||
* @type string[] $errors Tables that could not be renamed.
|
||||
* }
|
||||
*/
|
||||
public static function rename_to_backup(): array {
|
||||
global $wpdb;
|
||||
|
||||
$result = array(
|
||||
'renamed' => array(),
|
||||
'skipped' => array(),
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
foreach ( self::get_original_tables() as $table ) {
|
||||
$backup = self::backup_name( $table );
|
||||
|
||||
if ( '' === $backup ) {
|
||||
$result['errors'][] = $table;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip tables that belong to this plugin.
|
||||
$suffix = substr( $table, strlen( $wpdb->base_prefix . self::ORIGINAL_FRAGMENT ) );
|
||||
if ( in_array( $suffix, self::PLUGIN_TABLE_SUFFIXES, true ) ) {
|
||||
$result['skipped'][] = $table;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( self::table_exists( $backup ) ) {
|
||||
$result['skipped'][] = $table;
|
||||
continue;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$renamed = $wpdb->query( "RENAME TABLE `{$table}` TO `{$backup}`" );
|
||||
|
||||
if ( false === $renamed ) {
|
||||
$result['errors'][] = $table;
|
||||
} else {
|
||||
$result['renamed'][] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore backup tables to their original names.
|
||||
*
|
||||
* Intended as a recovery mechanism if the rename needs to be undone before
|
||||
* MLP5 has been activated. Tables whose original name already exists in the
|
||||
* database are skipped.
|
||||
*
|
||||
* @return array {
|
||||
* @type string[] $restored Tables successfully restored.
|
||||
* @type string[] $skipped Tables skipped (original name already exists).
|
||||
* @type string[] $errors Tables that could not be restored.
|
||||
* }
|
||||
*/
|
||||
public static function restore_from_backup(): array {
|
||||
global $wpdb;
|
||||
|
||||
$result = array(
|
||||
'restored' => array(),
|
||||
'skipped' => array(),
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
foreach ( self::get_backup_tables() as $table ) {
|
||||
$original = self::original_name( $table );
|
||||
|
||||
if ( '' === $original ) {
|
||||
$result['errors'][] = $table;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( self::table_exists( $original ) ) {
|
||||
$result['skipped'][] = $table;
|
||||
continue;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$restored = $wpdb->query( "RENAME TABLE `{$table}` TO `{$original}`" );
|
||||
|
||||
if ( false === $restored ) {
|
||||
$result['errors'][] = $table;
|
||||
} else {
|
||||
$result['restored'][] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a table exists in the database.
|
||||
*
|
||||
* @param string $table Full table name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function table_exists( string $table ): bool {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
return (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
|
||||
}
|
||||
}
|
||||
151
includes/class-robotstxt-mlp2-mlp5-wizard-state.php
Normal file
151
includes/class-robotstxt-mlp2-mlp5-wizard-state.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
/**
|
||||
* Migration wizard state manager.
|
||||
*
|
||||
* Persists the wizard's current step, per-step data, and completion flags
|
||||
* in a single network option. All public methods handle serialization
|
||||
* and defaults internally.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Wizard_State
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Wizard_State {
|
||||
|
||||
/**
|
||||
* Sitemeta key used to store the wizard state.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const OPTION_KEY = 'mlp_migrator_wizard_state';
|
||||
|
||||
/**
|
||||
* Total number of wizard steps.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const TOTAL_STEPS = 6;
|
||||
|
||||
/**
|
||||
* Cached state array.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static ?array $cache = null;
|
||||
|
||||
/**
|
||||
* Return the current wizard step (1–6). Defaults to 1 if no state exists.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_current_step(): int {
|
||||
$state = self::load();
|
||||
$step = isset( $state['step'] ) ? (int) $state['step'] : 1;
|
||||
return max( 1, min( self::TOTAL_STEPS, $step ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current wizard step.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
*/
|
||||
public static function set_step( int $step ): void {
|
||||
$state = self::load();
|
||||
$state['step'] = max( 1, min( self::TOTAL_STEPS, $step ) );
|
||||
self::save( $state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return stored data for a specific step.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
* @return array
|
||||
*/
|
||||
public static function get_step_data( int $step ): array {
|
||||
$state = self::load();
|
||||
$key = 'step_' . $step . '_data';
|
||||
return isset( $state[ $key ] ) && is_array( $state[ $key ] ) ? $state[ $key ] : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data for a specific step.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
* @param array $data Arbitrary data to store.
|
||||
*/
|
||||
public static function set_step_data( int $step, array $data ): void {
|
||||
$state = self::load();
|
||||
$state[ 'step_' . $step . '_data' ] = $data;
|
||||
self::save( $state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge data into an existing step's stored data.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
* @param array $data Data to merge (existing keys are overwritten).
|
||||
*/
|
||||
public static function merge_step_data( int $step, array $data ): void {
|
||||
$existing = self::get_step_data( $step );
|
||||
self::set_step_data( $step, array_merge( $existing, $data ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a step as complete.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
*/
|
||||
public static function mark_step_complete( int $step ): void {
|
||||
$state = self::load();
|
||||
$state[ 'step_' . $step . '_complete' ] = true;
|
||||
self::save( $state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a step has been marked complete.
|
||||
*
|
||||
* @param int $step Step number (1–6).
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_step_complete( int $step ): bool {
|
||||
$state = self::load();
|
||||
return ! empty( $state[ 'step_' . $step . '_complete' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the wizard by deleting the network option entirely.
|
||||
*/
|
||||
public static function reset(): void {
|
||||
delete_site_option( self::OPTION_KEY );
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (and cache) the state array from the network option.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function load(): array {
|
||||
if ( null === self::$cache ) {
|
||||
$raw = get_site_option( self::OPTION_KEY, array() );
|
||||
self::$cache = is_array( $raw ) ? $raw : array();
|
||||
}
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the state array to the network option.
|
||||
*
|
||||
* @param array $state State to persist.
|
||||
*/
|
||||
private static function save( array $state ): void {
|
||||
self::$cache = $state;
|
||||
update_site_option( self::OPTION_KEY, $state );
|
||||
}
|
||||
}
|
||||
120
includes/class-robotstxt-mlp2-mlp5-writer-content-relations.php
Normal file
120
includes/class-robotstxt-mlp2-mlp5-writer-content-relations.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
/**
|
||||
* MLP5 content relations writer.
|
||||
*
|
||||
* Creates relationship groups in MLP5 from MLP2 source data.
|
||||
*
|
||||
* MLP5 schema:
|
||||
* mlp_relationships (id BIGINT PK AUTO_INCREMENT, type VARCHAR(36))
|
||||
* mlp_content_relations (relationship_id BIGINT, site_id BIGINT, content_id BIGINT, PK(relationship_id, site_id))
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Writer_Content_Relations
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Writer_Content_Relations {
|
||||
|
||||
/**
|
||||
* Check whether any MLP5 relationship already references a given (site_id, content_id) pair.
|
||||
*
|
||||
* Used for idempotency: if the pair is already in MLP5, the group is skipped.
|
||||
*
|
||||
* @param int $site_id Site ID.
|
||||
* @param int $content_id Post or term ID.
|
||||
* @return bool
|
||||
*/
|
||||
public static function pair_exists_in_mlp5( int $site_id, int $content_id ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$table = $mlp5->get_content_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE site_id = %d AND content_id = %d",
|
||||
$site_id,
|
||||
$content_id
|
||||
)
|
||||
);
|
||||
|
||||
return $found > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a relationship group in MLP5 for the given members.
|
||||
*
|
||||
* Inserts one row into mlp_relationships and one row per member into
|
||||
* mlp_content_relations. Wrapped in a transaction for atomicity.
|
||||
*
|
||||
* @param string $type WordPress content type: post type slug or taxonomy name.
|
||||
* Pass 'post' for standard posts, 'term' for terms.
|
||||
* @param array[] $members Array of ['site_id' => int, 'element_id' => int].
|
||||
* @return bool True on success, false on any DB error.
|
||||
*/
|
||||
public static function create_group( string $type, array $members ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$rel_table = $mlp5->get_relationships_table();
|
||||
$cont_table = $mlp5->get_content_relations_table();
|
||||
|
||||
if ( '' === $rel_table || '' === $cont_table ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( 'START TRANSACTION' );
|
||||
|
||||
// Insert relationship group.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$inserted = $wpdb->insert(
|
||||
$rel_table,
|
||||
array( 'type' => $type ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
if ( false === $inserted ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( 'ROLLBACK' );
|
||||
return false;
|
||||
}
|
||||
|
||||
$relationship_id = (int) $wpdb->insert_id;
|
||||
|
||||
// Insert each member.
|
||||
foreach ( $members as $member ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$ok = $wpdb->insert(
|
||||
$cont_table,
|
||||
array(
|
||||
'relationship_id' => $relationship_id,
|
||||
'site_id' => (int) $member['site_id'],
|
||||
'content_id' => (int) $member['element_id'],
|
||||
),
|
||||
array( '%d', '%d', '%d' )
|
||||
);
|
||||
|
||||
if ( false === $ok ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( 'ROLLBACK' );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( 'COMMIT' );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
96
includes/class-robotstxt-mlp2-mlp5-writer-site-relations.php
Normal file
96
includes/class-robotstxt-mlp2-mlp5-writer-site-relations.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
/**
|
||||
* MLP5 site relations writer.
|
||||
*
|
||||
* Writes site connection rows to the MLP5 site_relations table.
|
||||
*
|
||||
* MLP5 site_relations schema (identical to MLP2):
|
||||
* site_1 INT UNSIGNED -- lower site ID
|
||||
* site_2 INT UNSIGNED -- higher site ID
|
||||
* PRIMARY KEY (site_1, site_2)
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5_Writer_Site_Relations
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5_Writer_Site_Relations {
|
||||
|
||||
/**
|
||||
* Check whether a site relation already exists in MLP5.
|
||||
*
|
||||
* @param int $site_1 First site ID (will be normalised to lower).
|
||||
* @param int $site_2 Second site ID.
|
||||
* @return bool
|
||||
*/
|
||||
public static function relation_exists( int $site_1, int $site_2 ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$table = $mlp5->get_site_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalise: site_1 must be the lower value.
|
||||
if ( $site_1 > $site_2 ) {
|
||||
list( $site_1, $site_2 ) = array( $site_2, $site_1 );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$found = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE site_1 = %d AND site_2 = %d",
|
||||
$site_1,
|
||||
$site_2
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $found > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a site relation to MLP5.
|
||||
*
|
||||
* Normalises site IDs (smaller value in site_1) before inserting.
|
||||
* Returns false if the target table does not exist.
|
||||
*
|
||||
* @param int $site_1 First site ID.
|
||||
* @param int $site_2 Second site ID.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public static function write_relation( int $site_1, int $site_2 ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$mlp5 = new Robotstxt_Mlp2_Mlp5_Detector_Mlp5();
|
||||
$table = $mlp5->get_site_relations_table();
|
||||
|
||||
if ( '' === $table ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalise.
|
||||
if ( $site_1 > $site_2 ) {
|
||||
list( $site_1, $site_2 ) = array( $site_2, $site_1 );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$result = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'site_1' => $site_1,
|
||||
'site_2' => $site_2,
|
||||
),
|
||||
array( '%d', '%d' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
}
|
||||
115
includes/class-robotstxt-mlp2-mlp5.php
Normal file
115
includes/class-robotstxt-mlp2-mlp5.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
/**
|
||||
* Main plugin bootstrap class.
|
||||
*
|
||||
* @package Robotstxt_Mlp2_Mlp5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Mlp2_Mlp5
|
||||
*
|
||||
* Bootstraps the MLP2 to MLP5 Migrator plugin. Handles activation,
|
||||
* deactivation, Multisite-only enforcement, and text domain loading.
|
||||
*/
|
||||
class Robotstxt_Mlp2_Mlp5 {
|
||||
|
||||
/**
|
||||
* Absolute path to the main plugin file.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $plugin_file;
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*
|
||||
* @param string $plugin_file Absolute path to the main plugin file.
|
||||
*/
|
||||
public static function init( string $plugin_file ): void {
|
||||
$instance = new self( $plugin_file );
|
||||
$instance->register_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $plugin_file Absolute path to the main plugin file.
|
||||
*/
|
||||
private function __construct( string $plugin_file ) {
|
||||
$this->plugin_file = $plugin_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks.
|
||||
*/
|
||||
private function register_hooks(): void {
|
||||
register_activation_hook( $this->plugin_file, array( $this, 'activate' ) );
|
||||
register_deactivation_hook( $this->plugin_file, array( $this, 'deactivate' ) );
|
||||
add_action( 'admin_notices', array( $this, 'multisite_notice' ) );
|
||||
add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) );
|
||||
// Run pending DB upgrades on every request after a plugin update.
|
||||
add_action( 'plugins_loaded', array( 'Robotstxt_Mlp2_Mlp5_Db_Upgrader', 'maybe_upgrade' ) );
|
||||
// Register AJAX handlers for the batch migration runner.
|
||||
Robotstxt_Mlp2_Mlp5_Ajax::register();
|
||||
// Register the Network Admin page (network_admin_menu hook fires inside).
|
||||
Robotstxt_Mlp2_Mlp5_Admin_Page::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin activation callback.
|
||||
*
|
||||
* Prevents activation on non-Multisite installations. On Multisite,
|
||||
* creates the migration log table.
|
||||
*/
|
||||
public function activate(): void {
|
||||
if ( ! is_multisite() ) {
|
||||
deactivate_plugins( plugin_basename( $this->plugin_file ), true );
|
||||
wp_die(
|
||||
esc_html__( 'MLP2 to MLP5 Migrator requires a WordPress Multisite installation.', 'robotstxt-mlp2-mlp5' ),
|
||||
esc_html__( 'Plugin Activation Error', 'robotstxt-mlp2-mlp5' ),
|
||||
array( 'back_link' => true )
|
||||
);
|
||||
}
|
||||
|
||||
Robotstxt_Mlp2_Mlp5_Db_Upgrader::maybe_upgrade();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin deactivation callback.
|
||||
*/
|
||||
public function deactivate(): void {
|
||||
// Reserved for future cleanup on deactivation.
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an admin notice when the plugin is loaded on a non-Multisite installation.
|
||||
*
|
||||
* Belt-and-suspenders guard in case the plugin ends up loaded outside of
|
||||
* the network activation flow.
|
||||
*/
|
||||
public function multisite_notice(): void {
|
||||
if ( is_multisite() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s</p></div>',
|
||||
esc_html__( 'MLP2 to MLP5 Migrator requires a WordPress Multisite installation and has been deactivated.', 'robotstxt-mlp2-mlp5' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the plugin text domain for internationalization.
|
||||
*/
|
||||
public function load_textdomain(): void {
|
||||
load_plugin_textdomain(
|
||||
'robotstxt-mlp2-mlp5',
|
||||
false,
|
||||
dirname( plugin_basename( $this->plugin_file ) ) . '/languages'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue