UK
<?phpnamespace App\Feature\Organize;use App\Model\Cache;use App\Model\Chain;use App\Model\Connection;use App\Model\Session;use JumPHP\Async\Task;/** * XenCenter's Organization views kept where XenCenter keeps them, in XAPI: * - a folder is the other_config key "folder" of an object ("/Production/Web"); a folder exists while an object is in it, and the *   empty ones (made with New Folder) are kept in the pool's other_config key "folders" (a JSON list of paths); * - tags are the `tags` field (VM.add_tags / VM.remove_tags, the same for host, SR, network, pool). * Pure code on the caches; the changes are XAPI calls on the connection (the cache follows through event.from). */final class Organizer{    /** what can be put in a folder or tagged */    public const CLASSES = ['pool', 'host', 'vm', 'sr', 'network'];    public const FOLDER_KEY = 'folder';    public const EMPTY_KEY = 'folders';    /** "Production//Web/" -> "/Production/Web"; "" or "/" -> "" (the root). */    public static function normalize(string $path): string    {        $parts = array_filter(array_map('trim', explode('/', $path)), fn (string $p) => $p !== '');        return $parts === [] ? '' : '/' . implode('/', $parts);    }    public static function parent(string $path): string    {        $at = strrpos($path, '/');        return $at === false || $at === 0 ? '' : substr($path, 0, $at);    }    public static function leaf(string $path): string    {        return substr($path, (int)strrpos($path, '/') + 1);    }    /** True when $path is $folder itself or a folder below it. */    public static function inside(string $path, string $folder): bool    {        return $folder !== '' && ($path === $folder || str_starts_with($path, $folder . '/'));    }    /** The XAPI class name of a cache class (VM, SR, host...). */    public static function xapiClass(string $class): string    {        return match ($class) { 'vm' => 'VM', 'sr' => 'SR', default => $class};    }    /** @return array<string, string> ref => class of everything that can be organized (real VMs and templates of users, not dom0 or snapshots) */    public static function objects(Cache $cache): array    {        $out = [];        if ($cache->isRealPool() && ($pool = $cache->poolRef()) !== null) {            $out[$pool] = 'pool';        }        foreach ($cache->hosts() as $ref => $h) {            $out[$ref] = 'host';        }        foreach ($cache->vms() as $ref => $vm) {            $out[$ref] = 'vm';        }        foreach ($cache->templates() as $ref => $t) {            if (empty($t['is_default_template'])) {                $out[$ref] = 'vm';            }        }        foreach ($cache->srs() as $ref => $sr) {            $out[$ref] = 'sr';        }        foreach ($cache->networks() as $ref => $n) {            $out[$ref] = 'network';        }        return $out;    }    public static function folderOf(Cache $cache, string $ref): string    {        return self::normalize((string)($cache->field($ref, 'other_config', [])[self::FOLDER_KEY] ?? ''));    }    /** @return list<string> */    public static function tagsOf(Cache $cache, string $ref): array    {        return array_values(array_map('strval', (array)$cache->field($ref, 'tags', [])));    }    /** @return list<string> the empty folders kept in the pool */    public static function emptyFolders(Cache $cache): array    {        $pool = $cache->poolRef();        $json = $pool === null ? '' : (string)($cache->field($pool, 'other_config', [])[self::EMPTY_KEY] ?? '');        $list = json_decode($json, true);        return is_array($list) ? array_values(array_filter(array_map(fn ($p) => self::normalize((string)$p), $list), fn ($p) => $p !== '')) : [];    }    /** @return list<string> every folder of the connected pools, with the folders above them, sorted */    public static function folders(Session $session): array    {        $all = [];        foreach ($session->connections as $c) {            if (!$c->isConnected()) {                continue;            }            $paths = self::emptyFolders($c->cache);            foreach (self::objects($c->cache) as $ref => $class) {                $paths[] = self::folderOf($c->cache, $ref);            }            foreach ($paths as $path) {                for (; $path !== ''; $path = self::parent($path)) {                    $all[$path] = true;                }            }        }        $list = array_map('strval', array_keys($all));        usort($list, 'strnatcasecmp');        return $list;    }    /** @return list<string> every tag used in the connected pools, sorted */    public static function allTags(Session $session): array    {        $all = [];        foreach ($session->connections as $c) {            if ($c->isConnected()) {                foreach (self::objects($c->cache) as $ref => $class) {                    foreach (self::tagsOf($c->cache, $ref) as $tag) {                        $all[$tag] = true;                    }                }            }        }        $list = array_map('strval', array_keys($all));        usort($list, 'strnatcasecmp');        return $list;    }    /** @return array<string, string> ref => its tags as one text (the search box matches them) */    public static function tagIndex(Session $session): array    {        $out = [];        foreach ($session->connections as $c) {            if ($c->isConnected()) {                foreach ($c->cache->all('vm') + $c->cache->all('host') + $c->cache->all('sr') + $c->cache->all('network') + $c->cache->all('pool') as $ref => $r) {                    if (!empty($r['tags'])) {                        $out[(string)$ref] = implode(' ', (array)$r['tags']);                    }                }            }        }        return $out;    }    /** "a, b ,,c" -> ['a', 'b', 'c'] @return list<string> */    public static function parseTags(string $text): array    {        return array_values(array_unique(array_filter(array_map('trim', explode(',', $text)), fn (string $t) => $t !== '')));    }    // changes (XAPI calls) ----------------------------------------------------------------------------------------------------    /** Puts an object in a folder ('' = the root). */    public static function setFolder(Connection $c, string $ref, string $folder): Task    {        $class = self::xapiClass((string)$c->cache->classOf($ref));        $folder = self::normalize($folder);        $steps = [fn () => $c->call("$class.remove_from_other_config", $ref, self::FOLDER_KEY)];        if ($folder !== '') {            $steps[] = fn () => $c->call("$class.add_to_other_config", $ref, self::FOLDER_KEY, $folder);        }        return Chain::run($steps);    }    /** The tags of an object become $tags (what is missing is added, what is too much removed). @param list<string> $tags */    public static function setTags(Connection $c, string $ref, array $tags): Task    {        $class = self::xapiClass((string)$c->cache->classOf($ref));        $now = self::tagsOf($c->cache, $ref);        $steps = [];        foreach (array_diff($now, $tags) as $tag) {            $steps[] = fn () => $c->call("$class.remove_tags", $ref, $tag);        }        foreach (array_diff($tags, $now) as $tag) {            $steps[] = fn () => $c->call("$class.add_tags", $ref, $tag);        }        return Chain::run($steps);    }    /** The pool's list of empty folders. @param list<string> $paths */    private static function writeEmpty(Connection $c, array $paths): Task    {        $pool = $c->cache->poolRef();        if ($pool === null) {            return Chain::run([]);        }        $paths = array_values(array_unique($paths));        sort($paths);        $steps = [fn () => $c->call('pool.remove_from_other_config', $pool, self::EMPTY_KEY)];        if ($paths !== []) {            $steps[] = fn () => $c->call('pool.add_to_other_config', $pool, self::EMPTY_KEY, (string)json_encode($paths, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));        }        return Chain::run($steps);    }    /** New Folder: kept empty in the pool until an object goes in. */    public static function newFolder(Connection $c, string $path): Task    {        $path = self::normalize($path);        return $path === '' ? Chain::run([]) : self::writeEmpty($c, [...self::emptyFolders($c->cache), $path]);    }    /** Rename (or move) a folder: the folder key of every object in it (and below) is rewritten, on every connected pool. */    public static function renameFolder(Session $session, string $old, string $new): void    {        $old = self::normalize($old);        $new = self::normalize($new);        if ($old === '' || $new === '' || $old === $new || self::inside($new, $old)) {            return;        }        $move = fn (string $path) => self::inside($path, $old) ? $new . substr($path, strlen($old)) : $path;        foreach ($session->connections as $c) {            if (!$c->isConnected()) {                continue;            }            foreach (self::objects($c->cache) as $ref => $class) {                $folder = self::folderOf($c->cache, $ref);                if (self::inside($folder, $old)) {                    self::setFolder($c, $ref, $move($folder))->then(fn () => null, fn () => null);                }            }            $empty = self::emptyFolders($c->cache);            if (array_filter($empty, fn ($p) => self::inside($p, $old)) !== []) {                self::writeEmpty($c, array_map($move, $empty))->then(fn () => null, fn () => null);            }        }    }    /** Delete a folder: its objects (and those of its subfolders) go back to the root; the empty folders below it go. */    public static function deleteFolder(Session $session, string $path): void    {        $path = self::normalize($path);        foreach ($session->connections as $c) {            if (!$c->isConnected() || $path === '') {                continue;            }            foreach (self::objects($c->cache) as $ref => $class) {                if (self::inside(self::folderOf($c->cache, $ref), $path)) {                    self::setFolder($c, $ref, '')->then(fn () => null, fn () => null);                }            }            $empty = self::emptyFolders($c->cache);            $kept = array_values(array_filter($empty, fn ($p) => !self::inside($p, $path)));            if ($kept !== $empty) {                self::writeEmpty($c, $kept)->then(fn () => null, fn () => null);            }        }    }    /** Rename a tag on every object of every connected pool ('' = delete it). */    public static function renameTag(Session $session, string $old, string $new): void    {        $new = trim($new);        foreach ($session->connections as $c) {            if (!$c->isConnected()) {                continue;            }            foreach (self::objects($c->cache) as $ref => $class) {                $tags = self::tagsOf($c->cache, $ref);                if (in_array($old, $tags, true)) {                    $tags = array_values(array_diff($tags, [$old]));                    if ($new !== '' && !in_array($new, $tags, true)) {                        $tags[] = $new;                    }                    self::setTags($c, $ref, $tags)->then(fn () => null, fn () => null);                }            }        }    }}