TextureStore::class, 'fonts' => FontStore::class, 'sounds' => SoundStore::class]; /** @var array $loose file without a folder => its kind */ $loose = []; foreach ($kinds as $kind => $class) { foreach (glob("{$data}/{$kind}/*.*") ?: [] as $path) { if ($class::isValidName(basename($path))) { $loose[basename($path)] = $kind; } } } /** Every string in `$value` that is a file without a folder. @return list */ function looseIn(mixed $value, array $loose): array { if (is_string($value)) { return isset($loose[$value]) ? [$value] : []; } $found = []; if (is_array($value)) { foreach ($value as $item) { array_push($found, ...looseIn($item, $loose)); } } return $found; } /** Swaps every string in `$value` that `$map` knows. */ function rewrite(mixed $value, array $map): mixed { if (is_string($value)) { return $map[$value] ?? $value; } if (is_array($value)) { foreach ($value as $key => $item) { $value[$key] = rewrite($item, $map); } } return $value; } function readJson(string $path): ?array { $raw = file_get_contents($path); $doc = $raw === false ? null : json_decode($raw, true); return is_array($doc) ? $doc : null; } // The documents, each with the account it belongs to: a snapshot and a release are their project's. $owners = []; $docs = []; foreach (glob("{$data}/projects/*.json") ?: [] as $path) { $doc = readJson($path); if ($doc === null) { continue; } $owners[basename($path, '.json')] = is_string($doc['owner'] ?? null) ? $doc['owner'] : ''; $docs[] = ['path' => $path, 'kind' => 'project', 'owner' => $owners[basename($path, '.json')]]; } foreach (glob("{$data}/snapshots/*/*.json") ?: [] as $path) { $docs[] = ['path' => $path, 'kind' => 'snapshot', 'owner' => $owners[basename(dirname($path))] ?? '']; } foreach (glob("{$data}/releases/*.json") ?: [] as $path) { $projectId = readJson($path)['projectId'] ?? ''; $docs[] = ['path' => $path, 'kind' => 'release', 'owner' => is_string($projectId) ? ($owners[$projectId] ?? '') : '']; } /** @var array> $usedBy file => accounts using it */ $usedBy = []; /** @var array $stuck files a document names that cannot be rewritten */ $stuck = []; foreach ($docs as $i => $doc) { $files = array_unique(looseIn(readJson($doc['path'])['project'] ?? null, $loose)); $docs[$i]['files'] = $files; $account = $doc['owner'] !== '' && $users->exists($doc['owner']); foreach ($files as $file) { if ($account) { $usedBy[$file][$doc['owner']] = true; } else { $stuck[$file] = true; echo "kept: {$file} is named by {$doc['kind']} {$doc['path']} without an account\n"; } } } // Copies into each user's folder, and what their documents' names become. $maps = []; $copies = 0; foreach ($usedBy as $file => $accounts) { $kind = $loose[$file]; foreach (array_keys($accounts) as $account) { $folder = $dry ? ($users->folderOf($account) ?: 'new-folder') : $users->folderOf($account, true); $maps[$account][$file] = "{$folder}/{$file}"; $copies++; echo "copy: {$kind}/{$file} → {$kind}/{$folder}/ ({$account})\n"; if ($dry) { continue; } $dir = "{$data}/{$kind}/{$folder}"; if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException("Cannot create {$dir}"); } if (!is_file("{$dir}/{$file}")) { AtomicFile::write("{$dir}/{$file}", (string) file_get_contents("{$data}/{$kind}/{$file}"), AtomicFile::SERVED, 'stored file'); } OriginalName::write($dir, $file, OriginalName::read("{$data}/{$kind}", $file)); } } $rewritten = ['project' => 0, 'snapshot' => 0, 'release' => 0]; foreach ($docs as $doc) { $map = $maps[$doc['owner']] ?? []; if ($doc['files'] === [] || $map === []) { continue; } $rewritten[$doc['kind']]++; if ($dry) { continue; } $write = static function () use ($doc, $map): void { $current = readJson($doc['path']); if ($current === null) { return; } $current['project'] = rewrite($current['project'], $map); $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; if ($doc['kind'] === 'project') { $current['revision'] = (int) $current['revision'] + 1; $current['updatedAt'] = gmdate('c'); } if ($doc['kind'] !== 'release') { $flags |= JSON_PRETTY_PRINT; } AtomicFile::write($doc['path'], (string) json_encode($current, $flags), AtomicFile::HIDDEN, "{$doc['kind']} file"); }; if ($doc['kind'] !== 'project') { $write(); continue; } // Under the save lock, as `Store` takes it: a save running at the same time waits, then fails // on the revision instead of writing the old names back. $lock = fopen($doc['path'] . '.lock', 'c'); if ($lock === false || !flock($lock, LOCK_EX)) { throw new RuntimeException("Cannot lock {$doc['path']}"); } try { $write(); } finally { flock($lock, LOCK_UN); fclose($lock); } } $moved = 0; foreach (array_keys($usedBy) as $file) { if (isset($stuck[$file])) { continue; } $moved++; if (!$dry) { $dir = "{$data}/{$loose[$file]}"; unlink("{$dir}/{$file}"); OriginalName::delete($dir, $file); } } $unused = count($loose) - count($usedBy) - count(array_diff_key($stuck, $usedBy)); echo ($dry ? '[dry run] ' : ''), 'files used: ', count($usedBy) + count(array_diff_key($stuck, $usedBy)), ", copies made: {$copies}, moved away: {$moved}, kept for documents without an account: ", count($stuck), ", unused and nobody's: {$unused}\n", "rewritten: {$rewritten['project']} projects, {$rewritten['snapshot']} snapshots, {$rewritten['release']} releases\n";