n Name: Accordion Block Migration CLI * Description: WP-CLI command to migrate snl-blocks/snl-accordion to snl/accordion-wrapper * Version: 1.2.1 */ if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; } /** * Migrate accordion blocks from old structure to new TUI4 structure */ class Accordion_Block_Migration_Command { /** * Migrates accordion blocks from snl-blocks/snl-accordion to snl/accordion-wrapper * * ## OPTIONS * * [--dry-run] * : Run the migration without making any changes (preview only) * * [--post-type=] * : Limit migration to specific post type (default: page,post) * * [--blog-id=] * : Specific blog ID to migrate (use 'all' for all sites, defaults to main site) * * [--batch-size=] * : Number of posts to process per batch (default: 50) * * [--start-batch=] * : Resume from a specific batch number (default: 1) * * [--show-urls] * : Display the URL of each page as it's migrated, including skipped posts * * [--post-id=] * : Test migration on a specific post ID only * * [--debug] * : Output raw block attributes for debugging attribute mapping * * ## EXAMPLES * * # Preview changes without applying them * wp accordion-migrate run --dry-run * * # Test on a specific post * wp accordion-migrate run --post-id=123 --dry-run * * # Debug attribute keys on a specific post * wp accordion-migrate run --post-id=123 --dry-run --debug * * # Run the actual migration * wp accordion-migrate run * * # Migrate specific blog * wp accordion-migrate run --blog-id=2 * * # Migrate all sites in network * wp accordion-migrate run --blog-id=all * * # Use smaller batch size * wp accordion-migrate run --batch-size=25 * * # Resume from batch 3 * wp accordion-migrate run --start-batch=3 * * @when after_wp_load */ public function run( $args, $assoc_args ) { $dry_run = isset( $assoc_args['dry-run'] ); $debug = isset( $assoc_args['debug'] ); $post_types = isset( $assoc_args['post-type'] ) ? explode( ',', $assoc_args['post-type'] ) : array( 'page', 'post' ); $batch_size = isset( $assoc_args['batch-size'] ) ? absint( $assoc_args['batch-size'] ) : 50; $start_batch = isset( $assoc_args['start-batch'] ) ? absint( $assoc_args['start-batch'] ) : 1; $show_urls = isset( $assoc_args['show-urls'] ); $single_post_id = isset( $assoc_args['post-id'] ) ? absint( $assoc_args['post-id'] ) : null; // Handle multisite $blog_id = isset( $assoc_args['blog-id'] ) ? $assoc_args['blog-id'] : null; if ( is_multisite() && $blog_id === 'all' ) { if ( $single_post_id ) { WP_CLI::error( 'Cannot use --post-id with --blog-id=all' ); } $this->run_for_all_sites( $post_types, $batch_size, $start_batch, $dry_run, $show_urls, $debug ); return; } // Determine which site to run on $target_blog_id = $this->get_target_blog_id( $blog_id ); if ( is_multisite() ) { switch_to_blog( $target_blog_id ); WP_CLI::log( "Switched to blog ID: {$target_blog_id}" ); } if ( $dry_run ) { WP_CLI::warning( 'DRY RUN MODE - No changes will be made' ); } WP_CLI::log( 'Starting accordion block migration...' ); WP_CLI::log( 'From: snl-blocks/snl-accordion → To: snl/accordion-wrapper' ); WP_CLI::log( 'Post types: ' . implode( ', ', $post_types ) ); if ( $single_post_id ) { WP_CLI::log( "Testing on single post ID: {$single_post_id}" ); } else { WP_CLI::log( "Batch size: {$batch_size}" ); if ( $start_batch > 1 ) { WP_CLI::warning( "Resuming from batch: {$start_batch}" ); } } WP_CLI::log( '' ); $stats = array( 'total_processed' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0, 'blocks_migrated' => 0, ); if ( $single_post_id ) { $this->migrate_single_post( $single_post_id, $dry_run, $show_urls, $debug, $stats ); } else { $this->migrate_all_posts( $post_types, $batch_size, $start_batch, $dry_run, $show_urls, $debug, $stats ); } // Display summary $this->print_summary( $stats, $dry_run, is_multisite() ? $target_blog_id : null ); // Restore original blog in multisite if ( is_multisite() ) { restore_current_blog(); } } // ------------------------------------------------------------------------- // Single post migration (for testing) // ------------------------------------------------------------------------- /** * Migrate a single post (for testing) */ private function migrate_single_post( $post_id, $dry_run, $show_urls, $debug, &$stats ) { $post = get_post( $post_id ); if ( ! $post ) { WP_CLI::error( "Post ID {$post_id} not found." ); return; } WP_CLI::log( "Post: {$post->post_title} (ID: {$post_id})" ); WP_CLI::log( "URL: " . get_permalink( $post_id ) ); WP_CLI::log( "Status: {$post->post_status}" ); WP_CLI::log( '' ); $result = $this->process_post( $post, $dry_run, $show_urls, $debug ); if ( $result['updated'] ) { $stats['total_processed']++; $stats['updated']++; $stats['blocks_migrated'] += $result['blocks_migrated']; WP_CLI::log( "Result: " . ( $dry_run ? 'DRY RUN' : 'UPDATED' ) ); WP_CLI::log( "Accordion blocks migrated: {$result['blocks_migrated']}" ); WP_CLI::log( "URL: " . get_permalink( $post_id ) ); WP_CLI::log( '' ); WP_CLI::log( 'BEFORE:' ); WP_CLI::log( '-------' ); WP_CLI::log( $result['content_before'] ); WP_CLI::log( '' ); WP_CLI::log( 'AFTER:' ); WP_CLI::log( '------' ); WP_CLI::log( $result['content_after'] ); } else { $stats['total_processed']++; $stats['skipped']++; WP_CLI::log( 'No accordion blocks found in this post.' ); } } // ------------------------------------------------------------------------- // Batch migration // ------------------------------------------------------------------------- /** * Migrate all posts in batches */ private function migrate_all_posts( $post_types, $batch_size, $start_batch, $dry_run, $show_urls, $debug, &$stats ) { $total_count = $this->get_posts_with_accordion_count( $post_types ); if ( $total_count === 0 ) { WP_CLI::log( "No posts found with accordion blocks." ); return; } $total_batches = ceil( $total_count / $batch_size ); WP_CLI::log( "Found approximately {$total_count} post(s) with accordion blocks" ); WP_CLI::log( "Processing in {$total_batches} batch(es) of {$batch_size}" ); WP_CLI::log( '' ); for ( $current_batch = $start_batch; $current_batch <= $total_batches; $current_batch++ ) { $offset = ( $current_batch - 1 ) * $batch_size; WP_CLI::log( "→ Batch {$current_batch} of {$total_batches} (offset: {$offset})" ); $posts = get_posts( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $batch_size, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', ) ); $batch_count = count( $posts ); if ( $batch_count === 0 ) { WP_CLI::log( " No posts in this batch (migration complete)" ); break; } $progress = \WP_CLI\Utils\make_progress_bar( " Processing {$batch_count} posts", $batch_count ); foreach ( $posts as $post ) { $result = $this->process_post( $post, $dry_run, $show_urls, $debug ); if ( $result['updated'] ) { $stats['total_processed']++; $stats['updated']++; $stats['blocks_migrated'] += $result['blocks_migrated']; WP_CLI::log( " ✓ [" . ( $dry_run ? 'DRY RUN' : 'UPDATED' ) . "] (Post {$post->ID}) " . get_permalink( $post->ID ) . " — {$result['blocks_migrated']} block(s) migrated" ); } else { $stats['total_processed']++; $stats['skipped']++; if ( $show_urls ) { WP_CLI::log( " - [SKIP] (Post {$post->ID}) " . get_permalink( $post->ID ) . " — no accordion blocks" ); } } $progress->tick(); } $progress->finish(); // Give the database a breather between batches if ( $current_batch < $total_batches ) { sleep( 1 ); } } WP_CLI::log( '' ); } // ------------------------------------------------------------------------- // Core processing // ------------------------------------------------------------------------- /** * Process a single post — parse, check, transform, and optionally save */ private function process_post( $post, $dry_run, $show_urls, $debug ) { $result = array( 'updated' => false, 'has_blocks' => false, 'blocks_migrated' => 0, 'content_before' => '', 'content_after' => '', ); if ( ! has_blocks( $post->post_content ) ) { return $result; } $result['has_blocks'] = true; $blocks = parse_blocks( $post->post_content ); if ( ! $this->has_accordion_blocks( $blocks ) ) { return $result; } $result['content_before'] = $post->post_content; // Transform $transformed_blocks = $this->transform_blocks( $blocks, $result, $debug ); if ( $result['blocks_migrated'] === 0 ) { return $result; } $new_content = serialize_blocks( $transformed_blocks ); $result['content_after'] = $new_content; $result['updated'] = true; if ( ! $dry_run ) { global $wpdb; // Use direct DB update to bypass wp_update_post() validation // which can fail with "Invalid page template" when the theme // isn't fully loaded in CLI context or templates have changed. $db_result = $wpdb->update( $wpdb->posts, array( 'post_content' => $new_content ), array( 'ID' => $post->ID ), array( '%s' ), array( '%d' ) ); if ( $db_result === false ) { WP_CLI::warning( "Failed to update post ID {$post->ID}: " . $wpdb->last_error ); $result['updated'] = false; } else { // Clear the post cache so subsequent reads reflect the change clean_post_cache( $post->ID ); } } return $result; } // ------------------------------------------------------------------------- // Block traversal & transformation // ------------------------------------------------------------------------- /** * Recursively check whether the block tree contains any old accordion blocks */ private function has_accordion_blocks( $blocks ) { foreach ( $blocks as $block ) { if ( $block['blockName'] === 'snl-blocks/snl-accordion' ) { return true; } if ( ! empty( $block['innerBlocks'] ) && $this->has_accordion_blocks( $block['innerBlocks'] ) ) { return true; } } return false; } /** * Walk the block tree; replace every old accordion with the new wrapper */ private function transform_blocks( $blocks, &$result, $debug ) { $transformed = array(); foreach ( $blocks as $block ) { if ( $block['blockName'] === 'snl-blocks/snl-accordion' ) { $transformed[] = $this->transform_accordion_block( $block, $debug ); $result['blocks_migrated']++; } else { if ( ! empty( $block['innerBlocks'] ) ) { $block['innerBlocks'] = $this->transform_blocks( $block['innerBlocks'], $result, $debug ); } $transformed[] = $block; } } return $transformed; } /** * Transform one old snl-blocks/snl-accordion into snl/accordion-wrapper * containing snl/accordion children. */ private function transform_accordion_block( $old_block, $debug ) { $old_attrs = $old_block['attrs'] ?? array(); $old_inner_blocks = $old_block['innerBlocks'] ?? array(); // --- generate IDs (8-char hex, matching the block's own format) -------- $wrapper_id = 'accordion-' . substr( md5( uniqid( '', true ) ), 0, 8 ); // --- resolve old attributes ---------------------------------------------- $show_toggle_links = isset( $old_attrs['showToggled'] ) ? (bool) $old_attrs['showToggled'] : true; $allow_multiple = isset( $old_attrs['allowMultiple'] ) ? (bool) $old_attrs['allowMultiple'] : false; $render_toggle = $show_toggle_links; // --- wrapper attrs (only non-default values serialized into comment) ----- $wrapper_attrs = array( 'wrapperId' => $wrapper_id ); if ( ! $show_toggle_links ) { $wrapper_attrs['showToggleLinks'] = false; } if ( $allow_multiple ) { $wrapper_attrs['allowMultipleOpen'] = true; } // --- build each accordion item -------------------------------------------- $accordion_items = array(); $item_index = 0; foreach ( $old_inner_blocks as $card_block ) { if ( $card_block['blockName'] !== 'snl-blocks/snl-accordion-card' ) { continue; } $card_attrs = $card_block['attrs'] ?? array(); $card_inner_blocks = $card_block['innerBlocks'] ?? array(); $card_inner_content = $card_block['innerContent'] ?? array(); // ------------------------------------------------------------------ // Debug output // ------------------------------------------------------------------ if ( $debug ) { WP_CLI::log( " [DEBUG] Card attrs keys : " . implode( ', ', array_keys( $card_attrs ) ) ); WP_CLI::log( " [DEBUG] Card attrs : " . json_encode( $card_attrs ) ); WP_CLI::log( " [DEBUG] innerContent cnt: " . count( $card_inner_content ) ); WP_CLI::log( " [DEBUG] innerBlocks cnt : " . count( $card_inner_blocks ) ); WP_CLI::log( '' ); } // ------------------------------------------------------------------ // Resolve card attributes // ------------------------------------------------------------------ $title = ''; foreach ( array( 'title', 'cardTitle', 'heading', 'label', 'name' ) as $key ) { if ( ! empty( $card_attrs[ $key ] ) ) { $title = $card_attrs[ $key ]; break; } } $subtitle = $card_attrs['subtitle'] ?? ''; $item_id = 'accordion-item-' . substr( md5( uniqid( '', true ) ), 0, 8 ); // ------------------------------------------------------------------ // Item attrs // ------------------------------------------------------------------ $item_attrs = array( 'accordionId' => $wrapper_id, 'itemId' => $item_id, ); if ( $title !== '' ) { $item_attrs['title'] = $title; } if ( $subtitle !== '' ) { $item_attrs['subtitle'] = $subtitle; } if ( $allow_multiple ) { $item_attrs['allowMultipleOpen'] = true; } // ------------------------------------------------------------------ // Assemble the item block // ------------------------------------------------------------------ $accordion_items[] = array( 'blockName' => 'snl/accordion', 'attrs' => $item_attrs, 'innerBlocks' => $card_inner_blocks, 'innerHTML' => '', 'innerContent' => $card_inner_content, ); $item_index++; } // --- build wrapper innerHTML / innerContent ------------------------------ $html_wrapper_open = '
'; if ( $render_toggle ) { $html_wrapper_open .= ''; } $html_wrapper_close = '
'; $wrapper_inner_content = array(); $wrapper_inner_content[] = "\n" . $html_wrapper_open; foreach ( $accordion_items as $i => $item ) { $wrapper_inner_content[] = null; if ( $i < count( $accordion_items ) - 1 ) { $wrapper_inner_content[] = "\n"; } } $wrapper_inner_content[] = $html_wrapper_close . "\n"; // --- assemble wrapper block ---------------------------------------------- return array( 'blockName' => 'snl/accordion-wrapper', 'attrs' => $wrapper_attrs, 'innerBlocks' => $accordion_items, 'innerHTML' => '', 'innerContent' => $wrapper_inner_content, ); } // ------------------------------------------------------------------------- // Multisite helpers // ------------------------------------------------------------------------- /** * Run migration across every site in the network */ private function run_for_all_sites( $post_types, $batch_size, $start_batch, $dry_run, $show_urls, $debug ) { if ( ! is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } $sites = get_sites( array( 'number' => 0 ) ); $total_sites = count( $sites ); WP_CLI::log( "Processing {$total_sites} sites in the network..." ); WP_CLI::log( '' ); $global_stats = array( 'sites_processed' => 0, 'sites_with_changes' => 0, 'total_posts_updated' => 0, 'total_blocks_migrated' => 0, 'total_errors' => 0, ); foreach ( $sites as $site ) { switch_to_blog( $site->blog_id ); WP_CLI::log( "========================================" ); WP_CLI::log( "Site: {$site->blogname} (ID: {$site->blog_id})" ); WP_CLI::log( "URL: {$site->siteurl}" ); WP_CLI::log( "========================================" ); $stats = array( 'total_processed' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0, 'blocks_migrated' => 0, ); $this->migrate_all_posts( $post_types, $batch_size, $start_batch, $dry_run, $show_urls, $debug, $stats ); // Tally $global_stats['sites_processed']++; if ( $stats['updated'] > 0 ) { $global_stats['sites_with_changes']++; $global_stats['total_posts_updated'] += $stats['updated']; $global_stats['total_blocks_migrated'] += $stats['blocks_migrated']; } $global_stats['total_errors'] += $stats['errors']; WP_CLI::log( "Site summary: {$stats['updated']} posts updated, {$stats['blocks_migrated']} blocks migrated" ); WP_CLI::log( '' ); restore_current_blog(); } // Network-wide summary WP_CLI::log( '========================================' ); WP_CLI::log( 'NETWORK-WIDE SUMMARY' ); WP_CLI::log( '========================================' ); WP_CLI::success( "Sites processed: {$global_stats['sites_processed']}" ); WP_CLI::success( "Sites with changes: {$global_stats['sites_with_changes']}" ); WP_CLI::success( "Total posts updated: {$global_stats['total_posts_updated']}" ); WP_CLI::success( "Total blocks migrated: {$global_stats['total_blocks_migrated']}" ); if ( $global_stats['total_errors'] > 0 ) { WP_CLI::error( "Total errors: {$global_stats['total_errors']}", false ); } if ( $dry_run ) { WP_CLI::warning( 'This was a DRY RUN. Run without --dry-run to apply changes.' ); } } /** * Resolve --blog-id to an integer, verifying the blog exists */ private function get_target_blog_id( $blog_id ) { if ( ! is_multisite() ) { return get_current_blog_id(); } if ( $blog_id !== null && $blog_id !== 'all' ) { $target_id = absint( $blog_id ); $blog = get_blog_details( $target_id ); if ( ! $blog ) { WP_CLI::error( "Blog ID {$target_id} does not exist." ); } return $target_id; } return get_main_site_id(); } // ------------------------------------------------------------------------- // Counting / listing helpers // ------------------------------------------------------------------------- /** * Count posts that contain the old accordion block comment delimiter. */ private function get_posts_with_accordion_count( $post_types ) { $post_ids = get_posts( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); $count = 0; foreach ( $post_ids as $post_id ) { $content = get_post_field( 'post_content', $post_id ); if ( strpos( $content, 'wp:snl-blocks/snl-accordion' ) !== false ) { $count++; } } return $count; } /** * Recursively count old accordion blocks in a parsed block tree */ private function count_accordion_blocks( $blocks ) { $count = 0; foreach ( $blocks as $block ) { if ( $block['blockName'] === 'snl-blocks/snl-accordion' ) { $count++; } if ( ! empty( $block['innerBlocks'] ) ) { $count += $this->count_accordion_blocks( $block['innerBlocks'] ); } } return $count; } // ------------------------------------------------------------------------- // List sub-command // ------------------------------------------------------------------------- /** * List all posts that still contain old accordion blocks * * ## OPTIONS * * [--blog-id=] * : Specific blog ID to list (use 'all' for all sites) * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - csv * - json * --- * * ## EXAMPLES * * wp accordion-migrate list * wp accordion-migrate list --format=csv * wp accordion-migrate list --blog-id=all * * @when after_wp_load */ public function list( $args, $assoc_args ) { $blog_id = isset( $assoc_args['blog-id'] ) ? $assoc_args['blog-id'] : null; if ( is_multisite() && $blog_id === 'all' ) { $this->list_all_sites( $assoc_args ); return; } $target_blog_id = $this->get_target_blog_id( $blog_id ); if ( is_multisite() ) { switch_to_blog( $target_blog_id ); WP_CLI::log( "Listing for blog ID: {$target_blog_id}" ); WP_CLI::log( '' ); } $results = $this->get_posts_with_accordion_blocks( $target_blog_id ); if ( is_multisite() ) { restore_current_blog(); } if ( empty( $results ) ) { WP_CLI::success( 'No posts found with old accordion blocks!' ); return; } $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; $fields = is_multisite() && $blog_id !== null ? array( 'Blog_ID', 'ID', 'Title', 'Type', 'Block_Count', 'URL' ) : array( 'ID', 'Title', 'Type', 'Block_Count', 'URL' ); \WP_CLI\Utils\format_items( $format, $results, $fields ); } /** * Gather post rows for the list sub-command (single site) */ private function get_posts_with_accordion_blocks( $blog_id = null ) { $posts = get_posts( array( 'post_type' => array( 'page', 'post' ), 'post_status' => 'publish', 'posts_per_page' => -1, ) ); $results = array(); foreach ( $posts as $post ) { if ( ! has_blocks( $post->post_content ) ) { continue; } $blocks = parse_blocks( $post->post_content ); $block_count = $this->count_accordion_blocks( $blocks ); if ( $block_count > 0 ) { $row = array( 'ID' => $post->ID, 'Title' => $post->post_title, 'Type' => $post->post_type, 'Block_Count' => $block_count, 'URL' => get_permalink( $post->ID ), ); if ( is_multisite() && $blog_id !== null ) { $row['Blog_ID'] = $blog_id; } $results[] = $row; } } return $results; } /** * List sub-command: network-wide variant */ private function list_all_sites( $assoc_args ) { if ( ! is_multisite() ) { WP_CLI::error( 'This is not a multisite installation.' ); } $sites = get_sites( array( 'number' => 0 ) ); $all_results = array(); foreach ( $sites as $site ) { switch_to_blog( $site->blog_id ); $site_results = $this->get_posts_with_accordion_blocks( $site->blog_id ); foreach ( $site_results as &$row ) { $row['Blog_Name'] = $site->blogname; } unset( $row ); $all_results = array_merge( $all_results, $site_results ); restore_current_blog(); } if ( empty( $all_results ) ) { WP_CLI::success( 'No posts found with old accordion blocks across all sites!' ); return; } $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; \WP_CLI\Utils\format_items( $format, $all_results, array( 'Blog_ID', 'Blog_Name', 'ID', 'Title', 'Type', 'Block_Count', 'URL' ) ); } // ------------------------------------------------------------------------- // Shared output helpers // ------------------------------------------------------------------------- /** * Print the per-site (or single-site) migration summary */ private function print_summary( $stats, $dry_run, $blog_id = null ) { WP_CLI::log( '' ); WP_CLI::log( '========================================' ); WP_CLI::log( 'MIGRATION SUMMARY' ); if ( $blog_id !== null ) { WP_CLI::log( "Blog ID: {$blog_id}" ); } WP_CLI::log( '========================================' ); WP_CLI::success( "Posts processed: {$stats['total_processed']}" ); WP_CLI::success( "Posts updated: {$stats['updated']}" ); WP_CLI::success( "Accordion blocks migrated: {$stats['blocks_migrated']}" ); if ( $stats['skipped'] > 0 ) { WP_CLI::log( "Posts skipped (no accordion blocks): {$stats['skipped']}" ); } if ( $stats['errors'] > 0 ) { WP_CLI::error( "Errors: {$stats['errors']}", false ); } if ( $dry_run ) { WP_CLI::warning( 'This was a DRY RUN. Run without --dry-run to apply changes.' ); } } } WP_CLI::add_command( 'accordion-migrate', 'Accordion_Block_Migration_Command' );