<?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); } } } }}