Files
sori.studio/server/utils/navigation-tree.js

231 lines
5.7 KiB
JavaScript

import { buildNavigationEditorTree } from '../../lib/navigation-editor-tree.js'
/**
* 네비게이션 항목 배열이 유효한지 검증한다.
* @param {Array<Object>} 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<Object>} items - 네비게이션 항목
* @returns {Array<Object>} 정렬된 동일 배열의 얕은 복사
*/
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<string>} 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<Object>} 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 트리(순환 참조 없음).
* @param {Array<Object>} flatPrimary - location primary인 항목만
* @returns {Array<Object>}
*/
export const buildPublicPrimaryTree = (flatPrimary) => {
const list = flatPrimary.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 roots = []
for (const row of list) {
const id = String(row.id)
const node = byId.get(id)
const p = row.parentId
if (p && byId.has(String(p))) {
byId.get(String(p)).children.push(node)
} else {
roots.push(node)
}
}
/**
* @param {Array<any>} 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)
}