} Left out maps name to reason. */ public static function partition( array $tables, string $prefix ): array { $tables = array_values( array_map( 'strval', $tables ) ); $nested = self::nested_prefixes( $tables, $prefix ); $kept = []; $left_out = []; foreach ( $tables as $table ) { $reason = self::reason( $table, $prefix, $nested ); if ( null === $reason ) { $kept[] = $table; } else { $left_out[ $table ] = $reason; } } return [ 'kept' => $kept, 'left_out' => $left_out ]; } /** * Screens a table list the remote site advertised, or a selection for a pull. * * Names may be full or bare. With no known remote prefix only the name rules * that need no prefix apply: bare shiftora_ and Action Scheduler names. * * @param string[] $names Remote table names. * * @return array{ kept: string[], left_out: array } */ public static function screen_remote( array $names, string $remote_prefix ): array { $names = array_values( array_map( 'strval', $names ) ); if ( '' === $remote_prefix ) { $kept = []; $left_out = []; foreach ( $names as $name ) { if ( str_starts_with( $name, 'shiftora_' ) ) { $left_out[ $name ] = self::REASON_SHIFTORA; } elseif ( self::is_action_scheduler_table( $name ) ) { $left_out[ $name ] = self::REASON_ACTION_SCHEDULER; } else { $kept[] = $name; } } return [ 'kept' => $kept, 'left_out' => $left_out ]; } $full_of = static fn( string $name ): string => str_starts_with( $name, $remote_prefix ) ? $name : $remote_prefix . $name; $fulls = array_map( $full_of, $names ); $nested = self::nested_prefixes( $fulls, $remote_prefix ); $kept = []; $left_out = []; foreach ( $names as $i => $name ) { $reason = self::reason( $fulls[ $i ], $remote_prefix, $nested ); if ( null === $reason ) { $kept[] = $name; } else { $left_out[ $name ] = $reason; } } return [ 'kept' => $kept, 'left_out' => $left_out ]; } /** * One line for the migration log: how many tables were left out, and which. * * @param array $left_out Name to reason. */ public static function summarise( array $left_out ): string { $count = count( $left_out ); return sprintf( 'Left out %d %s that do not belong to this site\'s data: %s.', $count, 1 === $count ? 'table' : 'tables', implode( ', ', array_keys( $left_out ) ) ); } /** * Why a table is not eligible, or null when it is. * * @param string[] $nested Longer prefixes that hold a second install. */ private static function reason( string $table, string $prefix, array $nested ): ?string { if ( ! str_starts_with( $table, $prefix ) ) { return self::REASON_FOREIGN_PREFIX; } if ( str_starts_with( substr( $table, strlen( $prefix ) ), 'shiftora_' ) ) { return self::REASON_SHIFTORA; } if ( self::is_action_scheduler_table( $table ) ) { return self::REASON_ACTION_SCHEDULER; } foreach ( $nested as $inner ) { if ( str_starts_with( $table, $inner ) ) { return self::REASON_NESTED_INSTALL; } } return null; } /** * Prefixes under this one that carry a full second install: both options and * posts exist there. A purely numeric segment is a multisite blog (wp_2_), not * another install, and a plugin's tables (wp_wc_orders) never have both. * * @param string[] $tables Full names. * * @return string[] Full prefixes, such as wp_pc_. */ private static function nested_prefixes( array $tables, string $prefix ): array { $present = array_flip( $tables ); $nested = []; foreach ( $tables as $table ) { if ( ! str_starts_with( $table, $prefix ) || ! str_ends_with( $table, 'options' ) ) { continue; } $candidate = substr( $table, 0, -strlen( 'options' ) ); $segments = substr( $candidate, strlen( $prefix ) ); if ( 1 !== preg_match( '/^(?:[^_]+_)+$/', $segments ) || 1 === preg_match( '/^(?:\d+_)+$/', $segments ) ) { continue; } if ( isset( $present[ $candidate . 'posts' ] ) ) { $nested[] = $candidate; } } return $nested; } /** Action Scheduler names its tables with this segment under any prefix. */ private static function is_action_scheduler_table( string $table ): bool { $table = strtolower( $table ); return str_contains( $table, 'action_scheduler_' ) || str_contains( $table, 'actionscheduler_' ); } }