new; the old type stays registered as an alias, so unmigrated data * - historical revisions, imports, other sites - still renders and edits); * - applies per-type settings migrations from * `reactwoo/atomic/element_settings_migrations` (type => callable); * - repairs missing / empty / "0" / duplicate ids inside RWA subtrees with the * same deterministic ids the editor already uses in memory (Element_Id_Repair, * salted with the main document id), so the editor, the preview and the saved * data agree. * * Historical revisions are left untouched (reported only). Idempotent: a second * run reports zero changes. Dry-run by default. * * @package ReactWoo\Atomic */ namespace ReactWoo\Atomic\Migrations; use ReactWoo\Atomic\ElementorBridge\Element_Id_Repair; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * RWA element data migrator (types, settings, ids). */ final class Element_Data_Migrator { const VERSION = 1; /** * Old type => new type. Empty in 0.7.x (no renames shipped); filterable for future merges. * * @return array */ public static function renames() { $map = function_exists( 'apply_filters' ) ? apply_filters( 'reactwoo/atomic/element_type_renames', array() ) : array(); $out = array(); foreach ( (array) $map as $old => $new ) { if ( is_string( $old ) && is_string( $new ) && '' !== $old && '' !== $new && $old !== $new ) { $out[ $old ] = $new; } } return $out; } /** * Type (after rename) => callable( array $settings, array $element ): array. * * @return array */ public static function settings_migrations() { $map = function_exists( 'apply_filters' ) ? apply_filters( 'reactwoo/atomic/element_settings_migrations', array() ) : array(); return array_filter( (array) $map, 'is_callable' ); } /** * Type => callable( array $element ): array. Structural migrations (may replace the * element and its children), e.g. legacy leaf -> composite atoms. * * @return array */ public static function tree_migrations() { $map = function_exists( 'apply_filters' ) ? apply_filters( 'reactwoo/atomic/element_tree_migrations', array() ) : array(); return array_filter( (array) $map, 'is_callable' ); } /** * Migrate one element tree. Pure: no I/O. * * @param array $elements Top-level elements. * @param string $salt Main document id (id repair salt). * @param array $renames Type renames (null = filter). * @param array $settings_migrations Settings migrations (null = filter). * @return array{elements: array, changed: bool, renamed: array, settings: array, ids: array} */ public static function migrate_elements( array $elements, $salt, $renames = null, $settings_migrations = null ) { $renames = null === $renames ? self::renames() : $renames; $settings_migrations = null === $settings_migrations ? self::settings_migrations() : $settings_migrations; $result = array( 'elements' => $elements, 'changed' => false, 'renamed' => array(), 'settings' => array(), 'ids' => array(), ); $tree_migrations = self::tree_migrations(); $walk = static function ( array $els ) use ( &$walk, $renames, $settings_migrations, $tree_migrations, &$result ) { foreach ( $els as $i => $el ) { if ( ! is_array( $el ) ) { continue; } $key = ! empty( $el['widgetType'] ) ? 'widgetType' : 'elType'; $type = isset( $el[ $key ] ) && is_string( $el[ $key ] ) ? $el[ $key ] : ''; if ( '' !== $type && isset( $renames[ $type ] ) ) { $el[ $key ] = $renames[ $type ]; $result['renamed'][ $type ] = ( $result['renamed'][ $type ] ?? 0 ) + 1; $type = $renames[ $type ]; } if ( '' !== $type && isset( $tree_migrations[ $type ] ) ) { $after = call_user_func( $tree_migrations[ $type ], $el ); if ( is_array( $after ) && $after !== $el ) { $el = $after; $result['settings'][ $type ] = ( $result['settings'][ $type ] ?? 0 ) + 1; $key = ! empty( $el['widgetType'] ) ? 'widgetType' : 'elType'; $type = isset( $el[ $key ] ) && is_string( $el[ $key ] ) ? $el[ $key ] : ''; } } if ( '' !== $type && isset( $settings_migrations[ $type ] ) ) { $before = isset( $el['settings'] ) && is_array( $el['settings'] ) ? $el['settings'] : array(); $after = call_user_func( $settings_migrations[ $type ], $before, $el ); if ( is_array( $after ) && $after !== $before ) { $el['settings'] = $after; $result['settings'][ $type ] = ( $result['settings'][ $type ] ?? 0 ) + 1; } } if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) { $el['elements'] = $walk( $el['elements'] ); } $els[ $i ] = $el; } return $els; }; $migrated = $walk( $elements ); $repaired = Element_Id_Repair::repair( $migrated, (string) $salt ); self::count_id_changes( $migrated, $repaired, $result['ids'] ); $result['elements'] = $repaired; $result['changed'] = $result['renamed'] || $result['settings'] || $result['ids']; return $result; } /** * @param array $a Before id repair. * @param array $b After id repair (same shape). * @param array $counts Type => changed ids (by ref). * @return void */ private static function count_id_changes( array $a, array $b, array &$counts ) { foreach ( $a as $i => $el ) { if ( ! is_array( $el ) || ! isset( $b[ $i ] ) || ! is_array( $b[ $i ] ) ) { continue; } $old = array_key_exists( 'id', $el ) ? $el['id'] : null; $new = array_key_exists( 'id', $b[ $i ] ) ? $b[ $i ]['id'] : null; if ( $old !== $new ) { $type = Element_Id_Repair::element_type( $b[ $i ] ); $counts[ $type ] = ( $counts[ $type ] ?? 0 ) + 1; } if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) && isset( $b[ $i ]['elements'] ) && is_array( $b[ $i ]['elements'] ) ) { self::count_id_changes( $el['elements'], $b[ $i ]['elements'], $counts ); } } } /** * Every post that has `_elementor_data`, classified. * * @return list */ public static function targets() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( "SELECT p.ID, p.post_parent, p.post_type, p.post_status, p.post_name FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_elementor_data' ORDER BY p.ID ASC" ); $live = array( 'publish', 'draft', 'pending', 'private', 'future' ); $out = array(); foreach ( (array) $rows as $r ) { if ( 'revision' === $r->post_type ) { $kind = false !== strpos( (string) $r->post_name, '-autosave-v' ) ? 'autosave' : 'revision'; } elseif ( in_array( $r->post_status, $live, true ) ) { $kind = 'document'; } else { $kind = 'other'; } $out[] = array( 'id' => (int) $r->ID, 'parent' => (int) $r->post_parent, 'post_type' => (string) $r->post_type, 'status' => (string) $r->post_status, 'name' => (string) $r->post_name, 'kind' => $kind, ); } return $out; } /** * Run over the whole site. * * @param bool $apply Write changes (false = dry run). * @param bool $include_revisions Also migrate historical revisions. * @return array Report. */ public static function run( $apply = false, $include_revisions = false ) { $report = array( 'migrator' => 'Element_Data_Migrator v' . self::VERSION, 'mode' => $apply ? 'apply' : 'dry-run', 'renames' => self::renames(), 'scanned' => 0, 'with_rwa' => 0, 'touched' => array(), 'skipped' => array(), 'totals' => array( 'renamed' => array(), 'settings' => array(), 'ids' => array(), ), 'written' => 0, 'errors' => array(), ); foreach ( self::targets() as $t ) { ++$report['scanned']; $raw = get_metadata( 'post', $t['id'], '_elementor_data', true ); if ( is_array( $raw ) ) { $raw = wp_json_encode( $raw ); } $raw = (string) $raw; if ( '' === $raw || false === strpos( $raw, 'reactwoo-' ) ) { continue; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { $report['errors'][] = array( 'id' => $t['id'], 'error' => 'invalid JSON' ); continue; } $has_rwa = (bool) preg_match( '/"(?:elType|widgetType)"\s*:\s*"reactwoo-/', $raw ); $renames = self::renames(); foreach ( array_keys( $renames ) as $old ) { $has_rwa = $has_rwa || false !== strpos( $raw, '"' . $old . '"' ); } if ( ! $has_rwa ) { continue; } ++$report['with_rwa']; $salt = $t['parent'] > 0 ? $t['parent'] : $t['id']; $res = self::migrate_elements( $data, (string) $salt, $renames ); if ( ! $res['changed'] ) { continue; } $entry = array( 'id' => $t['id'], 'parent' => $t['parent'], 'post_type' => $t['post_type'], 'status' => $t['status'], 'kind' => $t['kind'], 'renamed' => $res['renamed'], 'settings' => $res['settings'], 'ids' => $res['ids'], 'md5' => md5( $raw ), ); if ( ( 'revision' === $t['kind'] && ! $include_revisions ) || 'other' === $t['kind'] ) { $entry['reason'] = 'revision' === $t['kind'] ? 'historical revision - left as is (aliases + in-memory id repair cover it)' : 'non-live status'; $report['skipped'][] = $entry; continue; } foreach ( array( 'renamed', 'settings', 'ids' ) as $k ) { foreach ( $res[ $k ] as $type => $n ) { $report['totals'][ $k ][ $type ] = ( $report['totals'][ $k ][ $type ] ?? 0 ) + $n; } } if ( $apply ) { $json = wp_json_encode( $res['elements'] ); if ( ! is_string( $json ) ) { $report['errors'][] = array( 'id' => $t['id'], 'error' => 'encode failed' ); continue; } // update_metadata (not update_post_meta): revisions / autosaves keep their own row. $ok = update_metadata( 'post', $t['id'], '_elementor_data', wp_slash( $json ) ); if ( false === $ok ) { $report['errors'][] = array( 'id' => $t['id'], 'error' => 'update_metadata failed' ); continue; } $entry['new_md5'] = md5( $json ); ++$report['written']; } $report['touched'][] = $entry; } return $report; } }