* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Menus\Administrator\Helper; use Joomla\CMS\Application\ApplicationHelper; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Filesystem\File; use Joomla\CMS\Filesystem\Folder; use Joomla\CMS\Helper\ContentHelper; use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Language\Text; use Joomla\CMS\Menu\AdministratorMenuItem; use Joomla\CMS\Table\Table; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; use Joomla\Registry\Registry; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Menus component helper. * * @since 1.6 */ class MenusHelper extends ContentHelper { /** * Defines the valid request variables for the reverse lookup. * * @var array */ protected static $_filter = array('option', 'view', 'layout'); /** * List of preset include paths * * @var array * * @since 4.0.0 */ protected static $presets = null; /** * Gets a standard form of a link for lookups. * * @param mixed $request A link string or array of request variables. * * @return mixed A link in standard option-view-layout form, or false if the supplied response is invalid. * * @since 1.6 */ public static function getLinkKey($request) { if (empty($request)) { return false; } // Check if the link is in the form of index.php?... if (is_string($request)) { $args = array(); if (strpos($request, 'index.php') === 0) { parse_str(parse_url(htmlspecialchars_decode($request), PHP_URL_QUERY), $args); } else { parse_str($request, $args); } $request = $args; } // Only take the option, view and layout parts. foreach ($request as $name => $value) { if ((!in_array($name, self::$_filter)) && (!($name == 'task' && !array_key_exists('view', $request)))) { // Remove the variables we want to ignore. unset($request[$name]); } } ksort($request); return 'index.php?' . http_build_query($request, '', '&'); } /** * Get the menu list for create a menu module * * @param int $clientId Optional client id - viz 0 = site, 1 = administrator, can be NULL for all * * @return array The menu array list * * @since 1.6 */ public static function getMenuTypes($clientId = 0) { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('a.menutype')) ->from($db->quoteName('#__menu_types', 'a')); if (isset($clientId)) { $clientId = (int) $clientId; $query->where($db->quoteName('a.client_id') . ' = :clientId') ->bind(':clientId', $clientId, ParameterType::INTEGER); } $db->setQuery($query); return $db->loadColumn(); } /** * Get a list of menu links for one or all menus. * * @param string $menuType An option menu to filter the list on, otherwise all menu with given client id links * are returned as a grouped array. * @param integer $parentId An optional parent ID to pivot results around. * @param integer $mode An optional mode. If parent ID is set and mode=2, the parent and children are excluded from the list. * @param array $published An optional array of states * @param array $languages Optional array of specify which languages we want to filter * @param int $clientId Optional client id - viz 0 = site, 1 = administrator, can be NULL for all (used only if menutype not given) * * @return array|boolean * * @since 1.6 */ public static function getMenuLinks($menuType = null, $parentId = 0, $mode = 0, $published = array(), $languages = array(), $clientId = 0) { $hasClientId = $clientId !== null; $clientId = (int) $clientId; $db = Factory::getDbo(); $query = $db->getQuery(true) ->select( [ 'DISTINCT ' . $db->quoteName('a.id', 'value'), $db->quoteName('a.title', 'text'), $db->quoteName('a.alias'), $db->quoteName('a.level'), $db->quoteName('a.menutype'), $db->quoteName('a.client_id'), $db->quoteName('a.type'), $db->quoteName('a.published'), $db->quoteName('a.template_style_id'), $db->quoteName('a.checked_out'), $db->quoteName('a.language'), $db->quoteName('a.lft'), $db->quoteName('e.name', 'componentname'), $db->quoteName('e.element'), ] ) ->from($db->quoteName('#__menu', 'a')) ->join('LEFT', $db->quoteName('#__extensions', 'e'), $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('a.component_id')); if (Multilanguage::isEnabled()) { $query->select( [ $db->quoteName('l.title', 'language_title'), $db->quoteName('l.image', 'language_image'), $db->quoteName('l.sef', 'language_sef'), ] ) ->join('LEFT', $db->quoteName('#__languages', 'l'), $db->quoteName('l.lang_code') . ' = ' . $db->quoteName('a.language')); } // Filter by the type if given, this is more specific than client id if ($menuType) { $query->where('(' . $db->quoteName('a.menutype') . ' = :menuType OR ' . $db->quoteName('a.parent_id') . ' = 0)') ->bind(':menuType', $menuType); } elseif ($hasClientId) { $query->where($db->quoteName('a.client_id') . ' = :clientId') ->bind(':clientId', $clientId, ParameterType::INTEGER); } // Prevent the parent and children from showing if requested. if ($parentId && $mode == 2) { $query->join('LEFT', $db->quoteName('#__menu', 'p'), $db->quoteName('p.id') . ' = :parentId') ->where( '(' . $db->quoteName('a.lft') . ' <= ' . $db->quoteName('p.lft') . ' OR ' . $db->quoteName('a.rgt') . ' >= ' . $db->quoteName('p.rgt') . ')' ) ->bind(':parentId', $parentId, ParameterType::INTEGER); } if (!empty($languages)) { $query->whereIn($db->quoteName('a.language'), (array) $languages, ParameterType::STRING); } if (!empty($published)) { $query->whereIn($db->quoteName('a.published'), (array) $published); } $query->where($db->quoteName('a.published') . ' != -2'); $query->order($db->quoteName('a.lft') . ' ASC'); try { // Get the options. $db->setQuery($query); $links = $db->loadObjectList(); } catch (\RuntimeException $e) { Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); return false; } if (empty($menuType)) { // If the menutype is empty, group the items by menutype. $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__menu_types')) ->where($db->quoteName('menutype') . ' <> ' . $db->quote('')) ->order( [ $db->quoteName('title'), $db->quoteName('menutype'), ] ); if ($hasClientId) { $query->where($db->quoteName('client_id') . ' = :clientId') ->bind(':clientId', $clientId, ParameterType::INTEGER); } try { $db->setQuery($query); $menuTypes = $db->loadObjectList(); } catch (\RuntimeException $e) { Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); return false; } // Create a reverse lookup and aggregate the links. $rlu = array(); foreach ($menuTypes as &$type) { $rlu[$type->menutype] = & $type; $type->links = array(); } // Loop through the list of menu links. foreach ($links as &$link) { if (isset($rlu[$link->menutype])) { $rlu[$link->menutype]->links[] = & $link; // Cleanup garbage. unset($link->menutype); } } return $menuTypes; } else { return $links; } } /** * Get the associations * * @param integer $pk Menu item id * * @return array * * @since 3.0 */ public static function getAssociations($pk) { $langAssociations = Associations::getAssociations('com_menus', '#__menu', 'com_menus.item', $pk, 'id', '', ''); $associations = array(); foreach ($langAssociations as $langAssociation) { $associations[$langAssociation->language] = $langAssociation->id; } return $associations; } /** * Load the menu items from database for the given menutype * * @param string $menutype The selected menu type * @param boolean $enabledOnly Whether to load only enabled/published menu items. * @param int[] $exclude The menu items to exclude from the list * * @return AdministratorMenuItem A root node with the menu items as children * * @since 4.0.0 */ public static function getMenuItems($menutype, $enabledOnly = false, $exclude = array()) { $root = new AdministratorMenuItem(); $db = Factory::getContainer()->get(DatabaseInterface::class); $query = $db->getQuery(true); // Prepare the query. $query->select($db->quoteName('m') . '.*') ->from($db->quoteName('#__menu', 'm')) ->where( [ $db->quoteName('m.menutype') . ' = :menutype', $db->quoteName('m.client_id') . ' = 1', $db->quoteName('m.id') . ' > 1', ] ) ->bind(':menutype', $menutype); if ($enabledOnly) { $query->where($db->quoteName('m.published') . ' = 1'); } // Filter on the enabled states. $query->select($db->quoteName('e.element')) ->join('LEFT', $db->quoteName('#__extensions', 'e'), $db->quoteName('m.component_id') . ' = ' . $db->quoteName('e.extension_id')) ->extendWhere( 'AND', [ $db->quoteName('e.enabled') . ' = 1', $db->quoteName('e.enabled') . ' IS NULL', ], 'OR' ); if (count($exclude)) { $exId = array_map('intval', array_filter($exclude, 'is_numeric')); $exEl = array_filter($exclude, 'is_string'); if ($exId) { $query->whereNotIn($db->quoteName('m.id'), $exId) ->whereNotIn($db->quoteName('m.parent_id'), $exId); } if ($exEl) { $query->whereNotIn($db->quoteName('e.element'), $exEl, ParameterType::STRING); } } // Order by lft. $query->order($db->quoteName('m.lft')); try { $menuItems = []; $iterator = $db->setQuery($query)->getIterator(); foreach ($iterator as $item) { $menuItems[$item->id] = new AdministratorMenuItem((array) $item); } unset($iterator); foreach ($menuItems as $menuitem) { // Resolve the alias item to get the original item if ($menuitem->type == 'alias') { static::resolveAlias($menuitem); } if ($menuitem->link = in_array($menuitem->type, array('separator', 'heading', 'container')) ? '#' : trim($menuitem->link)) { $menuitem->submenu = array(); $menuitem->class = $menuitem->img ?? ''; $menuitem->scope = $menuitem->scope ?? null; $menuitem->target = $menuitem->browserNav ? '_blank' : ''; } $menuitem->ajaxbadge = $menuitem->getParams()->get('ajax-badge'); $menuitem->dashboard = $menuitem->getParams()->get('dashboard'); if ($menuitem->parent_id > 1) { if (isset($menuItems[$menuitem->parent_id])) { $menuItems[$menuitem->parent_id]->addChild($menuitem); } } else { $root->addChild($menuitem); } } } catch (\RuntimeException $e) { Factory::getApplication()->enqueueMessage(Text::_('JERROR_AN_ERROR_HAS_OCCURRED'), 'error'); } return $root; } /** * Method to install a preset menu into database and link them to the given menutype * * @param string $preset The preset name * @param string $menutype The target menutype * * @return void * * @throws \Exception * * @since 4.0.0 */ public static function installPreset($preset, $menutype) { $root = static::loadPreset($preset, false); if (count($root->getChildren()) == 0) { throw new \Exception(Text::_('COM_MENUS_PRESET_LOAD_FAILED')); } static::installPresetItems($root, $menutype); } /** * Method to install a preset menu item into database and link it to the given menutype * * @param AdministratorMenuItem $node The parent node of the items to process * @param string $menutype The target menutype * * @return void * * @throws \Exception * * @since 4.0.0 */ protected static function installPresetItems($node, $menutype) { $db = Factory::getDbo(); $query = $db->getQuery(true); $items = $node->getChildren(); static $components = array(); if (!$components) { $query->select( [ $db->quoteName('extension_id'), $db->quoteName('element'), ] ) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('type') . ' = ' . $db->quote('component')); $components = $db->setQuery($query)->loadObjectList(); $components = array_column((array) $components, 'element', 'extension_id'); } Factory::getApplication()->triggerEvent('onPreprocessMenuItems', array('com_menus.administrator.import', &$items, null, true)); foreach ($items as $item) { /** @var \Joomla\CMS\Table\Menu $table */ $table = Table::getInstance('Menu'); $item->alias = $menutype . '-' . $item->title; // Temporarily set unicodeslugs if a menu item has an unicode alias $unicode = Factory::getApplication()->set('unicodeslugs', 1); $item->alias = ApplicationHelper::stringURLSafe($item->alias); Factory::getApplication()->set('unicodeslugs', $unicode); if ($item->type == 'separator') { // Do not reuse a separator $item->title = $item->title ?: '-'; $item->alias = microtime(true); } elseif ($item->type == 'heading' || $item->type == 'container') { // Try to match an existing record to have minimum collision for a heading $keys = array( 'menutype' => $menutype, 'type' => $item->type, 'title' => $item->title, 'parent_id' => (int) $item->getParent()->id, 'client_id' => 1, ); $table->load($keys); } elseif ($item->type == 'url' || $item->type == 'component') { if (substr($item->link, 0, 8) === 'special:') { $special = substr($item->link, 8); if ($special === 'language-forum') { $item->link = 'index.php?option=com_admin&view=help&layout=langforum'; } elseif ($special === 'custom-forum') { $item->link = ''; } } // Try to match an existing record to have minimum collision for a link $keys = array( 'menutype' => $menutype, 'type' => $item->type, 'link' => $item->link, 'parent_id' => (int) $item->getParent()->id, 'client_id' => 1, ); $table->load($keys); } // Translate "hideitems" param value from "element" into "menu-item-id" if ($item->type == 'container' && count($hideitems = (array) $item->getParams()->get('hideitems'))) { foreach ($hideitems as &$hel) { if (!is_numeric($hel)) { $hel = array_search($hel, $components); } } $query = $db->getQuery(true) ->select($db->quoteName('id')) ->from($db->quoteName('#__menu')) ->whereIn($db->quoteName('component_id'), $hideitems); $hideitems = $db->setQuery($query)->loadColumn(); $item->getParams()->set('hideitems', $hideitems); } $record = array( 'menutype' => $menutype, 'title' => $item->title, 'alias' => $item->alias, 'type' => $item->type, 'link' => $item->link, 'browserNav' => $item->browserNav, 'img' => $item->class, 'access' => $item->access, 'component_id' => array_search($item->element, $components) ?: 0, 'parent_id' => (int) $item->getParent()->id, 'client_id' => 1, 'published' => 1, 'language' => '*', 'home' => 0, 'params' => (string) $item->getParams(), ); if (!$table->bind($record)) { throw new \Exception($table->getError()); } $table->setLocation($item->getParent()->id, 'last-child'); if (!$table->check()) { throw new \Exception($table->getError()); } if (!$table->store()) { throw new \Exception($table->getError()); } $item->id = $table->get('id'); if ($item->hasChildren()) { static::installPresetItems($item, $menutype); } } } /** * Add a custom preset externally via plugin or any other means. * WARNING: Presets with same name will replace previously added preset *except* Joomla's default preset (joomla) * * @param string $name The unique identifier for the preset. * @param string $title The display label for the preset. * @param string $path The path to the preset file. * @param bool $replace Whether to replace the preset with the same name if any (except 'joomla'). * * @return void * * @since 4.0.0 */ public static function addPreset($name, $title, $path, $replace = true) { if (static::$presets === null) { static::getPresets(); } if ($name == 'joomla') { $replace = false; } if (($replace || !array_key_exists($name, static::$presets)) && is_file($path)) { $preset = new \stdClass(); $preset->name = $name; $preset->title = $title; $preset->path = $path; static::$presets[$name] = $preset; } } /** * Get a list of available presets. * * @return \stdClass[] * * @since 4.0.0 */ public static function getPresets() { if (static::$presets === null) { // Important: 'null' will cause infinite recursion. static::$presets = array(); $components = ComponentHelper::getComponents(); $lang = Factory::getApplication()->getLanguage(); foreach ($components as $component) { if (!$component->enabled) { continue; } $folder = JPATH_ADMINISTRATOR . '/components/' . $component->option . '/presets/'; if (!Folder::exists($folder)) { continue; } $lang->load($component->option . '.sys', JPATH_ADMINISTRATOR) || $lang->load($component->option . '.sys', JPATH_ADMINISTRATOR . '/components/' . $component->option); $presets = Folder::files($folder, '.xml'); foreach ($presets as $preset) { $name = File::stripExt($preset); $title = strtoupper($component->option . '_MENUS_PRESET_' . $name); static::addPreset($name, $title, $folder . $preset); } } // Load from template folder automatically $app = Factory::getApplication(); $tpl = JPATH_THEMES . '/' . $app->getTemplate() . '/html/com_menus/presets'; if (is_dir($tpl)) { $files = Folder::files($tpl, '\.xml$'); foreach ($files as $file) { $name = substr($file, 0, -4); $title = str_replace('-', ' ', $name); static::addPreset(strtolower($name), ucwords($title), $tpl . '/' . $file); } } } return static::$presets; } /** * Load the menu items from a preset file into a hierarchical list of objects * * @param string $name The preset name * @param bool $fallback Fallback to default (joomla) preset if the specified one could not be loaded? * @param AdministratorMenuItem $parent Root node of the menu * * @return AdministratorMenuItem * * @since 4.0.0 */ public static function loadPreset($name, $fallback = true, $parent = null) { $presets = static::getPresets(); if (!$parent) { $parent = new AdministratorMenuItem(); } if (isset($presets[$name]) && ($xml = simplexml_load_file($presets[$name]->path, null, LIBXML_NOCDATA)) && $xml instanceof \SimpleXMLElement) { static::loadXml($xml, $parent); } elseif ($fallback && isset($presets['default'])) { if (($xml = simplexml_load_file($presets['default']->path, null, LIBXML_NOCDATA)) && $xml instanceof \SimpleXMLElement) { static::loadXml($xml, $parent); } } return $parent; } /** * Method to resolve the menu item alias type menu item * * @param AdministratorMenuItem &$item The alias object * * @return void * * @since 4.0.0 */ public static function resolveAlias(&$item) { $obj = $item; while ($obj->type == 'alias') { $aliasTo = (int) $obj->getParams()->get('aliasoptions'); $db = Factory::getDbo(); $query = $db->getQuery(true); $query->select( [ $db->quoteName('a.id'), $db->quoteName('a.link'), $db->quoteName('a.type'), $db->quoteName('e.element'), ] ) ->from($db->quoteName('#__menu', 'a')) ->join('LEFT', $db->quoteName('#__extensions', 'e'), $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('a.component_id')) ->where($db->quoteName('a.id') . ' = :aliasTo') ->bind(':aliasTo', $aliasTo, ParameterType::INTEGER); try { $obj = new AdministratorMenuItem($db->setQuery($query)->loadAssoc()); if (!$obj) { $item->link = ''; return; } } catch (\Exception $e) { $item->link = ''; return; } } $item->id = $obj->id; $item->link = $obj->link; $item->type = $obj->type; $item->element = $obj->element; } /** * Parse the flat list of menu items and prepare the hierarchy of them using parent-child relationship. * * @param AdministratorMenuItem $item Menu item to preprocess * * @return void * * @since 4.0.0 */ public static function preprocess($item) { // Resolve the alias item to get the original item if ($item->type == 'alias') { static::resolveAlias($item); } if ($item->link = in_array($item->type, array('separator', 'heading', 'container')) ? '#' : trim($item->link)) { $item->class = $item->img ?? ''; $item->scope = $item->scope ?? null; $item->target = $item->browserNav ? '_blank' : ''; } } /** * Load a menu tree from an XML file * * @param \SimpleXMLElement[] $elements The xml menuitem nodes * @param AdministratorMenuItem $parent The menu hierarchy list to be populated * @param string[] $replace The substring replacements for iterator type items * * @return void * * @since 4.0.0 */ protected static function loadXml($elements, $parent, $replace = array()) { foreach ($elements as $element) { if ($element->getName() != 'menuitem') { continue; } $select = (string) $element['sql_select']; $from = (string) $element['sql_from']; /** * Following is a repeatable group based on simple database query. This requires sql_* attributes (sql_select and sql_from are required) * The values can be used like - "{sql:columnName}" in any attribute of repeated elements. * The repeated elements are place inside this xml node but they will be populated in the same level in the rendered menu */ if ($select && $from) { $hidden = $element['hidden'] == 'true'; $where = (string) $element['sql_where']; $order = (string) $element['sql_order']; $group = (string) $element['sql_group']; $lJoin = (string) $element['sql_leftjoin']; $iJoin = (string) $element['sql_innerjoin']; $db = Factory::getDbo(); $query = $db->getQuery(true); $query->select($select)->from($from); if ($where) { $query->where($where); } if ($order) { $query->order($order); } if ($group) { $query->group($group); } if ($lJoin) { $query->join('LEFT', $lJoin); } if ($iJoin) { $query->join('INNER', $iJoin); } $results = $db->setQuery($query)->loadObjectList(); // Skip the entire group if no items to iterate over. if ($results) { // Show the repeatable group heading node only if not set as hidden. if (!$hidden) { $child = static::parseXmlNode($element, $replace); $parent->addChild($child); } // Iterate over the matching records, items goes in the same level (not $item->submenu) as this node. if ('self' == (string) $element['sql_target']) { foreach ($results as $result) { static::loadXml($element->menuitem, $child, $result); } } else { foreach ($results as $result) { static::loadXml($element->menuitem, $parent, $result); } } } } else { $item = static::parseXmlNode($element, $replace); // Process the child nodes static::loadXml($element->menuitem, $item, $replace); $parent->addChild($item); } } } /** * Create a menu item node from an xml element * * @param \SimpleXMLElement $node A menuitem element from preset xml * @param string[] $replace The values to substitute in the title, link and element texts * * @return \stdClass * * @since 4.0.0 */ protected static function parseXmlNode($node, $replace = array()) { $item = new AdministratorMenuItem(); $item->id = null; $item->type = (string) $node['type']; $item->title = (string) $node['title']; $item->alias = (string) $node['alias']; $item->link = (string) $node['link']; $item->target = (string) $node['target']; $item->element = (string) $node['element']; $item->class = (string) $node['class']; $item->icon = (string) $node['icon']; $item->access = (int) $node['access']; $item->scope = (string) $node['scope'] ?: 'default'; $item->ajaxbadge = (string) $node['ajax-badge']; $item->dashboard = (string) $node['dashboard']; $params = new Registry(trim($node->params)); $params->set('menu-permission', (string) $node['permission']); if ($item->type == 'separator' && trim($item->title, '- ')) { $params->set('text_separator', 1); } if ($item->type == 'heading' || $item->type == 'container') { $item->link = '#'; } if ((string) $node['quicktask']) { $params->set('menu-quicktask', (string) $node['quicktask']); $params->set('menu-quicktask-title', (string) $node['quicktask-title']); $params->set('menu-quicktask-icon', (string) $node['quicktask-icon']); $params->set('menu-quicktask-permission', (string) $node['quicktask-permission']); } // Translate attributes for iterator values foreach ($replace as $var => $val) { $item->title = str_replace("{sql:$var}", $val, $item->title); $item->element = str_replace("{sql:$var}", $val, $item->element); $item->link = str_replace("{sql:$var}", $val, $item->link); $item->class = str_replace("{sql:$var}", $val, $item->class); $item->icon = str_replace("{sql:$var}", $val, $item->icon); $params->set('menu-quicktask', str_replace("{sql:$var}", $val, $params->get('menu-quicktask'))); } $item->setParams($params); return $item; } }