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..388be40 --- /dev/null +++ b/includes/Cli/MigrateFromKeyFigureBlockCommand.php @@ -0,0 +1,393 @@ +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. + * + * Always targets a single site. On multisite, use the global `--url` + * parameter to pick the site. + * + * ## OPTIONS + * + * [--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. + * + * [--skip-revisions] + * : Leave post revisions untouched. + * + * [--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 + * + * # Apply the migration. + * wp blockparty key-figure migrate + * + * # Report what would change. + * wp blockparty key-figure migrate --dry-run + * + * # Migrate one site of a network, revisions excluded. + * 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 --no-modernize + * + * @param string[] $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function __invoke( $args, $assoc_args ): void { + $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', '' ) ); + + WP_CLI::log( + sprintf( + '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' + ) + ); + + $this->migrate_posts(); + $this->migrate_widgets(); + + $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.' ); + } + } + + /** + * 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->dry_run ? '[dry-run]' : '[update]', + (int) $post->ID, + (string) $post->post_type + ) + ); + + if ( ! $this->dry_run ) { + $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->dry_run ? '[dry-run]' : '[update]', + (string) $key + ) + ); + } + + if ( $updated > 0 && ! $this->dry_run ) { + 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 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 ) ) ) ); + } + + /** + * 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' ] + ); + } +} diff --git a/includes/Migration/KeyFigureBlockMigrator.php b/includes/Migration/KeyFigureBlockMigrator.php new file mode 100644 index 0000000..ce8274f --- /dev/null +++ b/includes/Migration/KeyFigureBlockMigrator.php @@ -0,0 +1,279 @@ +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..b6e22a9 100644 --- a/readme.txt +++ b/readme.txt @@ -23,6 +23,37 @@ 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 must be run manually on the server: + + # Preview changes without writing to the database. + wp blockparty key-figure migrate --dry-run + + # Apply the migration. + wp blockparty key-figure migrate + + # Only rename the block and its CSS classes. + wp blockparty key-figure migrate --no-modernize + + # Limit the migration to some post types, with a smaller batch size. + 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 --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. + == Changelog == = 1.0.0 - 2024-04-02 =