10, 'debug_log_enabled' => 0, 'sql_rows_per_batch' => 2000, 'file_scan_batch_size' => 200, 'batch_preset' => 'medium', 'batch_size_custom' => 50, 'max_chunk_bytes' => 6 * MB_IN_BYTES, 'gzip_level' => 6, 'export_exclusions' => self::default_export_exclusions(), ); } /** * Default export exclusion patterns. * * @return array */ public static function default_export_exclusions(): array { $content = self::content_dir_name(); return array( '*.zip', '*.tar', '*.tar.gz', '*.tgz', '*.gz', '*.rar', '*.7z', '*.bz2', '*.xz', '*.sql', '*.sql.gz', '*.bak', '*.backup', '*.old', '*.tmp', '*.temp', '*.log', '*.cache', '.DS_Store', 'Thumbs.db', 'cache', 'backup', 'backups', 'upgrade', $content . '/cache', $content . '/backup*', $content . '/updraft', $content . '/ai1wm-backups', $content . '/uploads/wp-migrate-db', $content . '/uploads/spinx-quick-migrate', 'node_modules', 'vendor', '.git', '.svn', '__MACOSX', ); } /** * Folder name of the content directory (normally "wp-content", but it can be * renamed or moved via WP_CONTENT_DIR). Exclusion patterns use it as the * root alias, e.g. "{content}/cache". */ public static function content_dir_name(): string { return basename( wp_normalize_path( untrailingslashit( WP_CONTENT_DIR ) ) ); } /** * Path relative to the WordPress root (ABSPATH), without leading slash, or '' * when the path lives outside it (e.g. a content dir moved above the root). * * @param string $path Absolute path. */ public static function relative_to_root( string $path ): string { $root = wp_normalize_path( trailingslashit( ABSPATH ) ); $path = wp_normalize_path( untrailingslashit( $path ) ); return 0 === strpos( $path . '/', $root ) ? ltrim( substr( $path, strlen( $root ) ), '/' ) : ''; } /** * Human-readable location for UI hints: "/{path relative to the WordPress * root}", or the absolute path when it lives outside the root. * * @param string $path Absolute path. */ public static function display_path( string $path ): string { $rel = self::relative_to_root( $path ); return '' !== $rel ? '/' . $rel : wp_normalize_path( untrailingslashit( $path ) ); } /** * Where WordPress writes its debug log (WP_DEBUG_LOG may be a custom path). */ public static function debug_log_path(): string { if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) { return WP_DEBUG_LOG; } return trailingslashit( WP_CONTENT_DIR ) . 'debug.log'; } /** * Get merged settings. */ public static function get_settings(): array { $settings = get_option( 'spinxqm_settings', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } return wp_parse_args( $settings, self::default_settings() ); } /** * Per-request cache of the uploads base dir/URL. * * @var array|null */ private static ?array $uploads_cache = null; /** * Uploads base directory and URL, cached for the request. * * @return array */ private static function uploads(): array { if ( null === self::$uploads_cache ) { $dir = wp_get_upload_dir(); self::$uploads_cache = array( 'basedir' => (string) ( $dir['basedir'] ?? '' ), 'baseurl' => (string) ( $dir['baseurl'] ?? '' ), ); } return self::$uploads_cache; } /** * Get storage path. */ public static function storage_dir(): string { $uploads = self::uploads(); return trailingslashit( $uploads['basedir'] ) . 'spinx-quick-migrate'; } /** * Ensure storage dir exists. */ public static function ensure_storage_dir(): bool { $path = self::storage_dir(); if ( ! is_dir( $path ) ) { wp_mkdir_p( $path ); } if ( ! is_dir( $path ) || ! wp_is_writable( $path ) ) { return false; } self::protect_storage_dir(); self::schedule_housekeeping(); return true; } /** * Make sure the daily housekeeping event (`spinxqm_purge_storage`: old export / * backup files + abandoned find & replace staging tables) is scheduled. Cleared * on deactivation. */ public static function schedule_housekeeping(): void { if ( ! wp_next_scheduled( 'spinxqm_purge_storage' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'spinxqm_purge_storage' ); } } /** * Block direct web access to storage: an index.php stops directory listing * and a deny-all .htaccess blocks Apache/LiteSpeed. On servers that ignore * .htaccess (e.g. Nginx) the random token in every file name keeps URLs * unguessable. Files are served only via Downloads (capability + nonce). */ public static function protect_storage_dir(): void { $dir = trailingslashit( self::storage_dir() ); $files = array( 'index.php' => " "# Deny direct access to SPINX Quick Migrate exports and backups.\n" . "\n\tRequire all denied\n\n" . "\n\tOrder deny,allow\n\tDeny from all\n\n", ); require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; $fs = new \WP_Filesystem_Direct( null ); foreach ( $files as $name => $contents ) { // (Re)write when missing or outdated — e.g. an older .htaccess that // only disabled directory listing without denying access. if ( $fs->exists( $dir . $name ) && $fs->get_contents( $dir . $name ) === $contents ) { continue; } $fs->put_contents( $dir . $name, $contents, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644 ); } } /** * How long (seconds) export/backup files may stay in storage before the * daily purge removes them. Default 24 h; floored at 1 h. */ public static function storage_retention(): int { $ttl = (int) apply_filters( 'spinxqm_storage_retention', DAY_IN_SECONDS ); return max( HOUR_IN_SECONDS, $ttl ); } /** * Daily cron: delete export/backup files older than the retention window * (e.g. find & replace pre-flight backups, CLI output, abandoned downloads). */ public static function purge_storage(): void { $dir = self::storage_dir(); if ( ! is_dir( $dir ) ) { return; } $cutoff = time() - self::storage_retention(); foreach ( new \DirectoryIterator( $dir ) as $entry ) { if ( $entry->isDot() || ! $entry->isFile() ) { continue; } if ( ! preg_match( '/\.(zip|sql|gz)$/i', $entry->getFilename() ) ) { continue; } if ( $entry->getMTime() < $cutoff ) { wp_delete_file( $entry->getPathname() ); } } } /** * Build export filename. A random token makes the name unguessable, so a * file can't be fetched by predicting its timestamp. * * @param string $prefix Filename prefix (e.g. export type). * @param string $ext File extension, with or without a leading dot. * @return string */ public static function build_filename( string $prefix, string $ext ): string { $dt = gmdate( 'Ymd-His' ); $token = strtolower( wp_generate_password( 12, false ) ); return sanitize_file_name( $prefix . '-' . $dt . '-' . $token . '.' . ltrim( $ext, '.' ) ); } /** * Lifetime (seconds) for a job's transients (payload, file list, post-ID set). * Default 24 h so a long export/backup/find-replace never has its state expire * mid-run under the old 1 h ceiling; floored at 1 h and filterable. */ public static function job_ttl(): int { $ttl = (int) apply_filters( 'spinxqm_job_ttl', DAY_IN_SECONDS ); return max( HOUR_IN_SECONDS, $ttl ); } /** * Every table that carries this install's table prefix (what a full backup * covers). Used by the pre-flight find & replace backup. * * @return array */ public static function all_prefix_tables(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder return (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix ) . '%' ) ); } /** * Create job skeleton. * * @param string $type Job type (e.g. export, backup, find_replace). * @return array */ public static function create_job( string $type ): array { $id = wp_generate_uuid4(); $job = array( 'id' => $id, 'type' => $type, 'status' => 'queued', 'progress' => 0, 'message' => __( 'Starting...', 'spinx-quick-migrate' ), 'current_item' => '', 'processed' => 0, 'total' => 0, 'processed_size' => 0, 'total_size' => 0, 'sections' => array(), 'checkpoint' => array(), 'paused' => false, 'errors' => array(), 'cancelled' => false, 'result' => array(), 'created_at' => time(), 'started_at' => 0, 'finished_at' => 0, 'paused_at' => 0, 'paused_total' => 0, ); set_transient( 'spinxqm_job_' . $id, $job, HOUR_IN_SECONDS ); $queue = new Job_Queue(); $queue->sync_from_transient( $id ); self::schedule_housekeeping(); return $job; } /** * Update job state. * * @param string $id Job ID. * @param array $updates Fields to merge into the job state. */ public static function update_job( string $id, array $updates ): void { $key = 'spinxqm_job_' . $id; // Read fresh (cache-bust) so a progress update from a long-running step // doesn't overwrite a `cancelled`/`paused` flag set by a concurrent // pause/cancel request. array_merge keeps those booleans (the step never // sends them), so the running step still notices and stops. $job = self::get_job_fresh( $id ); if ( empty( $job ) ) { return; } $job = array_merge( $job, $updates ); set_transient( $key, $job, HOUR_IN_SECONDS ); $queue = new Job_Queue(); $queue->sync_from_transient( $id ); } /** * Get job. * * @param string $id Job ID. * @return array */ public static function get_job( string $id ): array { $job = get_transient( 'spinxqm_job_' . $id ); return is_array( $job ) ? $job : array(); } /** * Read the job bypassing the per-request object/options cache, so a * long-running step request sees pause/cancel flags flipped by a *separate* * concurrent request. Without this, `get_transient()` returns the value * cached at the start of the step and the flag is never noticed mid-batch. * * @param string $id Job ID. * @return array */ public static function get_job_fresh( string $id ): array { $key = 'spinxqm_job_' . $id; // Cover both storage backends: persistent object cache (transient group) // and the wp_options fallback (transient stored as an option). wp_cache_delete( $key, 'transient' ); wp_cache_delete( '_transient_' . $key, 'options' ); wp_cache_delete( '_transient_timeout_' . $key, 'options' ); return self::get_job( $id ); } /** * Mark job for cancellation. * * @param string $id Job ID. * @return bool False when the job is missing or already finished. */ public static function cancel_job( string $id ): bool { $job = self::get_job( $id ); if ( empty( $job ) ) { return false; } $status = (string) ( $job['status'] ?? '' ); if ( in_array( $status, array( 'success', 'error', 'cancelled', 'completed', 'failed' ), true ) ) { return false; } self::update_job( $id, array( 'cancelled' => true, 'status' => 'cancelled', 'message' => __( 'Operation cancelled.', 'spinx-quick-migrate' ), ) ); return true; } /** * Check if job is cancelled. * * @param string|null $id Job ID. * @return bool */ public static function is_job_cancelled( ?string $id ): bool { if ( empty( $id ) ) { return false; } // Fresh read: a running step must notice a cancel set by another request. $job = self::get_job_fresh( $id ); return ! empty( $job['cancelled'] ) || 'cancelled' === ( $job['status'] ?? '' ); } /** * Request a pause. The next batch tick stops rescheduling itself. * * @param string $id Job ID. * @return bool False when the job is missing or already finished. */ public static function pause_job( string $id ): bool { $job = self::get_job( $id ); if ( empty( $job ) ) { return false; } $status = (string) ( $job['status'] ?? '' ); if ( in_array( $status, array( 'success', 'error', 'cancelled', 'completed', 'failed' ), true ) ) { return false; } self::update_job( $id, array( 'paused' => true, 'paused_at' => time(), 'message' => __( 'Paused.', 'spinx-quick-migrate' ), ) ); return true; } /** * Clear the pause flag and fold the time spent paused into `paused_total` so * the elapsed timer excludes it. Caller re-arms the cron event. * * @param string $id Job ID. * @return bool False when the job is missing or not paused. */ public static function resume_job( string $id ): bool { $job = self::get_job( $id ); if ( empty( $job ) || empty( $job['paused'] ) ) { return false; } $paused_at = (int) ( $job['paused_at'] ?? 0 ); $paused_total = (int) ( $job['paused_total'] ?? 0 ); if ( $paused_at > 0 ) { $paused_total += max( 0, time() - $paused_at ); } self::update_job( $id, array( 'paused' => false, 'paused_at' => 0, 'paused_total' => $paused_total, 'message' => __( 'Resuming...', 'spinx-quick-migrate' ), ) ); return true; } /** * Whether a pause has been requested for this job. * * @param string|null $id Job ID. * @return bool */ public static function is_job_paused( ?string $id ): bool { if ( empty( $id ) ) { return false; } // Fresh read: a running step must notice a pause set by another request. $job = self::get_job_fresh( $id ); return ! empty( $job['paused'] ); } /** * Persist the batch checkpoint (resume position) for a job. * * @param string $id Job ID. * @param array $checkpoint Position descriptor. */ public static function set_checkpoint( string $id, array $checkpoint ): void { self::update_job( $id, array( 'checkpoint' => $checkpoint ) ); } /** * Read the saved checkpoint for a job. * * @param string $id Job ID. * @return array */ public static function get_checkpoint( string $id ): array { $job = self::get_job( $id ); return is_array( $job['checkpoint'] ?? null ) ? $job['checkpoint'] : array(); } /** * Find the most recent active (queued/running/paused) job, for popup recovery. * * @return array */ public static function get_active_job(): array { $queue = new Job_Queue(); $job_id = $queue->find_active(); if ( '' === $job_id ) { return array(); } return self::get_job( $job_id ); } /** * Total size of files under a directory (bytes). * * @param string $dir Absolute directory path. * @return int */ public static function directory_size( string $dir ): int { $size = 0; if ( ! is_dir( $dir ) ) { return 0; } try { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $iterator as $file ) { if ( $file->isFile() ) { $size += (int) $file->getSize(); } } } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch return $size; } return $size; } }