import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types' export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped' function createProjectGroupId(): string { const randomUUID = globalThis.crypto?.randomUUID if (randomUUID) { return randomUUID.call(globalThis.crypto) } return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}` } export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string { const trimmed = name.trim() return trimmed.length > 0 ? trimmed : fallback } export function createProjectGroup(input: { name: string parentPath?: string | null parentGroupId?: string | null createdFrom: ProjectGroupCreatedFrom tabOrder: number now?: number }): ProjectGroup { const now = input.now ?? Date.now() return { id: createProjectGroupId(), name: normalizeProjectGroupName(input.name), parentPath: input.parentPath ?? null, parentGroupId: input.parentGroupId ?? null, createdFrom: input.createdFrom, tabOrder: input.tabOrder, isCollapsed: false, color: null, createdAt: now, updatedAt: now } } export function normalizeProjectGroups(value: unknown): ProjectGroup[] { if (!Array.isArray(value)) { return [] } const groups: ProjectGroup[] = [] const seen = new Set() for (const candidate of value) { if (!candidate || typeof candidate !== 'object') { continue } const raw = candidate as Partial if (typeof raw.id !== 'string' || seen.has(raw.id)) { continue } seen.add(raw.id) const now = Date.now() groups.push({ id: raw.id, name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''), parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null, parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null, createdFrom: raw.createdFrom === 'manual' || raw.createdFrom === 'folder-scan' || raw.createdFrom === 'migration' ? raw.createdFrom : 'manual', tabOrder: typeof raw.tabOrder === 'number' && Number.isFinite(raw.tabOrder) ? raw.tabOrder : 0, isCollapsed: raw.isCollapsed === true, color: typeof raw.color === 'string' ? raw.color : null, createdAt: typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now, updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now }) } groups.sort( (left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name) ) const groupIds = new Set(groups.map((group) => group.id)) for (const group of groups) { if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) { group.parentGroupId = null } } return groups } export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] { const groupIds = new Set(groups.map((group) => group.id)) return repos.map((repo) => repo.projectGroupId && !groupIds.has(repo.projectGroupId) ? { ...repo, projectGroupId: null } : repo ) } export function getProjectGroupSubtreeIds( groups: readonly Pick[], rootGroupId: string ): Set { const childGroupsByParentId = new Map() for (const group of groups) { if (!group.parentGroupId) { continue } const children = childGroupsByParentId.get(group.parentGroupId) ?? [] children.push(group.id) childGroupsByParentId.set(group.parentGroupId, children) } const subtreeIds = new Set() const pending = [rootGroupId] while (pending.length > 0) { const groupId = pending.pop()! if (subtreeIds.has(groupId)) { continue } subtreeIds.add(groupId) // Why: imported project-group trees can be very wide; `push(...children)` // can exceed V8's argument limit while collecting descendants. for (const childGroupId of childGroupsByParentId.get(groupId) ?? []) { pending.push(childGroupId) } } return subtreeIds } export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number { let max = -1 for (const repo of repos) { if ((repo.projectGroupId ?? null) !== groupId) { continue } const order = repo.projectGroupOrder if (typeof order === 'number' && Number.isFinite(order)) { max = Math.max(max, order) } } return max + 1 }