vendor/pimcore/pimcore/lib/Navigation/Builder.php line 181

Open in your IDE?
  1. <?php
  2. /**
  3. * Pimcore
  4. *
  5. * This source file is available under two different licenses:
  6. * - GNU General Public License version 3 (GPLv3)
  7. * - Pimcore Commercial License (PCL)
  8. * Full copyright and license information is available in
  9. * LICENSE.md which is distributed with this source code.
  10. *
  11. * @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org)
  12. * @license http://www.pimcore.org/license GPLv3 and PCL
  13. */
  14. namespace Pimcore\Navigation;
  15. use Pimcore\Cache as CacheManager;
  16. use Pimcore\Http\RequestHelper;
  17. use Pimcore\Logger;
  18. use Pimcore\Model\Document;
  19. use Pimcore\Model\Site;
  20. use Pimcore\Navigation\Iterator\PrefixRecursiveFilterIterator;
  21. use Pimcore\Navigation\Page\Document as DocumentPage;
  22. use Pimcore\Navigation\Page\Url;
  23. use Symfony\Component\OptionsResolver\OptionsResolver;
  24. class Builder
  25. {
  26. /**
  27. * @var RequestHelper
  28. */
  29. private $requestHelper;
  30. /**
  31. * @internal
  32. *
  33. * @var string
  34. */
  35. protected $htmlMenuIdPrefix;
  36. /**
  37. * @internal
  38. *
  39. * @var string
  40. */
  41. protected $pageClass = DocumentPage::class;
  42. /**
  43. * @var int
  44. */
  45. private $currentLevel = 0;
  46. /**
  47. * @var array
  48. */
  49. private $navCacheTags = [];
  50. /**
  51. * @var OptionsResolver
  52. */
  53. private $optionsResolver;
  54. /**
  55. * @param RequestHelper $requestHelper
  56. * @param string|null $pageClass
  57. */
  58. public function __construct(RequestHelper $requestHelper, ?string $pageClass = null)
  59. {
  60. $this->requestHelper = $requestHelper;
  61. if (null !== $pageClass) {
  62. $this->pageClass = $pageClass;
  63. }
  64. $this->optionsResolver = new OptionsResolver();
  65. $this->configureOptions($this->optionsResolver);
  66. }
  67. /**
  68. * @param OptionsResolver $options
  69. */
  70. protected function configureOptions(OptionsResolver $options)
  71. {
  72. $options->setDefaults([
  73. 'root' => null,
  74. 'htmlMenuPrefix' => null,
  75. 'pageCallback' => null,
  76. 'cache' => true,
  77. 'cacheLifetime' => null,
  78. 'maxDepth' => null,
  79. 'active' => null,
  80. 'markActiveTrail' => true,
  81. ]);
  82. $options->setAllowedTypes('root', [Document::class, 'null']);
  83. $options->setAllowedTypes('htmlMenuPrefix', ['string', 'null']);
  84. $options->setAllowedTypes('pageCallback', ['callable', 'null']);
  85. $options->setAllowedTypes('cache', ['string', 'bool']);
  86. $options->setAllowedTypes('cacheLifetime', ['int', 'null']);
  87. $options->setAllowedTypes('maxDepth', ['int', 'null']);
  88. $options->setAllowedTypes('active', [Document::class, 'null']);
  89. $options->setAllowedTypes('markActiveTrail', ['bool']);
  90. }
  91. /**
  92. * @param array $options
  93. *
  94. * @return array
  95. */
  96. protected function resolveOptions(array $options): array
  97. {
  98. return $this->optionsResolver->resolve($options);
  99. }
  100. /**
  101. * @param array|Document|null $activeDocument
  102. * @param Document|null $navigationRootDocument
  103. * @param string|null $htmlMenuIdPrefix
  104. * @param \Closure|null $pageCallback
  105. * @param bool|string $cache
  106. * @param int|null $maxDepth
  107. * @param int|null $cacheLifetime
  108. *
  109. * @return Container
  110. *
  111. * @throws \Exception
  112. */
  113. public function getNavigation($activeDocument = null, $navigationRootDocument = null, $htmlMenuIdPrefix = null, $pageCallback = null, $cache = true, ?int $maxDepth = null, ?int $cacheLifetime = null)
  114. {
  115. //TODO Pimcore 11: remove the `if (...)` block to remove the BC layer
  116. if (func_num_args() > 1 || ($activeDocument !== null && !is_array($activeDocument))) {
  117. trigger_deprecation('pimcore/pimcore', '10.5', 'Calling Pimcore\Navigation\Builder::getNavigation() using extra arguments is deprecated and will be removed in Pimcore 11.' .
  118. 'Instead, specify the arguments as an array');
  119. } else {
  120. [
  121. 'root' => $navigationRootDocument,
  122. 'htmlMenuPrefix' => $htmlMenuIdPrefix,
  123. 'pageCallback' => $pageCallback,
  124. 'cache' => $cache,
  125. 'cacheLifetime' => $cacheLifetime,
  126. 'maxDepth' => $maxDepth,
  127. 'active' => $activeDocument,
  128. 'markActiveTrail' => $markActiveTrail,
  129. ] = $this->resolveOptions($activeDocument);
  130. }
  131. $markActiveTrail ??= true; //TODO Pimcore 11: remove with the BC layer
  132. $cacheEnabled = $cache !== false;
  133. $this->htmlMenuIdPrefix = $htmlMenuIdPrefix;
  134. if (!$navigationRootDocument) {
  135. $navigationRootDocument = Document::getById(1);
  136. }
  137. $navigation = null;
  138. $cacheKey = null;
  139. if ($cacheEnabled) {
  140. // the cache key consists out of the ID and the class name (eg. for hardlinks) of the root document and the optional html prefix
  141. $cacheKeys = ['root_id__' . $navigationRootDocument->getId(), $htmlMenuIdPrefix, get_class($navigationRootDocument)];
  142. if (Site::isSiteRequest()) {
  143. $site = Site::getCurrentSite();
  144. $cacheKeys[] = 'site__' . $site->getId();
  145. }
  146. if (is_string($cache)) {
  147. $cacheKeys[] = 'custom__' . $cache;
  148. }
  149. if ($pageCallback instanceof \Closure) {
  150. $cacheKeys[] = 'pageCallback_' . closureHash($pageCallback);
  151. }
  152. if ($maxDepth) {
  153. $cacheKeys[] = 'maxDepth_' . $maxDepth;
  154. }
  155. $cacheKey = 'nav_' . md5(serialize($cacheKeys));
  156. $navigation = CacheManager::load($cacheKey);
  157. }
  158. if (!$navigation instanceof Container) {
  159. $navigation = new Container();
  160. $this->navCacheTags = ['output', 'navigation'];
  161. if ($navigationRootDocument->hasChildren()) {
  162. $this->currentLevel = 0;
  163. $rootPage = $this->buildNextLevel($navigationRootDocument, true, $pageCallback, [], $maxDepth);
  164. $navigation->addPages($rootPage);
  165. }
  166. // we need to force caching here, otherwise the active classes and other settings will be set and later
  167. // also written into cache (pass-by-reference) ... when serializing the data directly here, we don't have this problem
  168. if ($cacheEnabled) {
  169. CacheManager::save($navigation, $cacheKey, $this->navCacheTags, $cacheLifetime, 999, true);
  170. }
  171. }
  172. if ($markActiveTrail) {
  173. $this->markActiveTrail($navigation, $activeDocument);
  174. }
  175. return $navigation;
  176. }
  177. /**
  178. * @internal
  179. *
  180. * @param Container $navigation
  181. * @param Document|null $activeDocument
  182. *
  183. * @return void
  184. */
  185. protected function markActiveTrail(Container $navigation, ?Document $activeDocument): void
  186. {
  187. $activePages = [];
  188. if ($this->requestHelper->hasMainRequest()) {
  189. $request = $this->requestHelper->getMainRequest();
  190. // try to find a page matching exactly the request uri
  191. $activePages = $this->findActivePages($navigation, 'uri', $request->getRequestUri());
  192. if (empty($activePages)) {
  193. // try to find a page matching the path info
  194. $activePages = $this->findActivePages($navigation, 'uri', $request->getPathInfo());
  195. }
  196. }
  197. if ($activeDocument) {
  198. if (empty($activePages)) {
  199. // use the provided pimcore document
  200. $activePages = $this->findActivePages($navigation, 'realFullPath', $activeDocument->getRealFullPath());
  201. }
  202. if (empty($activePages)) {
  203. // find by link target
  204. $activePages = $this->findActivePages($navigation, 'uri', $activeDocument->getFullPath());
  205. }
  206. }
  207. $isLink = static fn ($page): bool => $page instanceof DocumentPage && $page->getDocumentType() === 'link';
  208. // cleanup active pages from links
  209. // pages have priority, if we don't find any active page, we use all we found
  210. if ($nonLinkPages = array_filter($activePages, static fn ($page): bool => !$isLink($page))) {
  211. $activePages = $nonLinkPages;
  212. }
  213. if ($activePages) {
  214. // we found an active document, so we can build the active trail by getting respectively the parent
  215. foreach ($activePages as $activePage) {
  216. $this->addActiveCssClasses($activePage, true);
  217. }
  218. return;
  219. }
  220. if ($activeDocument) {
  221. // we didn't find the active document, so we try to build the trail on our own
  222. $allPages = new \RecursiveIteratorIterator($navigation, \RecursiveIteratorIterator::SELF_FIRST);
  223. foreach ($allPages as $page) {
  224. if (!$page instanceof Url || !$page->getUri()) {
  225. continue;
  226. }
  227. $uri = $page->getUri() . '/';
  228. $isActive = str_starts_with($activeDocument->getRealFullPath(), $uri)
  229. || ($isLink($page) && str_starts_with($activeDocument->getFullPath(), $uri));
  230. if ($isActive) {
  231. $page->setActive(true);
  232. $page->setClass($page->getClass() . ' active active-trail');
  233. }
  234. }
  235. }
  236. }
  237. /**
  238. * @internal
  239. *
  240. * @param Container $navigation navigation container to iterate
  241. * @param string $property name of property to match against
  242. * @param string $value value to match property against
  243. *
  244. * @return Page[]
  245. */
  246. protected function findActivePages(Container $navigation, string $property, string $value): array
  247. {
  248. $filterByPrefix = new PrefixRecursiveFilterIterator($navigation, $property, $value);
  249. $flatten = new \RecursiveIteratorIterator($filterByPrefix, \RecursiveIteratorIterator::SELF_FIRST);
  250. $filterMatches = new \CallbackFilterIterator($flatten, static fn (Page $page): bool => $page->get($property) === $value);
  251. return iterator_to_array($filterMatches, false);
  252. }
  253. /**
  254. * @internal
  255. *
  256. * @param Page $page
  257. * @param bool $isActive
  258. *
  259. * @throws \Exception
  260. */
  261. protected function addActiveCssClasses(Page $page, $isActive = false)
  262. {
  263. $page->setActive(true);
  264. $parent = $page->getParent();
  265. $isRoot = false;
  266. $classes = '';
  267. if ($parent instanceof DocumentPage) {
  268. $this->addActiveCssClasses($parent);
  269. } else {
  270. $isRoot = true;
  271. }
  272. $classes .= ' active';
  273. if (!$isActive) {
  274. $classes .= ' active-trail';
  275. }
  276. if ($isRoot && $isActive) {
  277. $classes .= ' mainactive';
  278. }
  279. $page->setClass($page->getClass() . $classes);
  280. }
  281. /**
  282. * @param string $pageClass
  283. *
  284. * @return $this
  285. */
  286. public function setPageClass(string $pageClass)
  287. {
  288. $this->pageClass = $pageClass;
  289. return $this;
  290. }
  291. /**
  292. * Returns the name of the pageclass
  293. *
  294. * @return String
  295. */
  296. public function getPageClass()
  297. {
  298. return $this->pageClass;
  299. }
  300. /**
  301. * @param Document $parentDocument
  302. *
  303. * @return Document[]
  304. */
  305. protected function getChildren(Document $parentDocument): array
  306. {
  307. // the intention of this function is mainly to be overridden in order to customize the behavior of the navigation
  308. // e.g. for custom filtering and other very specific use-cases
  309. return $parentDocument->getChildren();
  310. }
  311. /**
  312. * @internal
  313. *
  314. * @param Document $parentDocument
  315. * @param bool $isRoot
  316. * @param callable $pageCallback
  317. * @param array $parents
  318. * @param int|null $maxDepth
  319. *
  320. * @return Page[]
  321. *
  322. * @throws \Exception
  323. */
  324. protected function buildNextLevel($parentDocument, $isRoot = false, $pageCallback = null, $parents = [], $maxDepth = null)
  325. {
  326. $this->currentLevel++;
  327. $pages = [];
  328. $childs = $this->getChildren($parentDocument);
  329. $parents[$parentDocument->getId()] = $parentDocument;
  330. if (!is_array($childs)) {
  331. return $pages;
  332. }
  333. foreach ($childs as $child) {
  334. $classes = '';
  335. if ($child instanceof Document\Hardlink) {
  336. $child = Document\Hardlink\Service::wrap($child);
  337. if (!$child) {
  338. continue;
  339. }
  340. }
  341. // infinite loop detection, we use array keys here, because key lookups are much faster
  342. if (isset($parents[$child->getId()])) {
  343. Logger::critical('Navigation: Document with ID ' . $child->getId() . ' would produce an infinite loop -> skipped, parent IDs (' . implode(',', array_keys($parents)) . ')');
  344. continue;
  345. }
  346. if ($child instanceof Document\Folder || $child instanceof Document\Page || $child instanceof Document\Link) {
  347. $path = $child->getFullPath();
  348. if ($child instanceof Document\Link) {
  349. $path = $child->getHref();
  350. }
  351. /** @var DocumentPage $page */
  352. $page = new $this->pageClass();
  353. if (!$child instanceof Document\Folder) {
  354. $page->setUri($path . $child->getProperty('navigation_parameters') . $child->getProperty('navigation_anchor'));
  355. }
  356. $page->setLabel($child->getProperty('navigation_name'));
  357. $page->setActive(false);
  358. $page->setId($this->htmlMenuIdPrefix . $child->getId());
  359. $page->setClass($child->getProperty('navigation_class'));
  360. $page->setTarget($child->getProperty('navigation_target'));
  361. $page->setTitle($child->getProperty('navigation_title'));
  362. $page->setAccesskey($child->getProperty('navigation_accesskey'));
  363. $page->setTabindex($child->getProperty('navigation_tabindex'));
  364. $page->setRelation($child->getProperty('navigation_relation'));
  365. $page->setDocument($child);
  366. if (trim((string)$child->getProperty('navigation_name')) === '' || $child->getProperty('navigation_exclude') || !$child->getPublished()) {
  367. $page->setVisible(false);
  368. }
  369. if ($isRoot) {
  370. $classes .= ' main';
  371. }
  372. $page->setClass($page->getClass() . $classes);
  373. if ($child->hasChildren() && (!$maxDepth || $maxDepth > $this->currentLevel)) {
  374. $childPages = $this->buildNextLevel($child, false, $pageCallback, $parents, $maxDepth);
  375. $page->setPages($childPages);
  376. }
  377. if ($pageCallback instanceof \Closure) {
  378. $pageCallback($page, $child);
  379. }
  380. $this->navCacheTags[] = $page->getDocument()->getCacheTag();
  381. $pages[] = $page;
  382. }
  383. }
  384. $this->currentLevel--;
  385. return $pages;
  386. }
  387. }