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\Navigation;
11
12use Countable;
13use RecursiveIterator;
14use RecursiveIteratorIterator;
15use Traversable;
16use Zend\Stdlib\ErrorHandler;
17
18/**
19 * Zend\Navigation\Container
20 *
21 * AbstractContainer class for Zend\Navigation\Page classes.
22 */
23abstract class AbstractContainer implements Countable, RecursiveIterator
24{
25    /**
26     * Contains sub pages
27     *
28     * @var array
29     */
30    protected $pages = array();
31
32    /**
33     * An index that contains the order in which to iterate pages
34     *
35     * @var array
36     */
37    protected $index = array();
38
39    /**
40     * Whether index is dirty and needs to be re-arranged
41     *
42     * @var bool
43     */
44    protected $dirtyIndex = false;
45
46    // Internal methods:
47
48    /**
49     * Sorts the page index according to page order
50     *
51     * @return void
52     */
53    protected function sort()
54    {
55        if (!$this->dirtyIndex) {
56            return;
57        }
58
59        $newIndex = array();
60        $index    = 0;
61
62        foreach ($this->pages as $hash => $page) {
63            $order = $page->getOrder();
64            if ($order === null) {
65                $newIndex[$hash] = $index;
66                $index++;
67            } else {
68                $newIndex[$hash] = $order;
69            }
70        }
71
72        asort($newIndex);
73        $this->index      = $newIndex;
74        $this->dirtyIndex = false;
75    }
76
77    // Public methods:
78
79    /**
80     * Notifies container that the order of pages are updated
81     *
82     * @return void
83     */
84    public function notifyOrderUpdated()
85    {
86        $this->dirtyIndex = true;
87    }
88
89    /**
90     * Adds a page to the container
91     *
92     * This method will inject the container as the given page's parent by
93     * calling {@link Page\AbstractPage::setParent()}.
94     *
95     * @param  Page\AbstractPage|array|Traversable $page  page to add
96     * @return self fluent interface, returns self
97     * @throws Exception\InvalidArgumentException if page is invalid
98     */
99    public function addPage($page)
100    {
101        if ($page === $this) {
102            throw new Exception\InvalidArgumentException(
103                'A page cannot have itself as a parent'
104            );
105        }
106
107        if (!$page instanceof Page\AbstractPage) {
108            if (!is_array($page) && !$page instanceof Traversable) {
109                throw new Exception\InvalidArgumentException(
110                    'Invalid argument: $page must be an instance of '
111                    . 'Zend\Navigation\Page\AbstractPage or Traversable, or an array'
112                );
113            }
114            $page = Page\AbstractPage::factory($page);
115        }
116
117        $hash = $page->hashCode();
118
119        if (array_key_exists($hash, $this->index)) {
120            // page is already in container
121            return $this;
122        }
123
124        // adds page to container and sets dirty flag
125        $this->pages[$hash] = $page;
126        $this->index[$hash] = $page->getOrder();
127        $this->dirtyIndex = true;
128
129        // inject self as page parent
130        $page->setParent($this);
131
132        return $this;
133    }
134
135    /**
136     * Adds several pages at once
137     *
138     * @param  array|Traversable|AbstractContainer $pages pages to add
139     * @return self fluent interface, returns self
140     * @throws Exception\InvalidArgumentException if $pages is not array,
141     *                                            Traversable or AbstractContainer
142     */
143    public function addPages($pages)
144    {
145        if (!is_array($pages) && !$pages instanceof Traversable) {
146            throw new Exception\InvalidArgumentException(
147                'Invalid argument: $pages must be an array, an '
148                . 'instance of Traversable or an instance of '
149                . 'Zend\Navigation\AbstractContainer'
150            );
151        }
152
153        // Because adding a page to a container removes it from the original
154        // (see {@link Page\AbstractPage::setParent()}), iteration of the
155        // original container will break. As such, we need to iterate the
156        // container into an array first.
157        if ($pages instanceof AbstractContainer) {
158            $pages = iterator_to_array($pages);
159        }
160
161        foreach ($pages as $page) {
162            if (null === $page) {
163                continue;
164            }
165            $this->addPage($page);
166        }
167
168        return $this;
169    }
170
171    /**
172     * Sets pages this container should have, removing existing pages
173     *
174     * @param  array $pages pages to set
175     * @return self fluent interface, returns self
176     */
177    public function setPages(array $pages)
178    {
179        $this->removePages();
180        return $this->addPages($pages);
181    }
182
183    /**
184     * Returns pages in the container
185     *
186     * @return array  array of Page\AbstractPage instances
187     */
188    public function getPages()
189    {
190        return $this->pages;
191    }
192
193    /**
194     * Removes the given page from the container
195     *
196     * @param  Page\AbstractPage|int $page      page to remove, either a page
197     *                                          instance or a specific page order
198     * @param  bool                  $recursive [optional] whether to remove recursively
199     * @return bool whether the removal was successful
200     */
201    public function removePage($page, $recursive = false)
202    {
203        if ($page instanceof Page\AbstractPage) {
204            $hash = $page->hashCode();
205        } elseif (is_int($page)) {
206            $this->sort();
207            if (!$hash = array_search($page, $this->index)) {
208                return false;
209            }
210        } else {
211            return false;
212        }
213
214        if (isset($this->pages[$hash])) {
215            unset($this->pages[$hash]);
216            unset($this->index[$hash]);
217            $this->dirtyIndex = true;
218            return true;
219        }
220
221        if ($recursive) {
222            /** @var \Zend\Navigation\Page\AbstractPage $childPage */
223            foreach ($this->pages as $childPage) {
224                if ($childPage->hasPage($page, true)) {
225                    $childPage->removePage($page, true);
226                    return true;
227                }
228            }
229        }
230
231        return false;
232    }
233
234    /**
235     * Removes all pages in container
236     *
237     * @return self fluent interface, returns self
238     */
239    public function removePages()
240    {
241        $this->pages = array();
242        $this->index = array();
243        return $this;
244    }
245
246    /**
247     * Checks if the container has the given page
248     *
249     * @param  Page\AbstractPage $page page to look for
250     * @param  bool $recursive [optional] whether to search recursively.
251     *                         Default is false.
252     * @return bool whether page is in container
253     */
254    public function hasPage(Page\AbstractPage $page, $recursive = false)
255    {
256        if (array_key_exists($page->hashCode(), $this->index)) {
257            return true;
258        } elseif ($recursive) {
259            foreach ($this->pages as $childPage) {
260                if ($childPage->hasPage($page, true)) {
261                    return true;
262                }
263            }
264        }
265
266        return false;
267    }
268
269    /**
270     * Returns true if container contains any pages
271     *
272     * @param  bool $onlyVisible whether to check only visible pages
273     * @return bool  whether container has any pages
274     */
275    public function hasPages($onlyVisible = false)
276    {
277        if ($onlyVisible) {
278            foreach ($this->pages as $page) {
279                if ($page->isVisible()) {
280                    return true;
281                }
282            }
283            // no visible pages found
284            return false;
285        }
286        return count($this->index) > 0;
287    }
288
289    /**
290     * Returns a child page matching $property == $value, or null if not found
291     *
292     * @param  string $property        name of property to match against
293     * @param  mixed  $value           value to match property against
294     * @return Page\AbstractPage|null  matching page or null
295     */
296    public function findOneBy($property, $value)
297    {
298        $iterator = new RecursiveIteratorIterator($this, RecursiveIteratorIterator::SELF_FIRST);
299
300        foreach ($iterator as $page) {
301            if ($page->get($property) == $value) {
302                return $page;
303            }
304        }
305
306        return;
307    }
308
309    /**
310     * Returns all child pages matching $property == $value, or an empty array
311     * if no pages are found
312     *
313     * @param  string $property  name of property to match against
314     * @param  mixed  $value     value to match property against
315     * @return array  array containing only Page\AbstractPage instances
316     */
317    public function findAllBy($property, $value)
318    {
319        $found = array();
320
321        $iterator = new RecursiveIteratorIterator($this, RecursiveIteratorIterator::SELF_FIRST);
322
323        foreach ($iterator as $page) {
324            if ($page->get($property) == $value) {
325                $found[] = $page;
326            }
327        }
328
329        return $found;
330    }
331
332    /**
333     * Returns page(s) matching $property == $value
334     *
335     * @param  string $property  name of property to match against
336     * @param  mixed  $value     value to match property against
337     * @param  bool   $all       [optional] whether an array of all matching
338     *                           pages should be returned, or only the first.
339     *                           If true, an array will be returned, even if not
340     *                           matching pages are found. If false, null will
341     *                           be returned if no matching page is found.
342     *                           Default is false.
343     * @return Page\AbstractPage|null  matching page or null
344     */
345    public function findBy($property, $value, $all = false)
346    {
347        if ($all) {
348            return $this->findAllBy($property, $value);
349        }
350
351        return $this->findOneBy($property, $value);
352    }
353
354    /**
355     * Magic overload: Proxy calls to finder methods
356     *
357     * Examples of finder calls:
358     * <code>
359     * // METHOD                    // SAME AS
360     * $nav->findByLabel('foo');    // $nav->findOneBy('label', 'foo');
361     * $nav->findOneByLabel('foo'); // $nav->findOneBy('label', 'foo');
362     * $nav->findAllByClass('foo'); // $nav->findAllBy('class', 'foo');
363     * </code>
364     *
365     * @param  string $method             method name
366     * @param  array  $arguments          method arguments
367     * @throws Exception\BadMethodCallException  if method does not exist
368     */
369    public function __call($method, $arguments)
370    {
371        ErrorHandler::start(E_WARNING);
372        $result = preg_match('/(find(?:One|All)?By)(.+)/', $method, $match);
373        $error  = ErrorHandler::stop();
374        if (!$result) {
375            throw new Exception\BadMethodCallException(sprintf(
376                'Bad method call: Unknown method %s::%s',
377                get_class($this),
378                $method
379            ), 0, $error);
380        }
381        return $this->{$match[1]}($match[2], $arguments[0]);
382    }
383
384    /**
385     * Returns an array representation of all pages in container
386     *
387     * @return array
388     */
389    public function toArray()
390    {
391        $this->sort();
392        $pages   = array();
393        $indexes = array_keys($this->index);
394        foreach ($indexes as $hash) {
395            $pages[] = $this->pages[$hash]->toArray();
396        }
397        return $pages;
398    }
399
400    // RecursiveIterator interface:
401
402    /**
403     * Returns current page
404     *
405     * Implements RecursiveIterator interface.
406     *
407     * @return Page\AbstractPage current page or null
408     * @throws Exception\OutOfBoundsException  if the index is invalid
409     */
410    public function current()
411    {
412        $this->sort();
413
414        current($this->index);
415        $hash = key($this->index);
416        if (!isset($this->pages[$hash])) {
417            throw new Exception\OutOfBoundsException(
418                'Corruption detected in container; '
419                . 'invalid key found in internal iterator'
420            );
421        }
422
423        return $this->pages[$hash];
424    }
425
426    /**
427     * Returns hash code of current page
428     *
429     * Implements RecursiveIterator interface.
430     *
431     * @return string  hash code of current page
432     */
433    public function key()
434    {
435        $this->sort();
436        return key($this->index);
437    }
438
439    /**
440     * Moves index pointer to next page in the container
441     *
442     * Implements RecursiveIterator interface.
443     *
444     * @return void
445     */
446    public function next()
447    {
448        $this->sort();
449        next($this->index);
450    }
451
452    /**
453     * Sets index pointer to first page in the container
454     *
455     * Implements RecursiveIterator interface.
456     *
457     * @return void
458     */
459    public function rewind()
460    {
461        $this->sort();
462        reset($this->index);
463    }
464
465    /**
466     * Checks if container index is valid
467     *
468     * Implements RecursiveIterator interface.
469     *
470     * @return bool
471     */
472    public function valid()
473    {
474        $this->sort();
475        return current($this->index) !== false;
476    }
477
478    /**
479     * Proxy to hasPages()
480     *
481     * Implements RecursiveIterator interface.
482     *
483     * @return bool  whether container has any pages
484     */
485    public function hasChildren()
486    {
487        return $this->valid() && $this->current()->hasPages();
488    }
489
490    /**
491     * Returns the child container.
492     *
493     * Implements RecursiveIterator interface.
494     *
495     * @return Page\AbstractPage|null
496     */
497    public function getChildren()
498    {
499        $hash = key($this->index);
500
501        if (isset($this->pages[$hash])) {
502            return $this->pages[$hash];
503        }
504
505        return;
506    }
507
508    // Countable interface:
509
510    /**
511     * Returns number of pages in container
512     *
513     * Implements Countable interface.
514     *
515     * @return int  number of pages in the container
516     */
517    public function count()
518    {
519        return count($this->index);
520    }
521}
522