workspace map, both stored as JSON * in the current user's metadata (no global state, no schema migration). * * "Default" is not a stored workspace -- it is simply the bucket for any project that is not filed, * so it can never be renamed or removed. The names list holds only the user's custom workspaces. */ class WorkspaceOrgHelper extends Base { const NAMES_KEY = 'workspaceorg_names'; // JSON array of the user's custom workspace names const MAP_KEY = 'workspaceorg_map'; // JSON object { project_id: workspace_name } const DEFAULT_WORKSPACE = 'Default'; const MAX_NAME_LENGTH = 50; /** * Current user id (workspaces are always personal to whoever is logged in). * * @return int */ private function uid() { return (int) $this->userSession->getId(); } /** * The user's custom workspace names, de-duplicated and sorted A-Z (Default not included). * * @return string[] */ public function names() { $raw = $this->userMetadataModel->get($this->uid(), self::NAMES_KEY, ''); $names = $raw === '' ? array() : json_decode($raw, true); if (! is_array($names)) { $names = array(); } $names = array_values(array_unique(array_filter($names, 'is_string'))); natcasesort($names); return array_values($names); } /** * The project_id -> workspace_name map. * * @return array */ public function map() { $raw = $this->userMetadataModel->get($this->uid(), self::MAP_KEY, ''); $map = $raw === '' ? array() : json_decode($raw, true); return is_array($map) ? $map : array(); } /** * The workspace a project is filed under, validated against the current names; falls back to * Default when unassigned or pointing at a deleted workspace. * * @param int $project_id * @return string */ public function workspaceOf($project_id) { $project_id = (int) $project_id; $map = $this->map(); if (isset($map[$project_id]) && in_array($map[$project_id], $this->names(), true)) { return $map[$project_id]; } return self::DEFAULT_WORKSPACE; } /** * Badge text before a project title: the workspace name, or '' for Default (badge hidden). * * @param int $project_id * @return string */ public function badge($project_id) { $workspace = $this->workspaceOf($project_id); return $workspace === self::DEFAULT_WORKSPACE ? '' : $workspace; } /** * The options for a "move to" dropdown: custom workspaces A-Z, then Default last. * * @return string[] */ public function options() { $options = $this->names(); $options[] = self::DEFAULT_WORKSPACE; return $options; } /** * The current user's active projects grouped by workspace, ready for the page: an ordered map * of workspace_name => list of array('id','name'), custom workspaces A-Z then Default last, and * projects sorted A-Z within each. * * @return array */ public function grouped() { $groups = array(); foreach ($this->names() as $name) { $groups[$name] = array(); } $groups[self::DEFAULT_WORKSPACE] = array(); foreach ($this->projectUserRoleModel->getActiveProjectsByUser($this->uid()) as $project_id => $project_name) { $groups[$this->workspaceOf($project_id)][] = array('id' => (int) $project_id, 'name' => $project_name); } foreach ($groups as &$list) { usort($list, function ($a, $b) { return strnatcasecmp($a['name'], $b['name']); }); } unset($list); return $groups; } /** * Create a new (empty) workspace. Rejects blanks, duplicates, and the reserved "Default". * * @param string $name * @return bool */ public function addWorkspace($name) { $name = $this->clean($name); if ($name === '' || $this->isDefault($name) || $this->exists($name)) { return false; } $names = $this->names(); $names[] = $name; $this->saveNames($names); return true; } /** * Rename a workspace and rewrite it across the assignment map. * * @param string $old * @param string $new * @return bool */ public function renameWorkspace($old, $new) { $new = $this->clean($new); if ($new === '' || $this->isDefault($old) || $this->isDefault($new) || ! $this->exists($old)) { return false; } if (strcasecmp($old, $new) !== 0 && $this->exists($new)) { return false; // would collide with a different existing workspace } $names = array(); foreach ($this->names() as $name) { $names[] = $name === $old ? $new : $name; } $this->saveNames($names); $map = $this->map(); foreach ($map as $project_id => $workspace) { if ($workspace === $old) { $map[$project_id] = $new; } } $this->saveMap($map); return true; } /** * Remove a workspace; its projects fall back to Default (dropped from the map). * * @param string $name * @return bool */ public function removeWorkspace($name) { if ($this->isDefault($name) || ! $this->exists($name)) { return false; } $names = array_values(array_filter($this->names(), function ($n) use ($name) { return $n !== $name; })); $this->saveNames($names); $map = $this->map(); foreach ($map as $project_id => $workspace) { if ($workspace === $name) { unset($map[$project_id]); } } $this->saveMap($map); return true; } /** * File a project into a workspace. Default (or any unknown name) means "unassigned" -> removed * from the map. * * @param int $project_id * @param string $workspace */ public function assign($project_id, $workspace) { $project_id = (int) $project_id; $map = $this->map(); if ($this->isDefault($workspace) || ! $this->exists($workspace)) { unset($map[$project_id]); } else { $map[$project_id] = $workspace; } $this->saveMap($map); } private function clean($name) { return trim(mb_substr((string) $name, 0, self::MAX_NAME_LENGTH)); } private function isDefault($name) { return strcasecmp((string) $name, self::DEFAULT_WORKSPACE) === 0; } private function exists($name) { foreach ($this->names() as $existing) { if (strcasecmp($existing, $name) === 0) { return true; } } return false; } private function saveNames(array $names) { natcasesort($names); $this->userMetadataModel->save($this->uid(), array(self::NAMES_KEY => json_encode(array_values($names)))); } private function saveMap(array $map) { $this->userMetadataModel->save($this->uid(), array(self::MAP_KEY => json_encode($map, JSON_FORCE_OBJECT))); } }