items = new NavItemModel(); $this->maps = new RoleNavItemModel(); $this->service = new NavbarService(); } protected function ensureAdmin(): void { $session = session(); $roles = array_filter(array_merge( (array) $session->get('roles'), (array) $session->get('role') )); $normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles); if (!in_array('administrator', $normalizedRoles, true)) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); } } public function index(){ $this->ensureAdmin(); helper(['url', 'form']); return view('nav_builder/index'); } /** Case-insensitive, natural sort; ignores leading punctuation & articles (“a/an/the”) */ private function labelKey(string $s): string { $s = trim(preg_replace('/\s+/', ' ', $s)); $s = preg_replace('/^[^[:alnum:]]+/u', '', $s); // strip leading punctuation $s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); // drop leading articles return mb_strtolower($s, 'UTF-8'); } private function sortTreeAlpha(array &$nodes): void { usort($nodes, fn($a,$b) => strnatcasecmp( $this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '') )); foreach ($nodes as &$n) { if (!empty($n['children'])) { $this->sortTreeAlpha($n['children']); } } unset($n); } public function save() { $this->ensureAdmin(); $id = (int) $this->request->getPost('id'); // Accept either "menu_parent_id" or "parent_id" from the form $menuParentRaw = $this->request->getPost('menu_parent_id'); if ($menuParentRaw === null) { $menuParentRaw = $this->request->getPost('parent_id'); } $menuParentId = ($menuParentRaw === '' || $menuParentRaw === null) ? null : (int) $menuParentRaw; if ($id && $menuParentId === $id) { // item cannot be its own parent $menuParentId = null; } // Validate parent exists (optional but helpful) if ($menuParentId !== null) { $parent = $this->items->select('id')->where('id', $menuParentId)->first(); if (!$parent) { if ($this->wantsJson()) { return $this->response ->setStatusCode(422) ->setJSON([ 'ok' => false, 'message' => 'Selected parent does not exist.', 'csrf' => $this->csrfPayload(), ]); } return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput(); } } $data = [ 'menu_parent_id' => $menuParentId, 'label' => trim((string) $this->request->getPost('label')), 'url' => trim((string) $this->request->getPost('url')) ?: null, 'icon_class' => trim((string) $this->request->getPost('icon_class')) ?: null, 'target' => trim((string) $this->request->getPost('target')) ?: null, 'sort_order' => (int) $this->request->getPost('sort_order'), 'is_enabled' => (int) ($this->request->getPost('is_enabled') ? 1 : 0), ]; // Save (force insert to return ID) if ($id) { $this->items->update($id, $data); } else { $id = (int) $this->items->insert($data, true); // ensure insertID is returned } // Roles $roleIds = array_values(array_unique(array_filter( array_map('intval', (array) ($this->request->getPost('roles') ?? [])), fn ($v) => $v > 0 ))); $this->maps->where('nav_item_id', $id)->delete(); foreach ($roleIds as $rid) { $this->maps->insert(['role_id' => $rid, 'nav_item_id' => $id]); } $this->service->clearCache(); if ($this->wantsJson()) { return $this->response->setJSON([ 'ok' => true, 'message' => 'Menu saved.', 'id' => $id, 'csrf' => $this->csrfPayload(), 'payload' => $this->buildNavPayload(), ]); } return redirect()->back()->with('success', 'Menu saved.'); } private function wantsJson(): bool { return $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json'); } private function csrfPayload(): array { return [ 'name' => csrf_token(), 'hash' => csrf_hash(), ]; } public function delete($id) { $this->ensureAdmin(); $id = (int) $id; $this->items->delete($id); // children handled by FK (SET NULL on parent) $this->service->clearCache(); return redirect()->back()->with('success', 'Menu item deleted.'); } public function reorder() { $this->ensureAdmin(); $structure = $this->request->getPost('structure') ?? []; if (is_array($structure) && !empty($structure)) { $updates = $this->normalizeStructureUpdates($structure); foreach ($updates as $row) { $this->items->update($row['id'], [ 'menu_parent_id' => $row['menu_parent_id'], 'sort_order' => $row['sort_order'], ]); } $this->service->clearCache(); return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]); } // Backward-compatible payload used by the previous builder. $orders = $this->request->getPost('orders') ?? []; foreach ($orders as $id => $order) { $this->items->update((int) $id, ['sort_order' => (int) $order]); } $this->service->clearCache(); return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]); } public function roleAccess() { $this->ensureAdmin(); $action = strtolower(trim((string) $this->request->getPost('action'))); $navItemId = (int) $this->request->getPost('nav_item_id'); $sourceRoleId = (int) $this->request->getPost('source_role_id'); $targetRoleId = (int) $this->request->getPost('target_role_id'); if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) { return $this->response ->setStatusCode(422) ->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]); } if ($action === 'remove') { if (!$this->roleExists($sourceRoleId)) { return $this->response ->setStatusCode(422) ->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]); } $this->maps ->where('nav_item_id', $navItemId) ->where('role_id', $sourceRoleId) ->delete(); } elseif ($action === 'move') { if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) { return $this->response ->setStatusCode(422) ->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]); } if ($sourceRoleId !== $targetRoleId) { $this->maps ->where('nav_item_id', $navItemId) ->where('role_id', $sourceRoleId) ->delete(); $exists = $this->maps ->where('nav_item_id', $navItemId) ->where('role_id', $targetRoleId) ->first(); if (!$exists) { $this->maps->insert([ 'role_id' => $targetRoleId, 'nav_item_id' => $navItemId, ]); } } } else { return $this->response ->setStatusCode(422) ->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]); } $this->service->clearCache(); return $this->response->setJSON([ 'ok' => true, 'message' => 'Role access updated.', 'csrf' => $this->csrfPayload(), 'payload' => $this->buildNavPayload(), ]); } private function roleExists(int $roleId): bool { if ($roleId <= 0) { return false; } $db = \Config\Database::connect(); return (bool) $db->table('roles') ->select('id') ->where('id', $roleId) ->get(1) ->getFirstRow(); } private function normalizeStructureUpdates(array $structure): array { $existingRows = $this->items->select('id, menu_parent_id')->findAll(); $existingIds = array_map('intval', array_column($existingRows, 'id')); $existingIdLookup = array_fill_keys($existingIds, true); $updates = []; foreach ($structure as $row) { if (!is_array($row)) { continue; } $id = (int) ($row['id'] ?? 0); if ($id <= 0 || !isset($existingIdLookup[$id])) { continue; } $parentId = $row['parent_id'] ?? null; $parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId; if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) { $parentId = null; } $updates[] = [ 'id' => $id, 'menu_parent_id' => $parentId, 'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)), ]; } $updatesById = []; foreach ($updates as $row) { $updatesById[$row['id']] = $row; } foreach ($updatesById as $id => &$row) { if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) { $row['menu_parent_id'] = null; } } unset($row); return array_values($updatesById); } private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool { $parentById = []; foreach ($existingRows as $row) { $parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0 ? (int) $row['menu_parent_id'] : null; } foreach ($updatesById as $row) { $parentById[$row['id']] = $row['menu_parent_id']; } $seen = []; $current = $parentId; while ($current !== null) { if ($current === $id) { return true; } if (isset($seen[$current])) { return true; } $seen[$current] = true; $current = $parentById[$current] ?? null; } return false; } protected function distinctRoles(): array { $db = \Config\Database::connect(); return $db->table('roles') ->select('id, name') ->orderBy('name') ->get() ->getResultArray(); } public function data() { $this->ensureAdmin(); return $this->response->setJSON($this->buildNavPayload()); } private function buildNavPayload(): array { $all = $this->items ->orderBy('menu_parent_id', 'ASC') ->orderBy('label', 'ASC') ->orderBy('id', 'ASC') ->findAll(); $byId = []; foreach ($all as $a) { $a['children'] = []; $byId[$a['id']] = $a; } $tree = []; foreach ($byId as $id => &$node) { $pid = $node['menu_parent_id'] ?: null; if ($pid && isset($byId[$pid])) { $byId[$pid]['children'][] = &$node; } else { $tree[] = &$node; } } unset($node); $this->sortTreeAlpha($tree); $flatAlpha = $all; usort($flatAlpha, fn($a, $b) => strnatcasecmp( $this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '') )); $roleAssignments = []; $roleRows = $this->maps ->select('role_nav_items.nav_item_id, roles.id AS role_id, roles.name AS role_name') ->join('roles', 'roles.id = role_nav_items.role_id', 'left') ->findAll(); foreach ($roleRows as $row) { $navId = (int) ($row['nav_item_id'] ?? 0); if ($navId <= 0) continue; $roleId = (int) ($row['role_id'] ?? 0); $roleName = $row['role_name'] ?? ($roleId ? ('#' . $roleId) : null); if ($roleId > 0) { $roleAssignments[$navId]['ids'][] = $roleId; } if ($roleName !== null) { $roleAssignments[$navId]['names'][] = $roleName; } } $flattened = $this->flattenTreeForResponse($tree, $roleAssignments); $parentOptions = array_map(static function ($row) { return [ 'id' => (int) ($row['id'] ?? 0), 'label' => (string) ($row['label'] ?? ''), ]; }, $flatAlpha); $roles = array_map(static function ($role) { return [ 'id' => (int) ($role['id'] ?? 0), 'name' => (string) ($role['name'] ?? ''), ]; }, $this->distinctRoles()); return [ 'items' => $flattened, 'roles' => $roles, 'parentOptions' => $parentOptions, 'routeOptions' => $this->routeOptions(), ]; } private function routeOptions(): array { $routes = service('routes'); $getRoutes = $routes->getRoutes('GET', false); if (empty($getRoutes)) { $routes = $routes->loadRoutes(); $getRoutes = $routes->getRoutes('GET', false); } $options = []; foreach (array_keys($getRoutes) as $route) { $route = trim((string) $route, '/'); if ($route === '' || $this->shouldHideRouteOption($route)) { continue; } $options[] = [ 'value' => $route, 'label' => $this->routeLabel($route), 'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'), ]; } usort($options, static function ($a, $b) { return strnatcasecmp($a['label'] ?? '', $b['label'] ?? ''); }); return $options; } private function shouldHideRouteOption(string $route): bool { if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) { return true; } return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route); } private function routeLabel(string $route): string { $label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route; $label = str_replace(['_', '-'], ' ', $label); $label = preg_replace('#/+#', ' / ', $label) ?? $label; $label = preg_replace('/\s+/', ' ', $label) ?? $label; return ucwords(trim($label)); } private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array { foreach ($nodes as $node) { $raw = $node; unset($raw['children']); $navId = (int) ($raw['id'] ?? 0); $roles = $roleAssignments[$navId] ?? ['ids' => [], 'names' => []]; $rows[] = [ 'id' => $navId, 'label' => (string) ($raw['label'] ?? ''), 'url' => $raw['url'] ?? null, 'parent_label' => $parentLabel ?? '—', 'parent_id' => isset($raw['menu_parent_id']) && (int) $raw['menu_parent_id'] !== 0 ? (int) $raw['menu_parent_id'] : null, 'order' => (int) ($raw['sort_order'] ?? 0), 'enabled' => (int) ($raw['is_enabled'] ?? 0) === 1, 'target' => $raw['target'] ?? null, 'depth' => $depth, 'roles' => [ 'ids' => array_values(array_unique($roles['ids'] ?? [])), 'names' => array_values(array_unique($roles['names'] ?? [])), ], 'raw' => $raw, ]; if (!empty($node['children'])) { $this->flattenTreeForResponse( $node['children'], $roleAssignments, (string) ($raw['label'] ?? ''), $depth + 1, $rows ); } } return $rows; } private function sortTreeByOrder(array &$nodes): void { usort($nodes, function ($a, $b) { $order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0)); if ($order !== 0) { return $order; } $label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')); if ($label !== 0) { return $label; } return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)); }); foreach ($nodes as &$node) { if (!empty($node['children'])) { $this->sortTreeByOrder($node['children']); } } unset($node); } }