Settings > License), stored as * stacklar_remove_data_on_uninstall; Stacklar Forms has no setting for it. * Stacklar Forms Pro and Stacklar Forms share all of this, so deleting one * while the other is still installed removes nothing. */ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; } // Removing every row and table of the plugin in batches needs direct queries; // caching does not apply to data being deleted. // phpcs:disable WordPress.DB.DirectDatabaseQuery if ( ! class_exists( 'Stacklar_Uninstaller', false ) ) { final class Stacklar_Uninstaller { const OPTION_REMOVE_DATA = 'stacklar_remove_data_on_uninstall'; const BATCH_SIZE = 200; const SITE_BATCH_SIZE = 100; // Every post type registered under includes/. Revisions, meta, // comments and term links of these posts are removed with them. const POST_TYPES = array( 'stacklar', 'stacklar_popup', 'stacklar_email_tpl', 'stacklar_pdf_tpl', 'stacklar_integration', 'stacklar_entry', 'stacklar_abandonment', 'stacklar_pdf', 'stacklar_payment', 'stacklar_webhook_log', 'stacklar_int_log', ); // Stacklar_Booking_Service::tables(), without the site prefix. const BOOKING_TABLES = array( 'stacklar_booking_services', 'stacklar_booking_resources', 'stacklar_booking_service_resources', 'stacklar_booking_availability', 'stacklar_booking_blackouts', 'stacklar_bookings', 'stacklar_booking_locks', ); // Known hooks; any other scheduled hook starting with stacklar_ is cleared too. const CRON_HOOKS = array( 'stacklar_license_validation_check', 'stacklar_entries_cleanup_attribution', 'stacklar_entries_cleanup_uploads', 'stacklar_booking_cleanup_locks', 'stacklar_booking_send_reminders', 'stacklar_webhook_retry_delivery', 'stacklar_webhook_cleanup_deliveries', 'stacklar_integration_logs_cleanup', 'stacklar_cleanup_stripe_webhook_event', 'stacklar_cleanup_paypal_webhook_event', ); // Main files of both editions, relative to the plugins directory. const EDITION_PLUGINS = array( 'stacklar/stacklar.php', 'stacklar-forms/stacklar-forms.php', ); // Folders under the uploads base directory besides the private upload folder. const UPLOAD_DIRS = array( 'stacklar-pdfs', 'stacklar-tmp', 'stacklar-fonts', ); public static function run() { if ( self::is_other_edition_installed() ) { return; } if ( ! is_multisite() ) { if ( self::uninstall_site() ) { self::delete_user_meta(); } return; } // User meta is shared by the network, so it goes only when every site opted in. $every_site_removed = true; foreach ( self::get_site_ids() as $site_id ) { switch_to_blog( $site_id ); $every_site_removed = self::uninstall_site() && $every_site_removed; restore_current_blog(); } if ( $every_site_removed ) { self::delete_user_meta(); } } private static function is_other_edition_installed() { $uninstalling = defined( 'WP_UNINSTALL_PLUGIN' ) ? (string) WP_UNINSTALL_PLUGIN : ''; foreach ( self::EDITION_PLUGINS as $plugin ) { if ( $plugin !== $uninstalling && file_exists( WP_PLUGIN_DIR . '/' . $plugin ) ) { return true; } } return false; } /** * Returns true when this site's data was removed. */ private static function uninstall_site() { $remove_data = self::should_remove_data(); self::clear_cron_hooks(); if ( ! $remove_data ) { return false; } self::delete_posts(); self::drop_booking_tables(); self::delete_upload_dirs(); // Last, so the opt-in flag survives if an earlier step is interrupted. self::delete_options(); return true; } private static function should_remove_data() { $value = get_option( self::OPTION_REMOVE_DATA, '0' ); return true === $value || '1' === (string) $value; } private static function get_site_ids() { $site_ids = array(); $offset = 0; do { $batch = get_sites( array( 'fields' => 'ids', 'number' => self::SITE_BATCH_SIZE, 'offset' => $offset, 'update_site_meta_cache' => false, ) ); $batch = array_map( 'absint', (array) $batch ); $site_ids = array_merge( $site_ids, $batch ); $offset += self::SITE_BATCH_SIZE; } while ( count( $batch ) === self::SITE_BATCH_SIZE ); return array_unique( $site_ids ); } private static function clear_cron_hooks() { $hooks = self::CRON_HOOKS; $crons = _get_cron_array(); if ( is_array( $crons ) ) { foreach ( $crons as $events ) { if ( ! is_array( $events ) ) { continue; } foreach ( array_keys( $events ) as $hook ) { if ( 0 === strpos( (string) $hook, 'stacklar_' ) ) { $hooks[] = (string) $hook; } } } } // wp_unschedule_hook() also clears events scheduled with arguments. foreach ( array_unique( $hooks ) as $hook ) { wp_unschedule_hook( $hook ); } } private static function delete_posts() { global $wpdb; $previous = array(); while ( true ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( " . implode( ', ', array_fill( 0, count( self::POST_TYPES ), '%s' ) ) . ' ) ORDER BY ID ASC LIMIT %d', array_merge( self::POST_TYPES, array( self::BATCH_SIZE ) ) ) ); $ids = array_map( 'absint', (array) $ids ); // The same batch again means the last delete failed; stop instead of looping. if ( empty( $ids ) || $ids === $previous ) { return; } $previous = $ids; self::delete_post_batch( $ids ); } } private static function delete_post_batch( array $ids ) { global $wpdb; // One %d placeholder per ID, built inline so $wpdb->prepare() sees every value. $revision_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_parent IN ( " . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ) ); $ids = array_values( array_unique( array_merge( $ids, array_map( 'absint', (array) $revision_ids ) ) ) ); $comment_ids = array_map( 'absint', (array) $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN ( " . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ) ) ); if ( ! empty( $comment_ids ) ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->commentmeta} WHERE comment_id IN ( " . implode( ', ', array_fill( 0, count( $comment_ids ), '%d' ) ) . ' )', $comment_ids ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->comments} WHERE comment_ID IN ( " . implode( ', ', array_fill( 0, count( $comment_ids ), '%d' ) ) . ' )', $comment_ids ) ); clean_comment_cache( $comment_ids ); } $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ( " . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->term_relationships} WHERE object_id IN ( " . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->posts} WHERE ID IN ( " . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ) ); // clean_post_cache() needs the row, which is gone, so clear the cache groups directly. foreach ( $ids as $id ) { wp_cache_delete( $id, 'posts' ); wp_cache_delete( $id, 'post_meta' ); } wp_cache_set_posts_last_changed(); } private static function drop_booking_tables() { global $wpdb; foreach ( self::BOOKING_TABLES as $table ) { $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ); } } private static function delete_options() { global $wpdb; $names = $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", $wpdb->esc_like( 'stacklar_' ) . '%', $wpdb->esc_like( '_transient_stacklar_' ) . '%', $wpdb->esc_like( '_transient_timeout_stacklar_' ) . '%' ) ); // delete_option() keeps the options cache in step with the table. foreach ( (array) $names as $name ) { delete_option( $name ); } } private static function delete_user_meta() { global $wpdb; $like = $wpdb->esc_like( '_stacklar_' ) . '%'; $user_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s", $like ) ); if ( empty( $user_ids ) ) { return; } $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s", $like ) ); foreach ( $user_ids as $user_id ) { wp_cache_delete( absint( $user_id ), 'user_meta' ); } } private static function delete_upload_dirs() { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) { return; } $basedir = realpath( $uploads['basedir'] ); if ( false === $basedir || ! is_dir( $basedir ) ) { return; } $basedir = wp_normalize_path( $basedir ); // Same default and filter as Stacklar_Uploads::get_private_base_dir(). // The default folder is checked too, in case files predate the filter. $default_private = trailingslashit( $uploads['basedir'] ) . 'stacklar-private'; $dirs = array( (string) apply_filters( 'stacklar_private_upload_dir', $default_private ), $default_private, ); foreach ( self::UPLOAD_DIRS as $name ) { $dirs[] = trailingslashit( $uploads['basedir'] ) . $name; } foreach ( array_unique( $dirs ) as $dir ) { $target = self::resolve_dir_inside( $dir, $basedir ); if ( '' !== $target ) { self::delete_tree( $target ); } } } /** * Returns the real path of $dir when it is a directory strictly inside * $basedir, or '' when it must be left alone (missing, a symlink, the * uploads folder itself, or anywhere outside it). */ private static function resolve_dir_inside( $dir, $basedir ) { $dir = rtrim( wp_normalize_path( (string) $dir ), '/' ); if ( '' === $dir || is_link( $dir ) || ! is_dir( $dir ) ) { return ''; } $real = realpath( $dir ); if ( false === $real ) { return ''; } $real = trailingslashit( wp_normalize_path( $real ) ); $base = trailingslashit( $basedir ); if ( $real === $base || 0 !== strpos( $real, $base ) ) { return ''; } return untrailingslashit( $real ); } // Symlinks are unlinked, never followed, so nothing outside the tree is touched. private static function delete_tree( $dir ) { $entries = @scandir( $dir ); if ( false === $entries ) { return; } foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } $path = $dir . '/' . $entry; if ( is_link( $path ) || ! is_dir( $path ) ) { wp_delete_file( $path ); continue; } self::delete_tree( $path ); } // Deleting a plugin sets up WP_Filesystem before this file runs. global $wp_filesystem; if ( $wp_filesystem instanceof WP_Filesystem_Base ) { $wp_filesystem->rmdir( $dir ); } } } } Stacklar_Uninstaller::run();