group => absolute root */ private array $roots; /** @var array */ private array $excludes; private string $list; public function __construct(array $roots, array $excludes, string $list_path) { $this->roots = $roots; $this->excludes = $excludes; $this->list = $list_path; } public static function start(): array { return ['group' => 0, 'stack' => null, 'list_bytes' => 0, 'files' => [], 'skipped' => [], 'skipped_count' => 0, 'done' => false]; } /** Scan until $max_entries entries were looked at (a directory is always finished once started). */ public function run(array $cursor, int $max_entries): array { $groups = array_keys($this->roots); $seen = 0; $lines = ''; while (!$cursor['done'] && $seen < $max_entries) { if (!isset($groups[$cursor['group']])) { $cursor['done'] = true; break; } $group = $groups[$cursor['group']]; $root = rtrim($this->roots[$group], '/'); if ($cursor['stack'] === null) { $cursor['stack'] = is_dir($root) && !is_link($root) ? [''] : []; $cursor['files'][$group] = $cursor['files'][$group] ?? ['count' => 0, 'bytes' => 0]; } if ($cursor['stack'] === []) { $cursor['group']++; $cursor['stack'] = null; continue; } $dir = array_pop($cursor['stack']); $path = $root . ($dir === '' ? '' : '/' . $dir); $names = is_dir($path) && is_readable($path) ? scandir($path) : false; if ($names === false) { $cursor = self::skip($cursor, $group . '/' . $dir, 'unreadable'); continue; } $subdirs = []; foreach ($names as $name) { if ($name === '.' || $name === '..') { continue; } $seen++; $rel = $dir === '' ? $name : $dir . '/' . $name; if ($this->excludes[$group]->excluded($rel)) { continue; } $abs = $root . '/' . $rel; if (is_link($abs)) { $cursor = self::skip($cursor, $group . '/' . $rel, 'symlink'); } elseif (is_dir($abs)) { $subdirs[] = $rel; } elseif (!is_file($abs) || !is_readable($abs)) { $cursor = self::skip($cursor, $group . '/' . $rel, 'unreadable'); } else { $size = (int) filesize($abs); $lines .= json_encode(['g' => $group, 'r' => $rel, 's' => $size], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . "\n"; $cursor['files'][$group]['count']++; $cursor['files'][$group]['bytes'] += $size; } } // Reverse so that popping the stack visits subdirectories in name order. foreach (array_reverse($subdirs) as $sub) { $cursor['stack'][] = $sub; } } if ($lines !== '') { Fs::put($this->list, $lines, true); // a list missing files would leave them out, or move them aside in a mirror } clearstatcache(true, $this->list); $cursor['list_bytes'] = is_file($this->list) ? (int) filesize($this->list) : 0; return $cursor; } private static function skip(array $cursor, string $path, string $reason): array { $cursor['skipped_count']++; if (count($cursor['skipped']) < self::MAX_SKIPPED) { $cursor['skipped'][] = ['path' => $path, 'reason' => $reason]; } return $cursor; } }