From 76775d74dcc3ca113e293b2d04399acb28f50fd0 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 16:08:39 +0200 Subject: [PATCH 1/5] Add WP-CLI command to migrate legacy beapi/key-figure content. Content saved by the old plugin stays invalid until block names, BEM classes, and markup are rewritten; this command does that as a manual dry-run by default. Co-authored-by: Cursor --- .github/workflows/quality-php.yml | 2 + blockparty-key-figure.php | 11 +- composer.json | 6 +- grumphp.yml | 2 +- .../Cli/MigrateFromKeyFigureBlockCommand.php | 416 ++++++++++++++++++ includes/Migration/KeyFigureBlockMigrator.php | 258 +++++++++++ phpcs.xml | 1 + readme.txt | 29 ++ 8 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 includes/Cli/MigrateFromKeyFigureBlockCommand.php create mode 100644 includes/Migration/KeyFigureBlockMigrator.php diff --git a/.github/workflows/quality-php.yml b/.github/workflows/quality-php.yml index 30b16ca..68e0566 100644 --- a/.github/workflows/quality-php.yml +++ b/.github/workflows/quality-php.yml @@ -5,10 +5,12 @@ on: paths: - 'composer.json' - 'blockparty-key-figure.php' + - 'includes/**' push: paths: - 'composer.json' - 'blockparty-key-figure.php' + - 'includes/**' branches: - main diff --git a/blockparty-key-figure.php b/blockparty-key-figure.php index e3b673a..0c4b82d 100644 --- a/blockparty-key-figure.php +++ b/blockparty-key-figure.php @@ -15,6 +15,8 @@ namespace Blockparty\Key_Figure; +use Blockparty\Key_Figure\Cli\MigrateFromKeyFigureBlockCommand; + define( 'BLOCKPARTY_KEY_FIGURE_VERSION', '1.1.1' ); define( 'BLOCKPARTY_KEY_FIGURE_URL', plugin_dir_url( __FILE__ ) ); define( 'BLOCKPARTY_KEY_FIGURE_DIR', plugin_dir_path( __FILE__ ) ); @@ -55,4 +57,11 @@ function init() { do_action( 'blockparty_key_figure_block_init' ); } -add_action( 'init', __NAMESPACE__ . '\\init' ); +add_action( 'init', __NAMESPACE__ . '\\init', 10, 0 ); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once __DIR__ . '/includes/Migration/KeyFigureBlockMigrator.php'; + require_once __DIR__ . '/includes/Cli/MigrateFromKeyFigureBlockCommand.php'; + + \WP_CLI::add_command( 'blockparty key-figure migrate', MigrateFromKeyFigureBlockCommand::class ); +} diff --git a/composer.json b/composer.json index 0c9d846..1a66453 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,11 @@ "vimeo/psalm": "^5.20", "wp-coding-standards/wpcs": "^3.0" }, - "autoload": {}, + "autoload": { + "psr-4": { + "Blockparty\\Key_Figure\\": "includes/" + } + }, "autoload-dev": {}, "scripts": { "cs": "./vendor/bin/phpcs", diff --git a/grumphp.yml b/grumphp.yml index b7e5058..c0dd3a5 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -13,7 +13,7 @@ grumphp: jobs: ~ triggered_by: ['php', 'phtml', 'php3', 'php4', 'php5', 'php7'] phpcs: - standard: ['phpcs.xml.dist'] + standard: ['phpcs.xml'] triggered_by: [php] composer: no_check_all: true diff --git a/includes/Cli/MigrateFromKeyFigureBlockCommand.php b/includes/Cli/MigrateFromKeyFigureBlockCommand.php new file mode 100644 index 0000000..5ea6ac9 --- /dev/null +++ b/includes/Cli/MigrateFromKeyFigureBlockCommand.php @@ -0,0 +1,416 @@ +migrator = new KeyFigureBlockMigrator(); + } + + /** + * Migrate beapi/key-figure content to blockparty/key-figure. + * + * Rewrites the block name (`wp:beapi/key-figure`) and the BEM root class + * (`wp-block-beapi-key-figure`) in post content and in block widgets. Unless + * `--no-modernize` is used, the saved markup is also aligned with the current + * block output so migrated blocks stay valid in the editor. + * + * Runs as a dry-run unless `--live` is passed. + * + * ## OPTIONS + * + * [--live] + * : Save the migrated content. Without this flag nothing is written to the database. + * + * [--[no-]modernize] + * : Align the markup with the current block output: `p` key wrapper and number data attributes. Default: true. + * + * [--skip-revisions] + * : Leave post revisions untouched. + * + * [--blog_id=] + * : Only migrate this site of the network. Default: every site. + * + * [--post-type=] + * : Comma-separated list of post types to migrate. Default: every post type. + * + * [--posts-per-page=] + * : How many posts are read per batch. Default: 100. + * + * ## EXAMPLES + * + * # Report what would change, on every site. + * wp blockparty key-figure migrate + * + * # Migrate every site of the network. + * wp blockparty key-figure migrate --live + * + * # Migrate a single site, revisions excluded. + * wp blockparty key-figure migrate --live --blog_id=2 --skip-revisions + * + * # Only rename the block and its CSS classes. + * wp blockparty key-figure migrate --live --no-modernize + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function __invoke( $args, $assoc_args ): void { + $this->live = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'live', false ); + $this->modernize = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'modernize', true ); + $this->include_revisions = ! (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-revisions', false ); + $this->batch_size = max( 1, (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'posts-per-page', 100 ) ); + $this->post_types = $this->resolve_post_types( (string) WP_CLI\Utils\get_flag_value( $assoc_args, 'post-type', '' ) ); + + $sites = $this->resolve_sites( $assoc_args ); + + if ( ! $this->live ) { + WP_CLI::warning( 'Dry run: nothing is saved. Add --live to apply the migration.' ); + } + + foreach ( $sites as $site_id ) { + if ( is_multisite() ) { + switch_to_blog( $site_id ); + } + + WP_CLI::log( + sprintf( + 'Site %1$d (%2$s) — post types: %3$s, revisions: %4$s, modernize: %5$s', + $site_id, + home_url( '/' ), + empty( $this->post_types ) ? 'all' : implode( ', ', $this->post_types ), + $this->include_revisions ? 'yes' : 'no', + $this->modernize ? 'yes' : 'no' + ) + ); + + $this->migrate_posts(); + $this->migrate_widgets(); + + if ( is_multisite() ) { + restore_current_blog(); + } + } + + WP_CLI::success( + sprintf( + 'Done. Sites: %1$d, posts scanned: %2$d, posts %3$s: %4$d, widgets %3$s: %5$d, blocks renamed: %6$d, markup modernized: %7$d, markup skipped: %8$d.', + count( $sites ), + $this->posts_scanned, + $this->live ? 'updated' : 'to update', + $this->posts_updated, + $this->widgets_updated, + $this->migrator->renamed, + $this->migrator->modernized, + $this->migrator->skipped + ) + ); + + if ( $this->migrator->skipped > 0 ) { + WP_CLI::warning( 'Some blocks were renamed without markup modernization because no number could be read from their markup. Check them in the editor.' ); + } + } + + /** + * Migrate the post content of the current site. + * + * @return void + */ + private function migrate_posts(): void { + $after_id = 0; + + while ( true ) { + $posts = $this->fetch_posts( $after_id ); + $read = count( $posts ); + + foreach ( $posts as $post ) { + $after_id = (int) $post->ID; + ++$this->posts_scanned; + + $content = $this->migrator->migrate_content( (string) $post->post_content, $this->modernize ); + + if ( null === $content ) { + continue; + } + + ++$this->posts_updated; + + WP_CLI::log( + sprintf( + '%1$s post %2$d (%3$s)', + $this->live ? '[update]' : '[dry-run]', + (int) $post->ID, + (string) $post->post_type + ) + ); + + if ( $this->live ) { + $this->save_post( $post, $content ); + } + } + + if ( $read < $this->batch_size ) { + return; + } + } + } + + /** + * Migrate the block widgets of the current site. + * + * @return void + */ + private function migrate_widgets(): void { + $widgets = get_option( self::WIDGETS_OPTION ); + + if ( ! is_array( $widgets ) ) { + return; + } + + $updated = 0; + + foreach ( $widgets as $key => $widget ) { + if ( ! is_array( $widget ) || ! isset( $widget['content'] ) || ! is_string( $widget['content'] ) ) { + continue; + } + + $content = $this->migrator->migrate_content( $widget['content'], $this->modernize ); + + if ( null === $content ) { + continue; + } + + $widget['content'] = $content; + $widgets[ $key ] = $widget; + ++$updated; + ++$this->widgets_updated; + + WP_CLI::log( + sprintf( + '%1$s block widget %2$s', + $this->live ? '[update]' : '[dry-run]', + (string) $key + ) + ); + } + + if ( $updated > 0 && $this->live ) { + update_option( self::WIDGETS_OPTION, $widgets ); + } + } + + /** + * Read the next batch of posts holding legacy markup. + * + * @param int $after_id Only read posts with a greater ID. + * @return object[] + */ + private function fetch_posts( int $after_id ): array { + /** @var \wpdb $wpdb */ + global $wpdb; + + $sql = "SELECT ID, post_type, post_content FROM {$wpdb->posts} WHERE ID > %d AND ( post_content LIKE %s OR post_content LIKE %s )"; + + $values = [ + $after_id, + '%' . $wpdb->esc_like( 'wp:' . KeyFigureBlockMigrator::LEGACY_BLOCK_NAME ) . '%', + '%' . $wpdb->esc_like( KeyFigureBlockMigrator::LEGACY_CLASS_ROOT ) . '%', + ]; + + if ( ! $this->include_revisions ) { + $sql .= " AND post_type != 'revision'"; + } + + if ( ! empty( $this->post_types ) ) { + $sql .= ' AND post_type IN ( ' . implode( ', ', array_fill( 0, count( $this->post_types ), '%s' ) ) . ' )'; + $values = array_merge( $values, $this->post_types ); + } + + $sql .= ' ORDER BY ID ASC LIMIT %d'; + $values[] = $this->batch_size; + + /** @var object[] $posts */ + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared + $posts = $wpdb->get_results( $wpdb->prepare( $sql, $values ) ); + + return $posts; + } + + /** + * Save the migrated content of a single post. + * + * @param object $post Row read from the posts table. + * @param string $content Migrated content. + * @return void + */ + private function save_post( object $post, string $content ): void { + /** @var \wpdb $wpdb */ + global $wpdb; + + // Revisions are content snapshots: writing them through wp_update_post() + // would run the whole insert pipeline (status transition, hooks) on them. + if ( 'revision' === $post->post_type ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->update( $wpdb->posts, [ 'post_content' => $content ], [ 'ID' => (int) $post->ID ] ); + clean_post_cache( (int) $post->ID ); + + return; + } + + // wp_update_post() expects slashed data; without wp_slash(), block comment + // escapes like \u002d (for "--") become bare "u002d". + $updated = wp_update_post( + [ + 'ID' => (int) $post->ID, + 'post_content' => wp_slash( $content ), + ], + true + ); + + if ( is_wp_error( $updated ) ) { + WP_CLI::warning( + sprintf( + 'Failed to update post %1$d: %2$s', + (int) $post->ID, + $updated->get_error_message() + ) + ); + } + } + + /** + * Resolve the sites to migrate. + * + * @param array $assoc_args Associative arguments. + * @return int[] + */ + private function resolve_sites( array $assoc_args ): array { + $blog_id = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'blog_id', 0 ); + + if ( ! is_multisite() ) { + if ( $blog_id > 0 && get_current_blog_id() !== $blog_id ) { + WP_CLI::error( 'The --blog_id flag requires a multisite installation.' ); + } + + return [ get_current_blog_id() ]; + } + + if ( $blog_id > 0 ) { + if ( null === get_site( $blog_id ) ) { + WP_CLI::error( sprintf( 'Site %d does not exist.', $blog_id ) ); + } + + return [ $blog_id ]; + } + + return array_map( + 'intval', + get_sites( + [ + 'fields' => 'ids', + 'number' => 0, + ] + ) + ); + } + + /** + * Resolve the post types to migrate. + * + * @param string $raw Comma-separated post types from the CLI. + * @return string[] Empty when every post type is migrated. + */ + private function resolve_post_types( string $raw ): array { + if ( '' === trim( $raw ) ) { + return []; + } + + return array_values( array_filter( array_map( 'trim', explode( ',', $raw ) ) ) ); + } +} diff --git a/includes/Migration/KeyFigureBlockMigrator.php b/includes/Migration/KeyFigureBlockMigrator.php new file mode 100644 index 0000000..557feae --- /dev/null +++ b/includes/Migration/KeyFigureBlockMigrator.php @@ -0,0 +1,258 @@ +needs_migration( $content ) ) { + return null; + } + + /** @psalm-suppress MixedArgumentTypeCoercion */ + $migrated = serialize_blocks( $this->migrate_blocks( parse_blocks( $content ), $modernize ) ); + + // Legacy classes also live outside of key figure blocks (core/html, + // classic content, theme markup), where only a rename is possible. + $migrated = $this->rename_markup( $migrated ); + + return $migrated === $content ? null : $migrated; + } + + /** + * Walk every block and convert the legacy ones. + * + * @param array $blocks Parsed blocks. + * @param bool $modernize Whether to align the markup with the current block output. + * @return array + */ + private function migrate_blocks( array $blocks, bool $modernize ): array { + foreach ( $blocks as $index => $block ) { + if ( self::LEGACY_BLOCK_NAME === ( $block['blockName'] ?? '' ) ) { + $blocks[ $index ] = $this->convert_block( $block, $modernize ); + continue; + } + + if ( is_array( $block['innerBlocks'] ?? null ) && [] !== $block['innerBlocks'] ) { + /** @var array $inner_blocks */ + $inner_blocks = $block['innerBlocks']; + $block['innerBlocks'] = $this->migrate_blocks( $inner_blocks, $modernize ); + $blocks[ $index ] = $block; + } + } + + return $blocks; + } + + /** + * Convert a single legacy block. + * + * @param array $block Legacy block. + * @param bool $modernize Whether to align the markup with the current block output. + * @return array + */ + private function convert_block( array $block, bool $modernize ): array { + $attrs = is_array( $block['attrs'] ?? null ) ? (array) $block['attrs'] : []; + $inner_html = $this->rename_markup( (string) ( $block['innerHTML'] ?? '' ) ); + $inner_blocks = is_array( $block['innerBlocks'] ?? null ) ? (array) $block['innerBlocks'] : []; + + /** @var array $chunks */ + $chunks = is_array( $block['innerContent'] ?? null ) ? (array) $block['innerContent'] : [ $inner_html ]; + $inner_content = []; + + foreach ( $chunks as $chunk ) { + $inner_content[] = is_string( $chunk ) ? $this->rename_markup( $chunk ) : $chunk; + } + + $block['blockName'] = self::BLOCK_NAME; + $block['innerHTML'] = $inner_html; + $block['innerContent'] = $inner_content; + + ++$this->renamed; + + if ( ! $modernize || [] !== $inner_blocks ) { + return $block; + } + + $modernized = $this->modernize_markup( $inner_html, $attrs ); + + if ( null === $modernized ) { + ++$this->skipped; + + return $block; + } + + // Both values are read from the markup by the current block. + unset( $attrs['decimalSeparator'], $attrs['minimumFractionDigits'] ); + + $block['attrs'] = $attrs; + $block['innerHTML'] = $modernized; + $block['innerContent'] = [ $modernized ]; + + ++$this->modernized; + + return $block; + } + + /** + * Replace the legacy block name and BEM root class. + * + * @param string $markup Block delimiters and/or HTML. + * @return string + */ + private function rename_markup( string $markup ): string { + $markup = (string) preg_replace( + '#( blockparty-key-figure.php + ./includes/ ./build/ ./node_modules/ diff --git a/readme.txt b/readme.txt index ea978a3..ccb0175 100644 --- a/readme.txt +++ b/readme.txt @@ -23,6 +23,35 @@ This block allow to set a prefix, suffix and a number. It's possible to change t 1. Block configuration in columns block +== Migration from beapi/key-figure == + +Content created with the legacy `beapi/key-figure-block` plugin still stores the old block name (`wp:beapi/key-figure`) and the old BEM root class (`wp-block-beapi-key-figure`). Those blocks are invalid in the editor until the content is rewritten, which the `wp blockparty key-figure migrate` WP-CLI command does. + +The command scans the post content of every post type (revisions and reusable blocks included) and the block widgets, then: + +1. Renames `wp:beapi/key-figure` to `wp:blockparty/key-figure`, keeping the block attributes. +2. Renames `wp-block-beapi-key-figure` to `wp-block-blockparty-key-figure`, including the `__key`, `__prefix`, `__number`, `__suffix` and `__description` elements. +3. Aligns the markup with the current block output, unless `--no-modernize` is used: `p` key wrapper (instead of `div`), `data-decimal-separator` and `data-minimum-fraction-digits` on the number, and the raw number as text since formatting now happens on the front end. + +It runs as a dry-run by default, and must be run manually on the server: + + # Report what would change, on every site of the network. + wp blockparty key-figure migrate + + # Apply the migration. + wp blockparty key-figure migrate --live + + # Migrate a single site of the network, revisions excluded. + wp blockparty key-figure migrate --live --blog_id=2 --skip-revisions + + # Only rename the block and its CSS classes. + wp blockparty key-figure migrate --live --no-modernize + + # Limit the migration to some post types, with a smaller batch size. + wp blockparty key-figure migrate --live --post-type=post,page --posts-per-page=20 + +Run `wp help blockparty key-figure migrate` for the full list of options. + == Changelog == = 1.0.0 - 2024-04-02 = From 6f210f91cc814e16e63cb8c339568cc1b7f27bb0 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 16:46:14 +0200 Subject: [PATCH 2/5] Target a single site and rely on the native --url parameter. Iterating over every site with switch_to_blog() duplicated what --url already does, so --blog_id is dropped in favour of running the command once per site. Co-authored-by: Cursor --- .../Cli/MigrateFromKeyFigureBlockCommand.php | 89 ++++--------------- readme.txt | 10 ++- 2 files changed, 25 insertions(+), 74 deletions(-) diff --git a/includes/Cli/MigrateFromKeyFigureBlockCommand.php b/includes/Cli/MigrateFromKeyFigureBlockCommand.php index 5ea6ac9..20aef02 100644 --- a/includes/Cli/MigrateFromKeyFigureBlockCommand.php +++ b/includes/Cli/MigrateFromKeyFigureBlockCommand.php @@ -101,7 +101,8 @@ public function __construct() { * `--no-modernize` is used, the saved markup is also aligned with the current * block output so migrated blocks stay valid in the editor. * - * Runs as a dry-run unless `--live` is passed. + * Runs as a dry-run unless `--live` is passed, and always targets a single + * site. On multisite, use the global `--url` parameter to pick the site. * * ## OPTIONS * @@ -114,9 +115,6 @@ public function __construct() { * [--skip-revisions] * : Leave post revisions untouched. * - * [--blog_id=] - * : Only migrate this site of the network. Default: every site. - * * [--post-type=] * : Comma-separated list of post types to migrate. Default: every post type. * @@ -125,14 +123,14 @@ public function __construct() { * * ## EXAMPLES * - * # Report what would change, on every site. + * # Report what would change. * wp blockparty key-figure migrate * - * # Migrate every site of the network. + * # Apply the migration. * wp blockparty key-figure migrate --live * - * # Migrate a single site, revisions excluded. - * wp blockparty key-figure migrate --live --blog_id=2 --skip-revisions + * # Migrate one site of a network, revisions excluded. + * wp blockparty key-figure migrate --live --skip-revisions --url=example.com/site-2 * * # Only rename the block and its CSS classes. * wp blockparty key-figure migrate --live --no-modernize @@ -148,40 +146,27 @@ public function __invoke( $args, $assoc_args ): void { $this->batch_size = max( 1, (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'posts-per-page', 100 ) ); $this->post_types = $this->resolve_post_types( (string) WP_CLI\Utils\get_flag_value( $assoc_args, 'post-type', '' ) ); - $sites = $this->resolve_sites( $assoc_args ); - if ( ! $this->live ) { WP_CLI::warning( 'Dry run: nothing is saved. Add --live to apply the migration.' ); } - foreach ( $sites as $site_id ) { - if ( is_multisite() ) { - switch_to_blog( $site_id ); - } - - WP_CLI::log( - sprintf( - 'Site %1$d (%2$s) — post types: %3$s, revisions: %4$s, modernize: %5$s', - $site_id, - home_url( '/' ), - empty( $this->post_types ) ? 'all' : implode( ', ', $this->post_types ), - $this->include_revisions ? 'yes' : 'no', - $this->modernize ? 'yes' : 'no' - ) - ); - - $this->migrate_posts(); - $this->migrate_widgets(); + WP_CLI::log( + sprintf( + 'Site %1$d (%2$s) — post types: %3$s, revisions: %4$s, modernize: %5$s', + get_current_blog_id(), + home_url( '/' ), + empty( $this->post_types ) ? 'all' : implode( ', ', $this->post_types ), + $this->include_revisions ? 'yes' : 'no', + $this->modernize ? 'yes' : 'no' + ) + ); - if ( is_multisite() ) { - restore_current_blog(); - } - } + $this->migrate_posts(); + $this->migrate_widgets(); WP_CLI::success( sprintf( - 'Done. Sites: %1$d, posts scanned: %2$d, posts %3$s: %4$d, widgets %3$s: %5$d, blocks renamed: %6$d, markup modernized: %7$d, markup skipped: %8$d.', - count( $sites ), + 'Done. Posts scanned: %1$d, posts %2$s: %3$d, widgets %2$s: %4$d, blocks renamed: %5$d, markup modernized: %6$d, markup skipped: %7$d.', $this->posts_scanned, $this->live ? 'updated' : 'to update', $this->posts_updated, @@ -364,42 +349,6 @@ private function save_post( object $post, string $content ): void { } } - /** - * Resolve the sites to migrate. - * - * @param array $assoc_args Associative arguments. - * @return int[] - */ - private function resolve_sites( array $assoc_args ): array { - $blog_id = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'blog_id', 0 ); - - if ( ! is_multisite() ) { - if ( $blog_id > 0 && get_current_blog_id() !== $blog_id ) { - WP_CLI::error( 'The --blog_id flag requires a multisite installation.' ); - } - - return [ get_current_blog_id() ]; - } - - if ( $blog_id > 0 ) { - if ( null === get_site( $blog_id ) ) { - WP_CLI::error( sprintf( 'Site %d does not exist.', $blog_id ) ); - } - - return [ $blog_id ]; - } - - return array_map( - 'intval', - get_sites( - [ - 'fields' => 'ids', - 'number' => 0, - ] - ) - ); - } - /** * Resolve the post types to migrate. * diff --git a/readme.txt b/readme.txt index ccb0175..c922663 100644 --- a/readme.txt +++ b/readme.txt @@ -35,21 +35,23 @@ The command scans the post content of every post type (revisions and reusable bl It runs as a dry-run by default, and must be run manually on the server: - # Report what would change, on every site of the network. + # Report what would change. wp blockparty key-figure migrate # Apply the migration. wp blockparty key-figure migrate --live - # Migrate a single site of the network, revisions excluded. - wp blockparty key-figure migrate --live --blog_id=2 --skip-revisions - # Only rename the block and its CSS classes. wp blockparty key-figure migrate --live --no-modernize # Limit the migration to some post types, with a smaller batch size. wp blockparty key-figure migrate --live --post-type=post,page --posts-per-page=20 +The command always targets a single site. On multisite, run it for each site with the native `--url` parameter: + + wp blockparty key-figure migrate --live --url=example.com + wp blockparty key-figure migrate --live --url=example.com/site-2 + Run `wp help blockparty key-figure migrate` for the full list of options. == Changelog == From 0118e9edc1e35ffb46903b748cf00edd97666e07 Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 17:07:26 +0200 Subject: [PATCH 3/5] Write migrated content by default and preview with --dry-run. The previous --live flag inverted the usual WP-CLI convention; matching blockparty-icons keeps a no-op opt-in instead of an opt-in write. Co-authored-by: Cursor --- .../Cli/MigrateFromKeyFigureBlockCommand.php | 43 +++++++++---------- readme.txt | 16 +++---- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/includes/Cli/MigrateFromKeyFigureBlockCommand.php b/includes/Cli/MigrateFromKeyFigureBlockCommand.php index 20aef02..f6c6b65 100644 --- a/includes/Cli/MigrateFromKeyFigureBlockCommand.php +++ b/includes/Cli/MigrateFromKeyFigureBlockCommand.php @@ -24,11 +24,11 @@ class MigrateFromKeyFigureBlockCommand extends WP_CLI_Command { const WIDGETS_OPTION = 'widget_block'; /** - * Whether the content is saved. + * Whether the content is reported without being saved. * * @var bool */ - private bool $live = false; + private bool $dry_run = false; /** * Whether the markup is aligned with the current block output. @@ -101,13 +101,13 @@ public function __construct() { * `--no-modernize` is used, the saved markup is also aligned with the current * block output so migrated blocks stay valid in the editor. * - * Runs as a dry-run unless `--live` is passed, and always targets a single - * site. On multisite, use the global `--url` parameter to pick the site. + * Always targets a single site. On multisite, use the global `--url` + * parameter to pick the site. * * ## OPTIONS * - * [--live] - * : Save the migrated content. Without this flag nothing is written to the database. + * [--dry-run] + * : Report changes without updating the database. * * [--[no-]modernize] * : Align the markup with the current block output: `p` key wrapper and number data attributes. Default: true. @@ -123,38 +123,35 @@ public function __construct() { * * ## EXAMPLES * - * # Report what would change. + * # Apply the migration. * wp blockparty key-figure migrate * - * # Apply the migration. - * wp blockparty key-figure migrate --live + * # Report what would change. + * wp blockparty key-figure migrate --dry-run * * # Migrate one site of a network, revisions excluded. - * wp blockparty key-figure migrate --live --skip-revisions --url=example.com/site-2 + * wp blockparty key-figure migrate --skip-revisions --url=example.com/site-2 * * # Only rename the block and its CSS classes. - * wp blockparty key-figure migrate --live --no-modernize + * wp blockparty key-figure migrate --no-modernize * - * @param string[] $args Positional arguments. + * @param string[] $args Positional arguments. * @param array $assoc_args Associative arguments. * @return void */ public function __invoke( $args, $assoc_args ): void { - $this->live = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'live', false ); + $this->dry_run = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); $this->modernize = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'modernize', true ); $this->include_revisions = ! (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-revisions', false ); $this->batch_size = max( 1, (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'posts-per-page', 100 ) ); $this->post_types = $this->resolve_post_types( (string) WP_CLI\Utils\get_flag_value( $assoc_args, 'post-type', '' ) ); - if ( ! $this->live ) { - WP_CLI::warning( 'Dry run: nothing is saved. Add --live to apply the migration.' ); - } - WP_CLI::log( sprintf( - 'Site %1$d (%2$s) — post types: %3$s, revisions: %4$s, modernize: %5$s', + 'Migrating site %1$d (%2$s)%3$s — post types: %4$s, revisions: %5$s, modernize: %6$s', get_current_blog_id(), home_url( '/' ), + $this->dry_run ? ' [dry-run]' : '', empty( $this->post_types ) ? 'all' : implode( ', ', $this->post_types ), $this->include_revisions ? 'yes' : 'no', $this->modernize ? 'yes' : 'no' @@ -168,7 +165,7 @@ public function __invoke( $args, $assoc_args ): void { sprintf( 'Done. Posts scanned: %1$d, posts %2$s: %3$d, widgets %2$s: %4$d, blocks renamed: %5$d, markup modernized: %6$d, markup skipped: %7$d.', $this->posts_scanned, - $this->live ? 'updated' : 'to update', + $this->dry_run ? 'that would update' : 'updated', $this->posts_updated, $this->widgets_updated, $this->migrator->renamed, @@ -209,13 +206,13 @@ private function migrate_posts(): void { WP_CLI::log( sprintf( '%1$s post %2$d (%3$s)', - $this->live ? '[update]' : '[dry-run]', + $this->dry_run ? '[dry-run]' : '[update]', (int) $post->ID, (string) $post->post_type ) ); - if ( $this->live ) { + if ( ! $this->dry_run ) { $this->save_post( $post, $content ); } } @@ -259,13 +256,13 @@ private function migrate_widgets(): void { WP_CLI::log( sprintf( '%1$s block widget %2$s', - $this->live ? '[update]' : '[dry-run]', + $this->dry_run ? '[dry-run]' : '[update]', (string) $key ) ); } - if ( $updated > 0 && $this->live ) { + if ( $updated > 0 && ! $this->dry_run ) { update_option( self::WIDGETS_OPTION, $widgets ); } } diff --git a/readme.txt b/readme.txt index c922663..b6e22a9 100644 --- a/readme.txt +++ b/readme.txt @@ -33,24 +33,24 @@ The command scans the post content of every post type (revisions and reusable bl 2. Renames `wp-block-beapi-key-figure` to `wp-block-blockparty-key-figure`, including the `__key`, `__prefix`, `__number`, `__suffix` and `__description` elements. 3. Aligns the markup with the current block output, unless `--no-modernize` is used: `p` key wrapper (instead of `div`), `data-decimal-separator` and `data-minimum-fraction-digits` on the number, and the raw number as text since formatting now happens on the front end. -It runs as a dry-run by default, and must be run manually on the server: +It must be run manually on the server: - # Report what would change. - wp blockparty key-figure migrate + # Preview changes without writing to the database. + wp blockparty key-figure migrate --dry-run # Apply the migration. - wp blockparty key-figure migrate --live + wp blockparty key-figure migrate # Only rename the block and its CSS classes. - wp blockparty key-figure migrate --live --no-modernize + wp blockparty key-figure migrate --no-modernize # Limit the migration to some post types, with a smaller batch size. - wp blockparty key-figure migrate --live --post-type=post,page --posts-per-page=20 + wp blockparty key-figure migrate --post-type=post,page --posts-per-page=20 The command always targets a single site. On multisite, run it for each site with the native `--url` parameter: - wp blockparty key-figure migrate --live --url=example.com - wp blockparty key-figure migrate --live --url=example.com/site-2 + wp blockparty key-figure migrate --url=example.com + wp blockparty key-figure migrate --url=example.com/site-2 Run `wp help blockparty key-figure migrate` for the full list of options. From dc557a7221ad06307b46e64babc397854f79064a Mon Sep 17 00:00:00 2001 From: sgillot Date: Thu, 17 Sep 2026 17:19:16 +0200 Subject: [PATCH 4/5] Always emit the description paragraph when modernizing markup. Co-authored-by: Cursor --- includes/Migration/KeyFigureBlockMigrator.php | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/includes/Migration/KeyFigureBlockMigrator.php b/includes/Migration/KeyFigureBlockMigrator.php index 557feae..ce8274f 100644 --- a/includes/Migration/KeyFigureBlockMigrator.php +++ b/includes/Migration/KeyFigureBlockMigrator.php @@ -238,7 +238,28 @@ private function modernize_markup( string $markup, array $attrs ): ?string { $markup ); - return $this->convert_key_wrapper( $markup ); + return $this->ensure_description( $this->convert_key_wrapper( $markup ) ); + } + + /** + * Add the description paragraph that the current block always saves. + * + * @param string $markup Renamed block markup. + * @return string + */ + private function ensure_description( string $markup ): string { + if ( false !== strpos( $markup, self::CLASS_ROOT . '__description' ) ) { + return $markup; + } + + $paragraph = sprintf( '

', self::CLASS_ROOT ); + $wrapper = strrpos( $markup, '' ); + + if ( false === $wrapper ) { + return $markup . $paragraph; + } + + return substr_replace( $markup, $paragraph, $wrapper, 0 ); } /** From 382b32f19e3d0c4fade98ac82dd99803f8c20c9c Mon Sep 17 00:00:00 2001 From: sgillot Date: Tue, 22 Sep 2026 16:30:46 +0200 Subject: [PATCH 5/5] Use format_items for migration summary output. Replace the long success sprintf with a CLI table as suggested in review. Co-authored-by: Cursor --- .../Cli/MigrateFromKeyFigureBlockCommand.php | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/includes/Cli/MigrateFromKeyFigureBlockCommand.php b/includes/Cli/MigrateFromKeyFigureBlockCommand.php index f6c6b65..388be40 100644 --- a/includes/Cli/MigrateFromKeyFigureBlockCommand.php +++ b/includes/Cli/MigrateFromKeyFigureBlockCommand.php @@ -161,18 +161,7 @@ public function __invoke( $args, $assoc_args ): void { $this->migrate_posts(); $this->migrate_widgets(); - WP_CLI::success( - sprintf( - 'Done. Posts scanned: %1$d, posts %2$s: %3$d, widgets %2$s: %4$d, blocks renamed: %5$d, markup modernized: %6$d, markup skipped: %7$d.', - $this->posts_scanned, - $this->dry_run ? 'that would update' : 'updated', - $this->posts_updated, - $this->widgets_updated, - $this->migrator->renamed, - $this->migrator->modernized, - $this->migrator->skipped - ) - ); + $this->print_summary(); if ( $this->migrator->skipped > 0 ) { WP_CLI::warning( 'Some blocks were renamed without markup modernization because no number could be read from their markup. Check them in the editor.' ); @@ -359,4 +348,46 @@ private function resolve_post_types( string $raw ): array { return array_values( array_filter( array_map( 'trim', explode( ',', $raw ) ) ) ); } + + /** + * Print migration counters as a CLI table. + * + * @return void + */ + private function print_summary(): void { + $updated_label = $this->dry_run ? 'would update' : 'updated'; + + WP_CLI::success( 'Done.' ); + + WP_CLI\Utils\format_items( + 'table', + [ + [ + 'metric' => 'Posts scanned', + 'count' => $this->posts_scanned, + ], + [ + 'metric' => 'Posts ' . $updated_label, + 'count' => $this->posts_updated, + ], + [ + 'metric' => 'Widgets ' . $updated_label, + 'count' => $this->widgets_updated, + ], + [ + 'metric' => 'Blocks renamed', + 'count' => $this->migrator->renamed, + ], + [ + 'metric' => 'Markup modernized', + 'count' => $this->migrator->modernized, + ], + [ + 'metric' => 'Markup skipped', + 'count' => $this->migrator->skipped, + ], + ], + [ 'metric', 'count' ] + ); + } }