site_ids( $offset ); foreach ( $site_ids as $site_id ) { switch_to_blog( $site_id ); $this->clean_site(); restore_current_blog(); } $offset += count( $site_ids ); $more = self::BATCH === count( $site_ids ); } while ( $more ); } else { $this->clean_site(); } $this->clean_network(); } /** * One batch of site IDs. * * @param int $offset Sites already handled. * @return array */ protected function site_ids( int $offset ): array { return array_map( 'intval', get_sites( array( 'fields' => 'ids', 'number' => self::BATCH, 'offset' => $offset, ) ) ); } /** * Removes the current site's data. * * @return void */ protected function clean_site(): void { global $wpdb; foreach ( self::OPTIONS as $option ) { delete_option( $option ); } foreach ( self::SITE_TABLES as $table ) { $wpdb->query( 'DROP TABLE IF EXISTS `' . esc_sql( $wpdb->prefix . $table ) . '`' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- Table names can't be placeholders; the name is ours. } $this->delete_scheduled_posts(); foreach ( self::POST_META as $key ) { delete_post_meta_by_key( $key ); } $this->clean_block_content(); $this->clean_widgets(); $this->clear_cron(); $this->delete_transients( $wpdb->options, 'option_name', '_transient_' ); } /** * Removes network-wide data, once. On a single site, the same tables hold it. * * @return void */ protected function clean_network(): void { global $wpdb; foreach ( self::USER_META as $key ) { delete_metadata( 'user', 0, $key, '', true ); } foreach ( self::NETWORK_OPTIONS as $option ) { delete_site_option( $option ); } foreach ( self::NETWORK_TABLES as $table ) { $wpdb->query( 'DROP TABLE IF EXISTS `' . esc_sql( $wpdb->base_prefix . $table ) . '`' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- Table names can't be placeholders; the name is ours. } if ( is_multisite() ) { $this->delete_transients( $wpdb->sitemeta, 'meta_key', '_site_transient_' ); } else { $this->delete_transients( $wpdb->options, 'option_name', '_site_transient_' ); } } /** * Deletes every scheduled content item, with its meta and revisions, in batches. * * @return void */ private function delete_scheduled_posts(): void { global $wpdb; do { // The post type isn't registered while uninstalling, so query the table. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s LIMIT %d", self::SCHEDULED_POST_TYPE, self::BATCH ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( $ids as $id ) { wp_delete_post( (int) $id, true ); } $more = self::BATCH === count( $ids ); } while ( $more ); } /** * Removes rule attributes and schedule classes from blocks in every post that * contains them, revisions included. Saves with $wpdb, so no revisions are * created and kses can't change the rest of the markup. * * @return void */ private function clean_block_content(): void { global $wpdb; $last_id = 0; do { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE ID > %d AND ( post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s ) ORDER BY ID ASC LIMIT %d", $last_id, '%' . $wpdb->esc_like( self::BLOCK_ATTRIBUTE ) . '%', '%' . $wpdb->esc_like( self::SCHEDULE_ATTRIBUTE ) . '%', '%' . $wpdb->esc_like( 'yc-schedule-' ) . '%', self::BATCH ) ); foreach ( $rows as $row ) { $last_id = (int) $row->ID; $changed = false; $blocks = $this->clean_blocks( parse_blocks( (string) $row->post_content ), $changed ); if ( $changed ) { $wpdb->update( $wpdb->posts, array( 'post_content' => serialize_blocks( $blocks ) ), array( 'ID' => $last_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery clean_post_cache( $last_id ); } } $more = self::BATCH === count( $rows ); } while ( $more ); } /** * Removes the rule and schedule attributes and the schedule classes from * parsed blocks and their inner blocks. * * @param array> $blocks Parsed blocks. * @param bool $changed Set to true when anything was removed. * @return array> */ public function clean_blocks( array $blocks, bool &$changed ): array { foreach ( $blocks as $index => $block ) { foreach ( array( self::BLOCK_ATTRIBUTE, self::SCHEDULE_ATTRIBUTE ) as $attribute ) { if ( isset( $block['attrs'][ $attribute ] ) ) { unset( $block['attrs'][ $attribute ] ); $changed = true; } } $classes = $this->schedule_classes( $block['attrs']['className'] ?? '' ); if ( array() !== $classes ) { $block = $this->remove_classes( $block, $classes ); $changed = true; } if ( ! empty( $block['innerBlocks'] ) ) { $block['innerBlocks'] = $this->clean_blocks( $block['innerBlocks'], $changed ); } $blocks[ $index ] = $block; } return $blocks; } /** * Schedule classes in a block's className attribute. * * @param mixed $class_name className attribute. * @return array */ private function schedule_classes( $class_name ): array { if ( ! is_string( $class_name ) ) { return array(); } return array_values( preg_grep( self::SCHEDULE_CLASS, preg_split( '/\s+/', $class_name, -1, PREG_SPLIT_NO_EMPTY ) ) ); } /** * Removes classes from a block's className attribute and from its saved HTML, * so the block stays valid in the editor. * * @param array $block Parsed block. * @param array $classes Classes to remove. * @return array */ private function remove_classes( array $block, array $classes ): array { $remaining = array_diff( preg_split( '/\s+/', (string) $block['attrs']['className'], -1, PREG_SPLIT_NO_EMPTY ), $classes ); if ( array() === $remaining ) { unset( $block['attrs']['className'] ); } else { $block['attrs']['className'] = implode( ' ', $remaining ); } $block['innerHTML'] = $this->remove_classes_from_html( (string) ( $block['innerHTML'] ?? '' ), $classes ); foreach ( (array) ( $block['innerContent'] ?? array() ) as $index => $chunk ) { if ( is_string( $chunk ) ) { $block['innerContent'][ $index ] = $this->remove_classes_from_html( $chunk, $classes ); } } return $block; } /** * Removes classes from every tag in an HTML fragment. * * @param string $html HTML. * @param array $classes Classes to remove. * @return string */ private function remove_classes_from_html( string $html, array $classes ): string { $tags = new WP_HTML_Tag_Processor( $html ); while ( $tags->next_tag() ) { foreach ( $classes as $class ) { $tags->remove_class( $class ); } } return $tags->get_updated_html(); } /** * Removes the rule key from every classic widget's settings, and rules, * schedules and schedule classes from the blocks in block widgets. * * @return void */ private function clean_widgets(): void { global $wpdb; $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'widget_' ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( $names as $name ) { $instances = get_option( $name ); if ( ! is_array( $instances ) ) { continue; } $changed = false; foreach ( $instances as $number => $instance ) { if ( is_array( $instance ) && array_key_exists( self::WIDGET_KEY, $instance ) ) { unset( $instances[ $number ][ self::WIDGET_KEY ] ); $changed = true; } // Block widgets keep their blocks in `content`. if ( 'widget_block' === $name && is_array( $instance ) && isset( $instance['content'] ) && is_string( $instance['content'] ) ) { $cleaned = false; $blocks = $this->clean_blocks( parse_blocks( $instance['content'] ), $cleaned ); if ( $cleaned ) { $instances[ $number ]['content'] = serialize_blocks( $blocks ); $changed = true; } } } if ( $changed ) { update_option( $name, $instances ); } } } /** * Unschedules every cron event the plugin added (hooks starting with the prefix). * * @return void */ private function clear_cron(): void { $hooks = array(); foreach ( _get_cron_array() as $events ) { foreach ( array_keys( (array) $events ) as $hook ) { if ( 0 === strpos( (string) $hook, self::PREFIX ) ) { $hooks[ $hook ] = true; } } } foreach ( array_keys( $hooks ) as $hook ) { wp_unschedule_hook( (string) $hook ); } } /** * Deletes the plugin's transients and their timeouts. Each is deleted through * the options API, so WordPress's caches stay in sync. * * @param string $table Table. * @param string $column Name column. * @param string $type "_transient_" or "_site_transient_". * @return void */ private function delete_transients( string $table, string $column, string $type ): void { global $wpdb; $names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT %i FROM %i WHERE %i LIKE %s OR %i LIKE %s', $column, $table, $column, $wpdb->esc_like( $type . self::PREFIX ) . '%', $column, $wpdb->esc_like( $type . 'timeout_' . self::PREFIX ) . '%' ) ); foreach ( $names as $name ) { if ( $wpdb->sitemeta === $table ) { delete_site_option( (string) $name ); } else { delete_option( (string) $name ); } } } }