/** * Pure category tree helper functions. * These operate on plain category maps and have no side-effects. */ export const ROOT_CATEGORY_NAME = 'Home'; /** * @param {object} item * @returns {object} */ export function normalizeCategoryNode(item) { return { ...item, id: item.id, parentId: item.parentId, name: item.name == null ? null : String(item.name).trim() || null, posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [], }; } /** * Parse the raw GET /api/category/tree response body into normalized state. * @param {object} data * @param {number|string|null} companyRootCategoryId */ export function parseCategoryTreeResponse(data, companyRootCategoryId) { const list = Array.isArray(data.tree) ? data.tree : []; const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId; const categories = Object.fromEntries( list.map((item) => [String(item.id), normalizeCategoryNode(item)]), ); return { rootCategoryId, categories, languageId: data.languageId, }; } /** * Display name for a category node (root → "Home"). */ export function categoryDisplayName(name, categoryId, rootCategoryId) { if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { return ROOT_CATEGORY_NAME; } return name?.trim() || ''; } /** * URL slug for a category node (root → "Home"). */ export function categorySlug(node, rootCategoryId) { if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) { return ROOT_CATEGORY_NAME; } return node.slug || null; } /** * Direct children of `parentId`, sorted by `sort` then `id`. */ export function getCategoryChildren(categories, parentId) { if (!categories) return []; const parentKey = String(parentId); return Object.values(categories) .filter((item) => item.parentId != null && String(item.parentId) === parentKey) .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); } /** * Breadcrumb trail from root to `currentCategoryId`. */ export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) { if (!categories || currentCategoryId == null) return []; const crumbs = []; const seen = new Set(); let id = currentCategoryId; while (id != null && !seen.has(String(id))) { seen.add(String(id)); const node = categories[String(id)]; if (!node) break; crumbs.unshift({ id: node.id, name: categoryDisplayName(node.name, node.id, rootCategoryId), slug: categorySlug(node, rootCategoryId), }); const parentId = node.parentId; if (parentId == null || parentId === 0) break; if (String(parentId) === String(id)) break; id = parentId; } return crumbs; } /** * Map of category.id → category node from a raw tree response. * @param {object} data * @param {number|string|null} companyRootCategoryId */ export function normalizeCategoryMap(data, companyRootCategoryId) { return parseCategoryTreeResponse(data, companyRootCategoryId).categories; } /** * Returns an array containing the category id and all of its recursive descendant ids. * Useful for preventing cyclic moves. * @param {object} categories - id -> node map * @param {number|string} categoryId - Target category id * @returns {number[]} Array of category ids */ export function getCategoryDescendantIds(categories, categoryId) { const result = []; if (!categories || categoryId == null) return result; const numId = Number(categoryId); result.push(numId); const queue = [numId]; while (queue.length > 0) { const currentId = queue.shift(); const children = getCategoryChildren(categories, currentId); for (const child of children) { result.push(Number(child.id)); queue.push(Number(child.id)); } } return result; } /** * Normalizes a category slug path by stripping leading/trailing slashes. * @param {string|null|undefined} slugPath * @returns {string} */ export function cleanCategorySlugPath(slugPath) { return (slugPath ?? '').replace(/^\/+|\/+$/g, ''); } /** * Resolves a numeric/string category ID from a URL slug path using the category tree. * @param {object} categories - id -> node map * @param {number|string|null} rootCategoryId * @param {string|null|undefined} slugPath * @returns {number|string|null} */ export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) { if (!categories || rootCategoryId == null) return null; let segments = (slugPath ?? '') .split('/') .filter(Boolean) .map((segment) => decodeURIComponent(segment)); if (segments[0] === ROOT_CATEGORY_NAME) { segments = segments.slice(1); } if (segments.length === 0) { return rootCategoryId; } let currentId = rootCategoryId; for (const slug of segments) { const children = getCategoryChildren(categories, currentId); const match = children.find( (child) => categorySlug(child, rootCategoryId) === slug, ); if (!match) return null; currentId = match.id; } return currentId; }