481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
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<T>(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<object>();
|
|
|
|
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[] = [];
|
|
const warnings: string[] = [];
|
|
if (!isPlainObject(translations)) {
|
|
issues.push("translations must be a plain object");
|
|
return { issues, warnings };
|
|
}
|
|
for (const [key, value] of Object.entries(translations)) {
|
|
if (typeof value !== "string") {
|
|
issues.push(`translation for "${key}" must be a string`);
|
|
}
|
|
}
|
|
return { issues, warnings };
|
|
}
|
|
|
|
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<string, { name: string; payload: any[]; items: any[]; isActive?: boolean; createdAt: string }>();
|
|
|
|
function ensureMenuPayloadArray(value: any) {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function validateMenuTreeSnapshot(payload: any[]) {
|
|
const issues: string[] = [];
|
|
const warnings: string[] = [];
|
|
const seenNodeIds = new Set<string>();
|
|
|
|
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,
|
|
};
|