diff --git a/assets/js/editor/editor.js b/assets/js/editor/editor.js index 95c9de7..187b6a3 100644 --- a/assets/js/editor/editor.js +++ b/assets/js/editor/editor.js @@ -1,25 +1,28 @@ import ClassicBlockTransformer from './transform/ClassicBlockTransformer'; +import MigrationClient from './transform/MigrationClient'; + +let loaded = false; /** - * ConnectToBlocksSupport connects the JS implementation of - * Connect to Blocks to Gutenberg JS. + * ConvertToBlocksSupport connects the JS implementation of + * Convert to Blocks to Gutenberg JS. */ -class ConnectToBlocksEditorSupport { +class ConvertToBlocksEditorSupport { /** - * Returns the singleton instance of ConnectToBlocksEditorSupport. + * Returns the singleton instance of ConvertToBlocksEditorSupport. * - * @returns {ConnectToBlocksEditorSupport} + * @returns {ConvertToBlocksEditorSupport} */ static getInstance() { if (!this.instance) { - this.instance = new ConnectToBlocksEditorSupport(); + this.instance = new ConvertToBlocksEditorSupport(); } return this.instance; } /** - * Activates the ConnectToBlocksEditorSupport + * Activates the ConvertToBlocksEditorSupport */ enable() { document.addEventListener('DOMContentLoaded', this.didBlockEditorLoad.bind(this)); @@ -38,14 +41,43 @@ class ConnectToBlocksEditorSupport { registerPlugin('convert-to-blocks', { render: () => { - transformer.execute(); + // Don't render more than once, to avoid triggering multiple migrations + if (loaded) { + return null; + } + + loaded = true; + + // This delay allows Gutenberg to initialize legacy content into freeform blocks + setTimeout(() => { + const result = transformer.execute(); + const config = window.convert_to_blocks_agent || false; + + // if no migration config, then ignore this request + if (!config) { + return null; + } + + const client = new MigrationClient(config); + + // if no blocks transformed, then we can jump to the next post + if (!result) { + client.next(); + return null; + } + + client.save(); + + return null; + }, 500); + return null; }, }); } } -const support = ConnectToBlocksEditorSupport.getInstance(); +const support = ConvertToBlocksEditorSupport.getInstance(); support.enable(); -export default ConnectToBlocksEditorSupport; +export default ConvertToBlocksEditorSupport; diff --git a/assets/js/editor/transform/ClassicBlockTransformer.js b/assets/js/editor/transform/ClassicBlockTransformer.js index 6cac53b..af6d68a 100644 --- a/assets/js/editor/transform/ClassicBlockTransformer.js +++ b/assets/js/editor/transform/ClassicBlockTransformer.js @@ -10,10 +10,13 @@ class ClassicBlockTransformer { */ constructor() { this.wp = window.wp; + this.didTransform = false; } /** * Runs the Classic to Gutenberg Block transform on the current document. + * + * @returns {boolean} The result of the transformation. */ execute() { const coreEditor = this.wp.data.select('core/block-editor'); @@ -23,6 +26,8 @@ class ClassicBlockTransformer { /* Currently set to do 3 levels of recursion */ this.convertBlocks(blocks, 1, 3); } + + return this.didTransform; } /** @@ -65,6 +70,8 @@ class ClassicBlockTransformer { this.wp.data .dispatch('core/block-editor') .replaceBlocks(block.clientId, this.blockHandler(block)); + + this.didTransform = true; } else if (block.innerBlocks && block.innerBlocks.length > 0) { this.convertBlocks(block.innerBlocks); } diff --git a/assets/js/editor/transform/MigrationClient.js b/assets/js/editor/transform/MigrationClient.js new file mode 100644 index 0000000..7cc5aa3 --- /dev/null +++ b/assets/js/editor/transform/MigrationClient.js @@ -0,0 +1,104 @@ +const { wp, location } = window; + +/** + * MigrationClient provides the client-side support for the BE MigrationAgent. + */ +class MigrationClient { + /** + * Initializes the client with the specified config settings. + * + * @param {object} config The convert to blocks config + */ + constructor(config) { + this.config = config; + this.saved = false; + this.didNext = false; + } + + /** + * Saves the curent post by manually dispatching savePost. + */ + save() { + // don't rerun after save + if (this.saved) { + return; + } + + this.saved = true; + + const { dispatch, subscribe } = wp.data; + const editor = dispatch('core/editor'); + + subscribe(this.didSave.bind(this)); + editor.savePost(); + } + + /** + * On Post save, runs the next post migration. + */ + didSave() { + const { select } = wp.data; + const isSavingPost = select('core/editor').isSavingPost(); + const isAutosavingPost = select('core/editor').isAutosavingPost(); + + if (isAutosavingPost && !isSavingPost) { + return; + } + + if (this.hasNext()) { + this.next(); + } + } + + /** + * Checks if there is a post in the queue. + * + * @returns {boolean} True or false if next is present. + */ + hasNext() { + if (this.didNext) { + return false; + } + + if (!this.hasNextConfig()) { + return false; + } + + return this.config.agent.next; + } + + /** + * Navigates to the next post to migrate. + */ + next() { + if (!this.hasNextConfig()) { + return; + } + + this.didNext = true; + location.href = this.config.agent.next; + } + + /** + * Checks if the next migration post data is present in config. + * + * @returns {boolean} True or false if next config is present + */ + hasNextConfig() { + if (!this.config) { + return false; + } + + if (!this.config.agent) { + return false; + } + + if (!this.config.agent.next) { + return false; + } + + return true; + } +} + +export default MigrationClient; diff --git a/includes/ConvertToBlocks/MigrationAgent.php b/includes/ConvertToBlocks/MigrationAgent.php new file mode 100644 index 0000000..7aed24f --- /dev/null +++ b/includes/ConvertToBlocks/MigrationAgent.php @@ -0,0 +1,215 @@ +has_ctb_client_param() ) { + return; + } + + if ( ! $this->is_running() ) { + return; + } + + wp_localize_script( + 'convert_to_blocks_editor', + 'convert_to_blocks_agent', + [ + 'agent' => [ + 'next' => $this->next(), + ], + ] + ); + } + + /** + * Always register since this check needs to be happen later in lifecycle. + * + * @return bool + */ + public function can_register() { + return true; + } + + /** + * Starts the batch conversion + * + * @param array $opts Optional opts. + * @return bool + */ + public function start( $opts = [] ) { + $posts_to_update = $this->get_posts_to_update( $opts ); + + if ( empty( $posts_to_update ) ) { + return false; + } + + update_option( 'ctb_running', 1 ); + update_option( 'ctb_posts_to_update', $posts_to_update ); + update_option( 'ctb_cursor', -1 ); + + return $this->next(); + } + + /** + * Stops the batch conversion if running and resets the previous session. + */ + public function stop() { + update_option( 'ctb_running', 0 ); + update_option( 'ctb_posts_to_update', [] ); + update_option( 'ctb_cursor', -1 ); + } + + /** + * Returns the current status of the batch conversion. + * + * @return array + */ + public function get_status() { + $running = get_option( 'ctb_running' ); + $posts_to_update = get_option( 'ctb_posts_to_update' ); + + if ( empty( $posts_to_update ) ) { + $posts_to_update = []; + } + + $total = count( $posts_to_update ); + $cursor = get_option( 'ctb_cursor' ); + + if ( $total > 0 ) { + $progress = round( ( $cursor + 1 ) / $total * 100 ); + } else { + $progress = 0; + } + + return [ + 'running' => $running, + 'cursor' => $cursor, + 'total' => $total, + 'progress' => $progress, + 'active' => $this->get_client_link( $posts_to_update[ $cursor ] ?? 0 ), + ]; + } + + /** + * Returns a boolean based on whether a migration is currently running. + * + * @return bool + */ + public function is_running() { + $running = get_option( 'ctb_running' ); + return ! empty( $running ); + } + + /** + * Updates the progress cursor to jump to the next post in the queue. + */ + public function next() { + $posts_to_update = get_option( 'ctb_posts_to_update' ); + $total = count( $posts_to_update ); + $cursor = get_option( 'ctb_cursor' ); + + if ( $cursor + 1 < $total ) { + $next_cursor = ++$cursor; + update_option( 'ctb_cursor', $next_cursor ); + + return $this->get_client_link( $posts_to_update[ $next_cursor ] ); + } elseif ( $cursor + 1 === $total ) { + update_option( 'ctb_running', 0 ); + return false; + } else { + return false; + } + } + + /** + * Returns the next post URL to migrate. + * + * @param int $post_id The next post id. + * @return string + */ + public function get_client_link( $post_id ) { + if ( empty( $post_id ) ) { + return ''; + } + + $edit_post_link = admin_url( 'post.php' ); + + $args = [ + 'post' => $post_id, + 'action' => 'edit', + 'ctb_client' => $post_id, + ]; + + return add_query_arg( $args, $edit_post_link ); + } + + /** + * Returns the list of post ids that need to be migrated. + * + * @param array $opts Optional opts + * @return array + */ + public function get_posts_to_update( $opts = [] ) { + if ( ! empty( $opts['post_type'] ) ) { + $post_type = explode( ',', $opts['post_type'] ); + $post_type = array_filter( $post_type ); + + if ( empty( $post_type ) ) { + $post_type = [ 'post', 'page' ]; + } + } else { + $post_type = [ 'post', 'page' ]; + } + + $query_params = [ + 'post_type' => $post_type, + 'post_status' => 'publish', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'ignore_sticky_posts' => true, + ]; + + if ( ! empty( $opts['only'] ) ) { + $post_in = explode( ',', $opts['only'] ); + $post_in = array_map( 'intval', $post_in ); + $post_in = array_filter( $post_in ); + + $query_params['post__in'] = $post_in; + } + + $query = new \WP_Query( $query_params ); + $posts = $query->posts; + + return $posts; + } + + /** + * Returns a boolean based on whether the current url has the ctb_client + * editor parameter + * + * @return bool + */ + public function has_ctb_client_param() { + // phpcs:disable + $ctb_client = sanitize_text_field( $_GET['ctb_client'] ?? '' ); + // phpcs:enable + $ctb_client = intval( $ctb_client ); + + return ! empty( $ctb_client ); + } + +} diff --git a/includes/ConvertToBlocks/MigrationCommand.php b/includes/ConvertToBlocks/MigrationCommand.php new file mode 100644 index 0000000..b7325b5 --- /dev/null +++ b/includes/ConvertToBlocks/MigrationCommand.php @@ -0,0 +1,148 @@ +] + * : Optional comma delimited list of post types to migrate. Defaults to post,page + * + * [] + * : Optional comma delimited list of post ids to migrate. + * + * @synopsis [--post_type=] + * @synopsis [--only=] + * + * @param array $args The command args + * @param array $opts The command opts + */ + public function start( $args = [], $opts = [] ) { + $agent = new MigrationAgent(); + $delay = 5; // 5 second delay between each tick + + if ( $agent->is_running() ) { + \WP_CLI::error( 'Please stop the currently running migration first.' ); + } + + $result = $agent->start( $opts ); + + if ( empty( $result ) ) { + \WP_CLI::error( 'No posts to migrate.' ); + } + + $status = $agent->get_status( $opts ); + + if ( ! $status['running'] ) { + \WP_CLI::error( 'Failed to start migration.' ); + } + + \WP_CLI::log( 'Migration started...' ); + \WP_CLI::log( 'Please open the following URL in a browser to start the migration agent.' ); + \WP_CLI::line( '' ); + \WP_CLI::log( $result ); + \WP_CLI::line( '' ); + + $total = $status['total']; + + $message = "Converting $total Posts ..."; + $progress_bar = \WP_CLI\Utils\make_progress_bar( $message, $total ); + $progress_bar->tick(); + + $prev_progress = 0; + $ticks = 0; + + while ( true ) { + $status = $agent->get_status(); + + if ( ! $status['running'] ) { + break; + } + + $progress = $status['progress']; + + // since the WP CLI progress bar can't tick upto a progress % we need to + // tick in steps upto the progress % of total + if ( $progress !== $prev_progress ) { + $required_ticks = floor( $progress / 100 * $total ); + + while ( $ticks < $required_ticks ) { + $progress_bar->tick(); + $ticks++; + } + + $prev_progress = $progress; + } + + if ( $ticks < $total ) { + // sleeping helps reduce load on server + sleep( $delay ); + } else { + // don't need the full sleep delay on last tick + sleep( 1 ); + } + + // required as we need to reload options that the browser client is updating + wp_cache_delete( 'alloptions', 'options' ); + } + + $progress_bar->finish(); + + \WP_CLI::success( 'Migration finished successfully.' ); + + // cleanup the options used during migration + $agent->stop(); + } + + /** + * Stops the currently running migration if active. + * + * @param array $args The command args + * @param array $opts The command opts + */ + public function stop( $args = [], $opts = [] ) { + $agent = new MigrationAgent(); + + if ( ! $agent->is_running() ) { + \WP_CLI::warning( 'No migrations are currently running' ); + return; + } + + $agent->stop( $opts ); + + \WP_CLI::success( 'Migration stopped successfully' ); + } + + /** + * Prints the status of the currently running migration. + * + * @param array $args The command args + * @param array $opts The command opts + */ + public function status( $args = [], $opts = [] ) { + $agent = new MigrationAgent(); + $status = $agent->get_status( $opts ); + + if ( ! $status['running'] ) { + \WP_CLI::log( 'No migrations are currently running.' ); + return; + } + + \WP_CLI::log( 'Migration is currently running ...' ); + \WP_CLI::log( $status['progress'] . ' [' . ( $status['cursor'] + 1 ) . '/' . $status['total'] . ']' ); + \WP_CLI::log( 'Active: ' . $status['active'] ); + } + +} diff --git a/includes/ConvertToBlocks/Plugin.php b/includes/ConvertToBlocks/Plugin.php index 446be68..fe06557 100644 --- a/includes/ConvertToBlocks/Plugin.php +++ b/includes/ConvertToBlocks/Plugin.php @@ -116,6 +116,7 @@ public function init_admin() { new RevisionSupport(), new ClassicEditorSupport(), new Assets(), + new MigrationAgent(), ] ); } @@ -124,6 +125,7 @@ public function init_admin() { * Initializes the Plugin WP CLI commands */ public function init_commands() { + \WP_CLI::add_command( 'convert-to-blocks', '\ConvertToBlocks\MigrationCommand' ); } /* Helpers */