term ids. * * @var array */ private $created_terms = array(); /** * Pre-import state of posts this import updates, keyed by post id. * * @var array> */ private $updated_posts = array(); /** * Pre-import meta of posts this import updates, keyed by post id. * * Separate from the post row because an update replaces meta wholesale, and * the row alone puts back a post whose meta still belongs to the import. * * @var array> */ private $updated_meta = array(); /** * Options as they were before the import changed them, name => value. * * False means the option did not exist and is deleted on rollback. * * @var array */ private $updated_options = array(); /** * Option keys the import changed: option => existed, before, after. * * Per key rather than the whole option, so an undo puts back only what the * import changed and leaves everything the site owner changed since. * * @var array> */ private $option_changes = array(); /** * Non-post objects a module created, kind => ids. * * @var array */ private $created_objects = array(); /** * Constructor. * * @param string $job_id Job id. */ public function __construct( string $job_id ) { $this->job_id = $job_id; } /** * Record a post (or attachment) this import created. * * @param int $post_id New post id. * @return void */ public function record_created_post( int $post_id ): void { $this->created_posts[] = $post_id; } /** * Record an object a module created outside the posts table. * * @param string $kind Module-defined kind, e.g. 'wc_order'. * @param int $id New object id. * @return void */ public function record_created_object( string $kind, int $id ): void { $this->created_objects[ $kind ][] = $id; } /** * Posts this import created so far. * * @return int[] */ public function created_posts(): array { return array_map( 'intval', $this->created_posts ); } /** * Record a term this import created. * * @param string $taxonomy Taxonomy name. * @param int $term_id New term id. * @return void */ public function record_created_term( string $taxonomy, int $term_id ): void { $this->created_terms[ $taxonomy ][] = $term_id; } /** * Record a post's state before this import overwrites it. * * @param int $post_id Local post id. * @return void */ public function record_updated_post( int $post_id ): void { if ( isset( $this->updated_posts[ $post_id ] ) ) { return; } $post = get_post( $post_id, ARRAY_A ); if ( is_array( $post ) ) { $this->updated_posts[ $post_id ] = $post; $this->updated_meta[ $post_id ] = get_post_meta( $post_id ); } } /** * Snapshot an option before the import changes it. * * @param string $option Option name. * @return void */ public function record_updated_option( string $option ): void { if ( ! array_key_exists( $option, $this->updated_options ) ) { $this->updated_options[ $option ] = get_option( $option ); } } /** * Record the keys of an array option the import is about to change. * * @param string $option Option name. * @param array $current Value before the import. * @param array $merged Value the import writes. * @param bool $existed Whether the option existed at all. * @return void */ public function record_option_change( string $option, array $current, array $merged, bool $existed ): void { $change = isset( $this->option_changes[ $option ] ) ? $this->option_changes[ $option ] : array( 'existed' => $existed, 'before' => array(), 'absent' => array(), 'after' => array(), ); foreach ( $merged as $key => $value ) { if ( array_key_exists( $key, $current ) && $current[ $key ] === $value ) { continue; } // The first recorded "before" is the real one; a later merge in the // same run must not overwrite it with the import's own value. if ( ! array_key_exists( $key, $change['before'] ) && ! in_array( $key, $change['absent'], true ) ) { if ( array_key_exists( $key, $current ) ) { $change['before'][ $key ] = $current[ $key ]; } else { $change['absent'][] = $key; } } $change['after'][ $key ] = $value; } $this->option_changes[ $option ] = $change; } /** * Persist the snapshot so a later request can roll back. * * @return void */ public function save(): void { $parts = array_chunk( array_keys( $this->updated_posts ), self::POSTS_PER_PART ); foreach ( $parts as $index => $post_ids ) { $part = array(); foreach ( $post_ids as $post_id ) { $part[ $post_id ] = array( 'post' => $this->updated_posts[ $post_id ], 'meta' => isset( $this->updated_meta[ $post_id ] ) ? $this->updated_meta[ $post_id ] : array(), ); } update_option( $this->part_option( $index ), $part, false ); } $this->delete_parts( count( $parts ) ); update_option( self::OPTION_PREFIX . $this->job_id, array( 'created_posts' => $this->created_posts, 'created_terms' => $this->created_terms, 'parts' => count( $parts ), 'updated_options' => $this->updated_options, 'option_changes' => $this->option_changes, 'created_objects' => $this->created_objects, 'created_at' => gmdate( 'c' ), ), false ); } /** * Remove snapshots older than the retention the site owner chose. * * @param int $days Days to keep. * @return void */ public static function expire( int $days ): void { global $wpdb; $names = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::OPTION_PREFIX ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- a query WordPress has no API for; nothing to cache. $limit = time() - max( 1, $days ) * DAY_IN_SECONDS; foreach ( $names as $name ) { if ( false !== strpos( (string) $name, self::PART_SUFFIX ) ) { continue; } $stored = get_option( (string) $name ); $created = is_array( $stored ) && isset( $stored['created_at'] ) ? strtotime( (string) $stored['created_at'] ) : false; if ( false !== $created && $created < $limit ) { ( new self( substr( (string) $name, strlen( self::OPTION_PREFIX ) ) ) )->forget(); } } } /** * Delete this snapshot and its parts. * * @return void */ public function forget(): void { $this->delete_parts( 0 ); delete_option( self::OPTION_PREFIX . $this->job_id ); } /** * Option name for one part. * * @param int $index Part index. * @return string */ private function part_option( int $index ): string { return self::OPTION_PREFIX . $this->job_id . self::PART_SUFFIX . $index; } /** * Delete part options from an index on. * * @param int $from First index to delete. * @return void */ private function delete_parts( int $from ): void { $index = $from; while ( false !== get_option( $this->part_option( $index ) ) ) { delete_option( $this->part_option( $index ) ); ++$index; } } /** * Load a saved snapshot. * * @param string $job_id Job id. * @return Restore_Point|null Null when there is nothing stored for that job. */ public static function load( string $job_id ): ?Restore_Point { $stored = get_option( self::OPTION_PREFIX . $job_id ); if ( ! is_array( $stored ) ) { return null; } $restore = new self( $job_id ); $restore->created_posts = isset( $stored['created_posts'] ) ? (array) $stored['created_posts'] : array(); $restore->created_terms = isset( $stored['created_terms'] ) ? (array) $stored['created_terms'] : array(); $restore->updated_posts = isset( $stored['updated_posts'] ) ? (array) $stored['updated_posts'] : array(); $restore->updated_meta = isset( $stored['updated_meta'] ) ? (array) $stored['updated_meta'] : array(); for ( $index = 0; $index < (int) ( $stored['parts'] ?? 0 ); $index++ ) { foreach ( (array) get_option( $restore->part_option( $index ), array() ) as $post_id => $copy ) { $restore->updated_posts[ $post_id ] = $copy['post'] ?? array(); $restore->updated_meta[ $post_id ] = $copy['meta'] ?? array(); } } $restore->updated_options = isset( $stored['updated_options'] ) ? (array) $stored['updated_options'] : array(); $restore->option_changes = isset( $stored['option_changes'] ) ? (array) $stored['option_changes'] : array(); $restore->created_objects = isset( $stored['created_objects'] ) ? (array) $stored['created_objects'] : array(); return $restore; } /** * Undo the import, newest change first. * * @param Module|null $module The module that ran the import, to delete the * objects it created outside the posts table. * @return array What was reversed. */ public function rollback( ?Module $module = null ): array { $undone = array( 'posts_deleted' => 0, 'terms_deleted' => 0, 'posts_restored' => 0, 'objects_deleted' => 0, ); // Module objects first: an order points at products, never the reverse. if ( $module instanceof Module ) { // Newest kind first: orders were created after their customers, and // a customer is only removable once the orders pointing at it are gone. foreach ( array_reverse( $this->created_objects, true ) as $kind => $ids ) { $undone['objects_deleted'] += $module->rollback_objects( (string) $kind, array_reverse( array_map( 'intval', (array) $ids ) ) ); } } foreach ( array_reverse( $this->created_posts ) as $post_id ) { if ( wp_delete_post( (int) $post_id, true ) ) { ++$undone['posts_deleted']; } } foreach ( $this->created_terms as $taxonomy => $term_ids ) { foreach ( array_reverse( (array) $term_ids ) as $term_id ) { if ( true === wp_delete_term( (int) $term_id, (string) $taxonomy ) ) { ++$undone['terms_deleted']; } } } foreach ( $this->updated_posts as $post_id => $post ) { if ( ! is_array( $post ) || is_wp_error( wp_update_post( wp_slash( $post ), true ) ) ) { continue; } $this->restore_meta( (int) $post_id ); ++$undone['posts_restored']; } foreach ( $this->option_changes as $option => $change ) { $now = get_option( (string) $option ); if ( ! is_array( $now ) ) { continue; } foreach ( (array) $change['after'] as $key => $value ) { // Changed again since the import: that is the owner's now. Leave it. if ( ! array_key_exists( $key, $now ) || $now[ $key ] !== $value ) { continue; } if ( array_key_exists( $key, (array) $change['before'] ) ) { $now[ $key ] = $change['before'][ $key ]; } else { unset( $now[ $key ] ); } } if ( empty( $change['existed'] ) && array() === $now ) { delete_option( (string) $option ); } else { update_option( (string) $option, $now ); } } // Restore points saved before per-key recording: whole-option snapshots. foreach ( $this->updated_options as $option => $value ) { if ( false === $value ) { delete_option( (string) $option ); } else { $autoload = false === get_option( (string) $option ) ? false : null; update_option( (string) $option, $value, $autoload ); } } $this->forget(); return $undone; } /** * Put one post's meta back exactly as it was. * * Wholesale, not key by key: an import both changes existing keys and adds * new ones, and only clearing what is there now undoes the additions. * Each value is unserialized on the way back in, because get_post_meta() * hands back the raw stored string and maybe_serialize() deliberately * serializes an already-serialized string a second time to protect it. * * @param int $post_id Post whose meta to restore. * @return void */ private function restore_meta( int $post_id ): void { if ( ! isset( $this->updated_meta[ $post_id ] ) ) { return; } foreach ( array_keys( get_post_meta( $post_id ) ) as $key ) { delete_post_meta( $post_id, (string) $key ); } foreach ( (array) $this->updated_meta[ $post_id ] as $key => $values ) { foreach ( (array) $values as $value ) { add_post_meta( $post_id, (string) $key, wp_slash( maybe_unserialize( $value ) ) ); } } } }