robotstxt-mlp2-mlp5/includes/class-robotstxt-mlp2-mlp5-step-analysis.php
2026-03-02 11:22:59 +00:00

390 lines
16 KiB
PHP

<?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>
&nbsp;
<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 '';
}
}