Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions includes/class-press-this-cli-commands.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
/**
* Press This CLI Commands
*
* @package Press_This_Plugin
* @since 2.1.1
*/

/**
* WP-CLI commands for Press This.
*
* @since 2.1.1
*/
class Press_This_CLI_Commands {

/**
* Repair core/embed blocks saved before the className fix in #129.
*
* Finds posts whose content has a core/embed block with a `<figure>`
* className missing the `wp-block-embed-{provider}` class that
* Gutenberg's save() output requires, and rewrites the content to
* add it.
*
* ## OPTIONS
*
* [--dry-run]
* : Report the posts that would be changed, without saving.
*
* [--post-type=<post-types>]
* : Comma-separated list of post types to scan. Default: post,page.
*
* ## EXAMPLES
*
* wp press-this repair-embeds --dry-run
* wp press-this repair-embeds
* wp press-this repair-embeds --post-type=post
*
* @subcommand repair-embeds
*
* @since 2.1.1
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Associative arguments.
*/
public function repair_embeds( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP-CLI command signature.
require_once __DIR__ . '/class-press-this-embed-repair.php';

$dry_run = WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
$post_type = WP_CLI\Utils\get_flag_value( $assoc_args, 'post-type', 'post,page' );
$post_types = array_map( 'trim', explode( ',', $post_type ) );

$repaired_count = 0;
$failed_count = 0;
$scanned_count = 0;
$batch_size = 100;
$last_id = 0;

global $wpdb;

do {
// Query post_content directly with a LIKE match instead of WP_Query's
// 's' param, which also searches the title and can be altered by
// other plugins hooking the search (relevance sorting, stopwords).
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE ID > %d AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ') AND post_content LIKE %s ORDER BY ID ASC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts is a table name, not user input.
array_merge( array( $last_id ), $post_types, array( '%wp:embed%', $batch_size ) )
)
);

$batch_count = count( $post_ids );

foreach ( $post_ids as $post_id ) {
$post_id = (int) $post_id;
++$scanned_count;
$last_id = max( $last_id, $post_id );

$result = Press_This_Embed_Repair::repair_post( $post_id, $dry_run );

if ( 'would-repair' === $result ) {
++$repaired_count;
WP_CLI::log( sprintf( 'Would repair post %d: %s', $post_id, get_the_title( $post_id ) ) );
} elseif ( 'repaired' === $result ) {
++$repaired_count;
WP_CLI::log( sprintf( 'Repaired post %d: %s', $post_id, get_the_title( $post_id ) ) );
} elseif ( 'failed' === $result ) {
++$failed_count;
WP_CLI::warning( sprintf( 'Failed to repair post %d: %s', $post_id, $wpdb->last_error ) );
}
}
} while ( $batch_count === $batch_size );

$summary = sprintf(
$dry_run ? '%1$d post(s) would be repaired out of %2$d scanned.' : '%1$d post(s) repaired out of %2$d scanned.',
$repaired_count,
$scanned_count
);

if ( $failed_count > 0 ) {
WP_CLI::error( sprintf( '%1$s %2$d post(s) failed.', $summary, $failed_count ) );
}

WP_CLI::success( $summary );
}
}
176 changes: 176 additions & 0 deletions includes/class-press-this-embed-repair.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php
/**
* Press This Embed Repair
*
* Repairs core/embed blocks saved before the className fix in #129, where the
* figure element is missing the wp-block-embed-{provider} class that core/embed's
* save() output requires for block validation to pass.
*
* @package Press_This_Plugin
* @since 2.1.1
*/

/**
* Press This embed repair class.
*
* @since 2.1.1
*/
class Press_This_Embed_Repair {

/**
* Repair legacy core/embed blocks in a single post.
*
* Writes post_content directly rather than through wp_update_post(), so the
* repair leaves the post's status, dates and revisions alone and fires no save
* hooks. A missed-schedule post, for one, would otherwise be published.
*
* @since 2.1.1
*
* @param int $post_id Post ID.
* @param bool $dry_run Whether to only report the repair, without saving it.
* @return string One of 'repaired', 'would-repair', 'unchanged' or 'failed'.
*/
public static function repair_post( $post_id, $dry_run = false ) {
global $wpdb;

$post = get_post( $post_id );

if ( ! $post || ! has_block( 'core/embed', $post ) ) {
return 'unchanged';
}

$repaired_content = self::repair_content( $post->post_content );

if ( false === $repaired_content ) {
return 'unchanged';
}

if ( $dry_run ) {
return 'would-repair';
}

$updated = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Deliberately bypasses wp_update_post(); see above.
$wpdb->posts,
array( 'post_content' => $repaired_content ),
array( 'ID' => $post->ID )
);

if ( false === $updated ) {
return 'failed';
}

clean_post_cache( $post->ID );

return 'repaired';
}

/**
* Repair legacy core/embed blocks in post content.
*
* @since 2.1.1
*
* @param string $content Post content.
* @return string|false Repaired content, or false if nothing needed fixing.
*/
public static function repair_content( $content ) {
if ( false === strpos( $content, 'wp:embed' ) ) {
return false;
}

$repaired = false;
$blocks = self::repair_blocks( parse_blocks( $content ), $repaired );

if ( ! $repaired ) {
return false;
}

return serialize_blocks( $blocks );
}

/**
* Recursively walk parsed blocks and repair core/embed blocks in place.
*
* @since 2.1.1
*
* @param array $blocks Parsed blocks.
* @param bool $repaired Set true by reference when any block is changed.
* @return array Blocks, with embeds repaired where needed.
*/
private static function repair_blocks( array $blocks, &$repaired ) {
foreach ( $blocks as $index => $block ) {
if ( 'core/embed' === $block['blockName'] ) {
$fixed = self::fix_embed_block( $block );

if ( false !== $fixed ) {
$blocks[ $index ] = $fixed;
$repaired = true;
}
}

if ( ! empty( $block['innerBlocks'] ) ) {
$blocks[ $index ]['innerBlocks'] = self::repair_blocks( $block['innerBlocks'], $repaired );
}
}

return $blocks;
}

/**
* Add the missing wp-block-embed-{provider} class to a single embed block.
*
* @since 2.1.1
*
* @param array $block Parsed core/embed block.
* @return array|false Repaired block, or false if it did not need fixing.
*/
private static function fix_embed_block( array $block ) {
$provider = isset( $block['attrs']['providerNameSlug'] ) ? $block['attrs']['providerNameSlug'] : '';

// The className mismatch only exists for blocks with both type and
// providerNameSlug set -- e.g. the generic embed shape produced by
// get_suggested_content() has neither and is already self-consistent.
if ( empty( $provider ) || empty( $block['attrs']['type'] ) ) {
return false;
}

// providerNameSlug comes from stored post content, not a trusted source.
// Reject anything that isn't a plain CSS class token before it goes near HTML.
if ( ! is_string( $provider ) || sanitize_html_class( $provider ) !== $provider ) {
return false;
}

// Only the current save() shape, with the wrapper div, is affected. Older v1
// markup has no wrapper and already validates through core/embed's deprecations.
if ( false === strpos( $block['innerHTML'], 'wp-block-embed__wrapper' ) ) {
return false;
}

// Matches double-quoted class attributes only, which is what save() always
// emits. A hand-edited single-quoted class would just be left alone.
if ( ! preg_match( '/<figure\b[^>]*\sclass="([^"]*)"/', $block['innerHTML'], $matches, PREG_OFFSET_CAPTURE ) ) {
return false;
}

$wanted_class = 'wp-block-embed-' . $provider;
$existing_classes = preg_split( '/\s+/', trim( $matches[1][0] ) );

if ( in_array( $wanted_class, $existing_classes, true ) ) {
return false;
}

// core/embed has no innerBlocks, so innerContent should be a single chunk
// identical to innerHTML. Bail instead of guessing at a riskier rewrite
// if that assumption doesn't hold for some other reason.
if ( count( $block['innerContent'] ) !== 1 || $block['innerContent'][0] !== $block['innerHTML'] ) {
return false;
}

$class_end = $matches[1][1] + strlen( $matches[1][0] );
$new_html = substr( $block['innerHTML'], 0, $class_end ) . ' ' . $wanted_class . substr( $block['innerHTML'], $class_end );

$block['innerHTML'] = $new_html;
$block['innerContent'] = array( $new_html );

return $block;
}
}
8 changes: 7 additions & 1 deletion press-this-plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ function press_this_incompatible_notice() {
// Load text domain for translations.
add_action( 'init', 'press_this_load_textdomain' );

// Register WP-CLI commands.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
require_once __DIR__ . '/includes/class-press-this-cli-commands.php';
WP_CLI::add_command( 'press-this', 'Press_This_CLI_Commands' );
}

/**
* Load plugin text domain for translations.
*
Expand Down Expand Up @@ -397,7 +403,7 @@ function press_this_rest_save_post( $request ) {
$post_data['post_date_gmt'] = get_gmt_from_date( $post_data['post_date'] );
$post_data['post_status'] = 'future';
// Required: wp_update_post ignores post_date changes unless edit_date is true.
$post_data['edit_date'] = true;
$post_data['edit_date'] = true;
} else {
$post_data['post_status'] = 'pending';
}
Expand Down
Loading
Loading