*/ private array $roots; private array $parts; private string $dir; /** @var array */ private array $policies; /** @var array */ private array $untouched; /** * @param array $policies group => SubsiteFiles policy; a group taken "add-missing" keeps the items * this site has (they are never sent, replaced or mirrored) * @param array $untouched group => top-level items mirror never moves (the main site's uploads/sites) */ public function __construct(FileList $source, array $roots, array $parts, string $dir, array $policies = [], array $untouched = []) { $this->source = $source; $this->roots = $roots; $this->parts = $parts; $this->dir = $dir; $this->policies = $policies; $this->untouched = $untouched; } public static function start(): array { return ['phase' => 'compare', 'pos' => 0, 'needed' => 0, 'bytes' => 0, 'scan' => null, 'scan_pos' => 0, 'extras' => 0, 'extras_bytes' => 0, 'kept' => [], 'done' => false]; } public function needed(): IndexList { return new IndexList($this->dir . '/needed.bin'); } /** One JSON line per item to move aside in mirror mode: {"g": group, "r": item or upload path}. */ public function extras_path(): string { return $this->dir . '/extras.jsonl'; } public function run(array $cursor, Budget $budget): array { $needed = $this->needed(); $needed->truncate((int) $cursor['needed']); if ($cursor['phase'] === 'compare') { $cursor = $this->compare($cursor, $budget, $needed); } if ($cursor['phase'] === 'extras') { $cursor = $this->extras($cursor, $budget); } return $cursor; } private function compare(array $cursor, Budget $budget, IndexList $needed): array { $batch = []; $finished = true; foreach ($this->source->each((int) $cursor['pos']) as $i => $e) { if ($budget->exhausted()) { $finished = false; break; } if ($this->keeps($e)) { $top = RelPath::top($e['r']); if ($top !== 'index.php') { // WordPress's own file in themes/ and plugins/: kept too, but no theme or plugin to name $cursor['kept'][$e['g']][$top] = true; } } elseif ($this->needs($e)) { $batch[] = $i; $cursor['bytes'] += $e['s']; } $cursor['pos'] = $i + 1; if (count($batch) >= self::BATCH) { $needed->add($batch); $batch = []; $cursor['needed'] = $needed->count(); } } $needed->add($batch); $cursor['needed'] = $needed->count(); if ($finished) { $cursor['phase'] = $this->parts['mirror'] ? 'extras' : 'done'; $cursor['done'] = !$this->parts['mirror']; wp_delete_file($this->extras_path()); } return $cursor; } /** A theme or plugin this site already has, when the group is only added to (a subsite). */ private function keeps(array $e): bool { return ($this->policies[$e['g']] ?? '') === SubsiteFiles::ADD_MISSING && file_exists($this->roots[$e['g']] . '/' . RelPath::top($e['r'])); } private function needs(array $e): bool { if ($e['g'] !== 'uploads' || $this->parts['uploads']['mode'] === 'all') { return true; } $path = Packer::path($this->roots['uploads'], $e['r']); clearstatcache(true, (string) $path); if ($path === null || !is_file($path) || filesize($path) !== $e['s']) { return true; } return filemtime($path) !== $e['m'] && sha1_file($path) !== $e['h']; } /** * Mirror: whole themes and plugins this site has and the other doesn't (when all of them are copied), and * uploads files the other site doesn't have (all or changed mode). Excluded and protected items stay. */ private function extras(array $cursor, Budget $budget): array { $excludes = Groups::excludes($this->roots, $this->parts['excludes'], $this->untouched); if ($cursor['scan'] === null) { $lines = ''; foreach (['themes', 'plugins'] as $group) { if ($this->parts[$group]['mode'] !== 'all' || isset($this->policies[$group])) { continue; } $theirs = []; foreach ($this->source->each() as $e) { if ($e['g'] === $group) { $theirs[RelPath::top($e['r'])] = true; } } foreach (Inventory::top_level($this->roots[$group], $excludes[$group]) as $item) { if (!isset($theirs[$item])) { $lines .= self::line($group, $item); $cursor['extras']++; } } } Fs::put($this->extras_path(), $lines); $cursor['scan'] = in_array($this->parts['uploads']['mode'], ['all', 'changed'], true) ? Scanner::start() : ['done' => true, 'list_bytes' => 0]; wp_delete_file($this->dir . '/ours.jsonl'); } $ours = $this->dir . '/ours.jsonl'; if (empty($cursor['scan']['done'])) { $scanner = new Scanner(['uploads' => $this->roots['uploads']], ['uploads' => $excludes['uploads']], $ours); while (!$cursor['scan']['done'] && !$budget->exhausted()) { $cursor['scan'] = $scanner->run($cursor['scan'], 2000); } if (!$cursor['scan']['done']) { return $cursor; } } if (is_file($ours)) { $theirs = []; foreach ($this->source->each() as $e) { if ($e['g'] === 'uploads') { $theirs[$e['r']] = true; } } $h = Fs::open($ours, 'rb'); fseek($h, (int) $cursor['scan_pos']); $lines = ''; $finished = true; while (($raw = fgets($h)) !== false) { if ($budget->exhausted()) { $finished = false; break; } $cursor['scan_pos'] += strlen($raw); $e = json_decode($raw, true); if (is_array($e) && is_string($e['r'] ?? null) && RelPath::valid($e['r']) && !isset($theirs[$e['r']])) { $lines .= self::line('uploads', $e['r']); $cursor['extras']++; $cursor['extras_bytes'] += (int) ($e['s'] ?? 0); } } Fs::close($h); Fs::put($this->extras_path(), $lines, true); if (!$finished) { return $cursor; } wp_delete_file($ours); } $cursor['phase'] = 'done'; $cursor['done'] = true; return $cursor; } private static function line(string $group, string $rel): string { return json_encode(['g' => $group, 'r' => $rel], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; } }