1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\View\Helper\Navigation;
11
12use RecursiveIteratorIterator;
13use Zend\EventManager\EventManager;
14use Zend\EventManager\EventManagerAwareInterface;
15use Zend\EventManager\EventManagerInterface;
16use Zend\I18n\Translator\TranslatorInterface as Translator;
17use Zend\I18n\Translator\TranslatorAwareInterface;
18use Zend\Navigation;
19use Zend\Navigation\Page\AbstractPage;
20use Zend\Permissions\Acl;
21use Zend\ServiceManager\ServiceLocatorAwareInterface;
22use Zend\ServiceManager\ServiceLocatorInterface;
23use Zend\View;
24use Zend\View\Exception;
25
26/**
27 * Base class for navigational helpers
28 */
29abstract class AbstractHelper extends View\Helper\AbstractHtmlElement implements
30    EventManagerAwareInterface,
31    HelperInterface,
32    ServiceLocatorAwareInterface,
33    TranslatorAwareInterface
34{
35    /**
36     * @var EventManagerInterface
37     */
38    protected $events;
39
40    /**
41     * @var ServiceLocatorInterface
42     */
43    protected $serviceLocator;
44
45    /**
46     * AbstractContainer to operate on by default
47     *
48     * @var Navigation\AbstractContainer
49     */
50    protected $container;
51
52    /**
53     * The minimum depth a page must have to be included when rendering
54     *
55     * @var int
56     */
57    protected $minDepth;
58
59    /**
60     * The maximum depth a page can have to be included when rendering
61     *
62     * @var int
63     */
64    protected $maxDepth;
65
66    /**
67     * Indentation string
68     *
69     * @var string
70     */
71    protected $indent = '';
72
73    /**
74     * ACL to use when iterating pages
75     *
76     * @var Acl\AclInterface
77     */
78    protected $acl;
79
80    /**
81     * Whether invisible items should be rendered by this helper
82     *
83     * @var bool
84     */
85    protected $renderInvisible = false;
86
87    /**
88     * ACL role to use when iterating pages
89     *
90     * @var string|Acl\Role\RoleInterface
91     */
92    protected $role;
93
94    /**
95     * Whether ACL should be used for filtering out pages
96     *
97     * @var bool
98     */
99    protected $useAcl = true;
100
101    /**
102     * Translator (optional)
103     *
104     * @var Translator
105     */
106    protected $translator;
107
108    /**
109     * Translator text domain (optional)
110     *
111     * @var string
112     */
113    protected $translatorTextDomain = 'default';
114
115    /**
116     * Whether translator should be used
117     *
118     * @var bool
119     */
120    protected $translatorEnabled = true;
121
122    /**
123     * Default ACL to use when iterating pages if not explicitly set in the
124     * instance by calling {@link setAcl()}
125     *
126     * @var Acl\AclInterface
127     */
128    protected static $defaultAcl;
129
130    /**
131     * Default ACL role to use when iterating pages if not explicitly set in the
132     * instance by calling {@link setRole()}
133     *
134     * @var string|Acl\Role\RoleInterface
135     */
136    protected static $defaultRole;
137
138    /**
139     * Magic overload: Proxy calls to the navigation container
140     *
141     * @param  string $method    method name in container
142     * @param  array  $arguments rguments to pass
143     * @return mixed
144     * @throws Navigation\Exception\ExceptionInterface
145     */
146    public function __call($method, array $arguments = array())
147    {
148        return call_user_func_array(
149            array($this->getContainer(), $method),
150            $arguments
151        );
152    }
153
154    /**
155     * Magic overload: Proxy to {@link render()}.
156     *
157     * This method will trigger an E_USER_ERROR if rendering the helper causes
158     * an exception to be thrown.
159     *
160     * Implements {@link HelperInterface::__toString()}.
161     *
162     * @return string
163     */
164    public function __toString()
165    {
166        try {
167            return $this->render();
168        } catch (\Exception $e) {
169            $msg = get_class($e) . ': ' . $e->getMessage();
170            trigger_error($msg, E_USER_ERROR);
171            return '';
172        }
173    }
174
175    /**
176     * Finds the deepest active page in the given container
177     *
178     * @param  Navigation\AbstractContainer $container  container to search
179     * @param  int|null             $minDepth   [optional] minimum depth
180     *                                          required for page to be
181     *                                          valid. Default is to use
182     *                                          {@link getMinDepth()}. A
183     *                                          null value means no minimum
184     *                                          depth required.
185     * @param  int|null             $maxDepth   [optional] maximum depth
186     *                                          a page can have to be
187     *                                          valid. Default is to use
188     *                                          {@link getMaxDepth()}. A
189     *                                          null value means no maximum
190     *                                          depth required.
191     * @return array                            an associative array with
192     *                                          the values 'depth' and
193     *                                          'page', or an empty array
194     *                                          if not found
195     */
196    public function findActive($container, $minDepth = null, $maxDepth = -1)
197    {
198        $this->parseContainer($container);
199        if (!is_int($minDepth)) {
200            $minDepth = $this->getMinDepth();
201        }
202        if ((!is_int($maxDepth) || $maxDepth < 0) && null !== $maxDepth) {
203            $maxDepth = $this->getMaxDepth();
204        }
205
206        $found  = null;
207        $foundDepth = -1;
208        $iterator = new RecursiveIteratorIterator(
209            $container,
210            RecursiveIteratorIterator::CHILD_FIRST
211        );
212
213        /** @var \Zend\Navigation\Page\AbstractPage $page */
214        foreach ($iterator as $page) {
215            $currDepth = $iterator->getDepth();
216            if ($currDepth < $minDepth || !$this->accept($page)) {
217                // page is not accepted
218                continue;
219            }
220
221            if ($page->isActive(false) && $currDepth > $foundDepth) {
222                // found an active page at a deeper level than before
223                $found = $page;
224                $foundDepth = $currDepth;
225            }
226        }
227
228        if (is_int($maxDepth) && $foundDepth > $maxDepth) {
229            while ($foundDepth > $maxDepth) {
230                if (--$foundDepth < $minDepth) {
231                    $found = null;
232                    break;
233                }
234
235                $found = $found->getParent();
236                if (!$found instanceof AbstractPage) {
237                    $found = null;
238                    break;
239                }
240            }
241        }
242
243        if ($found) {
244            return array('page' => $found, 'depth' => $foundDepth);
245        }
246
247        return array();
248    }
249
250    /**
251     * Verifies container and eventually fetches it from service locator if it is a string
252     *
253     * @param  Navigation\AbstractContainer|string|null $container
254     * @throws Exception\InvalidArgumentException
255     */
256    protected function parseContainer(&$container = null)
257    {
258        if (null === $container) {
259            return;
260        }
261
262        if (is_string($container)) {
263            if (!$this->getServiceLocator()) {
264                throw new Exception\InvalidArgumentException(sprintf(
265                    'Attempted to set container with alias "%s" but no ServiceLocator was set',
266                    $container
267                ));
268            }
269
270            /**
271             * Load the navigation container from the root service locator
272             *
273             * The navigation container is probably located in Zend\ServiceManager\ServiceManager
274             * and not in the View\HelperPluginManager. If the set service locator is a
275             * HelperPluginManager, access the navigation container via the main service locator.
276             */
277            $sl = $this->getServiceLocator();
278            if ($sl instanceof View\HelperPluginManager) {
279                $sl = $sl->getServiceLocator();
280            }
281            $container = $sl->get($container);
282            return;
283        }
284
285        if (!$container instanceof Navigation\AbstractContainer) {
286            throw new  Exception\InvalidArgumentException(
287                'Container must be a string alias or an instance of '
288                . 'Zend\Navigation\AbstractContainer'
289            );
290        }
291    }
292
293    // Iterator filter methods:
294
295    /**
296     * Determines whether a page should be accepted when iterating
297     *
298     * Default listener may be 'overridden' by attaching listener to 'isAllowed'
299     * method. Listener must be 'short circuited' if overriding default ACL
300     * listener.
301     *
302     * Rules:
303     * - If a page is not visible it is not accepted, unless RenderInvisible has
304     *   been set to true
305     * - If $useAcl is true (default is true):
306     *      - Page is accepted if listener returns true, otherwise false
307     * - If page is accepted and $recursive is true, the page
308     *   will not be accepted if it is the descendant of a non-accepted page
309     *
310     * @param   AbstractPage    $page       page to check
311     * @param   bool            $recursive  [optional] if true, page will not be
312     *                                      accepted if it is the descendant of
313     *                                      a page that is not accepted. Default
314     *                                      is true
315     *
316     * @return  bool                        Whether page should be accepted
317     */
318    public function accept(AbstractPage $page, $recursive = true)
319    {
320        $accept = true;
321
322        if (!$page->isVisible(false) && !$this->getRenderInvisible()) {
323            $accept = false;
324        } elseif ($this->getUseAcl()) {
325            $acl = $this->getAcl();
326            $role = $this->getRole();
327            $params = array('acl' => $acl, 'page' => $page, 'role' => $role);
328            $accept = $this->isAllowed($params);
329        }
330
331        if ($accept && $recursive) {
332            $parent = $page->getParent();
333
334            if ($parent instanceof AbstractPage) {
335                $accept = $this->accept($parent, true);
336            }
337        }
338
339        return $accept;
340    }
341
342    /**
343     * Determines whether a page should be allowed given certain parameters
344     *
345     * @param   array   $params
346     * @return  bool
347     */
348    protected function isAllowed($params)
349    {
350        $results = $this->getEventManager()->trigger(__FUNCTION__, $this, $params);
351        return $results->last();
352    }
353
354    // Util methods:
355
356    /**
357     * Retrieve whitespace representation of $indent
358     *
359     * @param  int|string $indent
360     * @return string
361     */
362    protected function getWhitespace($indent)
363    {
364        if (is_int($indent)) {
365            $indent = str_repeat(' ', $indent);
366        }
367
368        return (string) $indent;
369    }
370
371    /**
372     * Converts an associative array to a string of tag attributes.
373     *
374     * Overloads {@link View\Helper\AbstractHtmlElement::htmlAttribs()}.
375     *
376     * @param  array $attribs  an array where each key-value pair is converted
377     *                         to an attribute name and value
378     * @return string
379     */
380    protected function htmlAttribs($attribs)
381    {
382        // filter out null values and empty string values
383        foreach ($attribs as $key => $value) {
384            if ($value === null || (is_string($value) && !strlen($value))) {
385                unset($attribs[$key]);
386            }
387        }
388
389        return parent::htmlAttribs($attribs);
390    }
391
392    /**
393     * Returns an HTML string containing an 'a' element for the given page
394     *
395     * @param  AbstractPage $page  page to generate HTML for
396     * @return string              HTML string (<a href="…">Label</a>)
397     */
398    public function htmlify(AbstractPage $page)
399    {
400        $label = $this->translate($page->getLabel(), $page->getTextDomain());
401        $title = $this->translate($page->getTitle(), $page->getTextDomain());
402
403        // get attribs for anchor element
404        $attribs = array(
405            'id'     => $page->getId(),
406            'title'  => $title,
407            'class'  => $page->getClass(),
408            'href'   => $page->getHref(),
409            'target' => $page->getTarget()
410        );
411
412        /** @var \Zend\View\Helper\EscapeHtml $escaper */
413        $escaper = $this->view->plugin('escapeHtml');
414        $label   = $escaper($label);
415
416        return '<a' . $this->htmlAttribs($attribs) . '>' . $label . '</a>';
417    }
418
419    /**
420     * Translate a message (for label, title, …)
421     *
422     * @param  string $message    ID of the message to translate
423     * @param  string $textDomain Text domain (category name for the translations)
424     * @return string             Translated message
425     */
426    protected function translate($message, $textDomain = null)
427    {
428        if (is_string($message) && !empty($message)) {
429            if (null !== ($translator = $this->getTranslator())) {
430                if (null === $textDomain) {
431                    $textDomain = $this->getTranslatorTextDomain();
432                }
433
434                return $translator->translate($message, $textDomain);
435            }
436        }
437
438        return $message;
439    }
440
441    /**
442     * Normalize an ID
443     *
444     * Overrides {@link View\Helper\AbstractHtmlElement::normalizeId()}.
445     *
446     * @param  string $value
447     * @return string
448     */
449    protected function normalizeId($value)
450    {
451        $prefix = get_class($this);
452        $prefix = strtolower(trim(substr($prefix, strrpos($prefix, '\\')), '\\'));
453
454        return $prefix . '-' . $value;
455    }
456
457    /**
458     * Sets ACL to use when iterating pages
459     *
460     * Implements {@link HelperInterface::setAcl()}.
461     *
462     * @param  Acl\AclInterface $acl ACL object.
463     * @return AbstractHelper
464     */
465    public function setAcl(Acl\AclInterface $acl = null)
466    {
467        $this->acl = $acl;
468        return $this;
469    }
470
471    /**
472     * Returns ACL or null if it isn't set using {@link setAcl()} or
473     * {@link setDefaultAcl()}
474     *
475     * Implements {@link HelperInterface::getAcl()}.
476     *
477     * @return Acl\AclInterface|null  ACL object or null
478     */
479    public function getAcl()
480    {
481        if ($this->acl === null && static::$defaultAcl !== null) {
482            return static::$defaultAcl;
483        }
484
485        return $this->acl;
486    }
487
488    /**
489     * Checks if the helper has an ACL instance
490     *
491     * Implements {@link HelperInterface::hasAcl()}.
492     *
493     * @return bool
494     */
495    public function hasAcl()
496    {
497        if ($this->acl instanceof Acl\Acl
498            || static::$defaultAcl instanceof Acl\Acl
499        ) {
500            return true;
501        }
502
503        return false;
504    }
505
506    /**
507     * Set the event manager.
508     *
509     * @param   EventManagerInterface $events
510     * @return  AbstractHelper
511     */
512    public function setEventManager(EventManagerInterface $events)
513    {
514        $events->setIdentifiers(array(
515            __CLASS__,
516            get_called_class(),
517        ));
518
519        $this->events = $events;
520
521        $this->setDefaultListeners();
522
523        return $this;
524    }
525
526    /**
527     * Get the event manager.
528     *
529     * @return  EventManagerInterface
530     */
531    public function getEventManager()
532    {
533        if (null === $this->events) {
534            $this->setEventManager(new EventManager());
535        }
536
537        return $this->events;
538    }
539
540    /**
541     * Sets navigation container the helper operates on by default
542     *
543     * Implements {@link HelperInterface::setContainer()}.
544     *
545     * @param  string|Navigation\AbstractContainer $container Default is null, meaning container will be reset.
546     * @return AbstractHelper
547     */
548    public function setContainer($container = null)
549    {
550        $this->parseContainer($container);
551        $this->container = $container;
552
553        return $this;
554    }
555
556    /**
557     * Returns the navigation container helper operates on by default
558     *
559     * Implements {@link HelperInterface::getContainer()}.
560     *
561     * If no container is set, a new container will be instantiated and
562     * stored in the helper.
563     *
564     * @return Navigation\AbstractContainer  navigation container
565     */
566    public function getContainer()
567    {
568        if (null === $this->container) {
569            $this->container = new Navigation\Navigation();
570        }
571
572        return $this->container;
573    }
574
575    /**
576     * Checks if the helper has a container
577     *
578     * Implements {@link HelperInterface::hasContainer()}.
579     *
580     * @return bool
581     */
582    public function hasContainer()
583    {
584        return null !== $this->container;
585    }
586
587    /**
588     * Set the indentation string for using in {@link render()}, optionally a
589     * number of spaces to indent with
590     *
591     * @param  string|int $indent
592     * @return AbstractHelper
593     */
594    public function setIndent($indent)
595    {
596        $this->indent = $this->getWhitespace($indent);
597        return $this;
598    }
599
600    /**
601     * Returns indentation
602     *
603     * @return string
604     */
605    public function getIndent()
606    {
607        return $this->indent;
608    }
609
610    /**
611     * Sets the maximum depth a page can have to be included when rendering
612     *
613     * @param  int $maxDepth Default is null, which sets no maximum depth.
614     * @return AbstractHelper
615     */
616    public function setMaxDepth($maxDepth = null)
617    {
618        if (null === $maxDepth || is_int($maxDepth)) {
619            $this->maxDepth = $maxDepth;
620        } else {
621            $this->maxDepth = (int) $maxDepth;
622        }
623
624        return $this;
625    }
626
627    /**
628     * Returns maximum depth a page can have to be included when rendering
629     *
630     * @return int|null
631     */
632    public function getMaxDepth()
633    {
634        return $this->maxDepth;
635    }
636
637    /**
638     * Sets the minimum depth a page must have to be included when rendering
639     *
640     * @param  int $minDepth Default is null, which sets no minimum depth.
641     * @return AbstractHelper
642     */
643    public function setMinDepth($minDepth = null)
644    {
645        if (null === $minDepth || is_int($minDepth)) {
646            $this->minDepth = $minDepth;
647        } else {
648            $this->minDepth = (int) $minDepth;
649        }
650
651        return $this;
652    }
653
654    /**
655     * Returns minimum depth a page must have to be included when rendering
656     *
657     * @return int|null
658     */
659    public function getMinDepth()
660    {
661        if (!is_int($this->minDepth) || $this->minDepth < 0) {
662            return 0;
663        }
664
665        return $this->minDepth;
666    }
667
668    /**
669     * Render invisible items?
670     *
671     * @param  bool $renderInvisible
672     * @return AbstractHelper
673     */
674    public function setRenderInvisible($renderInvisible = true)
675    {
676        $this->renderInvisible = (bool) $renderInvisible;
677        return $this;
678    }
679
680    /**
681     * Return renderInvisible flag
682     *
683     * @return bool
684     */
685    public function getRenderInvisible()
686    {
687        return $this->renderInvisible;
688    }
689
690    /**
691     * Sets ACL role(s) to use when iterating pages
692     *
693     * Implements {@link HelperInterface::setRole()}.
694     *
695     * @param  mixed $role [optional] role to set. Expects a string, an
696     *                     instance of type {@link Acl\Role\RoleInterface}, or null. Default
697     *                     is null, which will set no role.
698     * @return AbstractHelper
699     * @throws Exception\InvalidArgumentException
700     */
701    public function setRole($role = null)
702    {
703        if (null === $role || is_string($role) ||
704            $role instanceof Acl\Role\RoleInterface
705        ) {
706            $this->role = $role;
707        } else {
708            throw new Exception\InvalidArgumentException(sprintf(
709                '$role must be a string, null, or an instance of '
710                . 'Zend\Permissions\Role\RoleInterface; %s given',
711                (is_object($role) ? get_class($role) : gettype($role))
712            ));
713        }
714
715        return $this;
716    }
717
718    /**
719     * Returns ACL role to use when iterating pages, or null if it isn't set
720     * using {@link setRole()} or {@link setDefaultRole()}
721     *
722     * Implements {@link HelperInterface::getRole()}.
723     *
724     * @return string|Acl\Role\RoleInterface|null
725     */
726    public function getRole()
727    {
728        if ($this->role === null && static::$defaultRole !== null) {
729            return static::$defaultRole;
730        }
731
732        return $this->role;
733    }
734
735    /**
736     * Checks if the helper has an ACL role
737     *
738     * Implements {@link HelperInterface::hasRole()}.
739     *
740     * @return bool
741     */
742    public function hasRole()
743    {
744        if ($this->role instanceof Acl\Role\RoleInterface
745            || is_string($this->role)
746            || static::$defaultRole instanceof Acl\Role\RoleInterface
747            || is_string(static::$defaultRole)
748        ) {
749            return true;
750        }
751
752        return false;
753    }
754
755    /**
756     * Set the service locator.
757     *
758     * @param  ServiceLocatorInterface $serviceLocator
759     * @return AbstractHelper
760     */
761    public function setServiceLocator(ServiceLocatorInterface $serviceLocator)
762    {
763        $this->serviceLocator = $serviceLocator;
764        return $this;
765    }
766
767    /**
768     * Get the service locator.
769     *
770     * @return ServiceLocatorInterface
771     */
772    public function getServiceLocator()
773    {
774        return $this->serviceLocator;
775    }
776
777    // Translator methods - Good candidate to refactor as a trait with PHP 5.4
778
779    /**
780     * Sets translator to use in helper
781     *
782     * @param  Translator $translator  [optional] translator.
783     *                                 Default is null, which sets no translator.
784     * @param  string     $textDomain  [optional] text domain
785     *                                 Default is null, which skips setTranslatorTextDomain
786     * @return AbstractHelper
787     */
788    public function setTranslator(Translator $translator = null, $textDomain = null)
789    {
790        $this->translator = $translator;
791        if (null !== $textDomain) {
792            $this->setTranslatorTextDomain($textDomain);
793        }
794
795        return $this;
796    }
797
798    /**
799     * Returns translator used in helper
800     *
801     * @return Translator|null
802     */
803    public function getTranslator()
804    {
805        if (! $this->isTranslatorEnabled()) {
806            return;
807        }
808
809        return $this->translator;
810    }
811
812    /**
813     * Checks if the helper has a translator
814     *
815     * @return bool
816     */
817    public function hasTranslator()
818    {
819        return (bool) $this->getTranslator();
820    }
821
822    /**
823     * Sets whether translator is enabled and should be used
824     *
825     * @param  bool $enabled
826     * @return AbstractHelper
827     */
828    public function setTranslatorEnabled($enabled = true)
829    {
830        $this->translatorEnabled = (bool) $enabled;
831        return $this;
832    }
833
834    /**
835     * Returns whether translator is enabled and should be used
836     *
837     * @return bool
838     */
839    public function isTranslatorEnabled()
840    {
841        return $this->translatorEnabled;
842    }
843
844    /**
845     * Set translation text domain
846     *
847     * @param  string $textDomain
848     * @return AbstractHelper
849     */
850    public function setTranslatorTextDomain($textDomain = 'default')
851    {
852        $this->translatorTextDomain = $textDomain;
853        return $this;
854    }
855
856    /**
857     * Return the translation text domain
858     *
859     * @return string
860     */
861    public function getTranslatorTextDomain()
862    {
863        return $this->translatorTextDomain;
864    }
865
866    /**
867     * Sets whether ACL should be used
868     *
869     * Implements {@link HelperInterface::setUseAcl()}.
870     *
871     * @param  bool $useAcl
872     * @return AbstractHelper
873     */
874    public function setUseAcl($useAcl = true)
875    {
876        $this->useAcl = (bool) $useAcl;
877        return $this;
878    }
879
880    /**
881     * Returns whether ACL should be used
882     *
883     * Implements {@link HelperInterface::getUseAcl()}.
884     *
885     * @return bool
886     */
887    public function getUseAcl()
888    {
889        return $this->useAcl;
890    }
891
892    // Static methods:
893
894    /**
895     * Sets default ACL to use if another ACL is not explicitly set
896     *
897     * @param  Acl\AclInterface $acl [optional] ACL object. Default is null, which
898     *                      sets no ACL object.
899     * @return void
900     */
901    public static function setDefaultAcl(Acl\AclInterface $acl = null)
902    {
903        static::$defaultAcl = $acl;
904    }
905
906    /**
907     * Sets default ACL role(s) to use when iterating pages if not explicitly
908     * set later with {@link setRole()}
909     *
910     * @param  mixed $role [optional] role to set. Expects null, string, or an
911     *                     instance of {@link Acl\Role\RoleInterface}. Default is null, which
912     *                     sets no default role.
913     * @return void
914     * @throws Exception\InvalidArgumentException if role is invalid
915     */
916    public static function setDefaultRole($role = null)
917    {
918        if (null === $role
919            || is_string($role)
920            || $role instanceof Acl\Role\RoleInterface
921        ) {
922            static::$defaultRole = $role;
923        } else {
924            throw new Exception\InvalidArgumentException(sprintf(
925                '$role must be null|string|Zend\Permissions\Role\RoleInterface; received "%s"',
926                (is_object($role) ? get_class($role) : gettype($role))
927            ));
928        }
929    }
930
931    /**
932     * Attaches default ACL listeners, if ACLs are in use
933     */
934    protected function setDefaultListeners()
935    {
936        if (!$this->getUseAcl()) {
937            return;
938        }
939
940        $this->getEventManager()->getSharedManager()->attach(
941            'Zend\View\Helper\Navigation\AbstractHelper',
942            'isAllowed',
943            array('Zend\View\Helper\Navigation\Listener\AclListener', 'accept')
944        );
945    }
946}
947