_*, mmold__*). Only the hashes of this site's own jobs count: the job * folders under jobs/, the snapshots under rollback/ and the last import, so on a database shared by several sites * another site's rollback tables are never dropped (orphans of job folders deleted by hand stay). Only when the setting delete_backups_on_uninstall is true: the storage root with every * backup, job and rollback copy in it, removed without following symlinks. Off by default, so backups survive an uninstall. * * @package Mench_Migrate */ defined( 'ABSPATH' ) || exit; final class Mench_Migrate_Uninstall { public static function run(): void { // Read before the options go: the folder name and the owner's choice live there. $settings = get_option( 'mench_migrate_settings', [] ); $dir = (string) get_option( 'mench_migrate_storage_dir', '' ); self::unschedule(); self::drop_tables( self::job_hashes( $dir ) ); if ( is_array( $settings ) && true === ( $settings['delete_backups_on_uninstall'] ?? false ) ) { self::delete_root( $dir ); } self::delete_options(); } /** Every scheduled event whose hook starts with mench_migrate_ (add-ons' included), whatever its arguments. */ private static function unschedule(): void { foreach ( (array) _get_cron_array() as $events ) { foreach ( array_keys( (array) $events ) as $hook ) { if ( is_string( $hook ) && 0 === strpos( $hook, 'mench_migrate_' ) ) { wp_unschedule_hook( $hook ); } } } } /** * The 8-hex table hashes of this site's jobs: every job folder, every snapshot (and the job id it records) and the * last import. Empty when the storage folder name is not ours. * * @return string[] */ private static function job_hashes( string $dir ): array { $ids = []; $last = get_option( 'mench_migrate_last_import' ); if ( is_array( $last ) && is_string( $last['job_id'] ?? null ) ) { $ids[] = $last['job_id']; } if ( 1 === preg_match( '/^mench-migrate-[a-z0-9]{12}$/', $dir ) ) { $root = rtrim( WP_CONTENT_DIR, '/\\' ) . '/' . $dir; foreach ( [ 'jobs', 'rollback' ] as $sub ) { foreach ( (array) @scandir( $root . '/' . $sub ) as $name ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! is_string( $name ) || '' === $name || '.' === $name[0] || 0 === strpos( $name, 'upl-' ) || ! is_dir( $root . '/' . $sub . '/' . $name ) ) { continue; } $ids[] = $name; $snap = $root . '/' . $sub . '/' . $name . '/snapshot.json'; if ( 'rollback' === $sub && is_file( $snap ) ) { $state = wp_json_file_decode( $snap, [ 'associative' => true ] ); // Small local snapshot.json in the plugin's own storage root. if ( is_array( $state ) && is_string( $state['job_id'] ?? null ) ) { $ids[] = $state['job_id']; } } } } } $out = []; foreach ( array_unique( $ids ) as $id ) { if ( 1 === preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $id ) ) { $out[] = substr( md5( $id ), 0, 8 ); } } return array_values( array_unique( $out ) ); } /** This site's import staging (mmtmp__) and rollback (mmold__) tables. */ private static function drop_tables( array $hashes ): void { global $wpdb; foreach ( $hashes as $hash ) { foreach ( [ 'mmtmp_', 'mmold_' ] as $kind ) { $head = $kind . $hash . '_'; $tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $head ) . '%' ) ); foreach ( $tables as $table ) { if ( is_string( $table ) && 0 === strpos( $table, $head ) && 1 === preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) { $wpdb->query( 'DROP TABLE IF EXISTS `' . $table . '`' ); // phpcs:ignore WordPress.DB -- name checked above. } } } } } /** * Option prefixes that belong to add-ons (every catalogue add-on, shipped or planned). The core uninstall never deletes * them: WordPress lets the core be deleted while its add-ons stay installed, and each add-on's own uninstall.php owns * its settings, secrets and open-upload records. */ private const ADDON_PREFIXES = [ 'mench_migrate_cloud_', 'mench_migrate_schedules_', 'mench_migrate_google_drive_', 'mench_migrate_dropbox_', 'mench_migrate_sftp_', 'mench_migrate_site_to_site_', 'mench_migrate_multisite_', ]; /** True for a (transient-stripped) option name that an add-on owns. */ private static function addon_owned( string $name ): bool { $bare = (string) preg_replace( '/^_(?:site_)?transient_(?:timeout_)?/', '', $name ); foreach ( self::ADDON_PREFIXES as $prefix ) { if ( 0 === strpos( $bare, $prefix ) ) { return true; } } return false; } /** The core's own options and transients (mench_migrate_*), never an add-on's (see ADDON_PREFIXES). */ private static function delete_options(): void { global $wpdb; $names = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( 'mench_migrate_' ) . '%', $wpdb->esc_like( '_transient_mench_migrate_' ) . '%', $wpdb->esc_like( '_transient_timeout_mench_migrate_' ) . '%', $wpdb->esc_like( '_site_transient_mench_migrate_' ) . '%', $wpdb->esc_like( '_site_transient_timeout_mench_migrate_' ) . '%' ) ); foreach ( $names as $name ) { if ( self::addon_owned( (string) $name ) ) { continue; } delete_option( (string) $name ); } } /** The storage root wp-content/mench-migrate-<12 [a-z0-9]>/, only when the stored name has exactly that shape. */ private static function delete_root( string $dir ): void { if ( 1 !== preg_match( '/^mench-migrate-[a-z0-9]{12}$/', $dir ) ) { return; } $root = rtrim( WP_CONTENT_DIR, '/\\' ) . '/' . $dir; if ( is_link( $root ) || ! is_dir( $root ) ) { return; } self::remove_tree( $root ); } /** Removes $dir and everything in it; a symlink is unlinked, never followed. */ private static function remove_tree( string $dir ): void { $items = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors foreach ( false === $items ? [] : $items as $name ) { if ( '.' === $name || '..' === $name ) { continue; } $path = $dir . '/' . $name; if ( is_link( $path ) || ! is_dir( $path ) ) { wp_delete_file( $path ); } else { self::remove_tree( $path ); } } @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removes an emptied folder of the plugin's own storage root during uninstall, where WP_Filesystem cannot prompt for credentials; a failure leaves the folder. } }