562 lines
18 KiB
PHP
562 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Models\NavItemModel;
|
|
use App\Models\RoleNavItemModel;
|
|
use App\Services\NavbarService;
|
|
|
|
class NavBuilderController extends BaseController
|
|
{
|
|
protected NavItemModel $items;
|
|
protected RoleNavItemModel $maps;
|
|
protected NavbarService $service;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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);
|
|
}
|
|
}
|