import { buildNavigationEditorTree } from '../../lib/navigation-editor-tree.js' /** * 네비게이션 항목 배열이 유효한지 검증한다. * @param {Array} items - { id, label, url, location, sortOrder, isVisible, isFolder, parentId } * @returns {{ ok: true } | { ok: false, message: string }} */ export const validateNavigationItems = (items) => { if (!Array.isArray(items) || items.length === 0) { return { ok: true } } const idSet = new Set() for (const item of items) { const id = String(item.id || '').trim() if (!id) { return { ok: false, message: '네비게이션 항목 id가 비어 있습니다.' } } if (idSet.has(id)) { return { ok: false, message: '네비게이션 항목 id가 중복되었습니다.' } } idSet.add(id) } const byId = new Map(items.map((i) => [String(i.id).trim(), i])) for (const item of items) { const id = String(item.id).trim() const loc = item.location if (loc === 'footer') { const p = item.parentId if (p != null && String(p).trim() !== '') { return { ok: false, message: '하단 네비게이션에는 하위 항목을 둘 수 없습니다.' } } } const p = item.parentId if (p != null && String(p).trim() !== '') { const pid = String(p).trim() if (!byId.has(pid)) { return { ok: false, message: '상위 메뉴 참조가 잘못되었습니다.' } } const parent = byId.get(pid) if (parent.location !== loc) { return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단)에 있어야 합니다.' } } } } const maxDepth = 12 for (const item of items) { const path = new Set() let cur = item let depth = 0 while (cur) { const cid = String(cur.id).trim() if (path.has(cid)) { return { ok: false, message: '메뉴 상위 참조에 순환이 있습니다.' } } path.add(cid) const p = cur.parentId if (p == null || String(p).trim() === '') { break } const pid = String(p).trim() cur = byId.get(pid) depth += 1 if (depth > maxDepth) { return { ok: false, message: '메뉴 계층이 너무 깊습니다.' } } if (!cur) { break } } } return { ok: true } } /** * 부모가 항상 자식보다 먼저 오도록 삽입 순서를 정한다. * @param {Array} items - 네비게이션 항목 * @returns {Array} 정렬된 동일 배열의 얕은 복사 */ export const orderNavigationItemsForInsert = (items) => { const byId = new Map(items.map((i) => [String(i.id).trim(), i])) const result = [] const placed = new Set() /** * @param {Object} item - 항목 * @param {Set} stack - 순환 방지 * @returns {void} */ const place = (item, stack) => { const id = String(item.id).trim() if (placed.has(id)) { return } const p = item.parentId if (p != null && String(p).trim() !== '') { const pid = String(p).trim() if (byId.has(pid)) { if (stack.has(pid)) { return } stack.add(pid) place(byId.get(pid), stack) stack.delete(pid) } } if (!placed.has(id)) { result.push(item) placed.add(id) } } for (const item of items) { place(item, new Set()) } if (result.length !== items.length) { for (const item of items) { const id = String(item.id).trim() if (!placed.has(id)) { result.push(item) placed.add(id) } } } return result } /** * location 기준 DFS로 sort_order를 10단위로 다시 부여한다. * @param {Array} items - 전체 항목(변경됨) * @param {'primary'|'footer'} location - 위치 * @returns {void} */ export const renumberSortOrderByTree = (items, location) => { const tree = buildNavigationEditorTree(items, location) let n = 0 /** * @param {Array<{ item: Object, children: any[] }>} nodes - 노드 * @returns {void} */ const walk = (nodes) => { for (const { item, children } of nodes) { n += 1 item.sortOrder = n * 10 if (children.length) { walk(children) } } } walk(tree) } /** * 공개 API용 primary 트리(순환 참조 없음). * 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면 * 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다. * @param {Array} flatPrimary - location primary인 항목만 * @returns {Array} */ export const buildPublicPrimaryTree = (flatPrimary) => { const sorted = [...(flatPrimary || [])].sort((a, b) => { const sa = a.sortOrder || 0 const sb = b.sortOrder || 0 if (sa !== sb) { return sa - sb } const la = String(a.label || '') const lb = String(b.label || '') if (la !== lb) { return la.localeCompare(lb) } return String(a.id).localeCompare(String(b.id)) }) const idSeen = new Set() const deduped = [] for (const row of sorted) { const id = String(row.id) if (idSeen.has(id)) { continue } idSeen.add(id) deduped.push(row) } const list = deduped.map((row) => ({ id: row.id, label: row.label, url: row.url, isFolder: Boolean(row.isFolder), isVisible: row.isVisible !== false, sortOrder: row.sortOrder || 0, parentId: row.parentId || null })) const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }])) const attachedAsChild = new Set() for (const row of list) { const id = String(row.id) const node = byId.get(id) const p = row.parentId const pid = p != null && String(p).trim() !== '' ? String(p).trim() : '' if (pid && pid !== id && byId.has(pid)) { byId.get(pid).children.push(node) attachedAsChild.add(id) } } const roots = [] for (const row of list) { const id = String(row.id) if (!attachedAsChild.has(id)) { roots.push(byId.get(id)) } } /** * @param {Array} nodes - 노드 * @returns {void} */ const sortNodes = (nodes) => { nodes.sort((a, b) => a.sortOrder - b.sortOrder) for (const node of nodes) { if (node.children.length) { sortNodes(node.children) } } } sortNodes(roots) /** * @param {any} node - 노드 * @returns {Object} */ const strip = (node) => { const base = { id: node.id, label: node.label, url: node.url, isFolder: node.isFolder, isVisible: node.isVisible } if (node.children.length) { return { ...base, children: node.children.map(strip) } } return base } return roots.map(strip) }