import { z } from 'zod'; import { buildTree, nodeDepth } from './tree'; /** * Label schemas, views, and hierarchy helpers shared between api and web * (issue #43). Labels organize pages within a pond and form a tree via * `parentId` (data-model.md §labels). They later scope permissions (M5): * a grant on a label applies to it and all its descendants, so the tree * helpers below are the single implementation both the label API and the * permission resolver (permissions.md §resolution) build on. */ /** * Maximum nesting depth of the label tree, counted as levels: a root label is * level 1, its child level 2, and so on. Moving or creating a label whose * deepest node would exceed this is rejected (issue #43 acceptance criteria). */ export const MAX_LABEL_DEPTH = 6; /** A hex colour like `#a1b2c3`; the picker in #44 constrains the input further. */ export const labelColorSchema = z .string() .trim() .regex(/^#[0-9a-fA-F]{6}$/, 'validation.labelColor') .transform((value) => value.toLowerCase()); /** Default colour for labels created without an explicit one (a neutral slate). */ export const DEFAULT_LABEL_COLOR = '#64748b'; export const labelNameSchema = z .string() .trim() .min(1, 'validation.required') .max(60, 'validation.tooLong'); export const createLabelInputSchema = z.object({ name: labelNameSchema, color: labelColorSchema.optional(), /** Parent label id for a nested label; omitted/null creates a root label. */ parentId: z.string().min(1).nullish(), }); export type CreateLabelInput = z.infer; /** Rename and/or recolour a label; both fields optional (at least one is used). */ export const updateLabelInputSchema = z .object({ name: labelNameSchema, color: labelColorSchema, }) .partial(); export type UpdateLabelInput = z.infer; /** Move a label to a new parent (or to the root with `null`). */ export const moveLabelInputSchema = z.object({ parentId: z.string().min(1).nullable(), }); export type MoveLabelInput = z.infer; /** Assign a label to a page. */ export const assignLabelInputSchema = z.object({ labelId: z.string().min(1), }); export type AssignLabelInput = z.infer; /** A single label as the api returns it (flat; the tree is built from these). */ export interface LabelView { id: string; pondId: string; parentId: string | null; name: string; color: string; createdAt: string; updatedAt: string; } /** A label with its children nested — the shape `GET /ponds/:id/labels` returns. */ export interface LabelTreeNode extends LabelView { children: LabelTreeNode[]; } /** Sorts siblings by name, case-insensitively, for a stable tree/picker order. */ function byName(a: LabelView, b: LabelView): number { return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); } /** * Nests a flat list of a pond's labels into a forest of {@link LabelTreeNode}s, * siblings sorted by name. A label whose `parentId` is not present in the list * (should not happen within one pond) is treated as a root, so no rows are ever * dropped. Reused by the sidebar tree, label pickers, and the permission * resolver, which all need the same hierarchy. */ export function buildLabelTree(labels: LabelView[]): LabelTreeNode[] { return buildTree(labels, byName); } /** * Flattens a label tree back into a depth-first list of {@link LabelView}s, in * the same sibling order {@link buildLabelTree} produced. Handy where a flat * list is needed again (chip lookup, subtree filtering, an indented picker) * after fetching the tree from `GET /ponds/:id/labels`. */ export function flattenLabelTree(nodes: LabelTreeNode[]): LabelView[] { const flat: LabelView[] = []; const walk = (node: LabelTreeNode): void => { const { children: _children, ...view } = node; flat.push(view); for (const child of node.children) walk(child); }; for (const node of nodes) walk(node); return flat; } /** * The subtree/ancestor/depth walks moved to the generic tree helpers (issue * #106) — labels and pages share one implementation. Re-exported here so the * label API keeps its established import surface; `labelDepth` keeps its name * because permission resolution documents it (permissions.md §label scope). */ export { collectAncestorIds, collectSubtreeIds, subtreeHeight } from './tree'; /** * Depth of a label as a 1-based level (a root label is 1). Derived from the * ancestor chain, so it is bounded even if the input is malformed. */ export function labelDepth(labels: LabelView[], labelId: string): number { return nodeDepth(labels, labelId); }