diff --git a/docs/lang_diff.txt b/docs/lang_diff.txt
index 10cd6ed7d..4ed915869 100644
--- a/docs/lang_diff.txt
+++ b/docs/lang_diff.txt
@@ -6,6 +6,88 @@ Below are language differences from a version to next version.
================================
2025/03/30: Version 2.5.12-Betas
================================
+/htdocs/modules/system/language/english/admin.php
+- added define('_AM_SYSTEM_MENUS', 'Menus');
+- added define('_AM_SYSTEM_MENUS_DESC', 'Manage site navigation menus');
+
+/htdocs/modules/system/language/english/modinfo.php
+- added define('_MI_SYSTEM_MENUS_ACTIVE', 'Enable Menu System');
+- added define('_MI_SYSTEM_MENUS_ACTIVE_DESC', 'Enable the built-in menu management system for site navigation');
+
+/htdocs/modules/system/language/english/admin/menus.php (NEW FILE)
+- added define('_AM_SYSTEM_MENUS_NAV_MAIN', 'Menu Manager');
+- added define('_AM_SYSTEM_MENUS_NAV_BACK', 'Back to Menu List');
+- added define('_AM_SYSTEM_MENUS_NAV_CATEGORY', 'Category View');
+- added define('_AM_SYSTEM_MENUS_NAV_TIPS', 'Tips:
...');
+- added define('_AM_SYSTEM_MENUS_ACTIVE', 'Active');
+- added define('_AM_SYSTEM_MENUS_ACTIVE_YES', 'Enabled');
+- added define('_AM_SYSTEM_MENUS_ACTIVE_NO', 'Disabled');
+- added define('_AM_SYSTEM_MENUS_SAVED', 'Saved successfully');
+- added define('_AM_SYSTEM_MENUS_DELETED', 'Deleted successfully');
+- added define('_AM_SYSTEM_MENUS_ORDER_SAVED', 'Order saved');
+- added define('_AM_SYSTEM_MENUS_LISTCAT', 'List Categories');
+- added define('_AM_SYSTEM_MENUS_LISTITEM', 'List Items');
+- added define('_AM_SYSTEM_MENUS_ADDCAT', 'Add Category');
+- added define('_AM_SYSTEM_MENUS_EDITCAT', 'Edit Category');
+- added define('_AM_SYSTEM_MENUS_DELCAT', 'Delete Category');
+- added define('_AM_SYSTEM_MENUS_CATTITLE', 'Category Title');
+- added define('_AM_SYSTEM_MENUS_CATTITLE_DESC', 'A language constant name (e.g. MENUS_HOME) can be used here so the label is translatable.');
+- added define('_AM_SYSTEM_MENUS_CATPREFIX', 'Prefix (HTML)');
+- added define('_AM_SYSTEM_MENUS_CATPREFIX_DESC', 'Optional markup shown before the category title, such as a Font Awesome icon.');
+- added define('_AM_SYSTEM_MENUS_CATSUFFIX', 'Suffix (HTML)');
+- added define('_AM_SYSTEM_MENUS_CATSUFFIX_DESC', 'Optional markup shown after the category title.');
+- added define('_AM_SYSTEM_MENUS_CATURL', 'URL');
+- added define('_AM_SYSTEM_MENUS_CATURL_DESC', 'Link for the category itself, such as "index.php" or an absolute URL.');
+- added define('_AM_SYSTEM_MENUS_CATTARGET', 'Link Target');
+- added define('_AM_SYSTEM_MENUS_CATPOSITION', 'Position');
+- added define('_AM_SYSTEM_MENUS_DELCAT_CONFIRM', 'Are you sure you want to delete the category "%s" and all its items?');
+- added define('_AM_SYSTEM_MENUS_ADDITEM', 'Add Item');
+- added define('_AM_SYSTEM_MENUS_EDITITEM', 'Edit Item');
+- added define('_AM_SYSTEM_MENUS_DELITEM', 'Delete Item');
+- added define('_AM_SYSTEM_MENUS_ITEMTITLE', 'Item Title');
+- added define('_AM_SYSTEM_MENUS_ITEMTITLE_DESC', 'A language constant name can be used here so the label is translatable.');
+- added define('_AM_SYSTEM_MENUS_ITEMPREFIX', 'Prefix (HTML)');
+- added define('_AM_SYSTEM_MENUS_ITEMPREFIX_DESC', 'Optional markup shown before the item title, such as a Font Awesome icon.');
+- added define('_AM_SYSTEM_MENUS_ITEMSUFFIX', 'Suffix (HTML)');
+- added define('_AM_SYSTEM_MENUS_ITEMSUFFIX_DESC', 'Optional markup shown after the item title. Supports the <{xoInboxCount}> placeholder.');
+- added define('_AM_SYSTEM_MENUS_ITEMURL', 'URL');
+- added define('_AM_SYSTEM_MENUS_ITEMTARGET', 'Link Target');
+- added define('_AM_SYSTEM_MENUS_ITEMPOSITION', 'Position');
+- added define('_AM_SYSTEM_MENUS_ITEMPARENT', 'Parent Item');
+- added define('_AM_SYSTEM_MENUS_ITEMCATEGORY', 'Category');
+- added define('_AM_SYSTEM_MENUS_DELITEM_CONFIRM', 'Are you sure you want to delete the item "%s" and its sub-items?');
+- added define('_AM_SYSTEM_MENUS_TARGET_SELF', 'Same window');
+- added define('_AM_SYSTEM_MENUS_TARGET_BLANK', 'New window');
+- added define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY', 'Groups that can see this category');
+- added define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY_DESC', 'Users must have category access before any of its items become visible.');
+- added define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM', 'Groups that can see this item');
+- added define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM_DESC', 'Item permissions are checked after the parent category permission.');
+- added define('_AM_SYSTEM_MENUS_ERROR_CATNOTFOUND', 'Category not found');
+- added define('_AM_SYSTEM_MENUS_ERROR_CATPROTECTED', 'Cannot delete a protected category');
+- added define('_AM_SYSTEM_MENUS_ERROR_CATINACTIVE', 'Cannot activate: the category is inactive');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND', 'Item not found');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED', 'Cannot delete a protected item');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMPARENT', 'Invalid parent item selected');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMCYCLE', 'Cannot set parent: it would create a circular reference');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMDEPTH', 'Maximum nesting depth (3 levels) exceeded');
+- added define('_AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE', 'Cannot activate: the parent item is inactive');
+- added define('_AM_SYSTEM_MENUS_ERROR_NOITEMS', 'There are no submenu items in this category.');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMEDIT', 'Enable this item before editing it');
+- added define('_AM_SYSTEM_MENUS_ERROR_ITEMDISABLE', 'Enable this item before deleting it');
+- added define('MENUS_HOME', 'Home');
+- added define('MENUS_ADMIN', 'Administration');
+- added define('MENUS_ACCOUNT', 'Account');
+- added define('MENUS_ACCOUNT_EDIT', 'Edit Account');
+- added define('MENUS_ACCOUNT_LOGIN', 'Login');
+- added define('MENUS_ACCOUNT_LOGOUT', 'Logout');
+- added define('MENUS_ACCOUNT_REGISTER', 'Sign Up');
+- added define('MENUS_ACCOUNT_MESSAGES', 'Messages');
+- added define('MENUS_ACCOUNT_NOTIFICATIONS', 'Notifications');
+- added define('MENUS_ACCOUNT_TOOLBAR', 'Toolbar');
+
+/htdocs/modules/system/constants.php
+- added define('XOOPS_SYSTEM_MENUS', 19);
+
/htdocs/modules/system/language/english/blocks.php
-added define('_MB_SYSTEM_WAITING_CONTENT_DEPRECATED', "Block 'Waiting Contents' is deprecated since XOOPS 2.5.11, please use Waiting module");
diff --git a/htdocs/class/theme.php b/htdocs/class/theme.php
index fb96b44e1..4c22750c1 100644
--- a/htdocs/class/theme.php
+++ b/htdocs/class/theme.php
@@ -1,21 +1,21 @@
* @author Taiwen Jiang
* @since 2.3.0
- * @package kernel
- * @subpackage xos_opal_Theme
*/
use Xmf\Request;
@@ -406,243 +406,14 @@ public function xoInit($options = [])
}
}
- // Load menu categories and their nested items so themes can render navigation
- if (\Xmf\Module\Helper::getHelper('system')->getConfig('active_menus')) {
+ // Load system menus if enabled
+ if (\Xmf\Module\Helper::getHelper('system') && \Xmf\Module\Helper::getHelper('system')->getConfig('active_menus')) {
$this->template->assign('xoMenuCategories', $this->loadMenus());
}
return true;
}
- /**
- * Load active menu categories with their nested items.
- *
- * @return array
- */
- protected function loadMenus()
- {
- // Include shared CSS for multilevel menus
- $css = 'multilevelmenu.css';
- $path = XOOPS_ROOT_PATH . '/modules/system/css/' . $css;
- if (file_exists($path)) {
- $this->addStylesheet(XOOPS_URL . '/modules/system/css/' . $css);
- }
-
- // Include shared JS for multilevel menus
- $js = 'multilevelmenu.js';
- $jsPath = XOOPS_ROOT_PATH . '/modules/system/js/' . $js;
- if (file_exists($jsPath)) {
- $this->addScript(XOOPS_URL . '/modules/system/js/' . $js);
- }
-
- $menus = [];
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- if (!is_object($menuscategoryHandler) && class_exists('XoopsMenusCategoryHandler')) {
- $menuscategoryHandler = new XoopsMenusCategoryHandler($GLOBALS['xoopsDB']);
- }
-
- $category_arr = [];
- $viewPermissionItem = [];
-
- if (is_object($menuscategoryHandler)) {
- // Verify table exists before querying
- $tableExists = false;
- try {
- $tableName = str_replace(['_', '%'], ['\\_', '\\%'], $GLOBALS['xoopsDB']->prefix('menuscategory'));
- $sql = "SHOW TABLES LIKE '" . $tableName . "'";
- $result = $GLOBALS['xoopsDB']->query($sql);
- if (false !== $result) {
- $tableExists = $GLOBALS['xoopsDB']->getRowsNum($result) > 0;
- }
- } catch (\Throwable $e) {
- $tableExists = false;
- }
- if ($tableExists) {
- $helper = Xmf\Module\Helper::getHelper('system');
- $moduleHandler = $helper->getModule();
- $groups = is_object($GLOBALS['xoopsUser']) ? $GLOBALS['xoopsUser']->getGroups() : (array) XOOPS_GROUP_ANONYMOUS;
- /** @var \XoopsGroupPermHandler $gpermHandler */
- $gpermHandler = xoops_getHandler('groupperm');
- $viewPermissionCat = $gpermHandler->getItemIds('menus_category_view', $groups, $moduleHandler->getVar('mid'));
- $viewPermissionItem = $gpermHandler->getItemIds('menus_items_view', $groups, $moduleHandler->getVar('mid'));
- if (!empty($viewPermissionCat)) {
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('category_active', 1));
- $criteria->add(new Criteria('category_id', '(' . implode(',', $viewPermissionCat) . ')', 'IN'));
- $criteria->setSort('category_position');
- $criteria->setOrder('ASC');
- $category_arr = $menuscategoryHandler->getAll($criteria);
- }
- }
- }
-
- if (!empty($category_arr)) {
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- if (!is_object($menusitemsHandler) && class_exists('XoopsMenusItemsHandler')) {
- $menusitemsHandler = new XoopsMenusItemsHandler($GLOBALS['xoopsDB']);
- }
-
- // Normalize relative URLs to absolute
- $normalizeUrl = function ($url) {
- if (!is_string($url) || $url === '') {
- return $url;
- }
- // Block javascript: scheme (stored XSS)
- if (preg_match('#^\s*javascript:#i', $url)) {
- return '';
- }
- // Pass through absolute URLs and common schemes
- if (preg_match('#^(https?://|mailto:|tel:|ftp://|/|\#)#i', $url)) {
- return $url;
- }
- return XOOPS_URL . '/' . ltrim($url, '/');
- };
-
- // Batch-fetch all permitted active items in one query (avoids N+1)
- $allItemsByCid = [];
- if (!empty($viewPermissionItem)) {
- $crit = new CriteriaCompo();
- $crit->add(new Criteria('items_id', '(' . implode(',', $viewPermissionItem) . ')', 'IN'));
- $crit->add(new Criteria('items_active', 1));
- $crit->setSort('items_position');
- $crit->setOrder('ASC');
- $allItems = $menusitemsHandler->getAll($crit);
- foreach ($allItems as $item) {
- $itemCid = $item->getVar('items_cid');
- $allItemsByCid[$itemCid][$item->getVar('items_id')] = $item;
- }
- }
-
- xoops_load('SystemMenusTree', 'system');
- // Recursive closure to build nested structure
- $buildNested = function ($treeObj, $parentId = 0) use (&$buildNested, $normalizeUrl) {
- $nodes = [];
- $children = $treeObj->getFirstChild($parentId);
- foreach ($children as $child) {
- $cid2 = $child->getVar('items_id');
- $entry = [
- 'id' => $cid2,
- 'title' => $child->getResolvedTitle(),
- 'prefix' => $this->renderMenuAffix($child->getVar('items_prefix', 'n')),
- 'suffix' => $this->renderMenuAffix($child->getVar('items_suffix', 'n')),
- 'url' => $normalizeUrl($child->getVar('items_url')),
- 'target' => ($child->getVar('items_target') == 1) ? '_blank' : '_self',
- 'active' => $child->getVar('items_active'),
- 'children' => $buildNested($treeObj, $cid2),
- ];
- $nodes[] = $entry;
- }
- return $nodes;
- };
-
- foreach ($category_arr as $cat) {
- try {
- $cid = $cat->getVar('category_id');
- $item_list = [];
- if (!empty($allItemsByCid[$cid])) {
- $myTree = new SystemMenusTree($allItemsByCid[$cid], 'items_id', 'items_pid');
- $item_list = $buildNested($myTree, 0);
- }
- $menus[] = [
- 'category_id' => $cid,
- 'category_title' => $cat->getResolvedTitle(),
- 'category_prefix' => $this->renderMenuAffix($cat->getVar('category_prefix', 'n')),
- 'category_suffix' => $this->renderMenuAffix($cat->getVar('category_suffix', 'n')),
- 'category_url' => $normalizeUrl($cat->getVar('category_url')),
- 'category_target' => ($cat->getVar('category_target') == 1) ? '_blank' : '_self',
- 'items' => $item_list,
- ];
- } catch (\Throwable $e) {
- continue;
- }
- }
- }
- return $menus;
- }
-
- /**
- * Render a menu prefix/suffix that may contain the xoInboxCount Smarty tag.
- *
- * @param string $value
- * @return string
- */
- protected function renderMenuAffix($value)
- {
- $value = (string)$value;
- if ('' === $value) {
- return $value;
- }
-
- // Replace <{xoInboxCount}> BEFORE strip_tags (it looks like an HTML tag)
- if (false !== stripos($value, 'xoInboxCount')) {
- try {
- $unread = $this->getInboxUnreadCount();
- $replacement = null === $unread ? '' : (string)$unread;
- $value = preg_replace('/<{\s*xoInboxCount(?:\s+[^}]*)?\s*}>/i', $replacement, $value);
- if (null === $value) {
- return '';
- }
- } catch (\Throwable $e) {
- // Remove the tag so strip_tags doesn't mangle it
- $value = preg_replace('/<{\s*xoInboxCount(?:\s+[^}]*)?\s*}>/i', '', $value);
- }
- }
-
- // Sanitize: only allow safe inline HTML tags, strip event handlers and javascript: URLs
- $value = strip_tags($value, '');
- $value = preg_replace('/\s+on\w+\s*=\s*"[^"]*"/i', '', $value);
- $value = preg_replace('/\s+on\w+\s*=\s*\'[^\']*\'/i', '', $value);
- $value = preg_replace('/\s+on\w+\s*=\s*[^\s>]+/i', '', $value);
- $value = preg_replace('/\b(href|src)\s*=\s*["\']?\s*javascript:[^"\'>\s]*/i', '', $value);
-
- return $value;
- }
-
- /**
- * Get unread private message count for current user.
- *
- * @return int|null
- */
- protected function getInboxUnreadCount()
- {
- global $xoopsUser;
-
- if (!isset($xoopsUser) || !is_object($xoopsUser)) {
- return null;
- }
-
- $freshRead = isset($GLOBALS['xoInboxCountFresh']);
- $pmScripts = ['pmlite', 'readpmsg', 'viewpmsg'];
- if (in_array(basename($_SERVER['SCRIPT_FILENAME'], '.php'), $pmScripts)) {
- if (!$freshRead) {
- unset($_SESSION['xoops_inbox_count'], $_SESSION['xoops_inbox_total'], $_SESSION['xoops_inbox_count_expire']);
- $GLOBALS['xoInboxCountFresh'] = true;
- }
- }
-
- $time = time();
- if (isset($_SESSION['xoops_inbox_count']) && (isset($_SESSION['xoops_inbox_count_expire']) && $_SESSION['xoops_inbox_count_expire'] > $time)) {
- return (int)$_SESSION['xoops_inbox_count'];
- }
-
- /** @var \XoopsPrivmessageHandler $pm_handler */
- $pm_handler = xoops_getHandler('privmessage');
-
- $xoopsPreload = XoopsPreload::getInstance();
- $xoopsPreload->triggerEvent('core.class.smarty.xoops_plugins.xoinboxcount', [$pm_handler]);
-
- $criteria = new CriteriaCompo(new Criteria('to_userid', $xoopsUser->getVar('uid')));
- $_SESSION['xoops_inbox_total'] = $pm_handler->getCount($criteria);
-
- $criteria->add(new Criteria('read_msg', 0));
- $_SESSION['xoops_inbox_count'] = $pm_handler->getCount($criteria);
- $_SESSION['xoops_inbox_count_expire'] = $time + 60;
-
- return (int)$_SESSION['xoops_inbox_count'];
- }
-
/**
* Generate cache id based on extra information of language and user groups
*
@@ -1146,4 +917,260 @@ public function resourcePath($path)
return $path;
}
+
+ /**
+ * Load menu CSS and JS assets into the theme.
+ */
+ private function loadMenuAssets(): void
+ {
+ $cssFile = 'modules/system/css/multilevelmenu.css';
+ $jsFile = 'modules/system/js/multilevelmenu.js';
+ if (file_exists(XOOPS_ROOT_PATH . '/' . $cssFile)) {
+ $this->addStylesheet($cssFile);
+ }
+ if (file_exists(XOOPS_ROOT_PATH . '/' . $jsFile)) {
+ $this->addScript($jsFile);
+ }
+ }
+
+ /**
+ * Verify that both menu database tables exist.
+ *
+ * @return bool True if both menuscategory and menusitems tables are present
+ */
+ private function hasMenuTables(): bool
+ {
+ /** @var \XoopsMySQLDatabase $db */
+ $db = \XoopsDatabaseFactory::getDatabaseConnection();
+ foreach (['menuscategory', 'menusitems'] as $tableName) {
+ $result = $db->query('SHOW TABLES LIKE ' . $db->quote($db->prefix($tableName)));
+ if (!$db->isResultSet($result) || !($result instanceof \mysqli_result) || 0 === $db->getRowsNum($result)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Load all visible menu categories and items for the current user.
+ *
+ * @return array>
+ */
+ private function loadMenus(): array
+ {
+ $this->loadMenuAssets();
+
+ if (!$this->hasMenuTables()) {
+ return [];
+ }
+
+ $permHandler = xoops_getHandler('groupperm');
+ if (!$permHandler instanceof \XoopsGroupPermHandler) {
+ return [];
+ }
+ $groups = is_object($GLOBALS['xoopsUser'] ?? null)
+ ? $GLOBALS['xoopsUser']->getGroups()
+ : [XOOPS_GROUP_ANONYMOUS];
+ $moduleId = (int) \Xmf\Module\Helper::getHelper('system')->getModule()->getVar('mid');
+
+ $visibleCatIds = $permHandler->getItemIds('menus_category_view', $groups, $moduleId);
+ if (empty($visibleCatIds)) {
+ return [];
+ }
+
+ $visibleItemIds = $permHandler->getItemIds('menus_items_view', $groups, $moduleId);
+
+ $catHandler = xoops_getHandler('menuscategory');
+ if (!$catHandler instanceof \XoopsMenusCategoryHandler) {
+ return [];
+ }
+ $categories = $catHandler->getActiveCategoriesByIds($visibleCatIds);
+
+ $itemHandler = xoops_getHandler('menusitems');
+ if (!$itemHandler instanceof \XoopsMenusItemsHandler) {
+ return [];
+ }
+ if (empty($visibleItemIds)) {
+ return $this->buildCategoryOutput($categories, []);
+ }
+ $allItems = $itemHandler->getActiveItemsByIds($visibleItemIds);
+
+ $itemsByCat = [];
+ foreach ($allItems as $item) {
+ $cid = (int) $item->getVar('items_cid');
+ $itemsByCat[$cid][] = $item;
+ }
+
+ return $this->buildCategoryOutput($categories, $itemsByCat);
+ }
+
+ /**
+ * Build the output array for categories with their item trees.
+ *
+ * @param array $categories Array of XoopsMenusCategory objects
+ * @param array $itemsByCat Items grouped by category ID
+ *
+ * @return array>
+ */
+ private function buildCategoryOutput(array $categories, array $itemsByCat): array
+ {
+ $output = [];
+ foreach ($categories as $cat) {
+ $cid = (int) $cat->getVar('category_id');
+ $catItems = $itemsByCat[$cid] ?? [];
+ $itemTree = $this->buildMenuItemTree($catItems);
+
+ $output[] = [
+ 'category_id' => $cid,
+ 'category_title' => $cat->getResolvedTitle(),
+ 'category_prefix' => $this->renderMenuAffix($cat->getVar('category_prefix', 'n')),
+ 'category_suffix' => $this->renderMenuAffix($cat->getVar('category_suffix', 'n')),
+ 'category_url' => $this->normalizeMenuUrl($cat->getVar('category_url', 'n')),
+ 'category_target' => (1 === (int) $cat->getVar('category_target')) ? '_blank' : '_self',
+ 'items' => $itemTree,
+ ];
+ }
+ return $output;
+ }
+
+ /**
+ * Build a nested tree from a flat list of menu items.
+ *
+ * @param array $items Flat list of XoopsMenusItems sorted by position
+ *
+ * @return array>
+ */
+ private function buildMenuItemTree(array $items): array
+ {
+ $indexed = [];
+ foreach ($items as $item) {
+ $id = (int) $item->getVar('items_id');
+ $indexed[$id] = [
+ 'id' => $id,
+ 'title' => $item->getResolvedTitle(),
+ 'prefix' => $this->renderMenuAffix($item->getVar('items_prefix', 'n')),
+ 'suffix' => $this->renderMenuAffix($item->getVar('items_suffix', 'n')),
+ 'url' => $this->normalizeMenuUrl($item->getVar('items_url', 'n')),
+ 'target' => (1 === (int) $item->getVar('items_target')) ? '_blank' : '_self',
+ 'active' => (int) $item->getVar('items_active'),
+ 'pid' => (int) $item->getVar('items_pid'),
+ 'children' => [],
+ ];
+ }
+
+ $tree = [];
+ foreach ($indexed as $id => &$node) {
+ $pid = $node['pid'];
+ unset($node['pid']);
+ if ($pid === 0) {
+ $tree[] = &$node;
+ } elseif (!isset($indexed[$pid])) {
+ // Parent hidden by permissions — drop this orphaned subtree
+ continue;
+ } else {
+ $indexed[$pid]['children'][] = &$node;
+ }
+ }
+ unset($node);
+
+ return $tree;
+ }
+
+ /**
+ * Normalize a menu URL for safe rendering.
+ *
+ * @param string $url The raw URL from the database
+ *
+ * @return string Safe URL
+ */
+ private function normalizeMenuUrl(string $url): string
+ {
+ $url = trim($url);
+ if ($url === '') {
+ return '#';
+ }
+
+ if (preg_match('/^\s*javascript\s*:/i', $url)) {
+ return '#';
+ }
+
+ if (preg_match('~^(https?://|mailto:|tel:|ftp://|/|#)~i', $url)) {
+ return $url;
+ }
+
+ return XOOPS_URL . '/' . ltrim($url, '/');
+ }
+
+ /**
+ * Sanitize and render a prefix/suffix HTML fragment.
+ *
+ * @param string $value Raw affix value from database
+ *
+ * @return string Safe HTML fragment
+ */
+ private function renderMenuAffix(string $value): string
+ {
+ if ($value === '') {
+ return '';
+ }
+
+ // Replace xoInboxCount BEFORE strip_tags since the placeholder looks like an HTML tag
+ if (false !== stripos($value, 'xoInboxCount')) {
+ $count = $this->getInboxUnreadCount();
+ $replacement = ($count !== null && $count > 0) ? (string) $count : '';
+ $value = preg_replace('/<\{\s*xoInboxCount(?:\s+[^}]*)?\s*\}>/i', $replacement, $value) ?? $value;
+ }
+
+ $value = strip_tags($value, '');
+ $value = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $value);
+ $value = preg_replace('/\s+on\w+\s*=\s*\S+/i', '', $value);
+ $value = preg_replace('/\s+style\s*=\s*["\'][^"\']*["\']/i', '', $value);
+ $value = preg_replace('/\s+style\s*=\s*\S+/i', '', $value);
+ $value = preg_replace('/javascript\s*:/i', '', $value);
+
+ return $value;
+ }
+
+ /**
+ * Get the unread private message count for the current user.
+ * Cached in session for 60 seconds.
+ *
+ * @return int|null Count, or null if not logged in
+ */
+ private function getInboxUnreadCount(): ?int
+ {
+ if (!is_object($GLOBALS['xoopsUser'] ?? null)) {
+ return null;
+ }
+
+ $uid = (int) $GLOBALS['xoopsUser']->getVar('uid');
+ $cacheKey = 'xo_inbox_count_' . $uid;
+ $cacheTime = 'xo_inbox_count_time_' . $uid;
+
+ $currentScript = basename($_SERVER['SCRIPT_NAME'] ?? '');
+ if ($currentScript === 'viewpmsg.php' || $currentScript === 'readpmsg.php') {
+ unset($_SESSION[$cacheKey], $_SESSION[$cacheTime]);
+ }
+
+ if (isset($_SESSION[$cacheKey], $_SESSION[$cacheTime])
+ && (time() - (int)$_SESSION[$cacheTime]) < 60
+ ) {
+ return (int) $_SESSION[$cacheKey];
+ }
+
+ /** @var \XoopsPrivmessageHandler $pmHandler */
+ $pmHandler = xoops_getHandler('privmessage');
+
+ $criteria = new \CriteriaCompo(new \Criteria('to_userid', (string) $uid));
+ $criteria->add(new \Criteria('read_msg', '0'));
+ $count = $pmHandler->getCount($criteria);
+
+ $preload = \XoopsPreload::getInstance();
+ $preload->triggerEvent('core.class.smarty.xoops_plugins.xoinboxcount', [$pmHandler]);
+
+ $_SESSION[$cacheKey] = $count;
+ $_SESSION[$cacheTime] = time();
+
+ return $count;
+ }
}
diff --git a/htdocs/kernel/menuscategory.php b/htdocs/kernel/menuscategory.php
index b703b232f..79650fd00 100644
--- a/htdocs/kernel/menuscategory.php
+++ b/htdocs/kernel/menuscategory.php
@@ -1,7 +1,5 @@
initVar('category_id', XOBJ_DTYPE_INT, null, false);
- $this->initVar('category_title', XOBJ_DTYPE_TXTBOX, null);
- $this->initVar('category_prefix', XOBJ_DTYPE_TXTAREA);
- $this->initVar('category_suffix', XOBJ_DTYPE_TXTAREA);
- $this->initVar('category_url', XOBJ_DTYPE_TXTBOX, null);
- $this->initVar('category_target', XOBJ_DTYPE_INT, 0);
- $this->initVar('category_position', XOBJ_DTYPE_INT, null, false);
- $this->initVar('category_protected', XOBJ_DTYPE_INT, 0);
- $this->initVar('category_active', XOBJ_DTYPE_INT, 1);
-
- // Load language file for menu items
- $language = $GLOBALS['xoopsConfig']['language'] ?? 'english';
- $fileinc = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/menus/menus.php";
- if (!isset(self::$languageFilesIncluded[$fileinc])) {
- if (file_exists($fileinc)) {
- include_once $fileinc;
- self::$languageFilesIncluded[$fileinc] = true;
- } else {
- // Fallback to English if language file not found
- $fallback = XOOPS_ROOT_PATH . '/modules/system/language/english/menus/menus.php';
- if ($fileinc !== $fallback && !isset(self::$languageFilesIncluded[$fallback]) && file_exists($fallback)) {
- include_once $fallback;
- self::$languageFilesIncluded[$fallback] = true;
- }
- }
- }
- // Load language file for admin menus
- $fileinc_admin = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/admin/menus.php";
- if (!isset(self::$languageFilesIncluded[$fileinc_admin])) {
- if (file_exists($fileinc_admin)) {
- include_once $fileinc_admin;
- self::$languageFilesIncluded[$fileinc_admin] = true;
- } else {
- $fallback_admin = XOOPS_ROOT_PATH . '/modules/system/language/english/admin/menus.php';
- if ($fileinc_admin !== $fallback_admin && !isset(self::$languageFilesIncluded[$fallback_admin]) && file_exists($fallback_admin)) {
- include_once $fallback_admin;
- self::$languageFilesIncluded[$fallback_admin] = true;
- }
- }
- }
+ $this->initVar('category_title', XOBJ_DTYPE_TXTBOX, '', true, 100);
+ $this->initVar('category_prefix', XOBJ_DTYPE_TXTAREA, '', false);
+ $this->initVar('category_suffix', XOBJ_DTYPE_TXTAREA, '', false);
+ $this->initVar('category_url', XOBJ_DTYPE_TXTBOX, '', false, 255);
+ $this->initVar('category_target', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('category_position', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('category_protected', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('category_active', XOBJ_DTYPE_INT, 1, false);
}
/**
- * Retrieve the resolved title for display.
+ * Return the database id generated by the most recent insert.
*
- * If the stored title is a constant name, resolves and returns its value.
- * Otherwise returns the stored title as-is.
+ * XOOPS historically exposes this helper on persistable objects so the
+ * admin controller can read back the primary key after a successful save.
*
- * @return string The resolved title value
+ * @return int Newly created category id
*/
- public function getResolvedTitle()
+ public function getNewEnreg(): int
{
- $title = (string)$this->getVar('category_title');
- if (0 === strpos($title, 'MENUS_') && defined($title)) {
- return (string)constant($title);
- }
- return $title;
+ return (int) \XoopsDatabaseFactory::getDatabaseConnection()->getInsertId();
}
/**
- * Retrieve the title for administration interface with constant reference.
+ * Load menu language files if not already loaded.
*
- * @return string The resolved title with optional constant reference
+ * Uses the standard xoops_loadLanguage() helper, which automatically
+ * falls back to English when a translation is not available.
*/
- public function getAdminTitle()
+ private static function ensureLanguageLoaded(): void
{
- $title = (string)$this->getVar('category_title');
- if (0 === strpos($title, 'MENUS_') && defined($title)) {
- return constant($title) . ' (' . $title . ')';
+ if (self::$langLoaded) {
+ return;
}
- return $title;
+ xoops_loadLanguage('menus/menus', 'system');
+ xoops_loadLanguage('admin/menus', 'system');
+ self::$langLoaded = true;
}
/**
- * @return mixed
+ * Retrieve the resolved title for display.
+ *
+ * If the stored title is a language constant name, resolves and returns
+ * its translated value. Otherwise returns the stored title as-is. Used
+ * for front-end rendering where the translated label should be shown
+ * without the raw constant token.
+ *
+ * @return string The resolved title value
*/
- public function getNewEnreg()
+ public function getResolvedTitle(): string
{
- global $xoopsDB;
- $new_enreg = $xoopsDB->getInsertId();
+ self::ensureLanguageLoaded();
+ $raw = $this->getVar('category_title', 'n');
+ if ($raw !== '' && str_starts_with((string) $raw, 'MENUS_') && defined($raw)) {
+ return constant($raw);
+ }
+ return (string) $raw;
+ }
- return $new_enreg;
+ /**
+ * Retrieve the title for the administration interface.
+ *
+ * When the stored value is a language constant, the admin view shows both
+ * the translated label and the original constant token so maintainers can
+ * identify which language constant is backing each menu entry.
+ *
+ * @return string The resolved title with an optional constant reference
+ */
+ public function getAdminTitle(): string
+ {
+ $raw = (string) $this->getVar('category_title', 'n');
+ $resolved = $this->getResolvedTitle();
+ if ($resolved !== $raw) {
+ return $resolved . ' (' . $raw . ')';
+ }
+ return $resolved;
}
/**
- * Build and return the category edit/create form.
+ * Build and return the category create/edit form.
+ *
+ * The form covers title, prefix/suffix affixes, URL, target, ordering,
+ * active state, and group permissions. Protected seeded categories remain
+ * editable only where it is safe to do so — core fields are locked while
+ * position and active state remain adjustable.
*
- * @param string|false $action Form action URL, defaults to REQUEST_URI
+ * @param string $action Form action URL
*
* @return \XoopsThemeForm
*/
- public function getFormCat($action = false)
+ public function getFormCat(string $action): \XoopsThemeForm
{
- if ($action === false) {
- $action = \Xmf\Request::getString('REQUEST_URI', '', 'SERVER');
- }
include_once XOOPS_ROOT_PATH . '/class/xoopsformloader.php';
- //form title
- $title = $this->isNew() ? sprintf(_AM_SYSTEM_MENUS_ADDCAT) : sprintf(_AM_SYSTEM_MENUS_EDITCAT);
-
- $form = new XoopsThemeForm($title, 'form', $action, 'post', true);
- $form->setExtra('enctype="multipart/form-data"');
-
- $isProtected = false;
- if (!$this->isNew()) {
- $form->addElement(new XoopsFormHidden('category_id', (string)$this->getVar('category_id')));
- $position = $this->getVar('category_position');
- $active = $this->getVar('category_active');
- $isProtected = (int)$this->getVar('category_protected') === 1;
- } else {
- $position = 0;
- $active = 1;
- }
+ $isEdit = (bool) $this->getVar('category_id');
+ $isProtected = (bool) $this->getVar('category_protected');
+ $title = $isEdit ? _AM_SYSTEM_MENUS_EDITCAT : _AM_SYSTEM_MENUS_ADDCAT;
- // title
- $title = new XoopsFormText(_AM_SYSTEM_MENUS_TITLECAT, 'category_title', 50, 255, (string)$this->getVar('category_title'));
- if ($isProtected) {
- $title->setExtra('readonly="readonly"');
+ $form = new \XoopsThemeForm($title, 'categoryform', $action, 'post', true);
+
+ if ($isEdit) {
+ $form->addElement(new \XoopsFormHidden('category_id', (string) $this->getVar('category_id')));
}
- $title->setDescription(_AM_SYSTEM_MENUS_TITLECAT_DESC);
- $form->addElement($title, true);
- // prefix
- $editor_configs = array(
- 'name' => 'category_prefix',
- 'value' => $this->getVar('category_prefix', 'n'),
- 'rows' => 1,
- 'cols' => 50,
- 'width' => '100%',
- 'height' => '200px',
- 'editor' => 'Plain Text'
+
+ $titleField = new \XoopsFormText(
+ _AM_SYSTEM_MENUS_CATTITLE,
+ 'category_title',
+ 60,
+ 100,
+ (string) $this->getVar('category_title', 'e')
);
- $prefix = new XoopsFormEditor(_AM_SYSTEM_MENUS_PREFIXCAT, 'category_prefix', $editor_configs, false, 'textarea');
+ $titleField->setDescription(_AM_SYSTEM_MENUS_CATTITLE_DESC);
if ($isProtected) {
- $prefix->setExtra('readonly="readonly"');
- if (isset($prefix->editor) && is_object($prefix->editor)) {
- $prefix->editor->setExtra('readonly="readonly"');
- }
+ $titleField->setExtra('readonly="readonly"');
}
- $prefix->setDescription(_AM_SYSTEM_MENUS_PREFIXCAT_DESC);
- $form->addElement($prefix, false);
- // suffix
- $editor_configs = array(
- 'name' => 'category_suffix',
- 'value' => $this->getVar('category_suffix', 'n'),
- 'rows' => 1,
- 'cols' => 50,
- 'width' => '100%',
- 'height' => '200px',
- 'editor' => 'Plain Text'
+ $form->addElement($titleField, true);
+
+ $prefixField = $this->buildAffixField('category_prefix', _AM_SYSTEM_MENUS_CATPREFIX, $isProtected);
+ $prefixField->setDescription(_AM_SYSTEM_MENUS_CATPREFIX_DESC);
+ $form->addElement($prefixField);
+
+ $suffixField = $this->buildAffixField('category_suffix', _AM_SYSTEM_MENUS_CATSUFFIX, $isProtected);
+ $suffixField->setDescription(_AM_SYSTEM_MENUS_CATSUFFIX_DESC);
+ $form->addElement($suffixField);
+
+ $urlField = new \XoopsFormText(
+ _AM_SYSTEM_MENUS_CATURL,
+ 'category_url',
+ 60,
+ 255,
+ (string) $this->getVar('category_url', 'e')
);
- $suffix = new XoopsFormEditor(_AM_SYSTEM_MENUS_SUFFIXCAT, 'category_suffix', $editor_configs, false, 'textarea');
+ $urlField->setDescription(_AM_SYSTEM_MENUS_CATURL_DESC);
if ($isProtected) {
- $suffix->setExtra('readonly="readonly"');
- if (isset($suffix->editor) && is_object($suffix->editor)) {
- $suffix->editor->setExtra('readonly="readonly"');
- }
+ $urlField->setExtra('readonly="readonly"');
}
- $suffix->setDescription(_AM_SYSTEM_MENUS_SUFFIXCAT_DESC);
- $form->addElement($suffix, false);
- // url
- $url = new XoopsFormText(_AM_SYSTEM_MENUS_URLCAT, 'category_url', 50, 255, (string)$this->getVar('category_url'));
- if ($isProtected) {
- $url->setExtra('readonly="readonly"');
- }
- $url->setDescription(_AM_SYSTEM_MENUS_URLCATDESC);
- $form->addElement($url, false);
- // target
- $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_TARGET, 'category_target', (string)$this->getVar('category_target'));
- $radio->addOption(0, _AM_SYSTEM_MENUS_TARGET_SELF);
- $radio->addOption(1, _AM_SYSTEM_MENUS_TARGET_BLANK);
- $form->addElement($radio, false);
- // position
- $form->addElement(new XoopsFormText(_AM_SYSTEM_MENUS_POSITIONCAT, 'category_position', 5, 5, $position));
-
- // active
- $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_ACTIVE, 'category_active', $active);
- $radio->addOption(1, _YES);
- $radio->addOption(0, _NO);
- $form->addElement($radio);
-
- // permission
+ $form->addElement($urlField);
+
+ $targetField = new \XoopsFormRadio(
+ _AM_SYSTEM_MENUS_CATTARGET,
+ 'category_target',
+ (string) $this->getVar('category_target')
+ );
+ $targetField->addOption('0', _AM_SYSTEM_MENUS_TARGET_SELF);
+ $targetField->addOption('1', _AM_SYSTEM_MENUS_TARGET_BLANK);
+ $form->addElement($targetField);
+
+ $form->addElement(new \XoopsFormText(
+ _AM_SYSTEM_MENUS_CATPOSITION,
+ 'category_position',
+ 5,
+ 10,
+ (string) $this->getVar('category_position')
+ ));
+ $form->addElement(new \XoopsFormRadioYN(
+ _AM_SYSTEM_MENUS_ACTIVE,
+ 'category_active',
+ (int) $this->getVar('category_active')
+ ));
+
$permHelper = new \Xmf\Module\Helper\Permission();
- $perm = $permHelper->getGroupSelectFormForItem('menus_category_view', $this->getVar('category_id'), _AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY, 'menus_category_view_perms', true);
- $perm->setDescription(_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY_DESC);
- $form->addElement($perm, false);
+ $permField = $permHelper->getGroupSelectFormForItem(
+ 'menus_category_view',
+ (int) $this->getVar('category_id'),
+ _AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY,
+ 'menus_category_view_perms',
+ true
+ );
+ $form->addElement($permField, false);
- $form->addElement(new XoopsFormHidden('op', 'savecat'));
- // submit
- $form->addElement(new XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
+ $form->addElement(new \XoopsFormHidden('op', 'savecat'));
+ $form->addElement(new \XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
return $form;
}
+
+ /**
+ * Build a textarea element for a prefix/suffix affix field.
+ *
+ * Affix values are stored raw so the front-end renderer can sanitize
+ * and expand placeholders like `<{xoInboxCount}>` at output time.
+ *
+ * @param string $varName The variable name
+ * @param string $label The form label
+ * @param bool $isProtected Whether the field should be readonly
+ *
+ * @return \XoopsFormTextArea
+ */
+ private function buildAffixField(string $varName, string $label, bool $isProtected): \XoopsFormTextArea
+ {
+ $value = (string) $this->getVar($varName, 'n');
+ $field = new \XoopsFormTextArea($label, $varName, $value, 3, 60);
+ if ($isProtected) {
+ $field->setExtra('readonly="readonly"');
+ }
+ return $field;
+ }
}
-class XoopsMenusCategoryHandler extends XoopsPersistableObjectHandler
+/**
+ * Handler for XoopsMenusCategory objects.
+ *
+ * Note: Constructor accepts \XoopsDatabase to match parent XoopsPersistableObjectHandler
+ * signature. At runtime, the concrete XoopsMySQLDatabase is always passed.
+ */
+class XoopsMenusCategoryHandler extends \XoopsPersistableObjectHandler
{
/**
- * Constructor
- *
- * @param XoopsDatabase $db reference to a xoopsDB object
+ * @param \XoopsDatabase $db Database connection
*/
- public function __construct($db)
+ public function __construct(\XoopsDatabase $db)
{
parent::__construct($db, 'menuscategory', 'XoopsMenusCategory', 'category_id', 'category_title');
}
+
+ /**
+ * Fetch active categories from a set of allowed IDs, sorted by position.
+ *
+ * Used by the theme loader after permission filtering to retrieve
+ * only the categories visible to the current user.
+ *
+ * @param int[] $categoryIds Category IDs the current user may see
+ *
+ * @return array
+ */
+ public function getActiveCategoriesByIds(array $categoryIds): array
+ {
+ $categoryIds = array_values(array_filter(array_map('intval', $categoryIds)));
+ if ([] === $categoryIds) {
+ return [];
+ }
+
+ $criteria = new \CriteriaCompo();
+ $criteria->add(new \Criteria('category_active', '1'));
+ $criteria->add(new \Criteria('category_id', $categoryIds, 'IN'));
+ $criteria->setSort('category_position');
+ $criteria->setOrder('ASC');
+
+ return $this->getAll($criteria);
+ }
}
diff --git a/htdocs/kernel/menusitems.php b/htdocs/kernel/menusitems.php
index bfa91cc67..50f127b45 100644
--- a/htdocs/kernel/menusitems.php
+++ b/htdocs/kernel/menusitems.php
@@ -1,6 +1,6 @@
initVar('items_id', XOBJ_DTYPE_INT, null, false);
- $this->initVar('items_pid', XOBJ_DTYPE_INT, null, false);
- $this->initVar('items_cid', XOBJ_DTYPE_INT, null, false);
- $this->initVar('items_title', XOBJ_DTYPE_TXTBOX, null);
- $this->initVar('items_prefix', XOBJ_DTYPE_TXTAREA);
- $this->initVar('items_suffix', XOBJ_DTYPE_TXTAREA);
- $this->initVar('items_url', XOBJ_DTYPE_TXTBOX, null);
- $this->initVar('items_target', XOBJ_DTYPE_INT, 0);
- $this->initVar('items_position', XOBJ_DTYPE_INT, null, false);
- $this->initVar('items_protected', XOBJ_DTYPE_INT, 0);
- $this->initVar('items_active', XOBJ_DTYPE_INT, 1);
-
- // Load language file for menu items
- $language = $GLOBALS['xoopsConfig']['language'] ?? 'english';
- $fileinc = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/menus/menus.php";
- if (!isset(self::$languageFilesIncluded[$fileinc])) {
- if (file_exists($fileinc)) {
- include_once $fileinc;
- self::$languageFilesIncluded[$fileinc] = true;
- } else {
- // Fallback to English if language file not found
- $fallback = XOOPS_ROOT_PATH . '/modules/system/language/english/menus/menus.php';
- if ($fileinc !== $fallback && !isset(self::$languageFilesIncluded[$fallback]) && file_exists($fallback)) {
- include_once $fallback;
- self::$languageFilesIncluded[$fallback] = true;
- }
- }
- }
- // Load language file for admin menus
- $fileinc_admin = XOOPS_ROOT_PATH . "/modules/system/language/{$language}/admin/menus.php";
- if (!isset(self::$languageFilesIncluded[$fileinc_admin])) {
- if (file_exists($fileinc_admin)) {
- include_once $fileinc_admin;
- self::$languageFilesIncluded[$fileinc_admin] = true;
- } else {
- $fallback_admin = XOOPS_ROOT_PATH . '/modules/system/language/english/admin/menus.php';
- if ($fileinc_admin !== $fallback_admin && !isset(self::$languageFilesIncluded[$fallback_admin]) && file_exists($fallback_admin)) {
- include_once $fallback_admin;
- self::$languageFilesIncluded[$fallback_admin] = true;
- }
- }
- }
+ $this->initVar('items_pid', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('items_cid', XOBJ_DTYPE_INT, 0, true);
+ $this->initVar('items_title', XOBJ_DTYPE_TXTBOX, '', true, 100);
+ $this->initVar('items_prefix', XOBJ_DTYPE_TXTAREA, '', false);
+ $this->initVar('items_suffix', XOBJ_DTYPE_TXTAREA, '', false);
+ $this->initVar('items_url', XOBJ_DTYPE_TXTBOX, '', false, 255);
+ $this->initVar('items_target', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('items_position', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('items_protected', XOBJ_DTYPE_INT, 0, false);
+ $this->initVar('items_active', XOBJ_DTYPE_INT, 1, false);
}
/**
- * Retrieve the resolved title for display.
+ * Return the database id generated by the most recent insert.
*
- * @return string The resolved title value
+ * The admin controller uses this helper after a successful item save
+ * when it needs the persisted primary key for permissions or redirects.
+ *
+ * @return int Newly created menu item id
*/
- public function getResolvedTitle()
+ public function getNewEnreg(): int
{
- $title = (string)$this->getVar('items_title');
- if (0 === strpos($title, 'MENUS_') && defined($title)) {
- return (string)constant($title);
- }
- return $title;
+ return (int) \XoopsDatabaseFactory::getDatabaseConnection()->getInsertId();
}
/**
- * Retrieve the title for administration interface with constant reference.
+ * Load menu language files if not already loaded.
*
- * @return string The resolved title with optional constant reference
+ * Uses the standard xoops_loadLanguage() helper, which automatically
+ * falls back to English when a translation is not available.
*/
- public function getAdminTitle()
+ private static function ensureLanguageLoaded(): void
{
- $title = (string)$this->getVar('items_title');
- if (0 === strpos($title, 'MENUS_') && defined($title)) {
- return constant($title) . ' (' . $title . ')';
+ if (self::$langLoaded) {
+ return;
}
- return $title;
+ xoops_loadLanguage('menus/menus', 'system');
+ xoops_loadLanguage('admin/menus', 'system');
+ self::$langLoaded = true;
}
/**
- * @return mixed
+ * Retrieve the resolved title for display.
+ *
+ * If the stored title is a language constant name, resolves and returns
+ * its translated value. Otherwise returns the stored title as-is. This
+ * is the value used when rendering menu items on the front end.
+ *
+ * @return string The resolved title value
*/
- public function getNewEnreg()
+ public function getResolvedTitle(): string
{
- global $xoopsDB;
- $new_enreg = $xoopsDB->getInsertId();
+ self::ensureLanguageLoaded();
+ $raw = $this->getVar('items_title', 'n');
+ if ($raw !== '' && str_starts_with((string) $raw, 'MENUS_') && defined($raw)) {
+ return constant($raw);
+ }
+ return (string) $raw;
+ }
- return $new_enreg;
+ /**
+ * Retrieve the title for the administration interface.
+ *
+ * When a menu item title is backed by a language constant, the admin view
+ * keeps the original token visible beside the translated value so it is
+ * easier to manage seeded entries and translations.
+ *
+ * @return string The resolved title with an optional constant reference
+ */
+ public function getAdminTitle(): string
+ {
+ $raw = (string) $this->getVar('items_title', 'n');
+ $resolved = $this->getResolvedTitle();
+ if ($resolved !== $raw) {
+ return $resolved . ' (' . $raw . ')';
+ }
+ return $resolved;
}
/**
- * Build and return the item edit/create form.
+ * Build and return the item create/edit form.
+ *
+ * The form is category-aware: it validates the owning category, limits
+ * the parent selector to active items in the same category, preserves
+ * raw affix values for later sanitization, and exposes the matching
+ * permission selector used by the admin controller.
*
- * @param int $category_id Category ID for this item
- * @param string|false $action Form action URL, defaults to REQUEST_URI
+ * @param int $categoryId Category id that owns the item
+ * @param string $action Form action URL
*
* @return \XoopsThemeForm
*/
- public function getFormItems($category_id, $action = false)
+ public function getFormItems(int $categoryId, string $action): \XoopsThemeForm
{
- if ($action === false) {
- $action = \Xmf\Request::getString('REQUEST_URI', '', 'SERVER');
- }
include_once XOOPS_ROOT_PATH . '/class/xoopsformloader.php';
- //form title
- $title = $this->isNew() ? sprintf(_AM_SYSTEM_MENUS_ADDITEM) : sprintf(_AM_SYSTEM_MENUS_EDITITEM);
-
- $form = new XoopsThemeForm($title, 'form', $action, 'post', true);
- $form->setExtra('enctype="multipart/form-data"');
-
- $isProtected = false;
- if (!$this->isNew()) {
- $form->addElement(new XoopsFormHidden('items_id', (string)$this->getVar('items_id')));
- $position = $this->getVar('items_position');
- $active = $this->getVar('items_active');
- $isProtected = (int)$this->getVar('items_protected') === 1;
- } else {
- $position = 0;
- $active = 1;
- }
+ $isEdit = (bool) $this->getVar('items_id');
+ $isProtected = (bool) $this->getVar('items_protected');
+ $title = $isEdit ? _AM_SYSTEM_MENUS_EDITITEM : _AM_SYSTEM_MENUS_ADDITEM;
+
+ $form = new \XoopsThemeForm($title, 'itemform', $action, 'post', true);
- // category
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- $category = $menuscategoryHandler->get($category_id);
- if (!is_object($category)) {
- $form->addElement(new XoopsFormLabel(_AM_SYSTEM_MENUS_TITLECAT, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY));
- return $form;
+ if ($isEdit) {
+ $form->addElement(new \XoopsFormHidden('items_id', (string) $this->getVar('items_id')));
}
- $form->addElement(new XoopsFormLabel(_AM_SYSTEM_MENUS_TITLECAT, (string)$category->getVar('category_title')));
- $form->addElement(new XoopsFormHidden('items_cid', $category_id));
+ $form->addElement(new \XoopsFormHidden('items_cid', (string) $categoryId));
- // Tree
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', $category_id));
- $criteria->add(new Criteria('items_active', 1));
- $criteria->setSort('items_position');
- $criteria->setOrder('ASC');
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- $item_arr = $menusitemsHandler->getall($criteria);
- // Use admin-friendly title for select labels
- foreach ($item_arr as $key => $obj) {
- if (is_object($obj) && method_exists($obj, 'getAdminTitle')) {
- $obj->setVar('items_title', $obj->getAdminTitle());
- $item_arr[$key] = $obj;
- }
+ $catHandler = xoops_getHandler('menuscategory');
+ $catObj = $catHandler->get($categoryId);
+ if ($catObj instanceof \XoopsMenusCategory && !$catObj->isNew()) {
+ $form->addElement(new \XoopsFormLabel(
+ _AM_SYSTEM_MENUS_ITEMCATEGORY,
+ htmlspecialchars($catObj->getAdminTitle(), ENT_QUOTES, 'UTF-8')
+ ));
}
- include_once $GLOBALS['xoops']->path('class/tree.php');
- $myTree = new XoopsObjectTree($item_arr, 'items_id', 'items_pid');
- $suparticle = $myTree->makeSelectElement('items_pid', 'items_title', '--', (string)$this->getVar('items_pid'), true, 0, '', _AM_SYSTEM_MENUS_PID);
+
+ $form->addElement($this->buildParentSelector($categoryId, $isProtected));
+
+ $titleField = new \XoopsFormText(
+ _AM_SYSTEM_MENUS_ITEMTITLE,
+ 'items_title',
+ 60,
+ 100,
+ (string) $this->getVar('items_title', 'e')
+ );
+ $titleField->setDescription(_AM_SYSTEM_MENUS_ITEMTITLE_DESC);
if ($isProtected) {
- $suparticle->setExtra('disabled="disabled"');
+ $titleField->setExtra('readonly="readonly"');
}
- $form->addElement($suparticle, false);
+ $form->addElement($titleField, true);
- // title
- $title = new XoopsFormText(_AM_SYSTEM_MENUS_TITLEITEM, 'items_title', 50, 255, (string)$this->getVar('items_title'));
+ $prefixField = $this->buildAffixField('items_prefix', _AM_SYSTEM_MENUS_ITEMPREFIX, $isProtected);
+ $prefixField->setDescription(_AM_SYSTEM_MENUS_ITEMPREFIX_DESC);
+ $form->addElement($prefixField);
+
+ $suffixField = $this->buildAffixField('items_suffix', _AM_SYSTEM_MENUS_ITEMSUFFIX, $isProtected);
+ $suffixField->setDescription(_AM_SYSTEM_MENUS_ITEMSUFFIX_DESC);
+ $form->addElement($suffixField);
+
+ $urlField = new \XoopsFormText(
+ _AM_SYSTEM_MENUS_ITEMURL,
+ 'items_url',
+ 60,
+ 255,
+ (string) $this->getVar('items_url', 'e')
+ );
if ($isProtected) {
- $title->setExtra('readonly="readonly"');
+ $urlField->setExtra('readonly="readonly"');
}
- $title->setDescription(_AM_SYSTEM_MENUS_TITLEITEM_DESC);
- $form->addElement($title, true);
- // prefix
- $editor_configs = array(
- 'name' => 'items_prefix',
- 'value' => $this->getVar('items_prefix', 'n'),
- 'rows' => 1,
- 'cols' => 50,
- 'width' => '100%',
- 'height' => '200px',
- 'editor' => 'Plain Text'
+ $form->addElement($urlField);
+
+ $targetField = new \XoopsFormRadio(
+ _AM_SYSTEM_MENUS_ITEMTARGET,
+ 'items_target',
+ (string) $this->getVar('items_target')
);
- $prefix = new XoopsFormEditor(_AM_SYSTEM_MENUS_PREFIXITEM, 'items_prefix', $editor_configs, false, 'textarea');
- if ($isProtected) {
- $prefix->setExtra('readonly="readonly"');
- if (isset($prefix->editor) && is_object($prefix->editor)) {
- $prefix->editor->setExtra('readonly="readonly"');
+ $targetField->addOption('0', _AM_SYSTEM_MENUS_TARGET_SELF);
+ $targetField->addOption('1', _AM_SYSTEM_MENUS_TARGET_BLANK);
+ $form->addElement($targetField);
+
+ $form->addElement(new \XoopsFormText(
+ _AM_SYSTEM_MENUS_ITEMPOSITION,
+ 'items_position',
+ 5,
+ 10,
+ (string) $this->getVar('items_position')
+ ));
+ $form->addElement(new \XoopsFormRadioYN(
+ _AM_SYSTEM_MENUS_ACTIVE,
+ 'items_active',
+ (int) $this->getVar('items_active')
+ ));
+
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permField = $permHelper->getGroupSelectFormForItem(
+ 'menus_items_view',
+ (int) $this->getVar('items_id'),
+ _AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM,
+ 'menus_items_view_perms',
+ true
+ );
+ $form->addElement($permField, false);
+
+ $form->addElement(new \XoopsFormHidden('op', 'saveitem'));
+ $form->addElement(new \XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
+
+ return $form;
+ }
+
+ /**
+ * Build a parent-item dropdown for the given category.
+ *
+ * @param int $categoryId The category to list items from
+ * @param bool $isProtected Whether the selector should be disabled
+ *
+ * @return \XoopsFormSelect
+ */
+ private function buildParentSelector(int $categoryId, bool $isProtected): \XoopsFormSelect
+ {
+ $currentId = (int) $this->getVar('items_id');
+ $handler = xoops_getHandler('menusitems');
+ $criteria = new \CriteriaCompo(new \Criteria('items_cid', (string) $categoryId));
+ $criteria->setSort('items_position');
+ $criteria->setOrder('ASC');
+ $allItems = $handler->getObjects($criteria);
+
+ $options = [0 => '---'];
+ foreach ($allItems as $sibling) {
+ $sid = (int) $sibling->getVar('items_id');
+ if ($sid !== $currentId) {
+ $options[$sid] = $sibling->getAdminTitle();
}
}
- $prefix->setDescription(_AM_SYSTEM_MENUS_PREFIXITEM_DESC);
- $form->addElement($prefix, false);
- // suffix
- $editor_configs = array(
- 'name' => 'items_suffix',
- 'value' => $this->getVar('items_suffix', 'n'),
- 'rows' => 1,
- 'cols' => 50,
- 'width' => '100%',
- 'height' => '200px',
- 'editor' => 'Plain Text'
+
+ $field = new \XoopsFormSelect(
+ _AM_SYSTEM_MENUS_ITEMPARENT,
+ 'items_pid',
+ (string) $this->getVar('items_pid'),
+ 1,
+ false
);
- $suffix = new XoopsFormEditor(_AM_SYSTEM_MENUS_SUFFIXITEM, 'items_suffix', $editor_configs, false, 'textarea');
+ $field->addOptionArray($options);
if ($isProtected) {
- $suffix->setExtra('readonly="readonly"');
- if (isset($suffix->editor) && is_object($suffix->editor)) {
- $suffix->editor->setExtra('readonly="readonly"');
- }
+ $field->setExtra('disabled="disabled"');
}
- $suffix->setDescription(_AM_SYSTEM_MENUS_SUFFIXITEM_DESC);
- $form->addElement($suffix, false);
- // url
- $url = new XoopsFormText(_AM_SYSTEM_MENUS_URLITEM, 'items_url', 50, 255, (string)$this->getVar('items_url'));
+ return $field;
+ }
+
+ /**
+ * Build a textarea element for a prefix/suffix affix field.
+ *
+ * Affix values are stored raw so the front-end renderer can sanitize
+ * and expand placeholders like `<{xoInboxCount}>` at output time.
+ *
+ * @param string $varName The variable name
+ * @param string $label The form label
+ * @param bool $isProtected Whether the field should be readonly
+ *
+ * @return \XoopsFormTextArea
+ */
+ private function buildAffixField(string $varName, string $label, bool $isProtected): \XoopsFormTextArea
+ {
+ $value = (string) $this->getVar($varName, 'n');
+ $field = new \XoopsFormTextArea($label, $varName, $value, 3, 60);
if ($isProtected) {
- $url->setExtra('readonly="readonly"');
+ $field->setExtra('readonly="readonly"');
}
- $form->addElement($url, false);
- // target
- $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_TARGET, 'items_target', (string)$this->getVar('items_target'));
- $radio->addOption(0, _AM_SYSTEM_MENUS_TARGET_SELF);
- $radio->addOption(1, _AM_SYSTEM_MENUS_TARGET_BLANK);
- $form->addElement($radio, false);
- // position
- $form->addElement(new XoopsFormText(_AM_SYSTEM_MENUS_POSITIONITEM, 'items_position', 5, 5, $position));
- // active
- $radio = new XoopsFormRadio(_AM_SYSTEM_MENUS_ACTIVE, 'items_active', $active);
- $radio->addOption(1, _YES);
- $radio->addOption(0, _NO);
- $form->addElement($radio);
-
- // permission
- $permHelper = new \Xmf\Module\Helper\Permission();
- $perm = $permHelper->getGroupSelectFormForItem('menus_items_view', $this->getVar('items_id'), _AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM, 'menus_items_view_perms', true);
- $perm->setDescription(_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM_DESC);
- $form->addElement($perm, false);
-
- $form->addElement(new XoopsFormHidden('op', 'saveitem'));
- // submit
- $form->addElement(new XoopsFormButton('', 'submit', _SUBMIT, 'submit'));
-
- return $form;
+ return $field;
}
}
-class XoopsMenusItemsHandler extends XoopsPersistableObjectHandler
+/**
+ * Handler for XoopsMenusItems objects.
+ *
+ * Note: Constructor accepts \XoopsDatabase to match parent XoopsPersistableObjectHandler
+ * signature. At runtime, the concrete XoopsMySQLDatabase is always passed.
+ */
+class XoopsMenusItemsHandler extends \XoopsPersistableObjectHandler
{
/**
- * Constructor
- *
- * @param XoopsDatabase $db reference to a xoopsDB object
+ * @param \XoopsDatabase $db Database connection
*/
- public function __construct($db)
+ public function __construct(\XoopsDatabase $db)
{
parent::__construct($db, 'menusitems', 'XoopsMenusItems', 'items_id', 'items_title');
}
+
+ /**
+ * Fetch active items from a set of allowed IDs, sorted by position.
+ *
+ * Used by the theme loader after permission filtering to retrieve
+ * only the items visible to the current user.
+ *
+ * @param int[] $itemIds Item IDs the current user may see
+ *
+ * @return array
+ */
+ public function getActiveItemsByIds(array $itemIds): array
+ {
+ $itemIds = array_values(array_filter(array_map('intval', $itemIds)));
+ if ([] === $itemIds) {
+ return [];
+ }
+
+ $criteria = new \CriteriaCompo();
+ $criteria->add(new \Criteria('items_active', '1'));
+ $criteria->add(new \Criteria('items_id', $itemIds, 'IN'));
+ $criteria->setSort('items_position');
+ $criteria->setOrder('ASC');
+
+ return $this->getAll($criteria);
+ }
+
+ /**
+ * Fetch all active items belonging to a single category, sorted by position.
+ *
+ * @param int $categoryId The parent category ID
+ *
+ * @return array
+ */
+ public function getActiveItemsByCategory(int $categoryId): array
+ {
+ $criteria = new \CriteriaCompo();
+ $criteria->add(new \Criteria('items_active', '1'));
+ $criteria->add(new \Criteria('items_cid', (string) $categoryId));
+ $criteria->setSort('items_position');
+ $criteria->setOrder('ASC');
+
+ return $this->getAll($criteria);
+ }
}
diff --git a/htdocs/modules/system/admin/menus/index.php b/htdocs/modules/system/admin/menus/index.php
index b8a448e80..7adb92ed4 100644
--- a/htdocs/modules/system/admin/menus/index.php
+++ b/htdocs/modules/system/admin/menus/index.php
@@ -15,5 +15,6 @@
* @since 2.5.12
* @author XOOPS Development Team
*/
-header('HTTP/1.0 404 Not Found');
+
+header('HTTP/1.1 404 Not Found');
exit();
diff --git a/htdocs/modules/system/admin/menus/main.php b/htdocs/modules/system/admin/menus/main.php
index 702bb5a15..921965445 100644
--- a/htdocs/modules/system/admin/menus/main.php
+++ b/htdocs/modules/system/admin/menus/main.php
@@ -10,853 +10,881 @@
*/
/**
- * @copyright XOOPS Project https://xoops.org/
- * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
- * @package system
- * @subpackage menus
+ * System Menu Administration
+ *
+ * @copyright 2001-2026 XOOPS Project (https://xoops.org)
+ * @license GNU GPL 2+ (https://www.gnu.org/licenses/gpl-2.0.html)
* @since 2.5.12
- * @author XOOPS Development Team, Grégory Mage (AKA GregMage)
+ * @author XOOPS Development Team
*/
+defined('XOOPS_ROOT_PATH') || exit('Restricted access');
+
+require_once XOOPS_ROOT_PATH . '/kernel/menuscategory.php';
+require_once XOOPS_ROOT_PATH . '/kernel/menusitems.php';
+require_once XOOPS_ROOT_PATH . '/modules/system/class/SystemMenusTree.php';
+
use Xmf\Request;
-use Xmf\Module\Helper;
+
+// --- Constants ---
+const MENUS_MAX_DEPTH = 3;
+const MENUS_ADMIN_URL = 'admin.php?fct=menus';
+
+// --- Access Control ---
+if (!is_object($xoopsUser) || !is_object($xoopsModule)
+ || !$xoopsUser->isAdmin($xoopsModule->mid())
+) {
+ exit(_NOPERM);
+}
+
+// --- Helper Functions ---
/**
- * Send a JSON response with a fresh XOOPS security token and exit.
+ * Send a JSON response and exit.
*
- * @param bool $success Whether the operation succeeded
- * @param array $extra Additional key/value pairs to merge into the response
+ * @param bool $success Whether the operation succeeded
+ * @param array $extra Additional data to include
+ *
+ * @throws \JsonException If JSON encoding fails
*/
-function menus_json_response($success, array $extra = [])
+function menus_send_json(bool $success, array $extra = []): void
{
- header('Content-Type: application/json');
+ // Refresh token as HTML input so JS can extract name+value for next request
+ $token = $GLOBALS['xoopsSecurity']->getTokenHTML();
+ header('Content-Type: application/json; charset=UTF-8');
echo json_encode(array_merge(
- ['success' => $success, 'token' => $GLOBALS['xoopsSecurity']->getTokenHTML()],
+ ['success' => $success, 'token' => $token],
$extra
- ));
+ ), JSON_THROW_ON_ERROR);
exit;
}
-// Check users rights
-if (!is_object($xoopsUser) || !is_object($xoopsModule) || !$xoopsUser->isAdmin($xoopsModule->mid())) {
- exit(_NOPERM);
+/**
+ * Check if the current operation returns JSON instead of HTML.
+ *
+ * @param string $op The operation name
+ *
+ * @return bool
+ */
+function menus_is_ajax(string $op): bool
+{
+ return in_array($op, ['saveorder', 'saveorderitems', 'toggleactivecat', 'toggleactiveitem'], true);
+}
+
+/**
+ * Prepare the output buffer for an AJAX response.
+ *
+ * @return void
+ */
+function menus_prepare_ajax(): void
+{
+ $GLOBALS['xoopsLogger']->activated = false;
+ while (ob_get_level()) {
+ ob_end_clean();
+ }
+}
+
+/**
+ * Validate CSRF token. On failure, send JSON error or redirect.
+ *
+ * @param bool $isAjax Whether this is an AJAX request
+ *
+ * @return void
+ */
+function menus_require_token(bool $isAjax): void
+{
+ if (!$GLOBALS['xoopsSecurity']->check()) {
+ $message = defined('_BADTOKEN') ? _BADTOKEN : 'Security token mismatch';
+ if ($isAjax) {
+ menus_send_json(false, ['message' => $message]);
+ }
+ redirect_header(MENUS_ADMIN_URL, 3, $message);
+ }
+}
+
+/**
+ * Reject javascript: URLs. Return trimmed URL or empty string.
+ *
+ * @param string $url The URL to sanitize
+ *
+ * @return string Safe URL
+ */
+function menus_sanitize_url(string $url): string
+{
+ // Decode HTML entities so encoded schemes (javascript:) don't bypass the check
+ $url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+ if ($url === '' || $url === '#') {
+ return $url;
+ }
+
+ // Relative URLs and fragment-only are safe
+ if ($url[0] === '/' || $url[0] === '.' || $url[0] === '?' || $url[0] === '#') {
+ return $url;
+ }
+
+ // Absolute URLs must use an allowed scheme
+ if (preg_match('#^([a-zA-Z][a-zA-Z0-9+.-]*):#', $url, $m)) {
+ $scheme = strtolower($m[1]);
+ if (!in_array($scheme, ['http', 'https', 'ftp', 'mailto'], true)) {
+ return '';
+ }
+ }
+
+ return $url;
+}
+
+/**
+ * Get the category handler (cached).
+ *
+ * @return XoopsMenusCategoryHandler
+ */
+function menus_cat_handler(): XoopsMenusCategoryHandler
+{
+ static $handler = null;
+ if (!$handler instanceof XoopsMenusCategoryHandler) {
+ $handler = xoops_getHandler('menuscategory');
+ if (!$handler instanceof XoopsMenusCategoryHandler) {
+ throw new \RuntimeException('menuscategory handler unavailable');
+ }
+ }
+ return $handler;
+}
+
+/**
+ * Get the item handler (cached).
+ *
+ * @return XoopsMenusItemsHandler
+ */
+function menus_item_handler(): XoopsMenusItemsHandler
+{
+ static $handler = null;
+ if (!$handler instanceof XoopsMenusItemsHandler) {
+ $handler = xoops_getHandler('menusitems');
+ if (!$handler instanceof XoopsMenusItemsHandler) {
+ throw new \RuntimeException('menusitems handler unavailable');
+ }
+ }
+ return $handler;
}
-// Define main template
-$GLOBALS['xoopsOption']['template_main'] = 'system_menus.tpl';
+/**
+ * Check whether an item can be enabled by walking its full parent chain.
+ *
+ * Both the owning category and every ancestor item must be active before
+ * an item may be toggled on.
+ *
+ * @param XoopsMenusItemsHandler $itemHandler Item handler
+ * @param XoopsMenusItems $item The item to check
+ *
+ * @return bool True when every ancestor is active
+ */
+function menus_item_can_be_enabled(XoopsMenusItemsHandler $itemHandler, XoopsMenusItems $item): bool
+{
+ // Category must exist and be active
+ $cat = menus_cat_handler()->get((int) $item->getVar('items_cid'));
+ if (!is_object($cat) || $cat->isNew()) {
+ return false; // Missing category — corrupted data
+ }
+ if (0 === (int) $cat->getVar('category_active')) {
+ return false;
+ }
+
+ // Walk the full parent chain (with visited-set guard against cycles)
+ $parentId = (int) $item->getVar('items_pid');
+ $visited = [];
+ while ($parentId > 0) {
+ if (isset($visited[$parentId])) {
+ return false; // Cycle detected — treat as invalid
+ }
+ $visited[$parentId] = true;
+ $parent = $itemHandler->get($parentId);
+ if (!is_object($parent) || $parent->isNew()) {
+ return false; // Missing parent — corrupted data
+ }
+ if (0 === (int) $parent->getVar('items_active')) {
+ return false;
+ }
+ $parentId = (int) $parent->getVar('items_pid');
+ }
+
+ return true;
+}
+
+/**
+ * Initialize all template variables to safe defaults.
+ *
+ * @param XoopsTpl $tpl The template engine
+ * @param string $op Current operation name
+ */
+function menus_assign_template_defaults(\XoopsTpl $tpl, string $op): void
+{
+ $tpl->assign('op', $op);
+ $tpl->assign('xoops_token', $GLOBALS['xoopsSecurity']->getTokenHTML());
+ $tpl->assign('category', []);
+ $tpl->assign('category_count', 0);
+ $tpl->assign('category_id', 0);
+ $tpl->assign('cat_title', '');
+ $tpl->assign('items', []);
+ $tpl->assign('items_count', 0);
+ $tpl->assign('nav_menu', '');
+ $tpl->assign('form', '');
+ $tpl->assign('error_message', '');
+ $tpl->assign('token', '');
+}
-// Get Action type
-$op = Request::getCmd('op', 'list');
+// --- Operation Dispatch ---
+$op = Request::getCmd('op', 'list', 'REQUEST');
+$isAjax = menus_is_ajax($op);
-// Call Header
-if ($op !== 'saveorder' && $op !== 'saveorderitems' && $op !== 'toggleactivecat' && $op !== 'toggleactiveitem') {
+if ($isAjax) {
+ menus_prepare_ajax();
+} else {
+ // Tell XOOPS which template to render before opening the admin page
+ $GLOBALS['xoopsOption']['template_main'] = 'system_menus.tpl';
xoops_cp_header();
- $xoopsTpl->assign('op', $op);
- $xoopsTpl->assign('xoops_token', $GLOBALS['xoopsSecurity']->getTokenHTML());
+ menus_assign_template_defaults($xoopsTpl, $op);
- // Define Stylesheet
$xoTheme->addStylesheet(XOOPS_URL . '/modules/system/css/admin.css');
$xoTheme->addStylesheet(XOOPS_URL . '/modules/system/css/menus.css');
- // Define scripts
$xoTheme->addScript('browse.php?Frameworks/jquery/jquery.js');
$xoTheme->addScript('browse.php?Frameworks/jquery/plugins/jquery.ui.js');
- $xoTheme->addScript('modules/system/js/nestedSortable.js');
$xoTheme->addScript('modules/system/js/admin.js');
+ $xoTheme->addScript('modules/system/js/nestedSortable.js');
$xoTheme->addScript('modules/system/js/menus.js');
-}
-$helper = Helper::getHelper('system');
-$nb_limit = $helper->getConfig('menus_pager', 15);
+ $xoBreadCrumb->addLink(_AM_SYSTEM_CONFIG, XOOPS_URL . '/modules/system/admin.php');
+}
switch ($op) {
+ // --- LIST ALL CATEGORIES ---
case 'list':
default:
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->addTips(sprintf(_AM_SYSTEM_MENUS_NAV_TIPS, $GLOBALS['xoopsConfig']['language']));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN);
+ $xoBreadCrumb->addTips(_AM_SYSTEM_MENUS_NAV_TIPS);
$xoBreadCrumb->render();
- $start = Request::getInt('start', 0);
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
+
+ $catHandler = menus_cat_handler();
+ $itemHandler = menus_item_handler();
+
$criteria = new CriteriaCompo();
$criteria->setSort('category_position');
$criteria->setOrder('ASC');
- $criteria->setStart($start);
- $criteria->setLimit($nb_limit);
- $category_arr = $menuscategoryHandler->getall($criteria);
- $category_count = $menuscategoryHandler->getCount($criteria);
- $xoopsTpl->assign('category_count', $category_count);
- if ($category_count > 0) {
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- xoops_load('SystemMenusTree', 'system');
- foreach (array_keys($category_arr) as $i) {
- $cid = $category_arr[$i]->getVar('category_id');
- $category = array();
- $category['id'] = $cid;
- $category['title'] = $category_arr[$i]->getAdminTitle();
- $category['prefix'] = $category_arr[$i]->getVar('category_prefix', 'n');
- $category['suffix'] = $category_arr[$i]->getVar('category_suffix', 'n');
- $category['url'] = $category_arr[$i]->getVar('category_url');
- $category['target'] = ($category_arr[$i]->getVar('category_target') == 1) ? '_blank' : '_self';
- $category['position'] = $category_arr[$i]->getVar('category_position');
- $category['active'] = $category_arr[$i]->getVar('category_active');
- $category['protected'] = $category_arr[$i]->getVar('category_protected');
- // Fetch items for this category
- $crit = new CriteriaCompo();
- $crit->add(new Criteria('items_cid', $cid));
- $crit->setSort('items_position');
- $crit->setOrder('ASC');
- $items_arr = $menusitemsHandler->getall($crit);
- $items_count = $menusitemsHandler->getCount($crit);
- $category['items_count'] = $items_count;
- $category['items'] = [];
- if ($items_count > 0) {
- foreach (array_keys($items_arr) as $j) {
- $item = [];
- $item['id'] = $items_arr[$j]->getVar('items_id');
- $item['title'] = $items_arr[$j]->getAdminTitle();
- $item['prefix'] = $items_arr[$j]->getVar('items_prefix', 'n');
- $item['suffix'] = $items_arr[$j]->getVar('items_suffix', 'n');
- $item['url'] = $items_arr[$j]->getVar('items_url');
- $item['target'] = ($items_arr[$j]->getVar('items_target') == 1) ? '_blank' : '_self';
- $item['active'] = $items_arr[$j]->getVar('items_active');
- $item['protected'] = $items_arr[$j]->getVar('items_protected');
- $item['pid'] = (int)$items_arr[$j]->getVar('items_pid');
- $category['items'][] = $item;
- }
- }
- $xoopsTpl->append('category', $category);
- unset($category);
- }
- // Display Page Navigation
- if ($category_count > $nb_limit) {
- $nav = new XoopsPageNav($category_count, $nb_limit, $start, 'start');
- $xoopsTpl->assign('nav_menu', $nav->renderNav(4));
- }
- } else {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- }
+ $categories = $catHandler->getObjects($criteria);
+
+ $catData = [];
+ foreach ($categories as $cat) {
+ $cid = (int) $cat->getVar('category_id');
+ $itemCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $cid));
+ $itemCriteria->setSort('items_position');
+ $itemCriteria->setOrder('ASC');
+ $items = $itemHandler->getObjects($itemCriteria);
+
+ $flatItems = [];
+ foreach ($items as $item) {
+ $flatItems[] = [
+ 'id' => (int) $item->getVar('items_id'),
+ 'pid' => (int) $item->getVar('items_pid'),
+ 'title' => $item->getAdminTitle(),
+ 'prefix' => $item->getVar('items_prefix', 'n'),
+ 'suffix' => $item->getVar('items_suffix', 'n'),
+ 'url' => (string) $item->getVar('items_url', 'n'),
+ 'active' => (int) $item->getVar('items_active'),
+ 'protected' => (int) $item->getVar('items_protected'),
+ ];
+ }
+
+ $catData[] = [
+ 'id' => $cid,
+ 'title' => $cat->getAdminTitle(),
+ 'prefix' => $cat->getVar('category_prefix', 'n'),
+ 'suffix' => $cat->getVar('category_suffix', 'n'),
+ 'url' => (string) $cat->getVar('category_url', 'n'),
+ 'target' => (int) $cat->getVar('category_target') === 1 ? '_blank' : '_self',
+ 'active' => (int) $cat->getVar('category_active'),
+ 'protected' => (int) $cat->getVar('category_protected'),
+ 'position' => (int) $cat->getVar('category_position'),
+ 'items_count' => count($flatItems),
+ 'items' => $flatItems,
+ ];
+ }
+
+ $xoopsTpl->assign('category', $catData);
+ $xoopsTpl->assign('category_count', count($catData));
+ $xoopsTpl->assign('token', $GLOBALS['xoopsSecurity']->createToken());
break;
+ // --- ADD CATEGORY FORM ---
case 'addcat':
$xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_ADDCAT);
$xoBreadCrumb->render();
- // Form
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- /** @var \XoopsMenusCategory $obj */
- $obj = $menuscategoryHandler->create();
- $form = $obj->getFormCat();
+
+ $cat = new XoopsMenusCategory();
+ $form = $cat->getFormCat(MENUS_ADMIN_URL);
$xoopsTpl->assign('form', $form->render());
break;
+ // --- EDIT CATEGORY FORM ---
case 'editcat':
$xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_EDITCAT);
$xoBreadCrumb->render();
- // Form
- $category_id = Request::getInt('category_id', 0);
- if ($category_id == 0) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- } else {
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- /** @var \XoopsMenusCategory $obj */
- $obj = $menuscategoryHandler->get($category_id);
- if (!is_object($obj)) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- } else {
- $form = $obj->getFormCat();
- $xoopsTpl->assign('form', $form->render());
- }
+
+ $catId = Request::getInt('category_id', 0, 'GET');
+ $cat = menus_cat_handler()->get($catId);
+ if (!is_object($cat) || $cat->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND);
}
+ $form = $cat->getFormCat(MENUS_ADMIN_URL);
+ $xoopsTpl->assign('form', $form->render());
break;
+ // --- SAVE CATEGORY ---
case 'savecat':
- if (!$GLOBALS['xoopsSecurity']->check()) {
- redirect_header('admin.php?fct=menus', 3, implode(' ', $GLOBALS['xoopsSecurity']->getErrors()));
- }
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- $id = Request::getInt('category_id', 0);
- $isProtected = false;
- /** @var \XoopsMenusCategory $obj */
- if ($id > 0) {
- $obj = $menuscategoryHandler->get($id);
- if (!is_object($obj)) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+ menus_require_token(false);
+ $catHandler = menus_cat_handler();
+ $catId = Request::getInt('category_id', 0, 'POST');
+
+ if ($catId > 0) {
+ $cat = $catHandler->get($catId);
+ if (!is_object($cat) || $cat->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND);
}
- $isProtected = (int)$obj->getVar('category_protected') === 1;
} else {
- $obj = $menuscategoryHandler->create();
+ $cat = $catHandler->create();
}
- // Server-side lock: protected categories keep immutable label and rendering fields.
+
+ $isProtected = (bool) $cat->getVar('category_protected');
+
if (!$isProtected) {
- $obj->setVar('category_title', Request::getString('category_title', ''));
- $obj->setVar('category_prefix', Request::getText('category_prefix', ''));
- $obj->setVar('category_suffix', Request::getText('category_suffix', ''));
- $catUrl = Request::getString('category_url', '');
- if (preg_match('/^\s*javascript:/i', $catUrl)) {
- $catUrl = '';
- }
- $obj->setVar('category_url', $catUrl);
- }
- $obj->setVar('category_target', Request::getInt('category_target', 0));
- $obj->setVar('category_position', Request::getInt('category_position', 0));
- $obj->setVar('category_active', Request::getInt('category_active', 1));
- /** @var \XoopsMenusCategory $obj */
- if ($menuscategoryHandler->insert($obj)) {
- // permissions
- if ($obj->getNewEnreg() == 0) {
- $perm_id = $obj->getVar('category_id');
- } else {
- $perm_id = $obj->getNewEnreg();
- }
- $permHelper = new \Xmf\Module\Helper\Permission();
- // permission view
- $groups_view = Request::getArray('menus_category_view_perms', [], 'POST');
- $permHelper->savePermissionForItem('menus_category_view', $perm_id, $groups_view);
- redirect_header('admin.php?fct=menus', 2, _AM_SYSTEM_DBUPDATED);
- } else {
- $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
+ $cat->setVar('category_title', Request::getString('category_title', '', 'POST'));
+ $cat->setVar('category_prefix', Request::getText('category_prefix', '', 'POST'));
+ $cat->setVar('category_suffix', Request::getText('category_suffix', '', 'POST'));
+ $cat->setVar('category_url', menus_sanitize_url(Request::getString('category_url', '', 'POST')));
+ $cat->setVar('category_target', Request::getInt('category_target', 0, 'POST'));
+ }
+ $cat->setVar('category_position', Request::getInt('category_position', 0, 'POST'));
+ $cat->setVar('category_active', Request::getInt('category_active', 1, 'POST'));
+
+ if (!$catHandler->insert($cat)) {
+ $xoopsTpl->assign('error_message', $cat->getHtmlErrors());
+ break;
}
+
+ // Save permissions using XMF helper
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->savePermissionForItem(
+ 'menus_category_view',
+ (int) $cat->getVar('category_id'),
+ Request::getArray('menus_category_view_perms', [], 'POST')
+ );
+
+ redirect_header(MENUS_ADMIN_URL, 2, _AM_SYSTEM_MENUS_SAVED);
break;
+ // --- DELETE CATEGORY ---
case 'delcat':
- $category_id = Request::getInt('category_id', 0);
- if ($category_id == 0) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- } else {
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->render();
- $surdel = Request::getBool('surdel', false);
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- /** @var \XoopsMenusCategory $obj */
- $obj = $menuscategoryHandler->get($category_id);
- if (!is_object($obj)) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- }
- if ((int)$obj->getVar('category_protected') === 1) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_CATPROTECTED);
- }
- if ($surdel === true) {
- if (!$GLOBALS['xoopsSecurity']->check()) {
- redirect_header('admin.php?fct=menus', 3, implode(' ', $GLOBALS['xoopsSecurity']->getErrors()));
- }
- // Collect item IDs before delete — FK CASCADE will remove the rows
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', $category_id));
- $items_arr = $menusitemsHandler->getall($criteria);
- $itemIds = [];
- foreach (array_keys($items_arr) as $i) {
- $itemIds[] = $items_arr[$i]->getVar('items_id');
- }
- if ($menuscategoryHandler->delete($obj)) {
- // Clean up permissions for category and its items
- $permHelper = new \Xmf\Module\Helper\Permission();
- $permHelper->deletePermissionForItem('menus_category_view', $category_id);
- foreach ($itemIds as $iid) {
- $permHelper->deletePermissionForItem('menus_items_view', $iid);
- }
- redirect_header('admin.php?fct=menus', 2, _AM_SYSTEM_DBUPDATED);
- } else {
- $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
- }
- } else {
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', $category_id));
- $items_arr = $menusitemsHandler->getall($criteria);
- $items = ' ';
- foreach (array_keys($items_arr) as $i) {
- $items .= '#' . $items_arr[$i]->getVar('items_id') . ': ' . htmlspecialchars((string)$items_arr[$i]->getVar('items_title'), ENT_QUOTES, 'UTF-8') . ' ';
- }
- xoops_confirm([
- 'surdel' => true,
- 'category_id' => $category_id,
- 'op' => 'delcat'
- ], Request::getString('REQUEST_URI', '', 'SERVER'), sprintf(_AM_SYSTEM_MENUS_SUREDELCAT, htmlspecialchars((string)$obj->getVar('category_title'), ENT_QUOTES, 'UTF-8')) . $items);
- }
+ $catId = Request::getInt('category_id', 0, 'REQUEST');
+ $confirm = Request::getInt('confirm', 0, 'POST');
+ $cat = menus_cat_handler()->get($catId);
+
+ if (!is_object($cat) || $cat->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND);
+ }
+ if ((int) $cat->getVar('category_protected') === 1) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATPROTECTED);
}
- break;
- case 'delitem':
- $item_id = Request::getInt('item_id', 0);
- $category_id = Request::getInt('category_id', 0);
- if ($item_id == 0) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
- } else {
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->render();
- include_once $GLOBALS['xoops']->path('class/tree.php');
- $surdel = Request::getBool('surdel', false);
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- /** @var \XoopsMenusItems $obj */
- $obj = $menusitemsHandler->get($item_id);
- if (!is_object($obj)) {
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
- }
- if ((int)$obj->getVar('items_protected') === 1) {
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED);
- }
- if ($obj->getVar('items_active') == 0) {
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMDISABLE);
- }
- if ($surdel === true) {
- if (!$GLOBALS['xoopsSecurity']->check()) {
- redirect_header('admin.php?fct=menus', 3, implode(' ', $GLOBALS['xoopsSecurity']->getErrors()));
- }
- if ($menusitemsHandler->delete($obj)) {
- $permHelper = new \Xmf\Module\Helper\Permission();
- $permHelper->deletePermissionForItem('menus_items_view', $item_id);
- // delete subitems of this item
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', (int)$obj->getVar('items_cid')));
- $items_arr = $menusitemsHandler->getall($criteria);
- $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
- $items_arr = $myTree->getAllChild($item_id);
- foreach (array_keys($items_arr) as $i) {
- $permHelper = new \Xmf\Module\Helper\Permission();
- $permHelper->deletePermissionForItem('menus_items_view', $items_arr[$i]->getVar('items_id'));
- $menusitemsHandler->delete($items_arr[$i]);
- }
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 2, _AM_SYSTEM_DBUPDATED);
- } else {
- $xoopsTpl->assign('error_message', $obj->getHtmlErrors());
- }
- } else {
- $objCid = (int)$obj->getVar('items_cid');
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', $objCid));
- $items_arr = $menusitemsHandler->getall($criteria);
- $myTree = new XoopsObjectTree($items_arr, 'items_id', 'items_pid');
- $items_arr = $myTree->getAllChild($item_id);
- $items = ' ';
- foreach (array_keys($items_arr) as $i) {
- $items .= '#' . $items_arr[$i]->getVar('items_id') . ': ' . htmlspecialchars((string)$items_arr[$i]->getVar('items_title'), ENT_QUOTES, 'UTF-8') . ' ';
+ if ($confirm) {
+ menus_require_token(false);
+
+ // Delete all items in this category
+ $itemHandler = menus_item_handler();
+ $itemCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $items = $itemHandler->getObjects($itemCriteria);
+
+ // Delete item permissions and items
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ foreach ($items as $item) {
+ $permHelper->deletePermissionForItem('menus_items_view', (int) $item->getVar('items_id'));
+ if (!$itemHandler->delete($item)) {
+ redirect_header(MENUS_ADMIN_URL, 3, 'Failed to delete item: ' . $item->getVar('items_id'));
}
- xoops_confirm([
- 'surdel' => true,
- 'item_id' => $item_id,
- 'category_id' => $objCid,
- 'op' => 'delitem'
- ], Request::getString('REQUEST_URI', '', 'SERVER'), sprintf(_AM_SYSTEM_MENUS_SUREDELITEM, htmlspecialchars((string)$obj->getVar('items_title'), ENT_QUOTES, 'UTF-8')) . $items);
}
+
+ // Delete category permissions and category
+ $permHelper->deletePermissionForItem('menus_category_view', $catId);
+ if (!menus_cat_handler()->delete($cat)) {
+ redirect_header(MENUS_ADMIN_URL, 3, 'Failed to delete category');
+ }
+
+ redirect_header(MENUS_ADMIN_URL, 2, _AM_SYSTEM_MENUS_DELETED);
+ } else {
+ xoops_confirm(
+ [
+ 'op' => 'delcat',
+ 'category_id' => $catId,
+ 'confirm' => 1,
+ ],
+ MENUS_ADMIN_URL,
+ sprintf(_AM_SYSTEM_MENUS_DELCAT_CONFIRM, htmlspecialchars($cat->getAdminTitle(), ENT_QUOTES, 'UTF-8'))
+ );
}
break;
- case 'saveorder':
- if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
- $GLOBALS['xoopsLogger']->activated = false;
- }
- while (ob_get_level()) {
- ob_end_clean();
- }
- if (!$GLOBALS['xoopsSecurity']->check()) {
- menus_json_response(false, ['message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors())]);
- }
+ // --- VIEW CATEGORY (show items) ---
+ case 'viewcat':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+
+ $catId = Request::getInt('category_id', 0, 'GET');
+ $cat = menus_cat_handler()->get($catId);
+ if (!is_object($cat) || $cat->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND);
+ }
+
+ $itemHandler = menus_item_handler();
+ $itemCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $itemCriteria->setSort('items_position');
+ $itemCriteria->setOrder('ASC');
+ $items = $itemHandler->getObjects($itemCriteria);
+
+ // Build flat item data for template
+ $itemData = [];
+ foreach ($items as $item) {
+ $itemData[] = [
+ 'id' => (int) $item->getVar('items_id'),
+ 'pid' => (int) $item->getVar('items_pid'),
+ 'title' => $item->getAdminTitle(),
+ 'prefix' => $item->getVar('items_prefix', 'n'),
+ 'suffix' => $item->getVar('items_suffix', 'n'),
+ 'url' => (string) $item->getVar('items_url', 'n'),
+ 'active' => (int) $item->getVar('items_active'),
+ 'protected' => (int) $item->getVar('items_protected'),
+ ];
+ }
+
+ $xoopsTpl->assign('category_id', $catId);
+ $xoopsTpl->assign('cat_title', $cat->getAdminTitle());
+ $xoopsTpl->assign('items_count', count($itemData));
+ $xoopsTpl->assign('items', $itemData);
+ $xoopsTpl->assign('token', $GLOBALS['xoopsSecurity']->createToken());
+ break;
- $order = Request::getArray('order', []);
- if (count($order) === 0) {
- menus_json_response(false, ['message' => 'No order provided']);
+ // --- ADD ITEM FORM ---
+ case 'additem':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_ADDITEM);
+ $xoBreadCrumb->render();
+
+ $catId = Request::getInt('category_id', 0, 'GET');
+ $parentId = Request::getInt('items_pid', 0, 'GET');
+ $xoopsTpl->assign('category_id', $catId);
+ $item = new XoopsMenusItems();
+ $item->setVar('items_cid', $catId);
+ $item->setVar('items_pid', $parentId);
+ $form = $item->getFormItems($catId, MENUS_ADMIN_URL);
+ $xoopsTpl->assign('form', $form->render());
+ break;
+
+ // --- EDIT ITEM FORM ---
+ case 'edititem':
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
+ $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
+ $xoBreadCrumb->render();
+
+ $itemId = Request::getInt('item_id', 0, 'GET');
+ $item = menus_item_handler()->get($itemId);
+ if (!is_object($item) || $item->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND);
}
+ $catId = (int) $item->getVar('items_cid');
+ $xoopsTpl->assign('category_id', $catId);
+ $form = $item->getFormItems($catId, MENUS_ADMIN_URL);
+ $xoopsTpl->assign('form', $form->render());
+ break;
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
+ // --- SAVE ITEM ---
+ case 'saveitem':
+ menus_require_token(false);
+ $itemHandler = menus_item_handler();
+ $itemId = Request::getInt('items_id', 0, 'POST');
+
+ if ($itemId > 0) {
+ $item = $itemHandler->get($itemId);
+ if (!is_object($item) || $item->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND);
+ }
+ // Existing item: derive category from DB, not POST (prevents cross-category moves via tampered form)
+ $catId = (int) $item->getVar('items_cid');
+ $parentId = (int) $item->getVar('items_protected')
+ ? (int) $item->getVar('items_pid') // Protected items keep their parent
+ : Request::getInt('items_pid', 0, 'POST');
+ } else {
+ $item = $itemHandler->create();
+ $catId = Request::getInt('items_cid', 0, 'POST');
+ $parentId = Request::getInt('items_pid', 0, 'POST');
- $pos = 1;
- $errors = [];
- foreach ($order as $id) {
- $id = (int)$id;
- if ($id <= 0) continue;
- $obj = $menuscategoryHandler->get($id);
- if (is_object($obj)) {
- $obj->setVar('category_position', $pos);
- if (!$menuscategoryHandler->insert($obj, true)) {
- $errors[] = "Failed to update id {$id}";
- }
- } else {
- $errors[] = "Not found id {$id}";
+ // Verify the category exists before creating a new item
+ $catCheck = menus_cat_handler()->get($catId);
+ if (!is_object($catCheck) || $catCheck->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND);
}
- $pos++;
}
+ $xoopsTpl->assign('category_id', $catId);
- if (empty($errors)) {
- menus_json_response(true);
- } else {
- menus_json_response(false, ['message' => implode('; ', $errors)]);
+ $isProtected = (bool) $item->getVar('items_protected');
+
+ // Validate parent
+ if ($parentId > 0) {
+ $allItemRows = [];
+ $allItemsCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $allItemsObj = $itemHandler->getObjects($allItemsCriteria);
+ foreach ($allItemsObj as $obj) {
+ $allItemRows[] = [
+ 'items_id' => (int) $obj->getVar('items_id'),
+ 'items_pid' => (int) $obj->getVar('items_pid'),
+ 'items_cid' => (int) $obj->getVar('items_cid'),
+ ];
+ }
+
+ $validation = SystemMenusTree::validateParent(
+ $itemId ?: PHP_INT_MAX, // New items can't be their own descendant
+ $catId,
+ $parentId,
+ $allItemRows,
+ MENUS_MAX_DEPTH
+ );
+
+ if ($validation !== true) {
+ $errorMap = [
+ 'self_parent' => _AM_SYSTEM_MENUS_ERROR_ITEMPARENT,
+ 'parent_not_found' => _AM_SYSTEM_MENUS_ERROR_ITEMPARENT,
+ 'cross_category' => _AM_SYSTEM_MENUS_ERROR_ITEMPARENT,
+ 'cycle' => _AM_SYSTEM_MENUS_ERROR_ITEMCYCLE,
+ 'max_depth' => _AM_SYSTEM_MENUS_ERROR_ITEMDEPTH,
+ ];
+ $msg = $errorMap[$validation] ?? _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 3, $msg);
+ }
}
- break;
- case 'saveorderitems':
- if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
- $GLOBALS['xoopsLogger']->activated = false;
+ $item->setVar('items_cid', $catId);
+ $item->setVar('items_pid', $parentId);
+
+ if (!$isProtected) {
+ $item->setVar('items_title', Request::getString('items_title', '', 'POST'));
+ $item->setVar('items_prefix', Request::getText('items_prefix', '', 'POST'));
+ $item->setVar('items_suffix', Request::getText('items_suffix', '', 'POST'));
+ $item->setVar('items_url', menus_sanitize_url(Request::getString('items_url', '', 'POST')));
+ $item->setVar('items_target', Request::getInt('items_target', 0, 'POST'));
}
- while (ob_get_level()) {
- ob_end_clean();
+ $item->setVar('items_position', Request::getInt('items_position', 0, 'POST'));
+
+ $wantActive = Request::getInt('items_active', 1, 'POST');
+ if ($wantActive === 1 && !menus_item_can_be_enabled(menus_item_handler(), $item)) {
+ $wantActive = 0;
}
- if (!$GLOBALS['xoopsSecurity']->check()) {
- menus_json_response(false, ['message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors())]);
+ $item->setVar('items_active', $wantActive);
+
+ if (!$itemHandler->insert($item)) {
+ $xoopsTpl->assign('error_message', $item->getHtmlErrors());
+ break;
}
- // nestedSortable sends serialized data: item[id]=parentId (or null for root)
- $serialized = Request::getString('item', '', 'POST');
- if (empty($serialized)) {
- menus_json_response(false, ['message' => 'No order provided']);
+ // Save permissions using XMF helper
+ $permHelper = new \Xmf\Module\Helper\Permission();
+ $permHelper->savePermissionForItem(
+ 'menus_items_view',
+ (int) $item->getVar('items_id'),
+ Request::getArray('menus_items_view_perms', [], 'POST')
+ );
+
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 2, _AM_SYSTEM_MENUS_SAVED);
+ break;
+
+ // --- DELETE ITEM ---
+ case 'delitem':
+ $itemId = Request::getInt('item_id', 0, 'REQUEST');
+ $confirm = Request::getInt('confirm', 0, 'POST');
+ $item = menus_item_handler()->get($itemId);
+
+ if (!is_object($item) || $item->isNew()) {
+ redirect_header(MENUS_ADMIN_URL, 3, _AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND);
}
+ $catId = (int) $item->getVar('items_cid');
- $parsed = [];
- parse_str($serialized, $parsed);
- $itemOrder = $parsed['item'] ?? [];
- if (!is_array($itemOrder) || count($itemOrder) === 0) {
- menus_json_response(false, ['message' => 'Invalid order data']);
+ if ((int) $item->getVar('items_protected') === 1) {
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 3, _AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED);
}
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
+ if ($confirm) {
+ menus_require_token(false);
- // Build proposed parent map and validate items exist with same category
- $parentMap = [];
- $itemObjects = [];
- $errors = [];
- foreach ($itemOrder as $id => $parentId) {
- $id = (int)$id;
- if ($id <= 0) continue;
- $obj = $menusitemsHandler->get($id);
- if (!is_object($obj)) {
- $errors[] = "Item not found id {$id}";
- continue;
- }
- $itemObjects[$id] = $obj;
- $newPid = !empty($parentId) ? (int)$parentId : 0;
- if ($newPid === $id) {
- $errors[] = "Item {$id} cannot be its own parent";
- continue;
+ // Collect and delete all descendants
+ $itemHandler = menus_item_handler();
+ $allCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $allItems = $itemHandler->getObjects($allCriteria);
+
+ $allRows = [];
+ foreach ($allItems as $obj) {
+ $allRows[] = [
+ 'items_id' => (int) $obj->getVar('items_id'),
+ 'items_pid' => (int) $obj->getVar('items_pid'),
+ 'items_cid' => (int) $obj->getVar('items_cid'),
+ ];
}
- if ($newPid > 0) {
- // Resolve parent from already-loaded objects or DB
- $parentObj = $itemObjects[$newPid] ?? $menusitemsHandler->get($newPid);
- if (!is_object($parentObj)) {
- $errors[] = "Parent {$newPid} not found for item {$id}";
- continue;
- }
- if ((int)$parentObj->getVar('items_cid') !== (int)$obj->getVar('items_cid')) {
- $errors[] = "Parent {$newPid} belongs to a different category than item {$id}";
- continue;
+ $descendantIds = SystemMenusTree::collectDescendantIds($allRows, $itemId);
+
+ // Abort if any descendant is protected — would leave orphaned records
+ foreach ($descendantIds as $descId) {
+ $descItem = $itemHandler->get($descId);
+ if (is_object($descItem) && (int) $descItem->getVar('items_protected') === 1) {
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 3, _AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED);
}
}
- $parentMap[$id] = $newPid;
- }
-
- // Validate full graph: no cycles, depth <= 3
- if (empty($errors)) {
- foreach ($parentMap as $id => $pid) {
- $visited = [$id];
- $current = $pid;
- $depth = 0;
- while ($current > 0) {
- if (in_array($current, $visited)) {
- $errors[] = "Cycle detected involving item {$id}";
- break;
- }
- $visited[] = $current;
- // Use proposed parent if in map, otherwise fall back to DB
- if (isset($parentMap[$current])) {
- $current = $parentMap[$current];
- } else {
- $dbObj = $menusitemsHandler->get($current);
- $current = is_object($dbObj) ? (int)$dbObj->getVar('items_pid') : 0;
- }
- $depth++;
- if ($depth >= 3) {
- $errors[] = "Depth limit exceeded for item {$id}";
- break;
+
+ $permHelper = new \Xmf\Module\Helper\Permission();
+
+ // Delete descendants
+ foreach ($descendantIds as $descId) {
+ $descItem = $itemHandler->get($descId);
+ if (is_object($descItem)) {
+ $permHelper->deletePermissionForItem('menus_items_view', $descId);
+ if (!$itemHandler->delete($descItem)) {
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 3, 'Failed to delete descendant item: ' . $descId);
}
}
}
- }
- // Only write if all validations passed
- if (empty($errors)) {
- $pos = 1;
- foreach ($itemOrder as $id => $parentId) {
- $id = (int)$id;
- if ($id <= 0 || !isset($itemObjects[$id]) || !isset($parentMap[$id])) {
- $pos++;
- continue;
- }
- $obj = $itemObjects[$id];
- $obj->setVar('items_position', $pos);
- $obj->setVar('items_pid', $parentMap[$id]);
- if (!$menusitemsHandler->insert($obj, true)) {
- $errors[] = "Failed to update item id {$id}";
- }
- $pos++;
+ // Delete the item itself
+ $permHelper->deletePermissionForItem('menus_items_view', $itemId);
+ if (!$itemHandler->delete($item)) {
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 3, 'Failed to delete item');
}
- }
- if (empty($errors)) {
- menus_json_response(true);
+ redirect_header(MENUS_ADMIN_URL . '#cat_' . $catId, 2, _AM_SYSTEM_MENUS_DELETED);
} else {
- menus_json_response(false, ['message' => implode('; ', $errors)]);
+ xoops_confirm(
+ [
+ 'op' => 'delitem',
+ 'item_id' => $itemId,
+ 'confirm' => 1,
+ ],
+ MENUS_ADMIN_URL,
+ sprintf(_AM_SYSTEM_MENUS_DELITEM_CONFIRM, htmlspecialchars($item->getAdminTitle(), ENT_QUOTES, 'UTF-8'))
+ );
}
break;
- case 'viewcat':
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
- $xoBreadCrumb->render();
- $category_id = Request::getInt('category_id', 0);
- $xoopsTpl->assign('category_id', $category_id);
- if ($category_id == 0) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- } else {
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- /** @var \XoopsMenusCategory $category */
- $category = $menuscategoryHandler->get($category_id);
- if (!is_object($category)) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- break;
- }
- $xoopsTpl->assign('category_id', $category->getVar('category_id'));
- $xoopsTpl->assign('cat_title', $category->getAdminTitle());
-
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- $criteria = new CriteriaCompo();
- $criteria->add(new Criteria('items_cid', $category_id));
- $criteria->setSort('items_position');
- $criteria->setOrder('ASC');
- $items_arr = $menusitemsHandler->getall($criteria);
- $items_count = $menusitemsHandler->getCount($criteria);
- $xoopsTpl->assign('items_count', $items_count);
- if ($items_count > 0) {
- foreach (array_keys($items_arr) as $i) {
- $item = [];
- $item['id'] = $items_arr[$i]->getVar('items_id');
- $item['title'] = $items_arr[$i]->getAdminTitle();
- $item['prefix'] = $items_arr[$i]->getVar('items_prefix', 'n');
- $item['suffix'] = $items_arr[$i]->getVar('items_suffix', 'n');
- $item['url'] = $items_arr[$i]->getVar('items_url');
- $item['target'] = ($items_arr[$i]->getVar('items_target') == 1) ? '_blank' : '_self';
- $item['active'] = $items_arr[$i]->getVar('items_active');
- $item['protected'] = $items_arr[$i]->getVar('items_protected');
- $item['pid'] = (int)$items_arr[$i]->getVar('items_pid');
- $item['cid'] = $items_arr[$i]->getVar('items_cid');
- $xoopsTpl->append('items', $item);
- unset($item);
+ // --- SAVE CATEGORY ORDER (AJAX) ---
+ case 'saveorder':
+ menus_require_token(true);
+ $order = Request::getArray('order', [], 'POST');
+ $catHandler = menus_cat_handler();
+
+ foreach ($order as $position => $catId) {
+ $cat = $catHandler->get((int) $catId);
+ if (is_object($cat) && !$cat->isNew()) {
+ $cat->setVar('category_position', $position + 1);
+ if (!$catHandler->insert($cat)) {
+ menus_send_json(false, ['message' => 'Failed to save category order']);
}
}
}
- break;
- case 'additem':
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
- $xoBreadCrumb->render();
- $category_id = Request::getInt('category_id', 0);
- $xoopsTpl->assign('category_id', $category_id);
- // Form
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- /** @var \XoopsMenusItems $obj */
- $obj = $menusitemsHandler->create();
- $form = $obj->getFormItems($category_id);
- $xoopsTpl->assign('form', $form->render());
+ menus_send_json(true, ['message' => _AM_SYSTEM_MENUS_ORDER_SAVED]);
break;
- case 'saveitem':
- if (!$GLOBALS['xoopsSecurity']->check()) {
- redirect_header('admin.php?fct=menus', 3, implode(' ', $GLOBALS['xoopsSecurity']->getErrors()));
- }
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
- $xoBreadCrumb->render();
+ // --- SAVE ITEM TREE ORDER (AJAX) ---
+ case 'saveorderitems':
+ menus_require_token(true);
+ $catId = Request::getInt('category_id', 0, 'POST');
+ $treeJson = Request::getString('tree', '', 'POST');
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- $id = Request::getInt('items_id', 0);
- $isProtected = false;
- /** @var \XoopsMenusItems $obj */
- if ($id > 0) {
- $obj = $menusitemsHandler->get($id);
- if (!is_object($obj)) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOITEM);
- }
- $isProtected = (int)$obj->getVar('items_protected') === 1;
- } else {
- $obj = $menusitemsHandler->create();
+ if ($catId <= 0 || $treeJson === '') {
+ menus_send_json(false, ['message' => 'Invalid data']);
}
- $items_cid = Request::getInt('items_cid', 0);
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandlerCheck */
- $menuscategoryHandlerCheck = xoops_getHandler('menuscategory');
- $cidObj = $menuscategoryHandlerCheck->get($items_cid);
- if (!is_object($cidObj)) {
- redirect_header('admin.php?fct=menus', 3, _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
+
+ $tree = json_decode($treeJson, true);
+ if (!is_array($tree)) {
+ menus_send_json(false, ['message' => 'Invalid JSON']);
}
- $obj->setVar('items_cid', $items_cid);
- $error_message = '';
- if (!$isProtected) {
- $itempid = Request::getInt('items_pid', 0);
- if ($itempid != 0 && $itempid == $id) {
- $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
- } elseif ($itempid != 0) {
- // Verify parent exists and belongs to same category
- $parentItem = $menusitemsHandler->get($itempid);
- if (!is_object($parentItem)) {
- $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
- } elseif ((int)$parentItem->getVar('items_cid') !== $items_cid) {
- $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMPARENT;
- } else {
- // Walk ancestor chain to detect cycles and enforce depth limit
- $depth = 1;
- $ancestorId = (int)$parentItem->getVar('items_pid');
- $isCycle = false;
- $visitedAncestors = [$itempid];
- while ($ancestorId > 0) {
- if (($id > 0 && $ancestorId === $id) || in_array($ancestorId, $visitedAncestors)) {
- $isCycle = true;
- break;
- }
- $visitedAncestors[] = $ancestorId;
- $ancestor = $menusitemsHandler->get($ancestorId);
- if (!is_object($ancestor)) {
- break;
- }
- $ancestorId = (int)$ancestor->getVar('items_pid');
- $depth++;
+
+ // Validate node shape, collect IDs, reject duplicates
+ $seenIds = [];
+ $validateNodes = function (array $nodes) use (&$validateNodes, &$seenIds): bool {
+ foreach ($nodes as $node) {
+ if (!is_array($node) || !isset($node['id'])) {
+ return false; // Malformed node
+ }
+ $id = (int) $node['id'];
+ if ($id <= 0) {
+ return false;
+ }
+ if (isset($seenIds[$id])) {
+ return false; // Duplicate
+ }
+ $seenIds[$id] = true;
+ if (isset($node['children'])) {
+ if (!is_array($node['children'])) {
+ return false; // children must be array
}
- if ($isCycle) {
- $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMCYCLE;
- } elseif ($depth >= 3) {
- $error_message .= _AM_SYSTEM_MENUS_ERROR_ITEMDEPTH;
- } else {
- $obj->setVar('items_pid', $itempid);
+ if (!$validateNodes($node['children'])) {
+ return false;
}
}
- } else {
- $obj->setVar('items_pid', 0);
}
+ return true;
+ };
+ if (!$validateNodes($tree)) {
+ menus_send_json(false, ['message' => 'Invalid or duplicate item IDs in tree']);
}
- if (!$isProtected) {
- $obj->setVar('items_title', Request::getString('items_title', ''));
- $obj->setVar('items_prefix', Request::getText('items_prefix', ''));
- $obj->setVar('items_suffix', Request::getText('items_suffix', ''));
- $itemUrl = Request::getString('items_url', '');
- if (preg_match('/^\s*javascript:/i', $itemUrl)) {
- $itemUrl = '';
- }
- $obj->setVar('items_url', $itemUrl);
- }
- $obj->setVar('items_position', Request::getInt('items_position', 0));
- $obj->setVar('items_target', Request::getInt('items_target', 0));
- $obj->setVar('items_active', Request::getInt('items_active', 1));
- /** @var \XoopsMenusItems $obj */
- if ($error_message == '') {
- if ($menusitemsHandler->insert($obj)) {
- // permissions
- if ($obj->getNewEnreg() == 0) {
- $perm_id = $obj->getVar('items_id');
- } else {
- $perm_id = $obj->getNewEnreg();
+
+ // Verify posted IDs match the category's current item set exactly
+ $itemHandler = menus_item_handler();
+ $currentCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $currentItems = $itemHandler->getObjects($currentCriteria);
+ $currentIds = [];
+ foreach ($currentItems as $ci) {
+ $currentIds[(int) $ci->getVar('items_id')] = true;
+ }
+ ksort($seenIds);
+ ksort($currentIds);
+ if ($seenIds !== $currentIds) {
+ menus_send_json(false, ['message' => 'Tree does not match current item set']);
+ }
+
+ // Validate depth
+ $depth = SystemMenusTree::computeDepth($tree);
+ if ($depth > MENUS_MAX_DEPTH) {
+ menus_send_json(false, ['message' => _AM_SYSTEM_MENUS_ERROR_ITEMDEPTH]);
+ }
+
+ // Walk the tree and update positions
+ $position = 0;
+
+ $failed = false;
+ $walkTree = function (array $nodes, int $parentId) use (&$walkTree, $itemHandler, $catId, &$position, &$failed): void {
+ foreach ($nodes as $node) {
+ if ($failed) {
+ return;
}
- $permHelper = new \Xmf\Module\Helper\Permission();
- $groups_view = Request::getArray('menus_items_view_perms', [], 'POST');
- $permHelper->savePermissionForItem('menus_items_view', $perm_id, $groups_view);
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $items_cid, 2, _AM_SYSTEM_DBUPDATED);
- } else {
- $xoopsTpl->assign('category_id', $items_cid);
- $htmlErrors = $obj->getHtmlErrors();
- if (empty($htmlErrors) || $htmlErrors === 'None' || trim(strip_tags($htmlErrors)) === 'None') {
- $htmlErrors = $GLOBALS['xoopsDB']->error();
+ $nodeItemId = (int) ($node['id'] ?? 0);
+ if ($nodeItemId <= 0) {
+ continue;
+ }
+ $nodeItem = $itemHandler->get($nodeItemId);
+ if (!is_object($nodeItem) || $nodeItem->isNew()) {
+ continue;
+ }
+ // Verify same category
+ if ((int) $nodeItem->getVar('items_cid') !== $catId) {
+ continue;
+ }
+ $nodeItem->setVar('items_pid', $parentId);
+ $nodeItem->setVar('items_position', ++$position);
+ if (!$itemHandler->insert($nodeItem)) {
+ $failed = true;
+ return;
}
- $xoopsTpl->assign('error_message', $htmlErrors);
- /** @var \XoopsMenusItems $obj */
- $form = $obj->getFormItems($items_cid);
- $xoopsTpl->assign('form', $form->render());
- }
- } else {
- /** @var \XoopsMenusItems $obj */
- $form = $obj->getFormItems($items_cid);
- $xoopsTpl->assign('form', $form->render());
- $xoopsTpl->assign('error_message', $error_message);
- }
- break;
- case 'edititem':
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_MAIN, system_adminVersion('menus', 'adminpath'));
- $xoBreadCrumb->addLink(_AM_SYSTEM_MENUS_NAV_CATEGORY);
- $xoBreadCrumb->render();
- $item_id = Request::getInt('item_id', 0);
- $category_id = Request::getInt('category_id', 0);
- $xoopsTpl->assign('category_id', $category_id);
- if ($item_id == 0 || $category_id == 0) {
- if ($item_id == 0) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOITEM);
- }
- if ($category_id == 0) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOCATEGORY);
- }
- } else {
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- /** @var \XoopsMenusItems $obj */
- $obj = $menusitemsHandler->get($item_id);
- if (!is_object($obj)) {
- $xoopsTpl->assign('error_message', _AM_SYSTEM_MENUS_ERROR_NOITEM);
- } elseif ($obj->getVar('items_active') == 0) {
- redirect_header('admin.php?fct=menus&op=viewcat&category_id=' . $category_id, 5, _AM_SYSTEM_MENUS_ERROR_ITEMEDIT);
- } else {
- $form = $obj->getFormItems($category_id);
- $xoopsTpl->assign('form', $form->render());
+ if (!empty($node['children'])) {
+ $walkTree($node['children'], $nodeItemId);
+ }
}
+ };
+
+ $walkTree($tree, 0);
+ if ($failed) {
+ menus_send_json(false, ['message' => 'Failed to save item order']);
}
+ menus_send_json(true, ['message' => _AM_SYSTEM_MENUS_ORDER_SAVED]);
break;
+ // --- TOGGLE CATEGORY ACTIVE (AJAX) ---
case 'toggleactivecat':
- if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
- $GLOBALS['xoopsLogger']->activated = false;
- }
- while (ob_get_level()) {
- ob_end_clean();
- }
- if (!$GLOBALS['xoopsSecurity']->check()) {
- menus_json_response(false, ['message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors())]);
- }
+ menus_require_token(true);
+ $catId = Request::getInt('category_id', 0, 'POST');
+ $cat = menus_cat_handler()->get($catId);
- $category_id = Request::getInt('category_id', 0);
- if ($category_id <= 0) {
- menus_json_response(false, ['message' => 'Invalid id']);
+ if (!is_object($cat) || $cat->isNew()) {
+ menus_send_json(false, ['message' => _AM_SYSTEM_MENUS_ERROR_CATNOTFOUND]);
}
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
-
- /** @var \XoopsMenusCategory $obj */
- $obj = $menuscategoryHandler->get($category_id);
- if (!is_object($obj)) {
- menus_json_response(false, ['message' => 'Not found']);
+ $newActive = $cat->getVar('category_active') ? 0 : 1;
+ $cat->setVar('category_active', $newActive);
+ if (!menus_cat_handler()->insert($cat)) {
+ menus_send_json(false, ['message' => 'Failed to update category']);
}
- $new = $obj->getVar('category_active') ? 0 : 1;
- $obj->setVar('category_active', $new);
- $res = $menuscategoryHandler->insert($obj, true);
- // Only cascade on deactivation; preserve child state on activation
- $updatedItems = [];
- if ($res && $new === 0) {
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
- $critCat = new Criteria('items_cid', $category_id);
- $allItems = $menusitemsHandler->getAll($critCat);
- foreach ($allItems as $itm) {
- if ((int)$itm->getVar('items_active') !== 0) {
- $itm->setVar('items_active', 0);
- if ($menusitemsHandler->insert($itm, true)) {
- $updatedItems[] = $itm->getVar('items_id');
- }
+ // If deactivating, cascade to all items
+ if ($newActive === 0) {
+ $itemHandler = menus_item_handler();
+ $criteria = new CriteriaCompo(new Criteria('items_cid', (string) $catId));
+ $items = $itemHandler->getObjects($criteria);
+ foreach ($items as $item) {
+ $item->setVar('items_active', 0);
+ if (!$itemHandler->insert($item)) {
+ menus_send_json(false, ['message' => 'Failed to cascade deactivation']);
}
}
}
- if ($res) {
- $extra = ['active' => (int)$new];
- if (!empty($updatedItems)) {
- $extra['updated'] = array_values($updatedItems);
- }
- menus_json_response(true, $extra);
- } else {
- menus_json_response(false, ['message' => 'Save failed']);
- }
+ menus_send_json(true, ['active' => $newActive]);
break;
+ // --- TOGGLE ITEM ACTIVE (AJAX) ---
case 'toggleactiveitem':
- if (isset($GLOBALS['xoopsLogger']) && is_object($GLOBALS['xoopsLogger'])) {
- $GLOBALS['xoopsLogger']->activated = false;
- }
- while (ob_get_level()) {
- ob_end_clean();
- }
+ menus_require_token(true);
+ $itemId = Request::getInt('item_id', 0, 'POST');
+ $item = menus_item_handler()->get($itemId);
- if (!$GLOBALS['xoopsSecurity']->check()) {
- menus_json_response(false, ['message' => implode(' ', $GLOBALS['xoopsSecurity']->getErrors())]);
+ if (!is_object($item) || $item->isNew()) {
+ menus_send_json(false, ['message' => _AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND]);
}
- $item_id = Request::getInt('item_id', 0);
- if ($item_id <= 0) {
- menus_json_response(false, ['message' => 'Invalid id']);
- }
+ $newActive = $item->getVar('items_active') ? 0 : 1;
- /** @var \XoopsMenusItemsHandler $menusitemsHandler */
- $menusitemsHandler = xoops_getHandler('menusitems');
+ // If activating, check the full parent chain (category + all ancestor items)
+ if ($newActive === 1 && !menus_item_can_be_enabled(menus_item_handler(), $item)) {
+ menus_send_json(false, ['message' => _AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE]);
+ }
- /** @var \XoopsMenusItems $obj */
- $obj = $menusitemsHandler->get($item_id);
- if (!is_object($obj)) {
- menus_json_response(false, ['message' => 'Not found']);
+ $item->setVar('items_active', $newActive);
+ if (!menus_item_handler()->insert($item)) {
+ menus_send_json(false, ['message' => 'Failed to update item']);
}
- $current = (int)$obj->getVar('items_active');
- $new = $current ? 0 : 1;
- // if activating, ensure owning category and ancestors are active
- if ($new) {
- /** @var \XoopsMenusCategoryHandler $menuscategoryHandler */
- $menuscategoryHandler = xoops_getHandler('menuscategory');
- $ownerCat = $menuscategoryHandler->get((int)$obj->getVar('items_cid'));
- if (is_object($ownerCat) && (int)$ownerCat->getVar('category_active') === 0) {
- menus_json_response(false, ['message' => _AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE]);
- }
- $parentId = (int)$obj->getVar('items_pid');
- while ($parentId > 0) {
- $parentObj = $menusitemsHandler->get($parentId);
- if (!is_object($parentObj)) {
- break;
- }
- if ((int)$parentObj->getVar('items_active') === 0) {
- menus_json_response(false, ['message' => _AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE]);
- }
- $parentId = (int)$parentObj->getVar('items_pid');
+ // If deactivating, cascade to descendants
+ if ($newActive === 0) {
+ $allCriteria = new CriteriaCompo(new Criteria('items_cid', (string) $item->getVar('items_cid')));
+ $allItems = menus_item_handler()->getObjects($allCriteria);
+ $allRows = [];
+ foreach ($allItems as $obj) {
+ $allRows[] = [
+ 'items_id' => (int) $obj->getVar('items_id'),
+ 'items_pid' => (int) $obj->getVar('items_pid'),
+ 'items_cid' => (int) $obj->getVar('items_cid'),
+ ];
}
- }
- $obj->setVar('items_active', $new);
- $res = $menusitemsHandler->insert($obj, true);
-
- // Only cascade on deactivation; preserve child state on activation
- $updatedChildren = [];
- if ($res && $new === 0) {
- $propagateDeactivation = function ($handler, $parentId, array &$updated) use (&$propagateDeactivation) {
- $criteria = new Criteria('items_pid', (int)$parentId);
- $children = $handler->getAll($criteria);
- foreach ($children as $child) {
- $childId = $child->getVar('items_id');
- if ((int)$child->getVar('items_active') !== 0) {
- $child->setVar('items_active', 0);
- if ($handler->insert($child, true)) {
- $updated[] = $childId;
- }
+ $descIds = SystemMenusTree::collectDescendantIds($allRows, $itemId);
+ foreach ($descIds as $descId) {
+ $descItem = menus_item_handler()->get($descId);
+ if (is_object($descItem)) {
+ $descItem->setVar('items_active', 0);
+ if (!menus_item_handler()->insert($descItem)) {
+ menus_send_json(false, ['message' => 'Failed to cascade deactivation']);
}
- $propagateDeactivation($handler, $childId, $updated);
}
- };
-
- $propagateDeactivation($menusitemsHandler, $item_id, $updatedChildren);
- }
-
- if ($res) {
- $extra = ['active' => (int)$new];
- if (!empty($updatedChildren)) {
- $extra['updated'] = array_values($updatedChildren);
}
- menus_json_response(true, $extra);
- } else {
- menus_json_response(false, ['message' => 'Save failed']);
}
+
+ menus_send_json(true, ['active' => $newActive]);
break;
+} // end switch
-}
-if ($op !== 'saveorder' && $op !== 'toggleactivecat' && $op !== 'toggleactiveitem') {
- // Call Footer
+if (!menus_is_ajax($op)) {
xoops_cp_footer();
}
diff --git a/htdocs/modules/system/admin/menus/xoops_version.php b/htdocs/modules/system/admin/menus/xoops_version.php
index 02a41ac50..09f827d29 100644
--- a/htdocs/modules/system/admin/menus/xoops_version.php
+++ b/htdocs/modules/system/admin/menus/xoops_version.php
@@ -1,19 +1,19 @@
_AM_SYSTEM_MENUS,
'version' => '1.0',
'description' => _AM_SYSTEM_MENUS_DESC,
'author' => '',
- 'credits' => 'XOOPS Development Team, Grégory Mage (AKA GregMage)',
+ 'credits' => 'XOOPS Development Team',
'help' => 'page=menus',
'license' => 'GPL see LICENSE',
'official' => 1,
@@ -22,4 +22,4 @@
'hasAdmin' => 1,
'adminpath' => 'admin.php?fct=menus',
'category' => XOOPS_SYSTEM_MENUS,
- ];
+];
diff --git a/htdocs/modules/system/class/SystemMenusTree.php b/htdocs/modules/system/class/SystemMenusTree.php
index d2ae083d2..ffc3e9379 100644
--- a/htdocs/modules/system/class/SystemMenusTree.php
+++ b/htdocs/modules/system/class/SystemMenusTree.php
@@ -1,105 +1,224 @@
path('class/tree.php');
-
/**
- * SystemMenusTree : extension of XoopsObjectTree for menus
- *
- * @category Xoops
- * @author XOOPS Development Team, Grégory Mage (AKA GregMage)
- * @copyright XOOPS Project https://xoops.org/
- * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
- * @link https://xoops.org
- * @since 2.5.12
+ * Static utility class for menu tree validation and traversal.
*/
-class SystemMenusTree extends XoopsObjectTree
+class SystemMenusTree
{
/**
- * @access private
+ * Validate whether $parentId is a valid parent for $itemId.
+ *
+ * @param int $itemId The item being moved/saved
+ * @param int $categoryId The category both must belong to
+ * @param int $parentId The proposed parent item ID (0 for root)
+ * @param array $allItems Flat array of item records with items_id, items_pid, items_cid
+ * @param int $maxDepth Maximum allowed tree depth
+ *
+ * @return bool|string True if valid, or error key string
*/
- protected $listTree = array();
- protected $cpt;
+ public static function validateParent(
+ int $itemId,
+ int $categoryId,
+ int $parentId,
+ array $allItems,
+ int $maxDepth
+ ): bool|string {
+ if ($parentId === 0) {
+ return true;
+ }
+ if ($parentId === $itemId) {
+ return 'self_parent';
+ }
+
+ $parent = null;
+ foreach ($allItems as $row) {
+ if ((int)$row['items_id'] === $parentId) {
+ $parent = $row;
+ break;
+ }
+ }
+ if ($parent === null) {
+ return 'parent_not_found';
+ }
+ if ((int)$parent['items_cid'] !== $categoryId) {
+ return 'cross_category';
+ }
+
+ $descendants = self::collectDescendantIds($allItems, $itemId);
+ if (in_array($parentId, $descendants, true)) {
+ return 'cycle';
+ }
+
+ $depthOfParent = self::depthOfNode($allItems, $parentId);
+ $subtreeDepth = self::subtreeDepth($allItems, $itemId);
+ if ($depthOfParent + 1 + $subtreeDepth > $maxDepth) {
+ return 'max_depth';
+ }
+
+ return true;
+ }
/**
- * Constructor
+ * Compute the maximum depth of a nested tree array.
+ *
+ * @param array $tree Nested array with 'children' key
*
- * @param array $objectArr Array of {@link XoopsObject}s
- * @param string $myId field name of object ID
- * @param string $parentId field name of parent object ID
- * @param string $rootId field name of root object ID
+ * @return int Depth (0 for empty)
*/
- public function __construct($objectArr, $myId, $parentId, $rootId = null)
+ public static function computeDepth(array $tree): int
{
- $this->cpt = 0;
- parent::__construct($objectArr, $myId, $parentId, $rootId);
+ if (empty($tree)) {
+ return 0;
+ }
+ $max = 0;
+ foreach ($tree as $node) {
+ $childDepth = self::computeDepth($node['children'] ?? []);
+ $max = max($max, 1 + $childDepth);
+ }
+ return $max;
}
/**
- * Build a flat tree array from the hierarchical structure
+ * Collect all descendant item IDs of a given root item using BFS.
*
- * @param string $fieldName Name of the member variable for title
- * @param string $prefix String to indent deeper levels
- * @param int $itemid Category ID to filter by (0 = all)
+ * @param array $allItems Flat array with items_id, items_pid
+ * @param int $rootId The root item ID
*
- * @return array $listTree Flat tree with level info
+ * @return array Descendant IDs (not including rootId)
*/
- public function makeTree(
- $fieldName,
- $prefix = '-',
- $itemid = 0
- ) {
- $this->addTree($fieldName, $itemid, 0, $prefix);
+ public static function collectDescendantIds(array $allItems, int $rootId): array
+ {
+ $descendants = [];
+ $queue = [$rootId];
- return $this->listTree;
+ while (!empty($queue)) {
+ $current = array_shift($queue);
+ foreach ($allItems as $row) {
+ $id = (int)$row['items_id'];
+ $pid = (int)$row['items_pid'];
+ if ($pid === $current && $id !== $rootId && !in_array($id, $descendants, true)) {
+ $descendants[] = $id;
+ $queue[] = $id;
+ }
+ }
+ }
+
+ return $descendants;
+ }
+
+ /**
+ * Flatten a tree for display (e.g., delete confirmation).
+ *
+ * @param array $items Array of XoopsMenusItems objects
+ * @param int $parentId Starting parent
+ * @param int $level Current indent level
+ * @param string $prefix Indentation prefix
+ *
+ * @return array
+ */
+ public static function flattenForDisplay(
+ array $items,
+ int $parentId = 0,
+ int $level = 0,
+ string $prefix = '--'
+ ): array {
+ $result = [];
+ foreach ($items as $item) {
+ if ((int)$item->getVar('items_pid') === $parentId) {
+ $indent = str_repeat($prefix, $level);
+ $result[] = [
+ 'id' => (int) $item->getVar('items_id'),
+ 'name' => $indent . ' ' . $item->getAdminTitle(),
+ 'level' => $level,
+ ];
+ $result = array_merge(
+ $result,
+ self::flattenForDisplay($items, (int) $item->getVar('items_id'), $level + 1, $prefix)
+ );
+ }
+ }
+ return $result;
}
/**
- * Recursively build the tree
+ * Compute the depth of a node from root.
*
- * @param string $fieldName Name of the field for title
- * @param int $itemid Category ID to filter by
- * @param int $key Current node key
- * @param string $prefix_orig Prefix string for indentation
- * @param string $prefix_curr Current accumulated prefix
- * @param int $level Current depth level
+ * @param array $allItems Flat item records
+ * @param int $nodeId Target node ID
*
- * @return void
- * @access private
+ * @return int Depth (root items = 1)
*/
- protected function addTree($fieldName, $itemid, $key, $prefix_orig, $prefix_curr = '', $level = 1)
+ private static function depthOfNode(array $allItems, int $nodeId): int
{
- if ($key > 0) {
- if (($itemid == $this->tree[$key]['obj']->getVar('items_cid')) || $itemid == 0) {
- $value = $this->tree[$key]['obj']->getVar('items_id');
- $name = $prefix_curr . ' ' . $this->tree[$key]['obj']->getVar($fieldName);
- $prefix_curr .= $prefix_orig;
- $this->listTree[$this->cpt]['name'] = $name;
- $this->listTree[$this->cpt]['id'] = $value;
- $this->listTree[$this->cpt]['level'] = $level;
- $this->listTree[$this->cpt]['obj'] = $this->tree[$key]['obj'];
- $this->cpt++;
- $level++;
+ $depth = 0;
+ $currentId = $nodeId;
+ $visited = [];
+
+ while ($currentId !== 0) {
+ if (in_array($currentId, $visited, true)) {
+ break;
+ }
+ $visited[] = $currentId;
+ $depth++;
+ $found = false;
+ foreach ($allItems as $row) {
+ if ((int)$row['items_id'] === $currentId) {
+ $currentId = (int)$row['items_pid'];
+ $found = true;
+ break;
+ }
+ }
+ if (!$found) {
+ break;
}
}
- if (isset($this->tree[$key]['child']) && !empty($this->tree[$key]['child'])) {
- foreach ($this->tree[$key]['child'] as $childKey) {
- $this->addTree($fieldName, $itemid, $childKey, $prefix_orig, $prefix_curr, $level);
+
+ return $depth;
+ }
+
+ /**
+ * Compute max depth of subtree below a node (not counting node itself).
+ *
+ * @param array $allItems Flat item records
+ * @param int $nodeId Root of subtree
+ *
+ * @return int
+ */
+ private static function subtreeDepth(array $allItems, int $nodeId): int
+ {
+ $children = [];
+ foreach ($allItems as $row) {
+ if ((int)$row['items_pid'] === $nodeId && (int)$row['items_id'] !== $nodeId) {
+ $children[] = (int)$row['items_id'];
}
}
+ if (empty($children)) {
+ return 0;
+ }
+ $max = 0;
+ foreach ($children as $childId) {
+ $max = max($max, 1 + self::subtreeDepth($allItems, $childId));
+ }
+ return $max;
}
}
diff --git a/htdocs/modules/system/constants.php b/htdocs/modules/system/constants.php
index b9d3dffbd..d363c274f 100644
--- a/htdocs/modules/system/constants.php
+++ b/htdocs/modules/system/constants.php
@@ -10,11 +10,9 @@
*/
/**
- * @copyright {@link https://xoops.org/ XOOPS Project}
- * @license {@link https://www.gnu.org/licenses/gpl-2.0.html GNU GPL 2 or later}
- * @package
- * @since
- * @author XOOPS Development Team
+ * @copyright 2000-2026 XOOPS Project https://xoops.org/
+ * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
+ * @author XOOPS Development Team
*/
define('XOOPS_SYSTEM_GROUP', 1);
@@ -33,7 +31,8 @@
define('XOOPS_SYSTEM_TPLSET', 15);
define('XOOPS_SYSTEM_FILEMANAGER', 16);
define('XOOPS_SYSTEM_MAINTENANCE', 17);
-define("XOOPS_SYSTEM_THEME1", 18);
+define('XOOPS_SYSTEM_THEME1', 18);
+
// Configuration Category
define('SYSTEM_CAT_MAIN', 0);
define('SYSTEM_CAT_USER', 1);
@@ -43,4 +42,6 @@
define('SYSTEM_CAT_MAIL', 5);
define('SYSTEM_CAT_AUTH', 6);
-define("XOOPS_SYSTEM_MENUS", 19);
+//2.5.12
+// Menus
+define('XOOPS_SYSTEM_MENUS', 19);
diff --git a/htdocs/modules/system/css/menus.css b/htdocs/modules/system/css/menus.css
index 5997dcd29..e18be3c71 100644
--- a/htdocs/modules/system/css/menus.css
+++ b/htdocs/modules/system/css/menus.css
@@ -106,8 +106,7 @@ ol.xo-menus-tree .xo-menus-url {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- width: 200px;
- min-width: 200px;
+ flex: 0 0 200px;
text-align: right;
}
@@ -142,7 +141,8 @@ ol.xo-menus-tree .xo-menus-badge {
}
/* Status toggle */
-ol.xo-menus-tree .xo-menus-status {
+ol.xo-menus-tree .xo-menus-status,
+ol.xo-menus-sortable .xo-menus-status {
flex-shrink: 0;
width: 20px;
text-align: center;
@@ -155,6 +155,7 @@ ol.xo-menus-tree .xo-menus-inactive {
ol.xo-menus-tree .xo-menus-status img,
ol.xo-menus-tree .xo-menus-actions img,
+ol.xo-menus-sortable .xo-menus-status img,
ol.xo-menus-tree .item-active-toggle img,
ol.xo-menus-tree .category-active-toggle img {
width: 16px;
@@ -163,19 +164,16 @@ ol.xo-menus-tree .category-active-toggle img {
border: none;
}
-/* Action icons */
-ol.xo-menus-tree .xo-menus-actions {
+/* Individual action icon cells */
+ol.xo-menus-tree .xo-menus-action,
+ol.xo-menus-sortable .xo-menus-action {
flex-shrink: 0;
- white-space: nowrap;
- width: 80px;
- text-align: right;
-}
-
-ol.xo-menus-tree .xo-menus-actions a {
- padding: 0 2px;
+ width: 20px;
+ text-align: center;
}
-ol.xo-menus-tree .xo-menus-actions img {
+ol.xo-menus-tree .xo-menus-action img,
+ol.xo-menus-sortable .xo-menus-action img {
width: 16px;
height: 16px;
border: none;
@@ -287,18 +285,7 @@ ol.xo-menus-sortable .xo-menus-sortable-url {
text-align: right;
}
-/* Action icons */
-ol.xo-menus-sortable .xo-menus-sortable-actions {
- flex-shrink: 0;
- white-space: nowrap;
- width: 80px;
- text-align: right;
-}
-
-ol.xo-menus-sortable .xo-menus-sortable-actions a {
- padding: 0 2px;
-}
-
+/* Sortable action images */
ol.xo-menus-sortable .xo-menus-sortable-actions img,
ol.xo-menus-sortable .item-active-toggle img {
width: 16px;
diff --git a/htdocs/modules/system/css/multilevelmenu.css b/htdocs/modules/system/css/multilevelmenu.css
index 1c02ae9af..6b4e409ff 100644
--- a/htdocs/modules/system/css/multilevelmenu.css
+++ b/htdocs/modules/system/css/multilevelmenu.css
@@ -1,27 +1,42 @@
/*
- * Multilevel dropdown menu helpers
+ * Shared styles for theme navigation dropdowns with nested menu items.
*
- * Shared stylesheet for handling nested dropdown positioning and hover
- * behaviour. Included automatically by class/theme.php for all themes.
- *
- * @copyright (c) 2000-2026 XOOPS Project (www.xoops.org)
+ * @copyright 2000-2026 XOOPS Project (www.xoops.org)
* @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
+ * @author XOOPS Development Team
*/
-/* === multilevel dropdown support === */
.dropdown-submenu {
position: relative;
}
.dropdown-submenu > .dropdown-menu {
- top: 0;
- left: 100%;
- margin-top: -1px;
+ top: -0.35rem;
+ left: calc(100% - 0.15rem);
+ margin-top: 0;
+ display: none;
}
-/* show on hover for desktop users */
-.dropdown-submenu:hover > .dropdown-menu {
+.dropdown-submenu.is-open > .dropdown-menu {
display: block;
}
-/* JS-toggled visibility */
-.dropdown-submenu > .dropdown-menu.show {
- display: block;
+.dropdown-submenu > .dropdown-toggle::after {
+ display: inline-block;
+ transform: rotate(-90deg);
+ margin-left: 0.5em;
+}
+
+/* Desktop: hover to open */
+@media (hover: hover) and (min-width: 992px) {
+ .dropdown-submenu:hover > .dropdown-menu {
+ display: block;
+ }
+}
+
+/* Mobile: stack vertically */
+@media (max-width: 991.98px) {
+ .dropdown-submenu > .dropdown-menu {
+ position: static;
+ margin-left: 1rem;
+ border: none;
+ box-shadow: none;
+ }
}
diff --git a/htdocs/modules/system/include/update.php b/htdocs/modules/system/include/update.php
index 3d639bcfb..c74b05de4 100644
--- a/htdocs/modules/system/include/update.php
+++ b/htdocs/modules/system/include/update.php
@@ -25,21 +25,21 @@ function xoops_module_update_system(XoopsModule $module, $prev_version = null)
{
// irmtfan bug fix: solve templates duplicate issue
$ret = null;
- if (null === $prev_version || version_compare($prev_version, '2.1.1', '<')) {
+ if (version_compare((string) $prev_version, '2.1.1', '<')) {
$ret = update_system_v211($module);
}
// Clean up legacy .html template rows replaced by .tpl equivalents
- if (null === $prev_version || version_compare($prev_version, '2.1.8', '<')) {
+ if (version_compare((string) $prev_version, '2.1.8', '<')) {
update_system_remove_legacy_html_templates($module);
}
- // Create menu management tables and seed default data
- if (null === $prev_version || version_compare($prev_version, '2.1.9', '<')) {
- update_system_v219_menus($module);
+ // Create/upgrade menu tables and seed defaults (added in 2.5.12)
+ if (!system_menu_update($module)) {
+ $ret = false;
}
- $errors = $module->getErrors();
- if (!empty($errors)) {
- print_r($errors);
- } else {
+
+ if (!empty($module->getErrors())) {
+ $ret = false;
+ } elseif ($ret !== false) {
$ret = true;
}
@@ -138,197 +138,490 @@ function update_system_remove_legacy_html_templates(XoopsModule $module)
}
/**
- * Create menu management tables and seed default data.
+ * Create or upgrade the menu management tables and seed protected defaults.
*
- * @param XoopsModule $module
+ * Standardizes the root parent sentinel to 0 (matching XOOPS convention)
+ * and enforces NOT NULL on affix columns.
+ *
+ * @param XoopsModule $module System module reference
*/
-function update_system_v219_menus(XoopsModule $module)
+function system_menu_update(XoopsModule $module): bool
{
- global $xoopsDB;
-
- $mid = $module->getVar('mid');
-
- // Create menuscategory table
- $sql = "CREATE TABLE IF NOT EXISTS " . $xoopsDB->prefix('menuscategory') . " (
- category_id INT AUTO_INCREMENT PRIMARY KEY,
- category_title VARCHAR(100) NOT NULL,
- category_prefix TEXT NOT NULL,
- category_suffix TEXT NOT NULL,
- category_url VARCHAR(255) NULL,
- category_target TINYINT(1) DEFAULT 0,
- category_position INT DEFAULT 0,
- category_protected INT DEFAULT 0,
- category_active TINYINT(1) DEFAULT 1
- ) ENGINE=InnoDB";
- $xoopsDB->exec($sql);
+ $db = \XoopsDatabaseFactory::getDatabaseConnection();
+ $mid = (int) $module->getVar('mid');
+
+ try {
+ system_menu_create_tables($db);
+ system_menu_normalize_schema($db);
+ system_menu_migrate_unsafe_urls($db);
+ system_menu_seed_defaults($db, $mid);
+ } catch (\Throwable $e) {
+ $module->setErrors('Menu migration failed: ' . $e->getMessage());
+ return false;
+ }
- // Create menusitems table
- $sql = "CREATE TABLE IF NOT EXISTS " . $xoopsDB->prefix('menusitems') . " (
- items_id INT AUTO_INCREMENT PRIMARY KEY,
- items_pid INT NULL,
- items_cid INT NULL,
- items_title VARCHAR(100) NOT NULL,
- items_prefix TEXT NOT NULL,
- items_suffix TEXT NOT NULL,
- items_url VARCHAR(255) NULL,
- items_target TINYINT(1) DEFAULT 0,
- items_position INT DEFAULT 0,
- items_protected INT DEFAULT 0,
- items_active TINYINT(1) DEFAULT 1,
- FOREIGN KEY (items_cid) REFERENCES " . $xoopsDB->prefix('menuscategory') . "(category_id) ON DELETE CASCADE
- ) ENGINE=InnoDB";
- $xoopsDB->exec($sql);
+ return true;
+}
- // Drop self-referencing FK on items_pid if it exists (XOOPS uses 0 for "no parent")
- $table = $xoopsDB->prefix('menusitems');
- $result = $xoopsDB->query("SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE"
- . " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}'"
- . " AND COLUMN_NAME = 'items_pid' AND REFERENCED_TABLE_NAME IS NOT NULL");
- if ($xoopsDB->isResultSet($result) && $result instanceof \mysqli_result) {
- while (false !== ($row = $xoopsDB->fetchArray($result))) {
- $xoopsDB->exec("ALTER TABLE {$table} DROP FOREIGN KEY `{$row['CONSTRAINT_NAME']}`");
- }
+/**
+ * Execute a DDL/DML statement and throw on failure.
+ *
+ * XOOPS DB layer returns false on error instead of throwing. This wrapper
+ * converts false returns into exceptions so the caller's try/catch works.
+ *
+ * @param XoopsMySQLDatabase $db Database connection
+ * @param string $sql SQL statement
+ *
+ * @throws \RuntimeException When the statement fails
+ */
+function system_menu_exec_or_throw(XoopsMySQLDatabase $db, string $sql): void
+{
+ $result = $db->exec($sql);
+ if ($result === false) {
+ throw new \RuntimeException('SQL failed: ' . mb_substr($sql, 0, 200));
}
+}
- // Widen affix columns from VARCHAR(100) to TEXT for existing installs
- $catTable = $xoopsDB->prefix('menuscategory');
- $xoopsDB->exec("ALTER TABLE {$catTable} MODIFY category_prefix TEXT NOT NULL");
- $xoopsDB->exec("ALTER TABLE {$catTable} MODIFY category_suffix TEXT NOT NULL");
- $xoopsDB->exec("ALTER TABLE {$table} MODIFY items_prefix TEXT NOT NULL");
- $xoopsDB->exec("ALTER TABLE {$table} MODIFY items_suffix TEXT NOT NULL");
-
- // Only seed data if all three targets have expected rows
- $catCount = $itemCount = $permCount = 0;
- $result = $xoopsDB->query("SELECT COUNT(*) FROM " . $xoopsDB->prefix('menuscategory'));
- if ($xoopsDB->isResultSet($result) && $result instanceof \mysqli_result) {
- [$catCount] = $xoopsDB->fetchRow($result);
- }
- $result = $xoopsDB->query("SELECT COUNT(*) FROM " . $xoopsDB->prefix('menusitems'));
- if ($xoopsDB->isResultSet($result) && $result instanceof \mysqli_result) {
- [$itemCount] = $xoopsDB->fetchRow($result);
+/**
+ * Drop foreign key constraints referencing a given parent table.
+ */
+function system_menu_drop_parent_foreign_keys(XoopsMySQLDatabase $db, string $tableName): void
+{
+ $result = $db->query(
+ "SELECT CONSTRAINT_NAME, TABLE_NAME
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
+ WHERE REFERENCED_TABLE_NAME = " . $db->quote($db->prefix($tableName)) . "
+ AND TABLE_SCHEMA = DATABASE()"
+ );
+ if (!$db->isResultSet($result) || !($result instanceof \mysqli_result)) {
+ return;
}
- $permTable = $xoopsDB->prefix('group_permission');
- $result = $xoopsDB->query("SELECT COUNT(*) FROM {$permTable}"
- . " WHERE gperm_name IN ('menus_category_view','menus_items_view')"
- . " AND gperm_modid = " . (int)$mid);
- if ($xoopsDB->isResultSet($result) && $result instanceof \mysqli_result) {
- [$permCount] = $xoopsDB->fetchRow($result);
+ while ($row = $db->fetchArray($result)) {
+ $db->exec("ALTER TABLE `{$row['TABLE_NAME']}` DROP FOREIGN KEY `{$row['CONSTRAINT_NAME']}`");
}
+}
- if ((int)$catCount > 0 && (int)$itemCount > 0 && (int)$permCount > 0) {
- // Fully seeded — only run migrations
- $xoopsDB->exec("UPDATE " . $xoopsDB->prefix('menuscategory')
- . " SET category_url = 'index.php'"
- . " WHERE category_url = '/' AND category_protected = 1");
- // Migrate toolbar item from javascript: URL to safe anchor
- $xoopsDB->exec("UPDATE " . $xoopsDB->prefix('menusitems')
- . " SET items_url = '#xswatch-toolbar-toggle'"
- . " WHERE items_url LIKE 'javascript:%' AND items_protected = 1");
- return;
+/**
+ * Create the menuscategory and menusitems tables if they do not exist.
+ */
+function system_menu_create_tables(XoopsMySQLDatabase $db): void
+{
+ $prefix = $db->prefix('menuscategory');
+ $sql = "CREATE TABLE IF NOT EXISTS `{$prefix}` (
+ `category_id` INT NOT NULL AUTO_INCREMENT,
+ `category_title` VARCHAR(100) NOT NULL DEFAULT '',
+ `category_prefix` TEXT NOT NULL,
+ `category_suffix` TEXT NOT NULL,
+ `category_url` VARCHAR(255) NOT NULL DEFAULT '',
+ `category_target` TINYINT(1) NOT NULL DEFAULT 0,
+ `category_position` INT NOT NULL DEFAULT 0,
+ `category_protected` INT NOT NULL DEFAULT 0,
+ `category_active` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`category_id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
+ system_menu_exec_or_throw($db, $sql);
+
+ // Drop orphan FKs before (re-)creating the items table
+ system_menu_drop_parent_foreign_keys($db, 'menuscategory');
+
+ $prefix = $db->prefix('menusitems');
+ $sql = "CREATE TABLE IF NOT EXISTS `{$prefix}` (
+ `items_id` INT NOT NULL AUTO_INCREMENT,
+ `items_pid` INT NOT NULL DEFAULT 0,
+ `items_cid` INT NOT NULL DEFAULT 0,
+ `items_title` VARCHAR(100) NOT NULL DEFAULT '',
+ `items_prefix` TEXT NOT NULL,
+ `items_suffix` TEXT NOT NULL,
+ `items_url` VARCHAR(255) NOT NULL DEFAULT '',
+ `items_target` TINYINT(1) NOT NULL DEFAULT 0,
+ `items_position` INT NOT NULL DEFAULT 0,
+ `items_protected` INT NOT NULL DEFAULT 0,
+ `items_active` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`items_id`),
+ KEY `idx_items_cid` (`items_cid`),
+ KEY `idx_items_pid` (`items_pid`),
+ CONSTRAINT `fk_items_category` FOREIGN KEY (`items_cid`)
+ REFERENCES `{$db->prefix('menuscategory')}` (`category_id`)
+ ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
+ system_menu_exec_or_throw($db, $sql);
+
+ // Re-add FK if it was dropped on an existing table (CREATE TABLE IF NOT EXISTS is a no-op)
+ $fkCheck = $db->query(
+ "SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS"
+ . " WHERE CONSTRAINT_NAME = 'fk_items_category'"
+ . " AND TABLE_NAME = " . $db->quote($prefix)
+ . " AND TABLE_SCHEMA = DATABASE()"
+ );
+ if ($db->isResultSet($fkCheck) && ($fkCheck instanceof \mysqli_result) && 0 === $db->getRowsNum($fkCheck)) {
+ system_menu_exec_or_throw(
+ $db,
+ "ALTER TABLE `{$prefix}` ADD CONSTRAINT `fk_items_category`"
+ . " FOREIGN KEY (`items_cid`) REFERENCES `{$db->prefix('menuscategory')}` (`category_id`)"
+ . " ON DELETE CASCADE"
+ );
}
+}
- // Partial or empty state — clean up before re-seeding
- if ((int)$catCount > 0 || (int)$itemCount > 0) {
- $xoopsDB->exec("DELETE FROM " . $xoopsDB->prefix('menusitems'));
- $xoopsDB->exec("DELETE FROM " . $xoopsDB->prefix('menuscategory'));
+/**
+ * Normalize the menu schema for XOOPS conventions.
+ *
+ * - Converts any NULL items_pid values to 0 (root sentinel)
+ * - Drops legacy self-referencing FK on items_pid
+ * - Enforces NOT NULL on items_pid
+ * - Enforces NOT NULL on all prefix/suffix TEXT columns
+ * - Migrates root-relative '/' category URLs to 'index.php' for subdirectory safety
+ */
+function system_menu_normalize_schema(XoopsMySQLDatabase $db): void
+{
+ $catTable = $db->prefix('menuscategory');
+ $itemTable = $db->prefix('menusitems');
+
+ // Drop self-referencing FK on items_pid (incompatible with 0-as-root convention)
+ $result = $db->query(
+ "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE"
+ . " WHERE TABLE_SCHEMA = DATABASE()"
+ . " AND TABLE_NAME = " . $db->quote($itemTable)
+ . " AND COLUMN_NAME = 'items_pid'"
+ . " AND REFERENCED_TABLE_NAME IS NOT NULL"
+ );
+ if ($db->isResultSet($result) && ($result instanceof \mysqli_result)) {
+ while ($row = $db->fetchArray($result)) {
+ $db->exec("ALTER TABLE `{$itemTable}` DROP FOREIGN KEY `{$row['CONSTRAINT_NAME']}`");
+ }
}
- if ((int)$permCount > 0) {
- $xoopsDB->exec("DELETE FROM {$permTable}"
- . " WHERE gperm_name IN ('menus_category_view','menus_items_view')"
- . " AND gperm_modid = " . (int)$mid);
+
+ // Normalize NULL parent IDs to 0
+ system_menu_exec_or_throw($db, "UPDATE `{$itemTable}` SET `items_pid` = 0 WHERE `items_pid` IS NULL");
+ system_menu_exec_or_throw($db, "ALTER TABLE `{$itemTable}` MODIFY `items_pid` INT NOT NULL DEFAULT 0");
+
+ // Enforce NOT NULL on affix columns
+ system_menu_exec_or_throw($db, "ALTER TABLE `{$catTable}` MODIFY `category_prefix` TEXT NOT NULL");
+ system_menu_exec_or_throw($db, "ALTER TABLE `{$catTable}` MODIFY `category_suffix` TEXT NOT NULL");
+ system_menu_exec_or_throw($db, "ALTER TABLE `{$itemTable}` MODIFY `items_prefix` TEXT NOT NULL");
+ system_menu_exec_or_throw($db, "ALTER TABLE `{$itemTable}` MODIFY `items_suffix` TEXT NOT NULL");
+
+ // Migrate root-relative '/' to 'index.php' (safe for subdirectory installs)
+ system_menu_exec_or_throw(
+ $db,
+ "UPDATE `{$catTable}` SET `category_url` = " . $db->quote('index.php')
+ . " WHERE `category_protected` = 1 AND `category_url` = " . $db->quote('/')
+ );
+}
+
+/**
+ * Ensure a protected category exists and matches the current seed definition.
+ *
+ * On upgrade, existing categories keep their active state so administrators
+ * do not lose local enable/disable decisions.
+ *
+ * @param XoopsMySQLDatabase $db Database connection
+ * @param array $definition Category seed definition
+ *
+ * @return int Persisted category id
+ */
+function system_menu_ensure_category(XoopsMySQLDatabase $db, array $definition): int
+{
+ $table = $db->prefix('menuscategory');
+ $result = $db->query(
+ "SELECT `category_id`, `category_active` FROM `{$table}`"
+ . " WHERE `category_title` = " . $db->quote($definition['title'])
+ . " AND `category_protected` = " . (int) $definition['protected']
+ . " ORDER BY `category_id` ASC"
+ );
+ if ($db->isResultSet($result) && ($result instanceof \mysqli_result) && ($row = $db->fetchArray($result))) {
+ // Existing row: only sync seed-owned content fields (prefix, suffix, url).
+ // Preserve admin-maintained layout fields (position, target, active).
+ system_menu_exec_or_throw($db, sprintf(
+ "UPDATE `%s` SET `category_prefix` = %s, `category_suffix` = %s, `category_url` = %s"
+ . " WHERE `category_id` = %d",
+ $table,
+ $db->quote($definition['prefix']),
+ $db->quote($definition['suffix']),
+ $db->quote($definition['url']),
+ (int) $row['category_id']
+ ));
+ return (int) $row['category_id'];
}
- // Seed default categories
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menuscategory')
- . " (category_title, category_prefix, category_suffix, category_url, category_target, category_position, category_protected, category_active)"
- . " VALUES ('MENUS_HOME', '', '', 'index.php', 0, 0, 1, 1)");
- $catHomeId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menuscategory')
- . " (category_title, category_prefix, category_suffix, category_url, category_target, category_position, category_protected, category_active)"
- . " VALUES ('MENUS_ADMIN', '', '', 'admin.php', 0, 10, 1, 1)");
- $catAdminId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menuscategory')
- . " (category_title, category_prefix, category_suffix, category_url, category_target, category_position, category_protected, category_active)"
- . " VALUES ('MENUS_ACCOUNT', '', '', '', 0, 20, 1, 1)");
- $catAccountId = $xoopsDB->getInsertId();
-
- // Seed default items under Account category
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_EDIT', '', '', 'user.php', 0, 1, 1, 1)");
- $itemEditId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_LOGIN', '', '', 'user.php', 0, 2, 1, 1)");
- $itemLoginId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_REGISTER', '', '', 'register.php', 0, 3, 1, 1)");
- $itemRegisterId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_MESSAGES', '', '<{xoInboxCount}>', 'viewpmsg.php', 0, 4, 1, 1)");
- $itemMessagesId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_NOTIFICATIONS', '', '', 'notifications.php', 0, 5, 1, 1)");
- $itemNotifId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_TOOLBAR', '', '', '#xswatch-toolbar-toggle', 0, 6, 1, 1)");
- $itemToolbarId = $xoopsDB->getInsertId();
-
- $xoopsDB->exec("INSERT INTO " . $xoopsDB->prefix('menusitems')
- . " (items_pid, items_cid, items_title, items_prefix, items_suffix, items_url, items_target, items_position, items_protected, items_active)"
- . " VALUES (0, {$catAccountId}, 'MENUS_ACCOUNT_LOGOUT', '', '', 'user.php?op=logout', 0, 7, 1, 1)");
- $itemLogoutId = $xoopsDB->getInsertId();
-
- // Seed permissions using the actual module ID
- $grpAdmin = defined('XOOPS_GROUP_ADMIN') ? (int)XOOPS_GROUP_ADMIN : 1;
- $grpUsers = defined('XOOPS_GROUP_USERS') ? (int)XOOPS_GROUP_USERS : 2;
- $grpAnon = defined('XOOPS_GROUP_ANONYMOUS') ? (int)XOOPS_GROUP_ANONYMOUS : 3;
- $allGroups = [$grpAdmin, $grpUsers, $grpAnon];
- $loggedInGroups = [$grpAdmin, $grpUsers];
-
- // Category permissions: Home visible to all groups
- foreach ($allGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$catHomeId}, {$mid}, 'menus_category_view')");
+ system_menu_exec_or_throw($db, sprintf(
+ "INSERT INTO `%s` (`category_title`,`category_prefix`,`category_suffix`,`category_url`,"
+ . "`category_target`,`category_position`,`category_protected`,`category_active`)"
+ . " VALUES (%s, %s, %s, %s, %d, %d, %d, %d)",
+ $table,
+ $db->quote($definition['title']),
+ $db->quote($definition['prefix']),
+ $db->quote($definition['suffix']),
+ $db->quote($definition['url']),
+ (int) $definition['target'],
+ (int) $definition['position'],
+ (int) $definition['protected'],
+ (int) $definition['active']
+ ));
+ return (int) $db->getInsertId();
+}
+
+/**
+ * Ensure an item exists under its category and matches the current seed definition.
+ *
+ * On upgrade, existing items keep their active state so administrators
+ * do not lose local enable/disable decisions.
+ *
+ * @param XoopsMySQLDatabase $db Database connection
+ * @param int $categoryId Parent category id
+ * @param array $definition Item seed definition
+ *
+ * @return int Persisted item id
+ */
+function system_menu_ensure_item(XoopsMySQLDatabase $db, int $categoryId, array $definition): int
+{
+ $table = $db->prefix('menusitems');
+ $result = $db->query(sprintf(
+ "SELECT `items_id`, `items_active` FROM `%s`"
+ . " WHERE `items_cid` = %d AND `items_title` = %s AND `items_protected` = %d"
+ . " ORDER BY `items_id` ASC",
+ $table,
+ $categoryId,
+ $db->quote($definition['title']),
+ (int) $definition['protected']
+ ));
+ if ($db->isResultSet($result) && ($result instanceof \mysqli_result) && ($row = $db->fetchArray($result))) {
+ // Existing row: only sync seed-owned content fields (prefix, suffix, url).
+ // Preserve admin-maintained layout fields (pid, position, target, active).
+ system_menu_exec_or_throw($db, sprintf(
+ "UPDATE `%s` SET `items_prefix` = %s, `items_suffix` = %s, `items_url` = %s"
+ . " WHERE `items_id` = %d",
+ $table,
+ $db->quote($definition['prefix']),
+ $db->quote($definition['suffix']),
+ $db->quote($definition['url']),
+ (int) $row['items_id']
+ ));
+ return (int) $row['items_id'];
}
- // Admin category visible to admin only
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$grpAdmin}, {$catAdminId}, {$mid}, 'menus_category_view')");
- // Account category visible to all groups
- foreach ($allGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$catAccountId}, {$mid}, 'menus_category_view')");
+
+ system_menu_exec_or_throw($db, sprintf(
+ "INSERT INTO `%s` (`items_cid`,`items_pid`,`items_title`,`items_prefix`,`items_suffix`,"
+ . "`items_url`,`items_target`,`items_position`,`items_protected`,`items_active`)"
+ . " VALUES (%d, %d, %s, %s, %s, %s, %d, %d, %d, %d)",
+ $table,
+ $categoryId,
+ (int) $definition['pid'],
+ $db->quote($definition['title']),
+ $db->quote($definition['prefix']),
+ $db->quote($definition['suffix']),
+ $db->quote($definition['url']),
+ (int) $definition['target'],
+ (int) $definition['position'],
+ (int) $definition['protected'],
+ (int) $definition['active']
+ ));
+ return (int) $db->getInsertId();
+}
+
+/**
+ * Seed menu permissions for a set of groups.
+ *
+ * @param int $moduleId System module id
+ * @param string $permName Permission name
+ * @param int $itemId Item or category id
+ * @param int[] $groupIds Group ids to grant
+ */
+function system_menu_seed_permissions(
+ int $moduleId,
+ string $permName,
+ int $itemId,
+ array $groupIds
+): void {
+ $handler = xoops_getHandler('groupperm');
+ foreach ($groupIds as $gid) {
+ // Idempotent: skip if this exact permission already exists
+ $criteria = new \CriteriaCompo();
+ $criteria->add(new \Criteria('gperm_groupid', (int) $gid));
+ $criteria->add(new \Criteria('gperm_itemid', $itemId));
+ $criteria->add(new \Criteria('gperm_name', $permName));
+ $criteria->add(new \Criteria('gperm_modid', $moduleId));
+ if ($handler->getCount($criteria) > 0) {
+ continue;
+ }
+ $perm = $handler->create();
+ $perm->setVar('gperm_groupid', (int) $gid);
+ $perm->setVar('gperm_itemid', $itemId);
+ $perm->setVar('gperm_name', $permName);
+ $perm->setVar('gperm_modid', $moduleId);
+ $handler->insert($perm);
}
+}
- // Item permissions
- // Edit Account: admin + registered
- foreach ($loggedInGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$itemEditId}, {$mid}, 'menus_items_view')");
+/**
+ * Seed default menu categories, items, and permissions.
+ */
+function system_menu_seed_defaults(XoopsMySQLDatabase $db, int $moduleId): void
+{
+ $adminGroup = defined('XOOPS_GROUP_ADMIN') ? (int) XOOPS_GROUP_ADMIN : 1;
+ $usersGroup = defined('XOOPS_GROUP_USERS') ? (int) XOOPS_GROUP_USERS : 2;
+ $anonGroup = defined('XOOPS_GROUP_ANONYMOUS') ? (int) XOOPS_GROUP_ANONYMOUS : 3;
+
+ $allGroups = [$adminGroup, $usersGroup, $anonGroup];
+ $authGroups = [$adminGroup, $usersGroup];
+ $adminGroups = [$adminGroup];
+
+ // --- Category definitions ---
+ $categories = [
+ 'home' => [
+ 'title' => 'MENUS_HOME',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'index.php',
+ 'target' => 0,
+ 'position' => 1,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $allGroups,
+ ],
+ 'admin' => [
+ 'title' => 'MENUS_ADMIN',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'admin.php',
+ 'target' => 0,
+ 'position' => 2,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $adminGroups,
+ ],
+ 'account' => [
+ 'title' => 'MENUS_ACCOUNT',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => '',
+ 'target' => 0,
+ 'position' => 3,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $allGroups,
+ ],
+ ];
+
+ // --- Item definitions (under Account) ---
+ $items = [
+ [
+ 'title' => 'MENUS_ACCOUNT_EDIT',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'user.php',
+ 'target' => 0,
+ 'position' => 1,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $authGroups,
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_LOGIN',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'user.php',
+ 'target' => 0,
+ 'position' => 2,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => [$anonGroup],
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_REGISTER',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'register.php',
+ 'target' => 0,
+ 'position' => 3,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => [$anonGroup],
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_MESSAGES',
+ 'prefix' => '',
+ 'suffix' => '<{xoInboxCount}>',
+ 'url' => 'viewpmsg.php',
+ 'target' => 0,
+ 'position' => 4,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $authGroups,
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_NOTIFICATIONS',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'notifications.php',
+ 'target' => 0,
+ 'position' => 5,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $authGroups,
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_TOOLBAR',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => '#xswatch-toolbar-toggle',
+ 'target' => 0,
+ 'position' => 6,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $authGroups,
+ ],
+ [
+ 'title' => 'MENUS_ACCOUNT_LOGOUT',
+ 'prefix' => '',
+ 'suffix' => '',
+ 'url' => 'user.php?op=logout',
+ 'target' => 0,
+ 'position' => 7,
+ 'pid' => 0,
+ 'protected' => 1,
+ 'active' => 1,
+ 'groups' => $authGroups,
+ ],
+ ];
+
+ // --- Persist categories ---
+ $categoryIds = [];
+ foreach ($categories as $key => $catDef) {
+ $categoryIds[$key] = system_menu_ensure_category($db, $catDef);
}
- // Login: anonymous only
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$grpAnon}, {$itemLoginId}, {$mid}, 'menus_items_view')");
- // Register: anonymous only
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$grpAnon}, {$itemRegisterId}, {$mid}, 'menus_items_view')");
- // Messages: admin + registered
- foreach ($loggedInGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$itemMessagesId}, {$mid}, 'menus_items_view')");
+
+ // --- Persist items (all under Account) ---
+ $itemIds = [];
+ foreach ($items as $itemDef) {
+ $itemIds[] = system_menu_ensure_item($db, $categoryIds['account'], $itemDef);
}
- // Notifications: admin + registered
- foreach ($loggedInGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$itemNotifId}, {$mid}, 'menus_items_view')");
+
+ // --- Seed category permissions (idempotent — skips existing) ---
+ foreach ($categories as $key => $catDef) {
+ system_menu_seed_permissions($moduleId, 'menus_category_view', $categoryIds[$key], $catDef['groups']);
}
- // Toolbar: admin only
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$grpAdmin}, {$itemToolbarId}, {$mid}, 'menus_items_view')");
- // Logout: admin + registered
- foreach ($loggedInGroups as $gid) {
- $xoopsDB->exec("INSERT INTO {$permTable} (gperm_groupid, gperm_itemid, gperm_modid, gperm_name) VALUES ({$gid}, {$itemLogoutId}, {$mid}, 'menus_items_view')");
+
+ // --- Seed item permissions ---
+ foreach ($items as $idx => $itemDef) {
+ system_menu_seed_permissions($moduleId, 'menus_items_view', $itemIds[$idx], $itemDef['groups']);
}
}
+
+/**
+ * Migrate any existing unsafe URLs (javascript:) to safe placeholders.
+ */
+function system_menu_migrate_unsafe_urls(XoopsMySQLDatabase $db): void
+{
+ $catTable = $db->prefix('menuscategory');
+ $itemTable = $db->prefix('menusitems');
+
+ system_menu_exec_or_throw($db, "UPDATE `{$catTable}` SET `category_url` = '#' WHERE TRIM(`category_url`) LIKE 'javascript:%'");
+ system_menu_exec_or_throw($db, "UPDATE `{$itemTable}` SET `items_url` = '#' WHERE TRIM(`items_url`) LIKE 'javascript:%'");
+}
diff --git a/htdocs/modules/system/js/menus.js b/htdocs/modules/system/js/menus.js
index 632229a18..128e37166 100644
--- a/htdocs/modules/system/js/menus.js
+++ b/htdocs/modules/system/js/menus.js
@@ -76,32 +76,40 @@ jQuery(function($){
}
}).disableSelection();
- // NESTED SORTABLE (items on viewcat page — allows nesting/reordering)
+ // NESTED SORTABLE (item lists — both inside accordion on list page and on viewcat page)
if ($.fn.nestedSortable) {
- $('ol.xo-menus-sortable').nestedSortable({
- handle: 'div',
- cancel: 'a, .item-active-toggle, .xo-menus-disclose',
- items: 'li',
- tolerance: 'pointer',
- toleranceElement: '> div',
- placeholder: 'ui-state-highlight',
- helper: 'clone',
- opacity: 0.6,
- revert: 250,
- tabSize: 25,
- maxLevels: 3,
- isTree: true,
- expandOnHover: 700,
- startCollapsed: false,
- update: function() {
- var serialized = $(this).nestedSortable('serialize');
- var data = $.extend({ item: serialized }, getTokenData());
- ajaxJsonPost('admin.php?fct=menus&op=saveorderitems', data, function(response){
- if (!(response && response.success)) {
- alert(response && response.message ? response.message : 'Save failed');
- }
- });
- }
+ $('ol.xo-menus-sortable').each(function() {
+ var $sortable = $(this);
+ $sortable.nestedSortable({
+ handle: 'div',
+ cancel: 'a, .item-active-toggle, .xo-menus-disclose',
+ items: 'li',
+ tolerance: 'pointer',
+ toleranceElement: '> div',
+ placeholder: 'ui-state-highlight',
+ helper: 'clone',
+ opacity: 0.6,
+ revert: 250,
+ tabSize: 25,
+ maxLevels: 3,
+ isTree: true,
+ expandOnHover: 700,
+ startCollapsed: false,
+ update: function() {
+ // data-category-id on viewcat, data-cid on list page accordions
+ var categoryId = $sortable.data('category-id') || $sortable.data('cid');
+ var tree = $sortable.nestedSortable('toHierarchy');
+ var data = $.extend({
+ category_id: categoryId,
+ tree: JSON.stringify(tree)
+ }, getTokenData());
+ ajaxJsonPost('admin.php?fct=menus&op=saveorderitems', data, function(response){
+ if (!(response && response.success)) {
+ alert(response && response.message ? response.message : 'Save failed');
+ }
+ });
+ }
+ });
});
}
}
@@ -157,6 +165,14 @@ jQuery(function($){
// initial state on page load
refreshChildLocks();
+ // Auto-expand category from URL hash (e.g. #cat_4)
+ if (window.location.hash) {
+ var $target = $(window.location.hash);
+ if ($target.length && $target.hasClass('xo-menus-has-children')) {
+ $target.removeClass('xo-menus-collapsed').addClass('xo-menus-expanded');
+ }
+ }
+
// TOGGLE ACTIVE (categories & items)
$(document).on('click', '.category-active-toggle, .item-active-toggle', function(e){
e.preventDefault();
diff --git a/htdocs/modules/system/js/multilevelmenu.js b/htdocs/modules/system/js/multilevelmenu.js
index 28ca708ba..8b61cb6b1 100644
--- a/htdocs/modules/system/js/multilevelmenu.js
+++ b/htdocs/modules/system/js/multilevelmenu.js
@@ -1,32 +1,98 @@
-/*
- * JavaScript helpers for multilevel dropdown menus
+/**
+ * Frontend Multi-Level Menu Helpers
*
- * Shared file included automatically by class/theme.php for every theme.
+ * Handles nested submenu toggle, keyboard accessibility, and
+ * the xswatch toolbar hook for both Bootstrap 4 and 5 themes.
*
- * Licensed under GNU GPL 2.0 or later (see LICENSE in root).
+ * @copyright 2001-2026 XOOPS Project (https://xoops.org)
+ * @license GNU GPL 2+ (https://www.gnu.org/licenses/gpl-2.0.html)
+ * @author XOOPS Development Team
*/
+document.addEventListener('DOMContentLoaded', function () {
+ 'use strict';
-// toggle submenus inside multilevel dropdowns
-document.addEventListener('DOMContentLoaded', function() {
- document.querySelectorAll('.dropdown-submenu > a').forEach(function(el) {
- el.addEventListener('click', function (e) {
- var href = this.getAttribute('href');
- // Only prevent default for empty/hash links; allow real URLs to navigate
- if (!href || href === '#' || href === 'javascript:;') {
- e.preventDefault();
+ /**
+ * Close all peer submenus at the same nesting level.
+ */
+ function closePeerSubmenus(currentItem) {
+ if (!currentItem || !currentItem.parentElement) {
+ return;
+ }
+ currentItem.parentElement.querySelectorAll(':scope > .dropdown-submenu.is-open').forEach(function (item) {
+ if (item !== currentItem) {
+ item.classList.remove('is-open');
+ var trigger = item.querySelector(':scope > .dropdown-toggle');
+ if (trigger) {
+ trigger.setAttribute('aria-expanded', 'false');
+ }
}
- e.stopPropagation();
- var sub = this.nextElementSibling;
- if (sub) sub.classList.toggle('show');
});
+ }
+
+ /**
+ * Close every open submenu on the page.
+ */
+ function closeAllSubmenus() {
+ document.querySelectorAll('.dropdown-submenu.is-open').forEach(function (item) {
+ item.classList.remove('is-open');
+ var trigger = item.querySelector(':scope > .dropdown-toggle');
+ if (trigger) {
+ trigger.setAttribute('aria-expanded', 'false');
+ }
+ });
+ }
+
+ // --- Submenu toggle on click ---
+ document.addEventListener('click', function (e) {
+ var toggle = e.target.closest('.dropdown-submenu > .dropdown-toggle');
+ if (!toggle) {
+ // Click outside any toggle — close all submenus
+ closeAllSubmenus();
+ return;
+ }
+
+ var submenu = toggle.closest('.dropdown-submenu');
+ var href = toggle.getAttribute('href') || '';
+
+ // If submenu is already open and link has a real URL, allow navigation
+ if (submenu.classList.contains('is-open') && href && href !== '#') {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ closePeerSubmenus(submenu);
+ submenu.classList.toggle('is-open');
+ toggle.setAttribute('aria-expanded', submenu.classList.contains('is-open') ? 'true' : 'false');
+ });
+
+ // --- Escape key closes all submenus ---
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closeAllSubmenus();
+ }
+ });
+
+ // --- Clean up when Bootstrap dropdown parent closes ---
+ document.addEventListener('hide.bs.dropdown', function (e) {
+ if (e.target) {
+ e.target.querySelectorAll('.dropdown-submenu.is-open').forEach(function (item) {
+ item.classList.remove('is-open');
+ var trigger = item.querySelector(':scope > .dropdown-toggle');
+ if (trigger) {
+ trigger.setAttribute('aria-expanded', 'false');
+ }
+ });
+ }
});
- // Hook anchor-based actions (e.g. toolbar toggle seeded as #xswatch-toolbar-toggle)
- document.querySelectorAll('a[href="#xswatch-toolbar-toggle"]').forEach(function(el) {
- el.addEventListener('click', function (e) {
+ // --- xswatch toolbar toggle hook ---
+ document.querySelectorAll('a[href="#xswatch-toolbar-toggle"]').forEach(function (link) {
+ link.addEventListener('click', function (e) {
e.preventDefault();
- if (typeof xswatchToolbarToggle === 'function') {
- xswatchToolbarToggle();
+ if (typeof window.xswatchToolbarToggle === 'function') {
+ window.xswatchToolbarToggle();
}
});
});
diff --git a/htdocs/modules/system/language/english/admin.php b/htdocs/modules/system/language/english/admin.php
index 529ce10fa..d76842879 100644
--- a/htdocs/modules/system/language/english/admin.php
+++ b/htdocs/modules/system/language/english/admin.php
@@ -74,6 +74,8 @@
//2.5.7
define('_AM_SYSTEM_USAGE', 'Usage');
define('_AM_SYSTEM_ACTIVE', 'Active');
+
//2.5.12
+// Menus
define('_AM_SYSTEM_MENUS', 'Menus');
-define('_AM_SYSTEM_MENUS_DESC', 'Menu manager');
+define('_AM_SYSTEM_MENUS_DESC', 'Manage site navigation menus');
diff --git a/htdocs/modules/system/language/english/admin/menus.php b/htdocs/modules/system/language/english/admin/menus.php
index 609b23c74..be3aa4b52 100644
--- a/htdocs/modules/system/language/english/admin/menus.php
+++ b/htdocs/modules/system/language/english/admin/menus.php
@@ -1,86 +1,101 @@
Tips:
'
+ . '
Use language constants (e.g. MENUS_HOME) as titles so they can be translated.
'
+ . '
Create a custom menus.php in your language folder to override constant values.
'
+ . '
Drag items to reorder. Nesting is supported up to 3 levels deep.
This page allows you to manage the main menus of your site.
-
You can create categories to organize your menus, and add submenu items under each category.
-
For each menu item, you can specify a title, URL, position, and whether it is active or not.
-
You can also use language constants for the menu titles. If a constant is used, its value will be displayed in parentheses next to the title.
-
To add custom menu title constants, copy menus.dist.php to menus.php and add your definitions there.
-
The file is located here: "modules/system/language/%s/menus/menus.php".
-
');
-// Main
+// Common
define('_AM_SYSTEM_MENUS_ACTIVE', 'Active');
-define('_AM_SYSTEM_MENUS_ACTIVE_NO', 'Disabled');
define('_AM_SYSTEM_MENUS_ACTIVE_YES', 'Enabled');
+define('_AM_SYSTEM_MENUS_ACTIVE_NO', 'Disabled');
+define('_AM_SYSTEM_MENUS_SAVED', 'Saved successfully');
+define('_AM_SYSTEM_MENUS_DELETED', 'Deleted successfully');
+define('_AM_SYSTEM_MENUS_ORDER_SAVED', 'Order saved');
+define('_AM_SYSTEM_MENUS_LISTCAT', 'List Categories');
+define('_AM_SYSTEM_MENUS_LISTITEM', 'List Items');
+
+// Category
define('_AM_SYSTEM_MENUS_ADDCAT', 'Add Category');
-define('_AM_SYSTEM_MENUS_ADDITEM', 'Adding a submenu item');
-define('_AM_SYSTEM_MENUS_DELCAT', 'Delete Category');
-define('_AM_SYSTEM_MENUS_DELITEM', 'Delete a submenu item');
define('_AM_SYSTEM_MENUS_EDITCAT', 'Edit Category');
-define('_AM_SYSTEM_MENUS_EDITITEM', 'Edit a submenu item');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMDISABLE', 'You cannot delete a menu that is disabled. Please enable the menu first, then try deleting it again.');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMEDIT', 'You cannot edit a menu that is disabled. Please enable the menu first, then try editing it again.');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED', 'You cannot delete a protected menu item.');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMPARENT', 'You cannot select a menu as its own parent.');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMCYCLE', 'You cannot select a descendant as the parent — this would create a cycle.');
-define('_AM_SYSTEM_MENUS_ERROR_ITEMDEPTH', 'Maximum nesting depth (3 levels) exceeded.');
-define('_AM_SYSTEM_MENUS_ERROR_CATPROTECTED', 'You cannot delete a protected menu category.');
-define('_AM_SYSTEM_MENUS_ERROR_NOCATEGORY', 'There are no menu categories. You must create one before adding menus.');
-define('_AM_SYSTEM_MENUS_ERROR_NOITEM', 'There are no submenu items.');
-define('_AM_SYSTEM_MENUS_ERROR_NOITEMS', 'There are no submenu items in this category.');
-define('_AM_SYSTEM_MENUS_ACTION', 'Action');
-define('_AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE', 'You cannot modify this item while its parent is inactive!');
-define('_AM_SYSTEM_MENUS_LISTCAT', 'List Categories');
-define('_AM_SYSTEM_MENUS_LISTITEM', 'List items');
-define('_AM_SYSTEM_MENUS_PID', 'Upper level menu');
-define('_AM_SYSTEM_MENUS_POSITIONCAT', 'Position of the menu category');
-define('_AM_SYSTEM_MENUS_POSITIONITEM', 'Position of the submenu item');
-define('_AM_SYSTEM_MENUS_PREFIXCAT', 'Prefix for the menu category title');
-define('_AM_SYSTEM_MENUS_PREFIXCAT_DESC', 'Optional — Text to display before the menu category title. HTML is allowed.');
-define('_AM_SYSTEM_MENUS_PREFIXITEM', 'Prefix for the submenu item title');
-define('_AM_SYSTEM_MENUS_PREFIXITEM_DESC', 'Optional — Text to display before the submenu item title. HTML is allowed.');
-define('_AM_SYSTEM_MENUS_SUFFIXCAT', 'Suffix for the menu category title');
-define('_AM_SYSTEM_MENUS_SUFFIXCAT_DESC', 'Optional — Text to display after the menu category title. HTML is allowed.');
-define('_AM_SYSTEM_MENUS_SUFFIXITEM', 'Suffix for the submenu item title');
-define('_AM_SYSTEM_MENUS_SUFFIXITEM_DESC', 'Optional — Text to display after the submenu item title. HTML is allowed.');
-define('_AM_SYSTEM_MENUS_SUREDELCAT', 'Are you sure you want to delete this menu category "%s" and all of its submenu items?');
-define('_AM_SYSTEM_MENUS_SUREDELITEM', 'Are you sure you want to delete this submenu item "%s" and all of its child submenu items?');
-define('_AM_SYSTEM_MENUS_TARGET', 'Target');
-define('_AM_SYSTEM_MENUS_TARGET_SELF', 'Same Window');
-define('_AM_SYSTEM_MENUS_TARGET_BLANK', 'New Window');
-define('_AM_SYSTEM_MENUS_TITLECAT', 'Name of the menu category');
-define('_AM_SYSTEM_MENUS_TITLECAT_DESC', 'You can use a constant for the title. If you do, the constant value will be shown in parentheses next to the title in admin side.');
-define('_AM_SYSTEM_MENUS_TITLEITEM', 'Name of the submenu item');
-define('_AM_SYSTEM_MENUS_TITLEITEM_DESC', 'You can use a constant for the title. If you do, the constant value will be shown in parentheses next to the title in admin side.');
-define('_AM_SYSTEM_MENUS_URLCAT', 'URL of the menu category');
-define('_AM_SYSTEM_MENUS_URLCATDESC', 'Optional — Only if you want the category title to be a link. Example: "http://www.example.com" for external links or "index.php?option=value" for internal links.');
-define('_AM_SYSTEM_MENUS_URLITEM', 'URL of the submenu item');
+define('_AM_SYSTEM_MENUS_DELCAT', 'Delete Category');
+define('_AM_SYSTEM_MENUS_CATTITLE', 'Category Title');
+define('_AM_SYSTEM_MENUS_CATTITLE_DESC', 'A language constant name (e.g. MENUS_HOME) can be used here so the label is translatable.');
+define('_AM_SYSTEM_MENUS_CATPREFIX', 'Prefix (HTML)');
+define('_AM_SYSTEM_MENUS_CATPREFIX_DESC', 'Optional markup shown before the category title, such as a Font Awesome icon.');
+define('_AM_SYSTEM_MENUS_CATSUFFIX', 'Suffix (HTML)');
+define('_AM_SYSTEM_MENUS_CATSUFFIX_DESC', 'Optional markup shown after the category title.');
+define('_AM_SYSTEM_MENUS_CATURL', 'URL');
+define('_AM_SYSTEM_MENUS_CATURL_DESC', 'Link for the category itself, such as "index.php" or an absolute URL.');
+define('_AM_SYSTEM_MENUS_CATTARGET', 'Link Target');
+define('_AM_SYSTEM_MENUS_CATPOSITION', 'Position');
+define('_AM_SYSTEM_MENUS_DELCAT_CONFIRM', 'Are you sure you want to delete the category "%s" and all its items?');
+
+// Item
+define('_AM_SYSTEM_MENUS_ADDITEM', 'Add Item');
+define('_AM_SYSTEM_MENUS_EDITITEM', 'Edit Item');
+define('_AM_SYSTEM_MENUS_DELITEM', 'Delete Item');
+define('_AM_SYSTEM_MENUS_ITEMTITLE', 'Item Title');
+define('_AM_SYSTEM_MENUS_ITEMTITLE_DESC', 'A language constant name can be used here so the label is translatable.');
+define('_AM_SYSTEM_MENUS_ITEMPREFIX', 'Prefix (HTML)');
+define('_AM_SYSTEM_MENUS_ITEMPREFIX_DESC', 'Optional markup shown before the item title, such as a Font Awesome icon.');
+define('_AM_SYSTEM_MENUS_ITEMSUFFIX', 'Suffix (HTML)');
+define('_AM_SYSTEM_MENUS_ITEMSUFFIX_DESC', 'Optional markup shown after the item title. Supports the <{xoInboxCount}> placeholder.');
+define('_AM_SYSTEM_MENUS_ITEMURL', 'URL');
+define('_AM_SYSTEM_MENUS_ITEMTARGET', 'Link Target');
+define('_AM_SYSTEM_MENUS_ITEMPOSITION', 'Position');
+define('_AM_SYSTEM_MENUS_ITEMPARENT', 'Parent Item');
+define('_AM_SYSTEM_MENUS_ITEMCATEGORY', 'Category');
+define('_AM_SYSTEM_MENUS_DELITEM_CONFIRM', 'Are you sure you want to delete the item "%s" and its sub-items?');
+
+// Target options
+define('_AM_SYSTEM_MENUS_TARGET_SELF', 'Same window');
+define('_AM_SYSTEM_MENUS_TARGET_BLANK', 'New window');
-// permissions
-define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY', 'Permission to view category');
-define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY_DESC', 'Select groups that are allowed to view this category. Note: If a category is not viewable, its submenu items will not be viewable either, regardless of their individual permissions.');
-define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM', 'Permission to view submenu item');
-define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM_DESC', 'Select groups that are allowed to view this submenu item. Note: If a submenu item is not viewable, it will not be visible to users in the frontend, regardless of their individual permissions.');
+// Permissions
+define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY', 'Groups that can see this category');
+define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_CATEGORY_DESC', 'Users must have category access before any of its items become visible.');
+define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM', 'Groups that can see this item');
+define('_AM_SYSTEM_MENUS_PERMISSION_VIEW_ITEM_DESC', 'Item permissions are checked after the parent category permission.');
-// Menus
+// Errors
+define('_AM_SYSTEM_MENUS_ERROR_CATNOTFOUND', 'Category not found');
+define('_AM_SYSTEM_MENUS_ERROR_CATPROTECTED', 'Cannot delete a protected category');
+define('_AM_SYSTEM_MENUS_ERROR_CATINACTIVE', 'Cannot activate: the category is inactive');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMNOTFOUND', 'Item not found');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMPROTECTED', 'Cannot delete a protected item');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMPARENT', 'Invalid parent item selected');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMCYCLE', 'Cannot set parent: it would create a circular reference');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMDEPTH', 'Maximum nesting depth (3 levels) exceeded');
+define('_AM_SYSTEM_MENUS_ERROR_PARENTINACTIVE', 'Cannot activate: the parent item is inactive');
+define('_AM_SYSTEM_MENUS_ERROR_NOITEMS', 'There are no submenu items in this category.');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMEDIT', 'Enable this item before editing it');
+define('_AM_SYSTEM_MENUS_ERROR_ITEMDISABLE', 'Enable this item before deleting it');
+
+// Menu content constants (used in seeded data)
define('MENUS_HOME', 'Home');
define('MENUS_ADMIN', 'Administration');
define('MENUS_ACCOUNT', 'Account');
define('MENUS_ACCOUNT_EDIT', 'Edit Account');
define('MENUS_ACCOUNT_LOGIN', 'Login');
define('MENUS_ACCOUNT_LOGOUT', 'Logout');
+define('MENUS_ACCOUNT_REGISTER', 'Sign Up');
define('MENUS_ACCOUNT_MESSAGES', 'Messages');
define('MENUS_ACCOUNT_NOTIFICATIONS', 'Notifications');
-define('MENUS_ACCOUNT_REGISTER', 'Sign Up');
define('MENUS_ACCOUNT_TOOLBAR', 'Toolbar');
diff --git a/htdocs/modules/system/language/english/menus/index.php b/htdocs/modules/system/language/english/menus/index.php
index b8a448e80..7adb92ed4 100644
--- a/htdocs/modules/system/language/english/menus/index.php
+++ b/htdocs/modules/system/language/english/menus/index.php
@@ -15,5 +15,6 @@
* @since 2.5.12
* @author XOOPS Development Team
*/
-header('HTTP/1.0 404 Not Found');
+
+header('HTTP/1.1 404 Not Found');
exit();
diff --git a/htdocs/modules/system/language/english/menus/menus.dist.php b/htdocs/modules/system/language/english/menus/menus.dist.php
index 91fe57402..2b4aa6c9b 100644
--- a/htdocs/modules/system/language/english/menus/menus.dist.php
+++ b/htdocs/modules/system/language/english/menus/menus.dist.php
@@ -10,16 +10,27 @@
*/
/**
- * Menu language constants template.
- *
- * To add custom menu title constants, copy this file to menus.php
- * in the same directory and add your definitions there.
- *
* @copyright XOOPS Project https://xoops.org/
* @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html)
* @since 2.5.12
* @author XOOPS Development Team
+ */
+
+ /**
+ * System Menu Language Template
*
- * Example:
- * define('MENUS_CUSTOM_LINK', 'My Custom Link');
+ * Copy this file to your custom language folder and modify
+ * the values to translate menu labels.
*/
+
+// Uncomment and modify to override default labels:
+// define('MENUS_HOME', 'Home');
+// define('MENUS_ADMIN', 'Administration');
+// define('MENUS_ACCOUNT', 'Account');
+// define('MENUS_ACCOUNT_EDIT', 'Edit Account');
+// define('MENUS_ACCOUNT_LOGIN', 'Login');
+// define('MENUS_ACCOUNT_LOGOUT', 'Logout');
+// define('MENUS_ACCOUNT_REGISTER', 'Sign Up');
+// define('MENUS_ACCOUNT_MESSAGES', 'Messages');
+// define('MENUS_ACCOUNT_NOTIFICATIONS', 'Notifications');
+// define('MENUS_ACCOUNT_TOOLBAR', 'Toolbar');
diff --git a/htdocs/modules/system/language/english/modinfo.php b/htdocs/modules/system/language/english/modinfo.php
index d9ee11b14..316ed2c14 100644
--- a/htdocs/modules/system/language/english/modinfo.php
+++ b/htdocs/modules/system/language/english/modinfo.php
@@ -1,8 +1,12 @@
<{/if}>
<{if $op|default:'' == 'additem' || $op|default:'' == 'edititem' || $op|default:'' == 'saveitem'}>
-