/** * Generic parent-id tree helpers (issue #106). Labels (issue #43) and pages * (issue #106) both form a forest via a nullable `parentId`; these helpers are * the single implementation of the subtree/ancestor/depth walks both trees * share. All of them are robust against malformed input (missing parents, * cycles): every node is visited at most once, so they always terminate. */ /** The minimal shape a tree helper needs: an id and a nullable parent id. */ export interface TreeItem { id: string; parentId: string | null; } /** A tree item with its children nested, as {@link buildTree} produces it. */ export type TreeNode = T & { children: TreeNode[] }; /** * Nests a flat list into a forest of {@link TreeNode}s. An item whose * `parentId` is not present in the list is treated as a root, so no rows are * ever dropped — that is also the deliberate fallback for a child whose parent * the server hid from the caller (issue #106 permission rule). Siblings keep * the input order unless a `compare` function is given; the page sidebar * relies on input order because the list arrives in the pond's sort order. */ export function buildTree( items: T[], compare?: (a: T, b: T) => number, ): TreeNode[] { const nodes = new Map>(); for (const item of items) nodes.set(item.id, { ...item, children: [] }); const roots: TreeNode[] = []; for (const item of items) { const node = nodes.get(item.id)!; const parent = item.parentId ? nodes.get(item.parentId) : undefined; if (parent && parent !== node) parent.children.push(node); else roots.push(node); } if (compare) { for (const node of nodes.values()) node.children.sort(compare); roots.sort(compare); } return roots; } /** Indexes items by id → parentId for the ancestor walks below. */ function parentIndex(items: TreeItem[]): Map { const index = new Map(); for (const item of items) index.set(item.id, item.parentId); return index; } /** * Ids of an item and all its descendants (its whole subtree). Used to reject a * move that would create a cycle (the new parent may not be inside the moved * subtree) and to gather what a subtree-wide operation covers. */ export function collectSubtreeIds(items: TreeItem[], rootId: string): Set { const childrenOf = new Map(); for (const item of items) { if (!item.parentId) continue; const siblings = childrenOf.get(item.parentId) ?? []; siblings.push(item.id); childrenOf.set(item.parentId, siblings); } const subtree = new Set(); const stack = [rootId]; while (stack.length > 0) { const id = stack.pop()!; if (subtree.has(id)) continue; subtree.add(id); for (const child of childrenOf.get(id) ?? []) stack.push(child); } return subtree; } /** * Ancestor ids of an item, nearest first (its parent, grandparent, …). Stops * on a missing parent or a cycle. */ export function collectAncestorIds(items: TreeItem[], itemId: string): string[] { const parents = parentIndex(items); const ancestors: string[] = []; const seen = new Set([itemId]); let current = parents.get(itemId) ?? null; while (current && !seen.has(current)) { ancestors.push(current); seen.add(current); current = parents.get(current) ?? null; } return ancestors; } /** * Depth of an item as a 1-based level (a root is 1). Derived from the ancestor * chain, so it is bounded even if the input is malformed. */ export function nodeDepth(items: TreeItem[], itemId: string): number { return collectAncestorIds(items, itemId).length + 1; } /** * Height of an item's subtree in levels (a leaf is 1, an item with children 2, * …). Combined with a target parent's depth it tells us whether a move keeps * the whole subtree within the depth limit. */ export function subtreeHeight(items: TreeItem[], rootId: string): number { const childrenOf = new Map(); for (const item of items) { if (!item.parentId) continue; const siblings = childrenOf.get(item.parentId) ?? []; siblings.push(item); childrenOf.set(item.parentId, siblings); } const heightFrom = (id: string, seen: Set): number => { if (seen.has(id)) return 0; seen.add(id); const children = childrenOf.get(id) ?? []; let max = 0; for (const child of children) max = Math.max(max, heightFrom(child.id, seen)); return max + 1; }; return heightFrom(rootId, new Set()); }