*/ public function of(string $file): array { $used = []; foreach (glob("{$this->data}/projects/*.json") ?: [] as $path) { if ($this->names($path, $file)) { $used[basename($path, '.json')] = true; } } foreach (glob("{$this->data}/snapshots/*/*.json") ?: [] as $path) { if ($this->names($path, $file)) { $used[basename(dirname($path))] = true; } } // A demo copy (#428) is a release a guest opens in the editor, and stored like one. foreach ([...glob("{$this->data}/releases/*.json") ?: [], ...glob("{$this->data}/demos/*.json") ?: []] as $path) { $release = $this->decoded($path, $file); if ($release !== null && is_string($release['projectId'] ?? null) && self::uses($release['project'] ?? null, $file)) { $used[$release['projectId']] = true; } } $uses = []; foreach (array_keys($used) as $id) { $uses[] = ['id' => (string) $id, ...$this->project((string) $id)]; } return $uses; } /** * Why a file cannot go. The asker's own projects are named; somebody else's only for an * administrator, everybody else learns no more than that there is one. * * @param list $uses */ public static function refusal(array $uses, string $asker, bool $seesAll): string { /** @var array $labels each name once, with how many projects carry it */ $labels = []; $others = 0; foreach ($uses as $use) { if (strcasecmp($use['owner'], $asker) === 0) { $label = "“{$use['name']}”"; } elseif ($seesAll) { $label = "“{$use['name']}” of " . ($use['owner'] === '' ? 'nobody' : $use['owner']); } else { $others++; continue; } $labels[$label] = ($labels[$label] ?? 0) + 1; } $named = []; foreach ($labels as $label => $count) { $named[] = $count === 1 ? $label : "{$label} ({$count} projects)"; } if ($others > 0) { $named[] = $others === 1 ? 'a project of another account' : "{$others} projects of other accounts"; } $list = count($named) > 1 ? implode(', ', array_slice($named, 0, -1)) . ' and ' . end($named) : $named[0]; return "Still used by {$list}, counting snapshots, releases and demo copies"; } /** Whether a value holds `$file` as a string anywhere inside it. */ public static function uses(mixed $value, string $file): bool { if (is_string($value)) { return $value === $file; } if (is_array($value)) { foreach ($value as $item) { if (self::uses($item, $file)) { return true; } } } return false; } private function names(string $path, string $file): bool { $doc = $this->decoded($path, $file); return $doc !== null && self::uses($doc['project'] ?? null, $file); } /** * The document, or null when it cannot name the file. The text is searched for the hash first, * which has no slash to be escaped: most documents are never decoded. */ private function decoded(string $path, string $file): ?array { $raw = file_get_contents($path); if ($raw === false || !str_contains($raw, basename($file))) { return null; } $doc = json_decode($raw, true); return is_array($doc) ? $doc : null; } /** @return array{owner:string,name:string} */ private function project(string $id): array { $path = "{$this->data}/projects/{$id}.json"; $raw = is_file($path) ? file_get_contents($path) : false; $doc = $raw === false ? null : json_decode($raw, true); return [ 'owner' => is_string($doc['owner'] ?? null) ? $doc['owner'] : '', 'name' => is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled', ]; } }