* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Component\MokoBackup\Administrator\Engine; defined('_JEXEC') or die; class FileScanner { private string $rootDir; private array $excludeDirs; private array $excludeFiles; /** * @param string $rootDir Root directory to scan * @param array $excludeDirs Relative directory paths to exclude * @param array $excludeFiles Filename patterns to exclude */ public function __construct(string $rootDir, array $excludeDirs = [], array $excludeFiles = []) { $this->rootDir = rtrim($rootDir, '/\\'); $this->excludeDirs = array_map(fn($d) => trim($d, '/\\'), $excludeDirs); $this->excludeFiles = $excludeFiles; } /** * Scan the root directory and return relative file paths. * * @return string[] Array of relative file paths */ public function scan(): array { $files = []; $this->scanDirectory('', $files); return $files; } private function scanDirectory(string $relativePath, array &$files): void { $fullPath = $this->rootDir . ($relativePath ? '/' . $relativePath : ''); if (!is_dir($fullPath) || !is_readable($fullPath)) { return; } $handle = opendir($fullPath); if ($handle === false) { return; } while (($entry = readdir($handle)) !== false) { if ($entry === '.' || $entry === '..') { continue; } $entryRelative = $relativePath ? $relativePath . '/' . $entry : $entry; $entryFull = $fullPath . '/' . $entry; if (is_dir($entryFull)) { if (!$this->isDirExcluded($entryRelative)) { $this->scanDirectory($entryRelative, $files); } } elseif (is_file($entryFull)) { if (!$this->isFileExcluded($entry)) { $files[] = $entryRelative; } } } closedir($handle); } private function isDirExcluded(string $relativePath): bool { $normalized = str_replace('\\', '/', $relativePath); foreach ($this->excludeDirs as $excluded) { if ($normalized === $excluded || str_starts_with($normalized, $excluded . '/')) { return true; } } // Always exclude .git if (basename($relativePath) === '.git') { return true; } return false; } private function isFileExcluded(string $filename): bool { foreach ($this->excludeFiles as $pattern) { if ($filename === $pattern || fnmatch($pattern, $filename)) { return true; } } return false; } }