$options Per-section file options (media, themes, plugins, mu-plugins, core) and export_exclusions. * @return array{files:array>,skipped_files_count:int,skipped_files_size:int,excluded_patterns:array} */ public static function collect_with_summary( array $options ): array { $settings = Helpers::get_settings(); $global_patterns = array(); if ( ! empty( $options['export_exclusions'] ) ) { $global_patterns = is_array( $options['export_exclusions'] ) ? $options['export_exclusions'] : array(); } elseif ( ! empty( $settings['export_exclusions'] ) && is_array( $settings['export_exclusions'] ) ) { $global_patterns = $settings['export_exclusions']; } $all_patterns = self::normalize_patterns( array_merge( Helpers::default_export_exclusions(), $global_patterns ) ); $all_files = array(); $skipped_count = 0; $skipped_size = 0; if ( ! empty( $options['media']['enabled'] ) ) { $media_mode = isset( $options['media']['mode'] ) ? (string) $options['media']['mode'] : 'all'; $cutoff = 0; if ( 'date' === $media_mode && ! empty( $options['media']['date'] ) ) { $cutoff = strtotime( (string) $options['media']['date'] ); } elseif ( 'changed' === $media_mode ) { $last = get_option( 'spinxqm_last_operation', array() ); if ( ! empty( $last['timestamp'] ) ) { $cutoff = (int) $last['timestamp']; } } $local_patterns = self::normalize_patterns( $options['media']['excludes'] ?? array() ); $result = self::scan_dir( wp_get_upload_dir()['basedir'], 'uploads', array_merge( $all_patterns, $local_patterns ), array(), 'all', array(), $cutoff ); $all_files = array_merge( $all_files, $result['files'] ); $skipped_count += $result['skipped_count']; $skipped_size += $result['skipped_size']; } if ( ! empty( $options['themes']['enabled'] ) ) { $local_patterns = self::normalize_patterns( $options['themes']['excludes'] ?? array() ); $result = self::scan_dir( get_theme_root(), 'themes', array_merge( $all_patterns, $local_patterns ), $options['themes']['selected'] ?? array(), $options['themes']['mode'] ?? 'all', $options['themes']['active'] ?? array(), 0 ); $all_files = array_merge( $all_files, $result['files'] ); $skipped_count += $result['skipped_count']; $skipped_size += $result['skipped_size']; } if ( ! empty( $options['plugins']['enabled'] ) ) { $local_patterns = self::normalize_patterns( $options['plugins']['excludes'] ?? array() ); $result = self::scan_dir( WP_PLUGIN_DIR, 'plugins', array_merge( $all_patterns, $local_patterns ), $options['plugins']['selected'] ?? array(), $options['plugins']['mode'] ?? 'all', $options['plugins']['active'] ?? array(), 0 ); $all_files = array_merge( $all_files, $result['files'] ); $skipped_count += $result['skipped_count']; $skipped_size += $result['skipped_size']; } if ( ! empty( $options['mu-plugins']['enabled'] ) && defined( 'WPMU_PLUGIN_DIR' ) ) { // Must-use plugins are always active and have no per-item selection UI, so // the whole directory is scanned (mode "all"), honouring only exclude globs. $local_patterns = self::normalize_patterns( $options['mu-plugins']['excludes'] ?? array() ); $result = self::scan_dir( WPMU_PLUGIN_DIR, 'mu-plugins', array_merge( $all_patterns, $local_patterns ), array(), 'all', array(), 0 ); $all_files = array_merge( $all_files, $result['files'] ); $skipped_count += $result['skipped_count']; $skipped_size += $result['skipped_size']; } if ( ! empty( $options['core']['enabled'] ) ) { $result = self::scan_core( $all_patterns ); $all_files = array_merge( $all_files, $result['files'] ); $skipped_count += $result['skipped_count']; $skipped_size += $result['skipped_size']; } return array( 'files' => $all_files, 'skipped_files_count' => $skipped_count, 'skipped_files_size' => $skipped_size, 'excluded_patterns' => $all_patterns, ); } /** * Build export file map by options. * * @param array $options Per-section file options (see collect_with_summary()). * @return array> */ public static function collect( array $options ): array { $data = self::collect_with_summary( $options ); return $data['files']; } /** * Scan one directory. * * @param string $base Absolute directory to scan. * @param string $zip_root Archive folder the files are placed under. * @param array $exclude_patterns Normalized exclusion patterns. * @param array $selected Top-level items selected for the list mode. * @param string $mode List mode: all, active, selected or exclude. * @param array $active Active top-level items (for the active mode). * @param int $cutoff Skip files modified before this timestamp (0 = no cutoff). * @return array{files:array>,skipped_count:int,skipped_size:int} */ private static function scan_dir( string $base, string $zip_root, array $exclude_patterns, array $selected, string $mode, array $active, int $cutoff = 0 ): array { if ( ! is_dir( $base ) || ! is_readable( $base ) ) { return array( 'files' => array(), 'skipped_count' => 0, 'skipped_size' => 0, ); } $files = array(); $skipped_count = 0; $skipped_size = 0; $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, RecursiveDirectoryIterator::SKIP_DOTS ) ); foreach ( $iter as $file ) { if ( ! $file->isFile() ) { continue; } $path = $file->getPathname(); $rel = ltrim( str_replace( wp_normalize_path( $base ), '', wp_normalize_path( $path ) ), '/' ); $top = strtok( $rel, '/' ); if ( ! self::include_item( (string) $top, $selected, $mode, $active ) ) { continue; } $zip_rel = $zip_root . '/' . $rel; if ( self::is_excluded( $rel, $exclude_patterns ) || self::is_excluded( $zip_rel, $exclude_patterns ) ) { ++$skipped_count; $skipped_size += (int) $file->getSize(); continue; } if ( $cutoff > 0 && $file->getMTime() < $cutoff ) { ++$skipped_count; $skipped_size += (int) $file->getSize(); continue; } $files[] = array( 'source' => $path, 'target' => $zip_root . '/' . $rel, ); } return array( 'files' => $files, 'skipped_count' => $skipped_count, 'skipped_size' => $skipped_size, ); } /** * Check inclusion by list mode. * * @param string $item Top-level file or directory name. * @param array $selected Selected items. * @param string $mode List mode: all, active, selected or exclude. * @param array $active Active items. * @return bool */ private static function include_item( string $item, array $selected, string $mode, array $active ): bool { $item_key = ( '.php' === substr( $item, -4 ) ) ? basename( $item, '.php' ) : $item; $selected = array_map( static function ( $val ) { $val = (string) $val; return ( '.php' === substr( $val, -4 ) ) ? basename( $val, '.php' ) : $val; }, $selected ); $active = array_map( static function ( $val ) { $val = (string) $val; return ( '.php' === substr( $val, -4 ) ) ? basename( $val, '.php' ) : $val; }, $active ); if ( 'active' === $mode ) { return in_array( $item_key, $active, true ); } if ( 'selected' === $mode ) { return in_array( $item_key, $selected, true ); } if ( 'exclude' === $mode ) { return ! in_array( $item_key, $selected, true ); } return true; } /** * Scan core files, excluding the content directory and wp-config.php. * * @param array $exclude_patterns Normalized exclusion patterns. * @return array{files:array>,skipped_count:int,skipped_size:int} */ private static function scan_core( array $exclude_patterns = array() ): array { $base = ABSPATH; $files = array(); $skipped_count = 0; $skipped_size = 0; // The content directory (wherever WP_CONTENT_DIR points) is exported by the // Media/Themes/Plugins components, not as core. '' = it lives outside ABSPATH. $content_rel = Helpers::relative_to_root( WP_CONTENT_DIR ); $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, RecursiveDirectoryIterator::SKIP_DOTS ) ); foreach ( $iter as $file ) { if ( ! $file->isFile() ) { continue; } $path = wp_normalize_path( $file->getPathname() ); $rel = ltrim( str_replace( wp_normalize_path( $base ), '', $path ), '/' ); if ( '' !== $content_rel && 0 === strpos( $rel, $content_rel . '/' ) ) { continue; } // Never package wp-config.php: it holds the database credentials and // authentication keys/salts, and the destination site has its own. if ( 'wp-config.php' === $rel ) { ++$skipped_count; $skipped_size += (int) $file->getSize(); continue; } $zip_rel = 'wordpress-core/' . $rel; if ( self::is_excluded( $rel, $exclude_patterns ) || self::is_excluded( $zip_rel, $exclude_patterns ) ) { ++$skipped_count; $skipped_size += (int) $file->getSize(); continue; } $files[] = array( 'source' => $file->getPathname(), 'target' => $zip_rel, ); } return array( 'files' => $files, 'skipped_count' => $skipped_count, 'skipped_size' => $skipped_size, ); } /** * Check if a relative path matches any exclude pattern. * * Matching rules — kept narrow so a pattern can NEVER swallow an unrelated file: * - PATH pattern (contains "/", e.g. "wp-content/backup*"): matches the relative * PATH only, anchored at the start, with a trailing `($|/)` so a directory pattern * also covers its contents. It is NEVER matched against the basename. This is the * fix for the bug where "wp-content/backup*" (stripped to "backup*") matched the * BASENAME of any file starting with "backup" — silently dropping files like * class/Common/BackupExport.php, class/Backups/, frontend .../backups-*.svg, * etc., from every export. * - NAME glob (no "/", has "*", e.g. "*.log"): matches the basename only. * - Bare name (no "/", no "*", e.g. "node_modules", "Thumbs.db"): matches any exact * path SEGMENT (a dir/file named exactly this, anywhere). * * @param string $relative_path Path relative to the scanned root. * @param array $patterns Normalized exclusion patterns. * @return bool */ private static function is_excluded( string $relative_path, array $patterns ): bool { $relative_path = wp_normalize_path( $relative_path ); $basename = basename( $relative_path ); $parts = array_filter( explode( '/', $relative_path ) ); foreach ( $patterns as $pattern ) { $pattern = trim( (string) $pattern ); if ( '' === $pattern ) { continue; } $pattern = wp_normalize_path( ltrim( $pattern, '/' ) ); if ( false !== strpos( $pattern, '/' ) ) { // PATH pattern → relative path only (never the basename). // A leading "{content-dir name}/" (e.g. "wp-content/cache") is a root // alias: also try the remainder against paths inside each component. $candidates = array( $pattern ); $alias = Helpers::content_dir_name() . '/'; if ( 0 === strpos( $pattern, $alias ) ) { $candidates[] = substr( $pattern, strlen( $alias ) ); } foreach ( $candidates as $candidate ) { $regex = '#^' . str_replace( array( '\\*\\*', '\\*' ), array( '.*', '[^/]*' ), preg_quote( $candidate, '#' ) ) . '($|/)#i'; if ( preg_match( $regex, $relative_path ) ) { return true; } } continue; } if ( false !== strpos( $pattern, '*' ) ) { // NAME glob → basename only. $regex = '#^' . str_replace( array( '\\*\\*', '\\*' ), array( '.*', '[^/]*' ), preg_quote( $pattern, '#' ) ) . '$#i'; if ( preg_match( $regex, $basename ) ) { return true; } continue; } // Bare name → any exact path segment. foreach ( $parts as $part ) { if ( 0 === strcasecmp( $part, $pattern ) ) { return true; } } } return false; } /** * Trim, drop empty and de-duplicate exclusion patterns, normalizing path separators. * * @param array $patterns Raw exclusion patterns. * @return array */ private static function normalize_patterns( array $patterns ): array { $out = array(); foreach ( $patterns as $pattern ) { $p = trim( (string) $pattern ); if ( '' === $p ) { continue; } $out[] = wp_normalize_path( $p ); } return array_values( array_unique( $out ) ); } }