import crypto from "crypto"; const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const; const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const; function isPlainObject(value: any) { return !!value && typeof value === "object" && !Array.isArray(value); } function isStructuralMenuNode(value: any) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; return MENU_NODE_VALUE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key)); } function cloneMenuValue(value: T): T { const cloneFn = (globalThis as any).structuredClone; if (typeof cloneFn === "function") { return cloneFn(value); } return JSON.parse(JSON.stringify(value)); } function getChildrenArray(node: any, isRoot?: boolean): any[] { if (Array.isArray(node.columns)) return node.columns; if (Array.isArray(node.links)) return node.links; if (Array.isArray(node.items)) return node.items; if (Array.isArray(node.children)) return node.children; if (isRoot) { node.columns = []; return node.columns; } node.links = []; return node.links; } function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any; parent: any; list: any[]; index: number } | null { for (let i = 0; i < tree.length; i++) { const item = tree[i]; if (item && typeof item === "object") { if (item.nodeId === targetId) { return { node: item, parent, list: tree, index: i }; } for (const key of Object.keys(item)) { if (Array.isArray(item[key])) { const res = findNodeAndParent(item[key], targetId, item, item[key]); if (res) return res; } } } } return null; } function collectMenuLinksFromTree(tree: any): Array<{ url: string; label?: any; source: "payload" }> { const links: Array<{ url: string; label?: any; source: "payload" }> = []; const visited = new WeakSet(); const walk = (value: any) => { if (Array.isArray(value)) { value.forEach(walk); return; } if (!value || typeof value !== "object") return; if (visited.has(value)) return; visited.add(value); if (!isStructuralMenuNode(value)) return; if (typeof value.url === "string" && value.url.trim()) { links.push({ url: value.url, label: value.label ?? value.title, source: "payload" }); } for (const key of MENU_NODE_ARRAY_KEYS) { if (Array.isArray(value[key])) { walk(value[key]); } } }; walk(tree); return links; } function listStructuralKeys(node: any) { return MENU_NODE_ARRAY_KEYS.filter((key) => Array.isArray(node?.[key])); } function describeMenuNode(node: any) { if (!isPlainObject(node)) return "invalid node"; if (typeof node.url === "string" && node.url.trim()) return `link ${node.url}`; const structuralKeys = listStructuralKeys(node); if (structuralKeys.includes("columns")) return "column container"; if (structuralKeys.length > 0) return `container (${structuralKeys.join(", ")})`; return "menu node"; } function validateMenuNodeData(nodeData: any, parentId?: string | null) { const issues: string[] = []; const warnings: string[] = []; if (!isPlainObject(nodeData)) { issues.push("nodeData must be a plain object"); return { issues, warnings }; } const hasUrl = typeof nodeData.url === "string" && nodeData.url.trim(); const hasLabel = nodeData.label !== undefined || nodeData.title !== undefined; const structuralKeys = listStructuralKeys(nodeData); for (const key of ["label", "title"] as const) { const value = nodeData[key]; if (value !== undefined && !isPlainObject(value) && typeof value !== "string") { issues.push(`${key} must be a string or localized object`); } } if (nodeData.url !== undefined && typeof nodeData.url !== "string") { issues.push("url must be a string when provided"); } for (const key of MENU_NODE_ARRAY_KEYS) { if (nodeData[key] !== undefined && !Array.isArray(nodeData[key])) { issues.push(`${key} must be an array when provided`); } } if (parentId === "root" && hasUrl && structuralKeys.length === 0) { issues.push("cannot add a leaf link directly under root; create a column container first"); } if (hasUrl && !hasLabel) { warnings.push("link node does not include a label/title yet"); } if (nodeData.translations !== undefined && !isPlainObject(nodeData.translations) && !Array.isArray(nodeData.translations)) { issues.push("translations must be an object or array when provided"); } return { issues, warnings }; } function validateMenuTranslations(translations: any) { const issues: string[] = []; if (!isPlainObject(translations)) { issues.push("translations must be a plain object"); return { issues }; } for (const [key, value] of Object.entries(translations)) { if (typeof value !== "string") { issues.push(`translation for "${key}" must be a string`); } } return { issues }; } function validateMenuItems(items: any) { const issues: string[] = []; const warnings: string[] = []; if (!Array.isArray(items)) { issues.push("items must be an array"); return { issues, warnings }; } items.forEach((item, index) => { if (!isPlainObject(item)) { issues.push(`items[${index}] must be a plain object`); return; } if (typeof item.url !== "string" || !item.url.trim()) { issues.push(`items[${index}].url must be a non-empty string`); } if (typeof item.label !== "string" || !item.label.trim()) { warnings.push(`items[${index}] is missing a human-readable label`); } if (item.order !== undefined && typeof item.order !== "number") { issues.push(`items[${index}].order must be a number when provided`); } if (item.translations !== undefined) { if (!Array.isArray(item.translations)) { issues.push(`items[${index}].translations must be an array when provided`); } else { item.translations.forEach((translation: any, translationIndex: number) => { if (!isPlainObject(translation)) { issues.push(`items[${index}].translations[${translationIndex}] must be a plain object`); return; } if (typeof translation.langCode !== "string" || !translation.langCode.trim()) { issues.push(`items[${index}].translations[${translationIndex}].langCode must be a non-empty string`); } if (typeof translation.label !== "string" || !translation.label.trim()) { issues.push(`items[${index}].translations[${translationIndex}].label must be a non-empty string`); } }); } } }); return { issues, warnings }; } type MenuTreeMutationResult = { payload: any[]; warnings: string[]; nodeId?: string; removedNode?: string; }; const menuSnapshots = new Map(); function ensureMenuPayloadArray(value: any) { return Array.isArray(value) ? value : []; } function validateMenuTreeSnapshot(payload: any[]) { const issues: string[] = []; const warnings: string[] = []; const seenNodeIds = new Set(); const walk = (value: any, path: string) => { if (Array.isArray(value)) { value.forEach((child, index) => walk(child, `${path}[${index}]`)); return; } if (!isPlainObject(value)) return; if (!isStructuralMenuNode(value)) { warnings.push(`${path}: non-structural object present in menu tree`); return; } if (value.nodeId && typeof value.nodeId === "string") { if (seenNodeIds.has(value.nodeId)) { issues.push(`${path}: duplicate nodeId ${value.nodeId}`); } seenNodeIds.add(value.nodeId); } else { warnings.push(`${path}: missing nodeId`); } const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key])); if (!hasChildren && !value.url) { warnings.push(`${path}: container node has no children and no url`); } for (const key of MENU_NODE_ARRAY_KEYS) { if (Array.isArray(value[key])) { walk(value[key], `${path}.${key}`); } } }; walk(payload, "payload"); return { issues, warnings }; } function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult { const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); const nextNode = cloneMenuValue(nodeData); const warnings: string[] = []; nextNode.nodeId = crypto.randomUUID(); if (nextNode.translations) { const labelKey = nextNode.title !== undefined ? "title" : "label"; if (typeof nextNode[labelKey] === "string") { nextNode[labelKey] = { en: nextNode[labelKey] }; } nextNode[labelKey] = { ...(nextNode[labelKey] || {}), ...nextNode.translations }; delete nextNode.translations; } if (!parentId || parentId === "root") { payload.push(nextNode); } else { const found = findNodeAndParent(payload, parentId); if (!found) { throw new Error(`Parent node ${parentId} not found`); } const children = getChildrenArray(found.node, found.parent === null); children.push(nextNode); } return { payload, warnings, nodeId: nextNode.nodeId }; } function applyMenuUpdateTranslation(tree: any[], nodeId: string, translations: any): MenuTreeMutationResult { const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); const found = findNodeAndParent(payload, nodeId); if (!found) throw new Error(`Node ${nodeId} not found`); const labelKey = found.node.title !== undefined ? "title" : "label"; found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations }; return { payload, warnings: [], nodeId }; } function applyMenuMoveNode(tree: any[], nodeId: string, newParentId?: string, orderIndex?: number): MenuTreeMutationResult { const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); const found = findNodeAndParent(payload, nodeId); if (!found) throw new Error(`Node ${nodeId} not found`); found.list.splice(found.index, 1); let targetList = payload; if (newParentId && newParentId !== "root") { const newParent = findNodeAndParent(payload, newParentId); if (!newParent) throw new Error(`New parent ${newParentId} not found`); targetList = getChildrenArray(newParent.node); } if (typeof orderIndex === "number" && orderIndex >= 0) { targetList.splice(orderIndex, 0, found.node); } else { targetList.push(found.node); } return { payload, warnings: [], nodeId }; } function applyMenuRemoveNode(tree: any[], nodeId: string): MenuTreeMutationResult { const payload = cloneMenuValue(ensureMenuPayloadArray(tree)); const found = findNodeAndParent(payload, nodeId); if (!found) throw new Error(`Node ${nodeId} not found`); found.list.splice(found.index, 1); return { payload, warnings: [], removedNode: describeMenuNode(found.node) }; } function normalizeMenuPayloadForWrite(payload: any) { const cloned = cloneMenuValue(ensureMenuPayloadArray(payload)); let modified = false; const walk = (value: any) => { if (Array.isArray(value)) { value.forEach(walk); return; } if (!isPlainObject(value)) return; if (!isStructuralMenuNode(value)) { if (Object.prototype.hasOwnProperty.call(value, "nodeId")) { delete value.nodeId; modified = true; } return; } if (!value.nodeId) { value.nodeId = crypto.randomUUID(); modified = true; } for (const key of MENU_NODE_ARRAY_KEYS) { if (Array.isArray(value[key])) { walk(value[key]); } } }; walk(cloned); return { payload: cloned, modified }; } function createMenuSnapshot(menu: any) { const snapshotId = crypto.randomUUID(); const payload = cloneMenuValue(ensureMenuPayloadArray(menu?.payload)); const items = cloneMenuValue(Array.isArray(menu?.items) ? menu.items : []); const snapshot = { name: menu?.name || "main", payload, items, isActive: menu?.isActive, createdAt: new Date().toISOString(), }; menuSnapshots.set(snapshotId, snapshot); return { snapshotId, ...snapshot }; } function restoreMenuSnapshot(snapshotId: string) { const snapshot = menuSnapshots.get(snapshotId); if (!snapshot) { throw new Error(`Snapshot ${snapshotId} not found`); } return cloneMenuValue(snapshot); } function applyBulkMenuOperations(tree: any[], operations: any[]) { let payload = cloneMenuValue(ensureMenuPayloadArray(tree)); const warnings: string[] = []; const applied: any[] = []; for (const [index, operation] of operations.entries()) { if (!isPlainObject(operation)) { throw new Error(`operations[${index}] must be a plain object`); } let result: MenuTreeMutationResult; switch (operation.type) { case "add_node": { const validation = validateMenuNodeData(operation.nodeData, operation.parentId); if (validation.issues.length > 0) { throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`); } result = applyMenuAddNode(payload, operation.parentId, operation.nodeData); warnings.push(...validation.warnings); applied.push({ type: operation.type, nodeId: result.nodeId }); break; } case "update_translation": { const validation = validateMenuTranslations(operation.translations); if (validation.issues.length > 0) { throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`); } if (typeof operation.nodeId !== "string" || !operation.nodeId) { throw new Error(`operations[${index}].nodeId must be a non-empty string`); } result = applyMenuUpdateTranslation(payload, operation.nodeId, operation.translations); warnings.push(...validation.warnings); applied.push({ type: operation.type, nodeId: result.nodeId }); break; } case "move_node": { if (typeof operation.nodeId !== "string" || !operation.nodeId) { throw new Error(`operations[${index}].nodeId must be a non-empty string`); } result = applyMenuMoveNode(payload, operation.nodeId, operation.newParentId, operation.orderIndex); applied.push({ type: operation.type, nodeId: result.nodeId }); break; } case "remove_node": { if (typeof operation.nodeId !== "string" || !operation.nodeId) { throw new Error(`operations[${index}].nodeId must be a non-empty string`); } result = applyMenuRemoveNode(payload, operation.nodeId); applied.push({ type: operation.type, removedNode: result.removedNode }); break; } default: throw new Error(`operations[${index}].type must be one of add_node, update_translation, move_node, remove_node`); } payload = result.payload; warnings.push(...result.warnings); } const normalized = normalizeMenuPayloadForWrite(payload); return { payload: normalized.payload, warnings, applied, normalized }; } export { MENU_NODE_ARRAY_KEYS, MENU_NODE_VALUE_KEYS, type MenuTreeMutationResult, applyBulkMenuOperations, applyMenuAddNode, applyMenuMoveNode, applyMenuRemoveNode, applyMenuUpdateTranslation, cloneMenuValue, collectMenuLinksFromTree, createMenuSnapshot, describeMenuNode, ensureMenuPayloadArray, findNodeAndParent, getChildrenArray, isPlainObject, isStructuralMenuNode, normalizeMenuPayloadForWrite, restoreMenuSnapshot, validateMenuItems, validateMenuNodeData, validateMenuTranslations, validateMenuTreeSnapshot, };