Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire WireArray
 *
 * WireArray is the base array access object used in the ProcessWire framework.
 * 
 * Several methods are duplicated here for syntactical convenience and jQuery-like usability. 
 * Many methods act upon the array and return $this, which enables WireArrays to be used for fluent interfaces.
 * WireArray is the base of the PageArray (subclass) which is the most used instance. 
 *
 * @todo can we implement next() and prev() like on Page, as alias to getNext() and getPrev()?
 * 
 * ProcessWire 3.x, Copyright 2021 by Ryan Cramer
 * https://processwire.com
 * 
 * @method WireArray and($item)
 * @method static WireArray new($items = array()) 
 * @property int $count Number of items
 * @property Wire|null $first First item
 * @property Wire|null $last Last item
 * @property array $keys All keys used in this WireArray
 * @property array $values All values used in this WireArray
 *
 * #pw-order-groups traversal,retrieval,manipulation,info,output-rendering,other-data-storage,changes,fun-tools,hooker
 * #pw-summary WireArray is the base iterable array type used throughout the ProcessWire framework.
 * 
 * #pw-body = 
 * **Nearly all collections of items in ProcessWire are derived from the WireArray type.** 
 * This includes collections of pages, fields, templates, modules and more. As a result, the WireArray class is one 
 * you will be interacting with regularly in the ProcessWire API, whether you know it or not. 
 * 
 * Below are all the public methods you can use to interact with WireArray types in ProcessWire. In addition to these
 * methods, you can also treat WireArray types like regular PHP arrays, in that you can `foreach()` them and get or 
 * set elements using array syntax, i.e. `$value = $items[$key];` to get an item or `$items[] = $item;` to add an item. 
 * #pw-body
 *
 */
class WireArray extends Wire implements \IteratorAggregate, \ArrayAccess, \Countable {

    /**
     * Basic type managed by the WireArray for data storage
     * 
     * @var Wire[]
     *
     */
    protected $data = array();

    /**
     * Any extra user defined data to accompany the WireArray
     *
     * See the data() method. Note these are not under change tracking. 
     *
     */
    protected $extraData = array();

    /**
     * Array containing the items that have been removed from this WireArray while trackChanges is on
     * 
     * @see getRemovedKeys()
     *
     */
    protected $itemsRemoved = array(); 

    /**
     * Array containing the items that have been added to this WireArray while trackChanges is on
     * 
     * @see getRemovedKeys()
     *
     */
    protected $itemsAdded = array();

    /**
     * Prevent addition of duplicates?
     * 
     * Applies only to non-associative WireArray types.
     * 
     * @var bool
     * 
     */
    protected $duplicateChecking = true;

    /**
     * Flags for PHP sort functions
     * 
     * @var int
     * 
     */
    protected $sortFlags = 0; // 0 == SORT_REGULAR

    /**
     * Construct
     * 
     */
    public function __construct() {
        if($this->className() === 'WireArray') $this->duplicateChecking = false;    
    }

    /**
     * Is the given item valid for storange in this array?
     * 
     * Template method that descending classes may use to validate items added to this WireArray
     * 
     * #pw-group-info
     *
     * @param mixed $item Item to test for validity
     * @return bool True if item is valid and may be added, false if not
     *
     */
    public function isValidItem($item) {
        if($item instanceof Wire) return true;
        $className = $this->className();
        if($className === 'WireArray' || $className === 'PaginatedArray') return true;
        return false;
    }

    /**
     * Is the given item key valid for use in this array?
     * 
     * Template method that descendant classes may use to validate the key of items added to this WireArray
     * 
     * #pw-group-info
     *
     * @param string|int $key Key to test
     * @return bool True if key is valid and may be used, false if not
     *
     */
    public function isValidKey($key) {
        // unused $key intentional for descending class/template purposes
        if($key) {}
        return true; 
    }

    /**
     * Is the given WireArray identical to this one?
     * 
     * #pw-group-info
     * 
     * @param WireArray $items
     * @param bool|int $strict Use strict mode? Optionally specify one of the following: 
     *  `true` (boolean): Default. Compares items, item object instances, order, and any other data contained in WireArray.
     *  `false` (boolean): Compares only that items in the WireArray resolve to the same order and values (though not object instances).
     * @return bool True if identical, false if not. 
     * 
     */
    public function isIdentical(WireArray $items, $strict = true) {
        if($items === $this) return true; 
        if($items->className() != $this->className()) return false;
        if(!$strict) return ((string) $this) === ((string) $items); 
        $a1 = $this->getArray();
        $a2 = $items->getArray();
        if($a1 === $a2) {
            // all items match
            $d1 = $this->data();
            $d2 = $items->data();
            if($d1 === $d2) {
                // all data matches
                return true;
            }
        }
        return false;
    }
    
    /**
     * Get the array key for the given item
     * 
     * - This is a template method that descendant classes may use to find a key from the item itself, or null if disabled. 
     * - This method is used internally by the add() and prepend() methods. 
     * 
     * #pw-internal
     *
     * @param object|Wire $item Item to get key for
     * @return string|int|null Found key, or null if not found. 
     *
     */
    public function getItemKey($item) {
        // in this base class, we don't make assumptions how the key is determined
        // so we just search the array to see if the item is already here and 
        // return it's key if it is here
        $key = array_search($item, $this->data, true); 
        return $key === false ? null : $key; 
    }

    /**
     * Get a new/blank item of the type that this WireArray holds
     * 
     * #pw-internal
     *
     * @throws WireException If class doesn't implement this method. 
     * @return Wire|null
     *
     */
    public function makeBlankItem() {
        $class = wireClassName($this, false); 
        if($class != 'WireArray' && $class != 'PaginatedArray') {
            throw new WireException("Class '$class' doesn't yet implement method 'makeBlankItem()' and it needs to.");
        }
        return null;
    }

    /**
     * Creates a new blank instance of this WireArray, for internal use. 
     * 
     * #pw-internal
     *
     * @return WireArray
     *
     */
    public function makeNew() {
        $class = get_class($this); 
        $newArray = $this->wire(new $class()); 
        return $newArray; 
    }

    /**
     * Creates a new populated copy/clone of this WireArray
     *
     * Same as a clone, except that descending classes may wish to replace the 
     * clone call a manually created WireArray to prevent deep cloning.
     * 
     * #pw-internal
     *
     * @return WireArray
     *
     */
    public function makeCopy() {
        return clone $this; 
    }

    /**
     * Import the given item(s) into this WireArray.
     * 
     * - Adds imported items to the end of the WireArray. 
     * - Skips over any items already present in the WireArray (when duplicateChecking is enabled)
     * 
     * #pw-group-manipulation
     * 
     * @param array|WireArray $items Items to import.
     * @return $this 
     * @throws WireException If given items not compatible with the WireArray
     *
     */
    public function import($items) {

        if(!is_array($items) && !self::iterable($items)) {
            throw new WireException('WireArray cannot import non arrays or non-iterable objects');
        }
    
        foreach($items as $key => $value) {
            if($this->duplicateChecking) {
                if(($k = $this->getItemKey($value)) !== null) $key = $k;
                if(isset($this->data[$key])) continue; // won't overwrite existing keys
                $this->set($key, $value);
            } else {
                $this->add($value); 
            }
        }

        return $this;
    }

    /**
     * Add an item to the end of the WireArray.
     * 
     * ~~~~~
     * $items->add($item); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param int|string|array|object|Wire|WireArray $item Item to add. 
     * @return $this
     * @throws WireException If given an item that can't be stored by this WireArray.
     * @see WireArray::prepend(), WireArray::append()
     * 
     */
    public function add($item) {

        if(!$this->isValidItem($item)) {
            if($item instanceof WireArray) {
                foreach($item as $i) $this->prepend($i); 
                return $this; 
            } else {
                throw new WireException("Item added to " . get_class($this) . " is not an allowed type"); 
            }
        }

        $key = null;
        if($this->duplicateChecking && ($key = $this->getItemKey($item)) !== null) {
            // avoid two copies of the same item, re-add it to the end 
            if(isset($this->data[$key])) unset($this->data[$key]); 
            $this->data[$key] = $item; 
        } else {
            $this->data[] = $item;
            end($this->data);
            $key = key($this->data);
        }

        $this->trackChange("add", null, $item); 
        $this->trackAdd($item, $key); 
        return $this;
    }

    /**
     * Insert an item either before or after another
     *
     * Provides the implementation for the insertBefore and insertAfter functions
     * 
     * @param int|string|array|object $item Item you want to insert
     * @param int|string|array|object $existingItem Item already present that you want to insert before/afer
     * @param bool $insertBefore True if you want to insert before, false if after
     * @return $this
     * @throws WireException if given an invalid item
     *
     */
    protected function _insert($item, $existingItem, $insertBefore = true) {

        if(!$this->isValidItem($item)) throw new WireException("You may not insert this item type"); 
        $data = array();
        $this->add($item); // first add the item, then we'll move it
        $itemKey = $this->getItemKey($item); 

        foreach($this->data as $key => $value) {
            if($value === $existingItem) {
                // found $existingItem, so insert $item and then insert $existingItem
                if($insertBefore) { 
                    $data[$itemKey] = $item; 
                    $data[$key] = $value; 
                } else {
                    $data[$key] = $value; 
                    $data[$itemKey] = $item; 
                }
                
            } else if($value === $item) {
                // skip over it since the above is doing the insert 
                continue; 

            } else {
                // continue populating existing data
                $data[$key] = $value; 
            }
        }
        $this->data = $data; 
        return $this; 
    }

    /**
     * Insert an item before an existing item
     * 
     * ~~~~~
     * $items->insertBefore($newItem, $existingItem); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param Wire|string|int $item Item you want to insert.
     * @param Wire|string|int $existingItem Item already present that you want to insert before.
     * @return $this
     *
     */
    public function insertBefore($item, $existingItem) {
        return $this->_insert($item, $existingItem, true); 
    }

    /**
     * Insert an item after an existing item
     * 
     * ~~~~~
     * $items->insertAfter($newItem, $existingItem); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param Wire|string|int $item Item you want to insert
     * @param Wire|string|int $existingItem Item already present that you want to insert after
     * @return $this
     *
     */
    public function insertAfter($item, $existingItem) {
        return $this->_insert($item, $existingItem, false); 
    }

    /**
     * Replace one item with the other
     * 
     * - The order of the arguments does not matter. 
     * - If both items are already present, they will change places. 
     * - If one item is not already present, it will replace the one that is. 
     * - If neither item is present, both will be added at the end.
     * 
     * ~~~~~
     * $items->replace($existingItem, $newItem); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param Wire|string|int $itemA
     * @param Wire|string|int $itemB
     * @return $this
     *
     */
    public function replace($itemA, $itemB) {
        $a = $this->get($itemA);
        $b = $this->get($itemB);
        if($a && $b) {
            // swap a and b, both already present in this WireArray
            $data = $this->data; 
            foreach($data as $key => $value) {
                $k = null;
                if($value === $a) {
                    if(method_exists($b, 'getItemKey')) {
                        $k = $b->getItemKey();
                    } else {
                        $k = $this->getItemKey($b);
                    }
                    $value = $b; 
                } else if($value === $b) {
                    if(method_exists($a, 'getItemKey')) {
                        $k = $a->getItemKey();
                    } else {
                        $k = $this->getItemKey($a);
                    }
                    $value = $a; 
                }
                if($k !== null) $key = $k;
                $data[$key] = $value;
            }
            $this->data = $data; 
        
        } else if($a) {
            // b not already in array, so replace a with b
            $this->_insert($itemB, $a); 
            $this->remove($a); 

        } else if($b) {
            // a not already in array, so replace b with a
            $this->_insert($itemA, $b); 
            $this->remove($b);
        }
        return $this; 
    }

    /**
     * Set an item by key in the WireArray.
     * 
     * #pw-group-manipulation
     *
     * @param int|string $key Key of item to set.
     * @param int|string|array|object|Wire $value Item value to set.
     * @throws WireException If given an item not compatible with this WireArray. 
     * @return $this
     *
     */
    public function set($key, $value) {

        if(!$this->isValidItem($value)) {
            throw new WireException("Item '$key' set to " . get_class($this) . " is not an allowed type");
        }
        if(!$this->isValidKey($key)) {
            throw new WireException("Key '$key' is not an allowed key for " . get_class($this));
        }

        $this->trackChange($key, isset($this->data[$key]) ? $this->data[$key] : null, $value); 
        $this->data[$key] = $value; 
        $this->trackAdd($value, $key); 
        return $this; 
    }

    /**
     * Enables setting of WireArray elements in object notation.
     *
     * Example: $myArray->myElement = 10; 
     * Not applicable to numerically indexed arrays.
     *
     * @param int|string $property Key of item to set. 
     * @param int|string|array|object Value of item to set. 
     * @throws WireException
     *
     */
    public function __set($property, $value) {
        if($this->getProperty($property)) {
            throw new WireException("Property '$property' is a reserved word and may not be set by direct reference.");
        }
        $this->set($property, $value); 
    }

    /**
     * Ensures that isset() and empty() work for this classes properties. 
     * 
     * @param string|int $key
     * @return bool
     *
     */
    public function __isset($key) {
        return isset($this->data[$key]);
    }

    /**
     * Ensures that unset() works for this classes data. 
     * 
     * @param int|string $key
     *
     */
    public function __unset($key) {
        $this->remove($key); 
    }
    
    /**
     * Like set() but accepts an array or WireArray to set multiple values at once
     * 
     * #pw-group-manipulation
     *
     * @param array|WireArray $data Array or WireArray of data that you want to set. 
     * @return $this
     *
     */
    public function setArray($data) {
        if(self::iterable($data)) {
            foreach($data as $key => $value) $this->set($key, $value); 
        }
        return $this; 
    }


    /**
     * Returns the value of the item at the given index, or null if not set. 
     *
     * You may also specify a selector, in which case this method will return the same result as 
     * the `WireArray::findOne()` method. See the $key argument description for more details on 
     * what can be provided. 
     * 
     * #pw-group-retrieval
     *
     * @param int|string|array $key Provide any of the following: 
     *  - Key of item to retrieve. 
     *  - Array of keys, in which case an array of matching items will be returned, indexed by your keys.
     *  - A selector string or selector array, to return the first item that matches the selector. 
     *  - A string of text with "{var}" tags in it that will be populated with any matching properties from this WireArray. 
     *  - A string like "foobar[]" which returns an array of all "foobar" properties from each item in the WireArray. 
     *  - A string containing the "name" property of any item, and the matching item will be returned. 
     * @return WireData|Page|mixed|array|null Value of item requested, or null if it doesn't exist.
     * @throws WireException
     *
     */
    public function get($key) {
        $match = null;

        // if an object was provided, get its key
        if(is_object($key)) {
            /** @var object $key */
            $key = $this->getItemKey($key);
            /** @var string|int $key */
        }

        // if given an array of keys, return all matching items
        if(is_array($key)) { 
            /** @var array $key */
            if(ctype_digit(implode('', array_keys($key)))) {
                $items = array();
                foreach($key as $k) {
                    $item = $this->get($k);
                    $items[$k] = $item;
                }
                return $items;
            } else {
                // selector array
                $item = $this->findOne($key);
                if($item === false) $item = null;
                return $item;
            }
        }

        // check if the index is set and return it if so
        if(isset($this->data[$key])) return $this->data[$key];

        // check if key contains something other than numbers, letters, underscores, hyphens
        if(is_string($key)) { 
            if(!ctype_alnum($key) && !ctype_alnum(strtr($key, '-_', 'ab'))) {

                // check if key contains a selector
                if(Selectors::stringHasSelector($key)) {
                    $item = $this->findOne($key);
                    if($item === false) $item = null;
                    return $item;
                }

                if(strpos($key, '{') !== false && strpos($key, '}')) {
                    // populate a formatted string with {tag} vars
                    return wirePopulateStringTags($key, $this);
                }

                // check if key is requesting a property array: i.e. "name[]"
                if(strpos($key, '[]') !== false && substr($key, -2) == '[]') {
                    return $this->explode(substr($key, 0, -2));
                }

                // check if key is asking for first match in "a|b|c"
                if(strpos($key, '|') !== false) {
                    $numericKeys = $this->usesNumericKeys();
                    foreach(explode('|', $key) as $k) {
                        if(isset($this->data[$k])) {
                            $match = $this->data[$k];
                        } else if($numericKeys) {
                            $match = $this->getItemThatMatches('name', $k);
                        }
                        if($match) break;
                    }
                    return $match;
                }
            }

            // if the WireArray uses numeric keys, then it's okay to
            // match a 'name' field if the provided key is a string
            if(is_string($key) && $this->usesNumericKeys()) {
                $match = $this->getItemThatMatches('name', $key);
            }
        }
        
        return $match; 
    }

    /**
     * Enables derefencing of WireArray elements in object notation. 
     *
     * Example: $myArray->myElement
     * Not applicable to numerically indexed arrays. 
     * Fuel properties and hooked properties have precedence with this type of call.
     * 
     * @param int|string $property 
     * @return Wire|WireData|Page|mixed|bool Value of item requested, or false if it doesn't exist. 
     *
     */
    public function __get($property) {
        $value = parent::__get($property); 
        if(is_null($value)) $value = $this->getProperty($property);
        if(is_null($value)) $value = $this->get($property); 
        return $value; 
    }

    /**
     * Get a predefined property of the array, or extra data that has been set.
     *
     * Default properties include;
     * 
     * - `count` (int): Number of items present in this WireArray.
     * - `last` (mixed): Last item in this WireArray.
     * - `first` (mixed): First item in this WireArray.
     * - `keys` (array): Keys used in this WireArray.
     * - `values` (array): Values present in this WireArray.
     * 
     * These can also be accessed by direct reference. 
     * 
     * ~~~~~
     * // Get count
     * $count = $items->getProperty('count'); 
     * 
     * // Same as above using direct access property
     * $count = $items->count; 
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param string $property Name of property to retrieve
     * @return Wire|mixed
     *
     */
    public function getProperty($property) {
        static $properties = array(
            // property => method to map to
            'count' => 'count', 
            'last' => 'last',
            'first' => 'first',
            'keys' => 'getKeys',
            'values' => 'getValues',
        );
        
        if(!in_array($property, $properties)) return $this->data($property); 
        $func = $properties[$property];
        return $this->$func();
    }

    /**
     * Return the first item in this WireArray having a property named $key with $value, or NULL if not found. 
     *
     * Used internally by get() and has() methods. 
     *
     * @param string $key Property to match. 
     * @param string|int|object $value $value to match.
     * @return Wire|null
     *
     */
    protected function getItemThatMatches($key, $value) {
        if(ctype_digit("$key")) return null;
        $item = null;
        foreach($this->data as $wire) {
            if(is_object($wire) && $wire instanceof Wire) { 
                if($wire->$key === $value) {
                    $item = $wire; 
                    break;
                }
            } else {
                if($wire === $value) {
                    $item = $wire; 
                    break;
                }
            }
        }
        return $item; 
    }

    /**
     * Does this WireArray have the given item, index, or match the given selector?
     *
     * If the WireArray uses numeric keys, then this will also match a WireData object's "name" field.
     * 
     * ~~~~~
     * // See if it has a given $item
     * if($items->has($item)) {
     *   // Has the given $item
     * }
     * 
     * // See if it has an object with a "name" property matching our text
     * if($items->has("name=something")) {
     *   // Has an item with a "name" property equal to "something"
     * }
     * 
     * // Same as above, but works since "name" is assumed for many types
     * if($items->has("something")) {
     *   // It has it
     * }
     * ~~~~~
     * 
     * #pw-group-retrieval
     * #pw-group-info
     * 
     * @param int|string|Wire $key Key of item to check or selector.
     * @return bool True if the item exists, false if not. 
     * 
     */ 
    public function has($key) {

        if(is_object($key)) {
            /** @var object|Wire $key */
            $key = $this->getItemKey($key);
            /** @var int|string $key */
        }
        
        if(is_array($key)) {
            // match selector array
            return $this->findOne($key) ? true : false;
        }

        if(array_key_exists($key, $this->data)) return true; 

        $match = null;
        if(is_string($key)) {

            if(Selectors::stringHasOperator($key)) {
                $match = $this->findOne($key); 

            } else if($this->usesNumericKeys()) {
                $match = $this->getItemThatMatches('name', $key); 
            }

        } 

        return $match ? true : false; 
    }

    /**
     * Get a PHP array of all the items in this WireArray with original keys maintained 
     * 
     * #pw-group-retrieval
     *
     * @return array Copy of the array that WireArray uses internally. 
     * @see WireArray::getValues()
     * 
     */
    public function getArray() {
        return $this->data; 
    }

    /**
     * Returns all items in the WireArray (for syntax convenience)
     * 
     * This is for syntax convenience, as it simply returns this instance of the WireArray.
     * 
     * #pw-group-retrieval
     * 
     * @return $this
     * 
     */
    public function getAll() {
        return $this;
    }


    /**
     * Returns a regular PHP array of all keys used in this WireArray.
     * 
     * #pw-group-retrieval
     * 
     * @return array Keys used in the WireArray.
     * 
     */
    public function getKeys() {
        return array_keys($this->data); 
    }

    /**
     * Returns a regular PHP array of all values used in this WireArray.
     * 
     * Unlike the `WireArray::getArray()` method, this does not attempt to maintain original 
     * keys of the items. The returned array is reindexed from 0. 
     * 
     * #pw-group-retrieval
     * 
     * @return array|Wire[] Values used in the WireArray.
     * @see WireArray::getArray()
     * 
     */
    public function getValues() {
        return array_values($this->data); 
    }

    /**
     * Get a random item from this WireArray. 
     *
     * - If one item is requested (default), the item is returned (unless `$alwaysArray` argument is true).
     * - If multiple items are requested, a new `WireArray` of those items is returned. 
     * - We recommend using this method when you just need 1 random item, and using the `WireArray::findRandom()` method
     *   when you need multiple random items. 
     * 
     * ~~~~~
     * // Get a single random item
     * $randomItem = $items->getRandom();
     * 
     * // Get 3 random items
     * $randomItems = $items->getRandom(3); 
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param int $num Number of items to return. Optional and defaults to 1. 
     * @param bool $alwaysArray If true, then method will always return an array of items, even if it only contains 1 item.
     * @return WireArray|Wire|mixed|null Returns value of item, or new WireArray of items if more than one requested. 
     * @see WireArray::findRandom(), WireArray::findRandomTimed()
     * 
     */
    public function getRandom($num = 1, $alwaysArray = false) {
        $items = $this->makeNew(); 
        if($num < 1) return $items;
        $count = $this->count();
        if(!$count) {
            if($num == 1 && !$alwaysArray) return null;
            return $items; 
        }
        $keys = array_rand($this->data, ($num > $count ? $count : $num)); 
        if($num == 1 && !$alwaysArray) return $this->data[$keys]; 
        if(!is_array($keys)) $keys = array($keys); 
        foreach($keys as $key) $items->add($this->data[$key]); 
        $items->setTrackChanges(true); 
        return $items; 
    }

    /**
     * Find a specified quantity of random elements from this WireArray. 
     *
     * Unlike `WireArray::getRandom()` this method always returns a WireArray (or derived type).
     * 
     * ~~~~~
     * // Get 3 random items
     * $randomItems = $items->findRandom(3); 
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param int $num Number of items to return 
     * @return WireArray
     * @see WireArray::getRandom(), WireArray::findRandomTimed()
     *
     */
    public function findRandom($num) {
        return $this->getRandom((int) $num, true);
    }

    /**
     * Find a quantity of random elements from this WireArray based on a timed interval (or user provided seed).
     *
     * If no `$seed` is provided, today's date (day) is used to seed the random number
     * generator, so you can use this function to rotate items on a daily basis.
     * 
     * _Idea and implementation provided by [mindplay.dk](https://twitter.com/mindplaydk)_
     * 
     * ~~~~~
     * // Get same 3 random items per day
     * $randomItems = $items->findRandomTimed(3); 
     * 
     * // Get same 3 random items per hour
     * $randomItems = $items->findRandomTimed('YmdH'); 
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param int $num The amount of items to extract from the given list
     * @param int|string $seed Optionally provide one of the following: 
     *   - A PHP [date()](http://php.net/manual/en/function.date.php) format string.
     *   - A number used to see the random number generator.
     *   - The default is the PHP date format "Ymd" which makes it randomize once daily. 
     * @return WireArray
     * @see WireArray::findRandom()
     *
     */
    public function findRandomTimed($num, $seed = 'Ymd') {

        if(is_string($seed)) $seed = crc32(date($seed));
        srand($seed);
        $keys = $this->getKeys();
        $items = $this->makeNew();
        
        while(count($keys) > 0 && count($items) < $num) {
            $index = rand(0, count($keys)-1);
            $key = $keys[$index];
            $items->add($this->get($key));
            array_splice($keys, $index, 1);
        }

        return $items;
    }

    /**
     * Get a slice of the WireArray.
     *
     * Given a starting point and a number of items, returns a new WireArray of those items. 
     * If `$limit` is omitted, then it includes everything beyond the starting point. 
     * 
     * ~~~~~
     * // Get first 3 items
     * $myItems = $items->slice(0, 3); 
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param int $start Starting index. 
     * @param int $limit Number of items to include. If omitted, includes the rest of the array.
     * @return WireArray Returns a new WireArray.
     *
     */
    public function slice($start, $limit = 0) {
        if($limit) {
            $slice = array_slice($this->data, $start, $limit);
        } else {
            $slice = array_slice($this->data, $start);
        }
        $items = $this->makeNew(); 
        $items->import($slice); 
        $items->setTrackChanges(true); 
        return $items; 
    }

    /**
     * Prepend an item to the beginning of the WireArray.
     * 
     * ~~~~~
     * // Add item to beginning
     * $items->prepend($item);
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param Wire|WireArray|mixed $item Item to prepend. 
     * @return $this This instance.
     * @throws WireException
     * @see WireArray::append()
     *
     */
    public function prepend($item) {

        if(!$this->isValidItem($item)) {
            if($item instanceof WireArray) {
                foreach(array_reverse($item->getArray()) as $i) $this->prepend($i); 
                return $this; 
            } else {
                throw new WireException("Item prepend to " . get_class($this) . " is not an allowed type"); 
            }
        }

        if($this->duplicateChecking && ($key = $this->getItemKey($item)) !== null) {
            // item already present
            $a = array($key => $item); 
            $this->data = $a + $this->data; // UNION operator for arrays
            // $this->data = array_merge($a, $this->data); 
        } else { 
            // new item
            array_unshift($this->data, $item); 
            reset($this->data);
            $key = key($this->data);
        }
        $this->trackChange('prepend', null, $item); 
        $this->trackAdd($item, $key); 
        return $this; 
    }

    /**
     * Append an item to the end of the WireArray 
     * 
     * This is a functionally identical alias of the `WireArray::add()` method here for
     * naming consistency with the `WireArray::prepend()` method. 
     * 
     * ~~~~~
     * // Add item to end 
     * $items->append($item); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     *
     * @param Wire|WireArray|mixed $item Item to append. 
     * @return $this This instance.
     * @see WireArray::prepend(), WireArray::add()
     *
     */
    public function append($item) {
        $this->add($item); 
        return $this; 
    }

    /**
     * Unshift an element to the beginning of the WireArray (alias for prepend)
     * 
     * This is for consistency with PHP's naming convention of the `array_unshift()` method.
     * 
     * #pw-group-manipulation
     *
     * @param Wire|WireArray|mixed $item Item to prepend. 
     * @return $this This instance.
     * @see WireArray::shift(), WireArray::prepend()
     *
     */
    public function unshift($item) {
        return $this->prepend($item); 
    }

    /**
     * Shift an element off the beginning of the WireArray and return it
     * 
     * Consistent with behavior of PHP's `array_shift()` method. 
     * 
     * #pw-group-manipulation
     * #pw-group-retrieval
     *
     * @return Wire|mixed|null Item shifted off the beginning or NULL if empty.
     * @see WireArray::unshift()
     *
     */
    public function shift() {
        reset($this->data);
        $key = key($this->data);
        $item = array_shift($this->data); 
        if(is_null($item)) return $item;
        $this->trackChange('shift', $item, null);
        $this->trackRemove($item, $key); 
        return $item; 
    }

    /**
     * Push an item to the end of the WireArray.
     * 
     * Same as `WireArray::add()` and `WireArray::append()`, but here for syntax convenience.
     * 
     * #pw-group-manipulation
     *
     * @param Wire|mixed $item Item to push. 
     * @return $this This instance.
     * @see WireArray::pop()
     *
     */
    public function push($item) {
        $this->add($item);  
        return $this; 
    }

    /**
     * Pop an element off the end of the WireArray and return it
     * 
     * #pw-group-retrieval
     * #pw-group-manipulation
     * 
     * @return Wire|mixed|null Item popped off the end or NULL if empty.
     *
     */
    public function pop() {
        end($this->data);
        $key = key($this->data);
        $item = array_pop($this->data); 
        if(is_null($item)) return $item;
        $this->trackChange('pop', $item, null);
        $this->trackRemove($item, $key); 
        return $item; 
    }

    /**
     * Shuffle/randomize this WireArray
     * 
     * #pw-group-manipulation
     *
     * @return $this This instance.
     *
     */
    public function shuffle() {

        $keys = $this->getKeys(); 
        $data = array();

        // shuffle the keys rather than the original array in case it's associative
        // because PHP's shuffle reindexes the array
        shuffle($keys); 
        foreach($keys as $key) {
            $data[$key] = $this->data[$key]; 
        }
        
        $this->trackChange('shuffle', $this->data, $data); 

        $this->data = $data; 

        return $this; 
    }

    /**
     * Returns a new WireArray of the item at the given index. 
     *  
     * Unlike `WireArray::get()` this returns a new WireArray with a single item, or a blank WireArray if item doesn't exist. 
     * Applicable to numerically indexed WireArray only.
     * 
     * #pw-group-retrieval
     * 
     * @param int $num Index number
     * @return WireArray
     * @see WireArray::eq()
     *
     */
    public function index($num) {
        return $this->slice($num, 1); 
    }

    /**
     * Returns the item at the given index starting from 0, or NULL if it doesn't exist.
     *  
     * Unlike the `WireArray::index()` method, this returns an actual item and not another WireArray.
     * 
     * #pw-group-retrieval
     * 
     * @param int $num Return the n'th item in this WireArray. Specify a negative number to count from the end rather than the start.
     * @return Wire|null
     * @see WireArray::index()
     *
     */
    public function eq($num) {
        $num = (int) $num; 
        $item = array_slice($this->data, $num, 1); 
        $item = count($item) ? reset($item) : null;
        return $item; 
    }
    
    /**
     * Returns the first item in the WireArray or boolean false if empty. 
     *
     * Note that this resets the internal WireArray pointer, which would affect other active iterations. 
     * 
     * ~~~~~
     * $item = $items->first();
     * ~~~~~
     * 
     * #pw-group-traversal
     * #pw-group-retrieval
     *
     * @return Wire|mixed|bool
     *
     */
    public function first() {
        return reset($this->data);
    }
    
    /**
     * Returns the last item in the WireArray or boolean false if empty.
     *
     * Note that this resets the internal WireArray pointer, which would affect other active iterations.
     * 
     * ~~~~~
     * $item = $items->last();
     * ~~~~~
     * 
     * #pw-group-traversal
     * #pw-group-retrieval
     * 
     * @return Wire|mixed|bool
     *
     */
    public function last() {
        return end($this->data); 
    }

    /**
     * Removes the given item or index from the WireArray (if it exists).
     * 
     * #pw-group-manipulation
     * 
     * @param int|string|Wire $key Item to remove (object), or index of that item, or (3.0.196+) selector string. 
     * @return $this This instance.
     *
     */
    public function remove($key) {

        $obj = is_object($key);
        if($obj) {
            $key = $this->getItemKey($key); 
        }

        if(array_key_exists($key, $this->data)) {
            $item = $this->data[$key];
            unset($this->data[$key]); 
            $this->trackChange("remove", $item, null); 
            $this->trackRemove($item, $key); 
        } else if(!$obj && is_string($key) && Selectors::stringHasSelector($key)) {
            foreach($this->find($key) as $item) {
                $this->remove($item);
            }
        }

        return $this;
    }

    /**
     * Removes multiple identified items at once
     * 
     * #pw-group-manipulation
     *
     * @param array|Wire|string|WireArray $items Items to remove
     * @return $this
     * 
     */
    public function removeItems($items) {
        if(!self::iterable($items)) $items = array($items);
        foreach($items as $item) $this->remove($item); 
        return $this;
    }

    /**
     * Removes all items from the WireArray, leaving it blank
     * 
     * #pw-group-manipulation
     * 
     * @return $this
     *
     */
    public function removeAll() {
        foreach($this as $key => $value) {
            $this->remove($key); 
        }
        return $this; 
    }

    /**
     * Remove an item without any record of the event or telling anything else. 
     * 
     * #pw-internal
     *
     * @param int|string|Wire $key Index of item or object instance of item. 
     * @return $this This instance. 
     *
     */
    public function removeQuietly($key) {
        if(is_object($key)) $key = $this->getItemKey($key);
        unset($this->data[$key]);
        return $this;
    }

    /**
     * Sort this WireArray by the given properties. 
     *
     * - Sort properties can be given as a string in the format `name, datestamp` or as an array of strings, 
     *   i.e. `["name", "datestamp"]`.
     * 
     * - You may also specify the properties as `property.subproperty`, where property resolves to a Wire derived object
     *   in each item, and subproperty resolves to a property within that object.
     * 
     * - Prepend or append a minus "-" to reverse the sort (per field).
     * 
     * ~~~~~
     * // Sort newest to oldest
     * $items->sort("-created"); 
     * 
     * // Sort by last_name then first_name
     * $items->sort("last_name, first_name"); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param string|array $properties Field names to sort by (CSV string or array). 
     * @param int|null $flags Optionally specify sort flags (see sortFlags method for details). 
     * @return $this reference to current instance.
     */
    public function sort($properties, $flags = null) {
        $_flags = $this->sortFlags; // remember
        if(is_int($flags)) $this->sortFlags($flags);
        $result = $this->_sort($properties);
        if(is_int($flags) && $flags !== $_flags) $this->sortFlags($_flags); // restore
        return $result;
    }

    /**
     * Sort this WireArray by the given properties (internal use)
     * 
     * This function contains additions and modifications by @niklaka.
     *
     * $properties can be given as a sortByField string, i.e. "name, datestamp" OR as an array of strings, i.e. array("name", "datestamp")
     * You may also specify the properties as "property.subproperty", where property resolves to a Wire derived object, 
     * and subproperty resolves to a property within that object. 
     * 
     * @param string|array $properties Field names to sort by (comma separated string or an array). Prepend or append a minus "-" to reverse the sort (per field).
     * @param int $numNeeded *Internal* amount of rows that need to be sorted (optimization used by filterData)
     * @return $this reference to current instance.
     */
    protected function _sort($properties, $numNeeded = null) {

        // string version is used for change tracking
        $isArray = is_array($properties);
        $propertiesStr = $isArray ? implode(',', $properties) : $properties;
        if(!$isArray) $properties = explode(',', $properties);
        
        if(empty($properties)) return $this;

        // shortcut for random (only allowed as the sole sort property)
        // no warning/error for issuing more properties though
        // TODO: warning for random+more properties (and trackChange() too)
        if($properties[0] === 'random') return $this->shuffle();
        
        $data = $this->stableSort($this, $properties, $numNeeded);
        if($this->trackChanges) $this->trackChange("sort:$propertiesStr", $this->data, $data);
        $this->data = $data; 

        return $this;
    }

    /**
     * Get or set sort flags that affect behavior of any sorting functions
     * 
     * The following constants may be used when setting the sort flags:
     * 
     * - `SORT_REGULAR` compare items normally (don’t change types)
     * - `SORT_NUMERIC` compare items numerically
     * - `SORT_STRING` compare items as strings
     * - `SORT_LOCALE_STRING` compare items as strings, based on the current locale
     * - `SORT_NATURAL` compare items as strings using “natural ordering” like natsort()
     * - `SORT_FLAG_CASE` can be combined (bitwise OR) with SORT_STRING or SORT_NATURAL to sort strings case-insensitively
     * - `SORT_APPEND_NULLS` can be used on its own or combined with any of above (bitwise OR) to specify that null 
     *    or blank values should be treated as unsortable and appended to the end of the sortable set rather than sorted as 
     *    blank values. This duplicates the behavior prior to 3.0.194 (available only in 3.0.194+). Note that this flag
     *    is unique to ProcessWire only and is not in PHP. 
     *
     * For more details, see `$sort_flags` argument at: https://www.php.net/manual/en/function.sort.php
     * 
     * #pw-group-manipulation
     * 
     * @param bool $sortFlags Optionally specify flag(s) to set
     * @return int Returns current flags
     * @since 3.0.129
     * 
     */
    public function sortFlags($sortFlags = false) {
        if(is_int($sortFlags)) $this->sortFlags = $sortFlags;
        return $this->sortFlags;
    }

    /**
     * Sort given array by first given property.
     *
     * This function contains additions and modifications by @niklaka.
     *
     * @param array|WireArray &$data Reference to an array to sort.
     * @param array $properties Array of properties: first property is used now and others in recursion, if needed.
     * @param int $numNeeded *Internal* amount of rows that need to be sorted (optimization used by filterData)
     * @return array Sorted array (at least $numNeeded items, if $numNeeded is given)
     */
    protected function stableSort(&$data, $properties, $numNeeded = null) {

        $property = trim(array_shift($properties));
        $nullable = array();
        $sortable = array();
        $reverse = false;
        $subProperty = '';
        $sortFlags = $this->sortFlags;
        $sortNulls = true;
        
        if($sortFlags >= SORT_APPEND_NULLS && ($sortFlags & SORT_APPEND_NULLS)) {
            $sortNulls = false;
            $sortFlags -= SORT_APPEND_NULLS;
        }
    
        $pos = strpos($property, '-');
        if($pos !== false && ($pos === 0 || substr($property, -1) == '-')) {
            $reverse = true;
            $property = trim($property, '-');
        }

        $pos = strpos($property, '.');
        if($pos) {
            list($property, $subProperty) = explode('.', $property, 2);
        }

        foreach($data as $item) {
            /** @var Wire $item */
            $key = $this->getItemPropertyValue($item, $property); 

            // if item->property resolves to another Wire, then try to get the subProperty from that Wire (if it exists)
            if($key instanceof Wire && $subProperty) {
                $key = $this->getItemPropertyValue($key, $subProperty);
            }

            if($key === null) {
                if($sortNulls) {
                    $key = "\0"; // sort as ascii null
                } else {
                    $nullable[] = $item;
                    continue; 
                }
            } else if($key === false) {
                $key = 0;
            } else if($key === true) {
                $key = 1;
            } else if(is_int($key)) {
                // ok
            } else if(ctype_digit("$key")) {
                $key = (int) "$key";
            } else {
                $key = (string) $key;
                if(trim($key) === '') {
                    if($sortNulls) {
                        $key = ' '; // ensure sort value higher than \0
                    } else {
                        $nullable[] = $item;
                        continue; 
                    }
                }
            }

            if(isset($sortable[$key])) {
                // key resolved to the same value that another did, so keep them together by converting this index to an array
                // this makes the algorithm stable (for equal keys the order would be undefined)
                if(is_array($sortable[$key])) {
                    $sortable[$key][] = $item;
                } else {
                    $sortable[$key] = array($sortable[$key], $item);
                }
            } else { 
                $sortable[$key] = $item; 
            }
        }

        // sort the items by the keys we collected
        if($reverse) {
            krsort($sortable, $sortFlags);
        } else {
            ksort($sortable, $sortFlags);
        }

        // add the items that resolved to no key to the end, as an array
        if(!empty($nullable)) $sortable[] = $nullable; 

        // restore sorted array to lose sortable keys and restore proper keys
        $a = array();
        foreach($sortable as $key => $value) {
            if(is_array($value)) {
                // if more properties to sort by exist, use them for this sub-array
                $n = null;
                if($numNeeded) $n = $numNeeded - count($a); 
                if(count($properties)) {
                    $value = $this->stableSort($value, $properties, $n);
                }
                foreach($value as $k => $v) {
                    $newKey = $this->getItemKey($v); 
                    $a[$newKey] = $v; 
                    // are we done yet?
                    if($numNeeded && count($a) > $numNeeded) break;
                }
            } else {
                $newKey = $this->getItemKey($value); 
                $a[$newKey] = $value;   
            }
            // are we done yet?
            if($numNeeded && count($a) > $numNeeded) break;
        }

        return $a;
    }

    /**
     * Get the value of $property from $item
     *
     * Used by the WireArray::sort method to retrieve a value from a Wire object. 
     * Primarily here as a template method so that it can be overridden. 
     * Lets it prepare the Wire for any states needed for sorting. 
     *
     * @param Wire $item
     * @param string $property
     * @return mixed
     *
     */
    protected function getItemPropertyValue(Wire $item, $property) {
        if(strpos($property, '.') !== false) return WireData::_getDot($property, $item); 
        return $item->$property; 
    }

    /**
     * Filter out Wires that don't match the selector. 
     * 
     * This is applicable to and destructive to the WireArray.
     * This function contains additions and modifications by @niklaka.
     *
     * @param string|array|Selectors $selectors Selector string|array to use as the filter.
     * @param bool|int $not Make this a "not" filter? Use int 1 for “not all” mode as if selectors had brackets around it. (default is false)
     * @return $this reference to current [filtered] instance
     *
     */
    protected function filterData($selectors, $not = false) {

        if(is_object($selectors) && $selectors instanceof Selectors) {
            $selectors = clone $selectors;
        } else {
            if(!is_array($selectors) && ctype_digit("$selectors")) $selectors = "id=$selectors";
            $selector = $selectors; 
            $selectors = $this->wire(new Selectors()); 
            $selectors->init($selector);
        }
        
        $this->filterDataSelectors($selectors); 

        $fields = $this->wire()->fields;
        $sort = array();
        $start = 0;
        $limit = null;
        $eq = null;
        $notAll = $not === 1;
        if($notAll) $not = true;

        // leave sort, limit and start away from filtering selectors
        foreach($selectors as $selector) {
            $remove = true; 
            $field = $selector->field;

            if($field === 'sort') {
                // use all sort selectors
                $sort[] = $selector->value; 

            } else if($field === 'start') { 
                // use only the last start selector
                $start = (int) $selector->value;

            } else if($field === 'limit') {
                // use only the last limit selector
                $limit = (int) $selector->value;

            } else if(($field === 'index' || $field == 'eq') && !$fields->get($field)) {
                // eq or index properties
                switch($selector->value) {
                    case 'first': $eq = 0; break;
                    case 'last': $eq = -1; break;
                    default: $eq = (int) $selector->value;
                }

            } else {
                // everything else is to be saved for filtering
                $remove = false;
            }

            if($remove) $selectors->remove($selector);
        }
        
        // now filter the data according to the selectors that remain
        foreach($this->data as $key => $item) {
            $qty = 0;
            $qtyMatch = 0;  
            foreach($selectors as $selector) {
                $qty++;
                if(is_array($selector->field)) {
                    $value = array();
                    foreach($selector->field as $field) {
                        $value[] = (string) $this->getItemPropertyValue($item, $field);
                    }
                } else {
                    $value = (string) $this->getItemPropertyValue($item, $selector->field);
                }
                if($not === $selector->matches($value) && isset($this->data[$key])) {
                    $qtyMatch++;
                    if($notAll) continue; // will do this outside the loop of all in $selectors match
                    $this->trackRemove($this->data[$key], $key);
                    unset($this->data[$key]);
                }
            }
            if($notAll && $qty && $qty === $qtyMatch) {
                $this->trackRemove($this->data[$key], $key);
                unset($this->data[$key]);
            }
        }

        if(!is_null($eq)) {
            if($eq === -1) {
                $limit = -1;
                $start = null;
            } else if($eq === 0) {
                $start = 0;
                $limit = 1;
            } else {
                $start = $eq;
                $limit = 1;
            }
        }
        
        if($limit < 0 && $start < 0) {
            // we don't support double negative, so double negative makes a positive 
            $start = abs($start);
            $limit = abs($limit);
        } else {
            if($limit < 0) {
                if($start) {
                    $start = $start - abs($limit);
                    $limit = abs($limit);
                } else {
                    $start = count($this->data) - abs($limit);
                    $limit = count($this->data);
                }
            }
            if($start < 0) {
                $start = count($this->data) - abs($start);
            }
        }

        // if $limit has been given, tell sort the amount of rows that will be used
        if(count($sort)) $this->_sort($sort, $limit ? $start+$limit : null); 
        if($start || $limit) {
            $this->data = array_slice($this->data, $start, $limit, true);
        }

        if($this->trackChanges()) $this->trackChange("filterData:$selectors"); 
        return $this; 
    }

    /**
     * Prepare selectors for filtering
     * 
     * Template method for descending classes to modify selectors if needed
     * 
     * @param Selectors $selectors
     * 
     */
    protected function filterDataSelectors(Selectors $selectors) { }

    /**
     * Filter this WireArray to only include items that match the given selector (destructive)
     * 
     * ~~~~~
     * // Filter $items to contain only those with "featured" property having value 1
     * $items->filter("featured=1"); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param string|array|Selectors $selector Selector string or array to use as the filter. 
     * @return $this reference to current instance.
     * @see filterData
     *
     */
    public function filter($selector) {
        // Same as filterData, but for public interface without the $not option. 
        return $this->filterData($selector, false); 
    }

    /**
     * Filter this WireArray to only include items that DO NOT match the selector (destructive)
     * 
     * ~~~~~
     * // returns all pages that don't have a 'nonav' variable set to a positive value. 
     * $pages->not("nonav"); 
     * ~~~~~
     * 
     * #pw-group-manipulation
     * 
     * @param string|array|Selectors $selector 
     * @return $this reference to current instance.
     * @see filterData
     *
     */
    public function not($selector) {
        // Same as filterData, but for public interface with the $not option specifically set to "true".
        return $this->filterData($selector, true); 
    }

    /**
     * Like the not() method but $selector evaluated as if it had (brackets) around it 
     *
     * #pw-internal Until we've got a better description for what this does
     *
     * @param string|array|Selectors $selector
     * @return $this reference to current instance.
     * @see filterData
     *
     */
    public function notAll($selector) {
        return $this->filterData($selector, 1); 
    }

    /**
     * Find all items in this WireArray that match the given selector.
     * 
     * This is non destructive and returns a brand new WireArray.
     * 
     * ~~~~~
     * // Find all items with a title property containing the word "foo"
     * $matches = $items->find("title%=foo"); 
     * if($matches->count()) {
     *   echo "Found $matches->count items"; 
     * } else {
     *   echo "Sorry, no items were found";
     * }
     * ~~~~~
     * 
     * #pw-group-retrieval
     *
     * @param string|array|Selectors $selector 
     * @return WireArray 
     *
     */
    public function find($selector) {
        $a = $this->makeCopy();
        if(empty($selector)) return $a;
        $a->filter($selector);  
        return $a; 
    }

    /**
     * Find a single item by selector
     * 
     * This is the same as `WireArray::find()` except that it returns a single
     * item rather than a new WireArray of items. 
     * 
     * ~~~~~
     * // Get an item with name "foo-bar"
     * $item = $items->findOne("name=foo-bar"); 
     * if($item) {
     *   // item was found
     * } else {
     *   // item was not found 
     * }
     * ~~~~~
     * 
     * #pw-group-retrieval
     * 
     * @param string|array|Selectors $selector
     * @return Wire|bool Returns item from WireArray or false if the result is empty.
     * @see WireArray::find()
     *
     */
    public function findOne($selector) {
        return $this->find($selector)->first();
    }

    /** 
     * Determines if the given item iterable as an array.
     *
     * - Returns true for arrays and WireArray derived objects. 
     * - Can be called statically like this `WireArray::iterable($a)`.
     * 
     * #pw-group-info
     * 
     * @param mixed $item Item to check for iterability. 
     * @return bool True if item is an iterable array or WireArray (or subclass of WireArray).
     *
     */
    public static function iterable($item) {
        if(is_array($item)) return true;
        if($item instanceof WireArray) return true;
        return false;
    }

    /**
     * Allows iteration of the WireArray. 
     * 
     * - Fulfills PHP's IteratorAggregate interface so that you can traverse the WireArray. 
     * - No need to call this method directly, just use PHP's `foreach()` method on the WireArray.
     * 
     * ~~~~~
     * // Traversing a WireArray with foreach:
     * foreach($items as $item) {
     *   // ... 
     * }
     * ~~~~~
     * 
     * #pw-group-traversal
     * 
     * @return \ArrayObject|Wire[]
     *
     */
    #[\ReturnTypeWillChange] 
    public function getIterator() {
        return new \ArrayObject($this->data); 
    }

    /**
     * Returns the number of items in this WireArray.
     *
     * Fulfills PHP's Countable interface, meaning it also enables this WireArray to be used with PHP's `count()` function. 
     * 
     * ~~~~~
     * // These two are the same
     * $qty = $items->count();
     * $qty = count($items); 
     * ~~~~~
     * 
     * #pw-group-retrieval
     * 
     * @return int
     * 
     */
    #[\ReturnTypeWillChange] 
    public function count() {
        return count($this->data); 
    }

    /**
     * Sets an index in the WireArray.
     *
     * For the \ArrayAccess interface. 
     * 
     * #pw-internal
     * 
     * @param int|string $key Key of item to set.
     * @param Wire|mixed $value Value of item. 
     * 
     */
    #[\ReturnTypeWillChange] 
    public function offsetSet($key, $value) {
        $this->set($key, $value); 
    }

    /**
     * Returns the value of the item at the given index, or false if not set.
     * 
     * #pw-internal
     *
     * @param int|string $key Key of item to retrieve. 
     * @return Wire|mixed|bool Value of item requested, or false if it doesn't exist. 
     * 
     */
    #[\ReturnTypeWillChange] 
    public function offsetGet($key) {
        if($this->offsetExists($key)) {
            return $this->data[$key];
        } else {
            return false;
        }
    }
    
    /**
     * Unsets the value at the given index. 
     *
     * For the \ArrayAccess interface.
     * 
     * #pw-internal
     *
     * @param int|string $key Key of the item to unset. 
     * @return bool True if item existed and was unset. False if item didn't exist. 
     * 
     */
    #[\ReturnTypeWillChange] 
    public function offsetUnset($key) {
        if($this->offsetExists($key)) {
            $this->remove($key); 
            return true;
        } else {
            return false;
        }
    }

    /**
     * Determines if the given index exists in this WireArray.  
     *
     * For the \ArrayAccess interface.
     * 
     * #pw-internal
     * 
     * @param int|string $key Key of the item to check for existance.
     * @return bool True if the item exists, false if not.
     * 
     */
    #[\ReturnTypeWillChange] 
    public function offsetExists($key) {
        return array_key_exists($key, $this->data);
    }

    /**
     * Returns a string representation of this WireArray.
     * 
     * @return string
     * 
     */
    public function __toString() {
        $s = '';
        foreach($this as $key => $value) {
            if(is_array($value)) $value = "array(" . count($value) . ")";
            $s .= "$value|";
        }
        $s = rtrim($s, '|'); 
        return $s; 
    }

    /**
     * Return a new reversed version of this WireArray.
     * 
     * #pw-group-retrieval
     *
     * @return WireArray
     *
     */ 
    public function reverse() {
        $a = $this->makeNew();
        $a->import(array_reverse($this->data, true)); 
        return $a; 
    }

    /**
     * Return a new array that is unique (no two of the same elements)
     * 
     * This is the equivalent to PHP's [array_unique()](http://php.net/manual/en/function.array-unique.php) function. 
     * 
     * #pw-group-retrieval
     *
     * @param int $sortFlags Sort flags per PHP's `array_unique()` function (default=`SORT_STRING`)
     * @return WireArray 
     *
     */
    public function unique($sortFlags = SORT_STRING) {
        $a = $this->makeNew();  
        $a->import(array_unique($this->data, $sortFlags)); 
        return $a; 
    }


    /**
     * Clears out any tracked changes and turns change tracking ON or OFF
     * 
     * #pw-internal
     *
     * @param bool $trackChanges True to turn change tracking ON, or false to turn OFF. Default of true is assumed. 
     * @return Wire|WireArray
     *
     */
    public function resetTrackChanges($trackChanges = true) {
        $this->itemsAdded = array();
        $this->itemsRemoved = array();
        return parent::resetTrackChanges($trackChanges); 
    }

    /**
     * Track an item added
     *
     * @param Wire|mixed $item
     * @param int|string $key 
     *
     */
    protected function trackAdd($item, $key) {
        if($key) {}
        if($this->trackChanges()) $this->itemsAdded[] = $item;
        // wire this WireArray to the same instance of $item, if it isn’t already wired
        if($this->_wire === null && $item instanceof Wire && $item->isWired()) $item->wire($this);
    }

    /**
     * Track an item removed
     *
     * @param Wire|mixed $item
     * @param int|string $key
     *
     */
    protected function trackRemove($item, $key) {
        if($key) {}
        if($this->trackChanges()) $this->itemsRemoved[] = $item; 
    }

    /**
     * Return array of all items added to this WireArray (while change tracking is enabled)
     * 
     * #pw-group-changes
     *
     * @return array|Wire[]
     *
     */
    public function getItemsAdded() {
        return $this->itemsAdded; 
    }

    /**
     * Return array of all items removed from this WireArray (when change tracking is enabled)
     * 
     * #pw-group-changes
     *
     * @return array|Wire[]
     *
     */
    public function getItemsRemoved() {
        return $this->itemsRemoved; 
    }

    /**
     * Given an item, get the item that comes after it in the WireArray
     * 
     * #pw-group-retrieval
     *
     * @param Wire $item 
     * @param bool $strict If false, string comparison will be used rather than exact instance comparison.
     * @return Wire|null Returns next item if found, or null if not
     *
     */
    public function getNext($item, $strict = true) {
        if(!$this->isValidItem($item)) return null;
        $key = $this->getItemKey($item); 
        $useStr = false;
        if($key === null) {
            if($strict) return null;
            $key = (string) $item;  
            $useStr = true;
        }
        $getNext = false; 
        $nextItem = null;
        foreach($this->data as $k => $v) {
            if($getNext) {
                $nextItem = $v;     
                break;
            }
            if($useStr) $k = (string) $v;
            if($k === $key) $getNext = true; 
        }
        return $nextItem; 
    }

    /**
     * Given an item, get the item before it in the WireArray
     * 
     * #pw-group-retrieval
     *
     * @param Wire $item
     * @param bool $strict If false, string comparison will be used rather than exact instance comparison.
     * @return Wire|null Returns item that comes before given item, or null if not found 
     *
     */
    public function getPrev($item, $strict = true) {
        if(!$this->isValidItem($item)) return null;
        $key = $this->getItemKey($item);
        $useStr = false;
        if($key === null) {
            if($strict) return null;
            $key = (string) $item;
            $useStr = true;
        }
        $prevItem = null; 
        $lastItem = null;
        foreach($this->data as $k => $v) {
            if($useStr) $k = (string) $v;
            if($k === $key) {
                $prevItem = $lastItem; 
                break;
            }
            $lastItem = $v; 
        }
        return $prevItem; 
    }

    /**
     * Does this WireArray use numeric keys only? 
     *
     * We determine this by creating a blank item and seeing what the type is of it's key. 
     * 
     * #pw-internal
     * 
     * @return bool
     *
     */
    protected function usesNumericKeys() {

        static $testItem = null;
        static $usesNumericKeys = null; 

        if(!is_null($usesNumericKeys)) return $usesNumericKeys; 
        if(is_null($testItem)) $testItem = $this->makeBlankItem(); 
        if(is_null($testItem)) return true; 

        $key = $this->getItemKey($testItem); 
        $usesNumericKeys = is_int($key) ? true : false;
        return $usesNumericKeys; 
    }

    /**
     * Combine all elements into a delimiter-separated string containing the given property from each item
     *
     * Similar to PHP's `implode()` function.
     * 
     * #pw-link [Introduction of implode method](https://processwire.com/talk/topic/5098-new-wirearray-api-additions-on-dev/)
     * #pw-group-retrieval
     * #pw-group-fun-tools
     * #pw-group-output-rendering
     * 
     * @param string $delimiter The delimiter to separate each item by (or the glue to tie them together).
     *  If not needed, this argument may be omitted and $property supplied first (also shifting $options to 2nd arg).
     * @param string|callable $property The property to retrieve from each item, or a function that returns the value to store.
     *  If a function/closure is provided it is given the $item (argument 1) and the $key (argument 2), and it should 
     *  return the value (string) to use. If delimiter is omitted, this becomes the first argument. 
     * @param array $options Optional options to modify the behavior:
     *  - `skipEmpty` (bool): Whether empty items should be skipped (default=true)
     *  - `prepend` (string): String to prepend to result. Ignored if result is blank. 
     *  - `append` (string): String to append to result. Ignored if result is blank. 
     *  - Please note that if delimiter is omitted, $options becomes the second argument. 
     * @return string
     * @see WireArray::each(), WireArray::explode()
     *
     */
    public function implode($delimiter, $property = '', array $options = array()) {

        $defaults = array(
            'skipEmpty' => true, 
            'prepend' => '', 
            'append' => ''
        );

        if(!count($this->data)) return '';
            
        $firstItem = reset($this->data);
        $itemIsObject = is_object($firstItem);

        if(!is_string($delimiter) && is_callable($delimiter)) {
            // first delimiter argument omitted and a function was supplied 
            // property is assumed to be blank
            if(is_array($property)) $options = $property; 
            $property = $delimiter; 
            $delimiter = '';
        } else if($itemIsObject && (empty($property) || is_array($property))) {
            // delimiter was omitted, forcing $property to be first arg
            if(is_array($property)) $options = $property; 
            $property = $delimiter; 
            $delimiter = '';
        }

        $options = array_merge($defaults, $options); 
        $isFunction = !is_string($property) && is_callable($property); 
        $str = '';
        $n = 0;

        foreach($this as $key => $item) {
            if($isFunction) {
                $value = $property($item, $key);
            } else if(strlen($property) && $itemIsObject) {
                $value = $item->get($property);
            } else {
                $value = $item;
            }
            if(is_array($value)) $value = 'array(' . count($value) . ')';
            $value = (string) $value; 
            if(!strlen($value) && $options['skipEmpty']) continue; 
            if($n) $str .= $delimiter; 
            $str .= $value; 
            $n++;
        }

        if(strlen($str) && ($options['prepend'] || $options['append'])) {
            $str = "$options[prepend]$str$options[append]";
        }

        return $str; 
    }

    /**
     * Return a plain array of the requested property from each item
     *
     * You may provide an array of properties as the $property, in which case it will return an
     * array of associative arrays with all requested properties for each item.
     *
     * You may also provide a function as the $property. That function receives the $item
     * as the first argument and $key as the second. It should return the value that will be stored.
     *
     * The keys of the returned array remain consistent with the original WireArray.
     * 
     * #pw-link [Introduction of explode method](https://processwire.com/talk/topic/5098-new-wirearray-api-additions-on-dev/)
     * #pw-group-retrieval
     * #pw-group-fun-tools
     *
     * @param string|callable|array $property Property or properties to retrieve, or callable function that should receive items.
     * @param array $options Options to modify default behavior:
     *  - `getMethod` (string): Method to call on each item to retrieve $property (default = "get")
     *  - `key` (string|null): Property of Wire objects to use for key of array, or omit (null) for non-associative array (default).
     * @return array
     * @see WireArray::each(), WireArray::implode()
     *
     */
    public function explode($property = '', array $options = array()) {
        $defaults = array(
            'getMethod' => 'get', // method used to get value from each item
            'key' => null,
        );
        $options = array_merge($defaults, $options);
        $getMethod = $options['getMethod'];
        $isArray = is_array($property);
        $isFunction = !$isArray && !is_string($property) && is_callable($property);
        $values = array();
        foreach($this as $key => $item) {
            if(!is_object($item)) {
                $values[$key] = $item;
                continue;
            }
            if(!empty($options['key']) && is_string($options['key'])) {
                $key = $item->get($options['key']); 
                if(!is_string($key) || !is_int($key)) $key = (string) $key; 
                if(!strlen($key)) continue;
                if(isset($values[$key])) continue;
            }
            if($isFunction) {
                $values[$key] = $property($item, $key);
            } else if($isArray) {
                $values[$key] = array();
                foreach($property as $p) {
                    $values[$key][$p] = $getMethod == 'get' ? $item->get($p) : $item->$getMethod($p);
                }
            } else {
                $values[$key] = $getMethod == 'get' ? $item->get($property) : $item->$getMethod($property);
            }
        }
        return $values;
    }

    /**
     * Return a new copy of this WireArray with the given item(s) appended
     *
     * Primarily for syntax convenience in fluent interfaces. 
     * 
     * ~~~~~
     * if($page->parents->and($page)->has($featured)) { 
     *   // either $page or its parents has the $featured page
     * }
     * ~~~~~
     * 
     * #pw-group-traversal
     * #pw-group-fun-tools
     * #pw-link [Introduction of and method](https://processwire.com/talk/topic/5098-new-wirearray-api-additions-on-dev/)
     *
     * @param Wire|WireArray $item Item(s) to append
     * @return WireArray New WireArray containing this one and the given item(s). 
     *
     */
    public function ___and($item) {
        $a = $this->makeCopy();
        $a->add($item); 
        return $a; 
    }

    /**
     * Store or retrieve an extra data value in this WireArray
     *
     * The data() function is exactly the same thing that it is in jQuery: <http://api.jquery.com/data/>. 
     *
     * ~~~~~~
     * // set a data value named 'foo' to value 'bar'
     * $a->data('foo', 'bar'); 
     * 
     * // retrieve the previously set data value
     * $bar = $a->data('foo'); 
     * 
     * // get all previously set data
     * $all = $a->data(); 
     * ~~~~~~
     * 
     * #pw-group-other-data-storage
     * #pw-link [Introduction of data method](https://processwire.com/talk/topic/5098-new-wirearray-api-additions-on-dev/)
     *
     * @param string|null|array|bool $key Name of data property you want to get or set, or: 
     *  - Omit to get all data properties. 
     *  - Specify associative array of [property => value] to set multiple properties. 
     *  - Specify associative array and boolean TRUE for $value argument to replace all data with the new array given in $key.
     *  - Specify regular array of property names to return multiple properties. 
     *  - Specify boolean FALSE to unset property name specified in $value argument. 
     * @param mixed|null|bool $value Value of data property you want to set. Omit when getting properties. 
     *  - Specify boolean TRUE to replace all data with associative array of data given in $key argument. 
     * @return WireArray|mixed|array|null Returns one of the following, depending on specified arguments: 
     *  - `mixed` when getting a single property: whatever you set is what you will get back.
     *  - `null` if the property you are trying to get does not exist in the data.
     *  - `$this` reference to this WireArray if you were setting a value. 
     *  - `array` of all data if you specified no arguments or requested multiple keys.
     *
     */
    public function data($key = null, $value = null) {
        if($key === null && $value === null) {
            // get all properties
            return $this->extraData;
        } else if(is_array($key)) {
            // get or set multiple properties
            if($value === true) {
                // replace all data with data in given $key array
                $this->extraData = $key;
            } else {
                // test if array is associative
                if(ctype_digit(implode('0', array_keys($key)))) {
                    // regular, non-associative array, GET only requested properties
                    $a = array();
                    foreach($key as $k) {
                        $a[$k] = isset($this->extraData[$k]) ? $this->extraData[$k] : null;
                    }
                    return $a;
                } else if(count($key)) {
                    // associative array, setting multiple values to extraData
                    $this->extraData = array_merge($this->extraData, $key);
                }
            }
        } else if($key === false && is_string($value)) {
            // unset a property
            unset($this->extraData[$value]); 
            
        } else if($value === null) {
            // get a property
            return isset($this->extraData[$key]) ? $this->extraData[$key] : null;
        } else {
            // set a property
            $this->extraData[$key] = $value;
        }
        return $this; 
    }

    /**
     * Remove a property/value previously set with the WireArray::data() method. 
     * 
     * #pw-group-other-data-storage
     *
     * @param string $key Name of property you want to remove
     * @return $this
     *
     */
    public function removeData($key) {
        unset($this->extraData[$key]); 
        return $this;
    }

    /**
     * Enables use of $var('key')
     *
     * @param string $key
     * @return mixed
     *
     */
    public function __invoke($key) {
        if(in_array($key, array('first', 'last', 'count'))) return $this->$key();
        if(is_int($key) || ctype_digit($key)) {
            if($this->usesNumericKeys()) {
                // if keys are already numeric, we use them
                return $this->get((int) $key); 
            } else {
                // if keys are not numeric, we delegete numbers to eq(n)
                return $this->eq((int) $key);
            }
        } else if(is_callable($key) || (is_string($key) && strpos($key, '{') !== false && strpos($key, '}'))) {
            return $this->each($key);
        }
        return $this->get($key);
    }

    /**
     * Handler for when an unknown/unhooked method call is executed
     * 
     * If interested in hooking this, please see the `Wire::callUnknown()` method for more 
     * details on the purpose and potential hooking implementation of this method. 
     * 
     * The implementation built-in to WireArray provides a couple of handy capabilities to all 
     * WireArray derived classes (assuming that `$items` is an instance of any WireArray): 
     * 
     * - It enables you to call `$items->foobar()` and receive a regular PHP array 
     *   containing the value of the "foobar" property from each item in this WireArray. 
     *   It is equivalent to calling `$items->explode('foobar')`. Of course, substitute 
     *   "foobar" with the name of any property present on items in the WireArray. 
     * 
     * - It enables you to call `$items->foobar(", ")` and receive a string containing
     *   the value of the "foobar" property from each item, delimited by the string you
     *   provided as an argument (a comma and space ", " in this case). This is equivalent
     *   to calling `$items->implode(", ", "foobar")`. 
     * 
     * - Also note that if you call `$items->foobar(", ", $options)` where $options is an 
     *   array, it is equivalent to `$items->implode(", ", "foobar", $options)`. 
     * 
     * ~~~~~
     * // Get array of all "title" values from each item 
     * $titlesArray = $items->title(); 
     * 
     * // Get a newline separated string of all "title" values from each item
     * $titlesString = $items->title("\n"); 
     * ~~~~~
     * 
     * #pw-hooker
     * #pw-group-fun-tools
     *
     * @param string $method Requested method name
     * @param array $arguments Arguments provided to the method
     * @return null|mixed
     * @throws WireException
     *
     */
    protected function ___callUnknown($method, $arguments) {
        
        if(!isset($arguments[0])) {
            // explode the property to an array
            return $this->explode($method);
            
        } else if(is_string($arguments[0])) {
            // implode the property identified by $method and glued by $arguments[0]
            // with optional $options as second argument
            $delimiter = $arguments[0];
            $options = array();
            if(isset($arguments[1]) && is_array($arguments[1])) $options = $arguments[1]; 
            return $this->implode($delimiter, $method, $options); 
            
        } else {
            // fail
            return parent::___callUnknown($method, $arguments); 
        }
    }

    /**
     * Perform an action upon each item in the WireArray
     * 
     * This is typically used to execute a function for each item, or to build a string 
     * or array from each item. 
     * 
     * ~~~~~
     * // Generate navigation list of page children: 
     * echo $page->children()->each(function($child) {
     *   return "<li><a href='$child->url'>$child->title</a></li>";
     * });
     * 
     * // If 2 arguments specified to custom function(), 1st is the key, 2nd is the value
     * echo $page->children()->each(function($key, $child) {
     *   return "<li><a href='$child->url'>$key: $child->title</a></li>";
     * });
     * 
     * // Same as above using different method (template string):
     * echo $page->children()->each("<li><a href='{url}'>{title}</a></li>");
     * 
     * // If WireArray used to hold non-object items, use only {key} and/or {value}
     * echo $items->each('<li>{key}: {value}</li>');
     * 
     * // Get an array of all "title" properties 
     * $titles = $page->children()->each("title"); 
     * 
     * // Get array of "title" and "url" properties. Returns an array
     * // containing an associative array for each item with "title" and "url"
     * $properties = $page->children()->each(["title", "url"]); 
     * ~~~~~
     * 
     * #pw-group-traversal
     * #pw-group-output-rendering
     * #pw-group-fun-tools
     * 
     * @param callable|string|array|null $func Accepts any of the following:
     * 1. Callable function that each item will be passed to as first argument. If this 
     *    function returns a string, it will be appended to that of the other items and 
     *    the result returned by this each() method.
     * 2. Markup or text string with variable {tags} within it where each {tag} resolves
     *    to a property in each item. This each() method will return the concatenated result.
     * 3. A property name (string) common to items in this WireArray. The result will be 
     *    returned as an array. 
     * 4. An array of property names common to items in this WireArray. The result will be
     *    returned as an array of associative arrays indexed by property name. 
     *      
     * @return array|null|string|WireArray Returns one of the following (related to numbers above):
     *   - `$this` (1a): WireArray if given a function that has no return values (if using option #1 in arguments). 
     *   - `string` (1b): Containing the concatenated results of all function calls, if your function 
     *     returns strings (if using option #1 in arguments). 
     *   - `string` (2): Returns the processed and concatenated result (string) of all items in your 
     *     template string (if using option #2 in arguments). 
     *   - `array` (3): Returns regular PHP array of the property values for each item you requested
     *     (if using option #3 in arguments). 
     *   - `array` (4): Returns an array of associative arrays containing the property values for each item
     *     you requested (if using option #4 in arguments). 
     * @see WireArray::implode(), WireArray::explode()
     * 
     */
    public function each($func = null) {
        $result = null; // return value, if it's detected that one is desired
        if(is_callable($func)) {
            $funcInfo = new \ReflectionFunction($func);
            $useIndex = $funcInfo->getNumberOfParameters() > 1;
            foreach($this as $index => $item) {
                $val = $useIndex ? $func($index, $item) : $func($item);
                if($val && is_string($val)) {
                    // function returned a string, so we assume they are wanting us to return the result
                    if(is_null($result)) $result = '';
                    // if returned value resulted in {tags}, go ahead and parse them
                    if(strpos($val, '{') !== false && strpos($val, '}')) {
                        if(is_object($item)) {
                            $val = wirePopulateStringTags($val, $item);
                        } else {
                            $val = wirePopulateStringTags($val, array('key' => $index, 'value' => $item));
                        }
                    }
                    $result .= $val;
                }
            }
        } else if(is_string($func) && strpos($func, '{') !== false && strpos($func, '}')) {
            // string with variables
            $result = '';
            foreach($this as $key => $item) {
                if(is_object($item)) {
                    $result .= wirePopulateStringTags($func, $item);
                } else {
                    $result .= wirePopulateStringTags($func, array('key' => $key, 'value' => $item));
                }
            }
        } else {
            // array or string or null
            if(is_null($func)) $func = 'name';
            $result = $this->explode($func);
        }
    
        return $result === null ? $this : $result;
    }

    /**
     * Divide this WireArray into $qty slices and return array of them (each being another WireArray)
     * 
     * This is not destructive to the original WireArray as it returns new WireArray objects. 
     *
     * #pw-group-retrieval
     * #pw-group-traversal
     * 
     * @param int $qty Number of slices
     * @return array Array of WireArray objects
     * 
     */
    public function slices($qty) {
        $slices = array();
        if($qty < 1) return $slices;
        $total = $this->count();
        $limit = $total ? ceil($total / $qty) : 0;
        $start = 0;
        for($n = 0; $n < $qty; $n++) {
            if($start < $total) {
                $slice = $this->slice($start, $limit);
            } else {
                $slice = $this->makeNew();
            }
            $slices[] = $slice;
            $start += $limit;
        }
        return $slices;
    }

    /**
     * Set the current duplicate checking state
     * 
     * Applies only to non-associative WireArray types. 
     * 
     * @param bool $value True to enable dup check, false to disable
     * 
     */
    public function setDuplicateChecking($value) {
        if(!$this->usesNumericKeys()) return;
        $this->duplicateChecking = (bool) $value;
    }

    /**
     * debugInfo PHP 5.6+ magic method
     *
     * @return array
     *
     */
    public function __debugInfo() {
    
        $info = array(
            'count' => $this->count(),
            'items' => array(),
        );
        
        $info = array_merge($info, parent::__debugInfo());
        
        if(count($this->data)) {
            $info['items'] = array();
            foreach($this->data as $key => $value) {
                if(is_object($value) && $value instanceof Wire) $key = $value->className() . ":$key";
                $info['items'][$key] = $this->debugInfoItem($value);
            }
        }

        if(count($this->extraData)) $info['extraData'] = $this->extraData;
        
        $trackers = array(
            'itemsAdded' => $this->itemsAdded, 
            'itemsRemoved' => $this->itemsRemoved
        );
        
        foreach($trackers as $key => $value) {
            if(!count($value)) continue;
            $info[$key] = array();
            foreach($value as $k => $v) {
                $info[$key][] = $this->debugInfoItem($v); 
            }
        }
        
        return $info;
    }

    /**
     * Return debug info for one item from this WireArray
     * 
     * #pw-internal
     * 
     * @param mixed $item
     * @return mixed|null|string
     * 
     */
    public function debugInfoItem($item) {
        if(is_object($item)) {
            if($item instanceof Page) {
                $item = $item->debugInfoSmall();
            } else if($item instanceof WireData) {
                $_item = $item;
                $item = $item->get('name');
                if(!$item) $item = $_item->get('id');
                if(!$item) $item = $_item->className();
            } else {
                // keep $value as it is
            }
        }
        return $item;
    }

    /**
     * Static method caller, primarily for support of WireArray::new() method
     * 
     * @param string $name
     * @param array $arguments
     * @return mixed
     * @throws WireException
     * 
     */
    public static function __callStatic($name, $arguments) {
        $class = get_called_class();
        if($name === 'new') {
            $n = count($arguments);
            if($n === 0) {
                // no items specified
                $items = null;
            } else if($n === 1) {
                $items = reset($arguments);
                if(is_array($items) || $items instanceof WireArray) {
                    // multiple items specified in one argument             
                } else {
                    // one item specified
                    $items = array($items);
                }
            } else {
                // multiple items specified as arguments
                $items = $arguments;
            }
            return self::newInstance($items, $class);
        } else {
            throw new WireException("Unrecognized static method: $class::$name()");
        }
    }

    /**
     * Create new instance of this class
     * 
     * Method for internal use, use `$a = WireArray::new($items)` or `$a = WireArrray($items)` instead. 
     * 
     * #pw-internal 
     * 
     * @param array|WireArray|null $items Items to add or omit (null) for none
     * @param string $class Class name to instantiate or omit for called class
     * @return WireArray
     * 
     */
    public static function newInstance($items = null, $class = '') {
        if(empty($class)) $class = get_called_class();
        /** @var WireArray $a */
        $a = new $class();
        if($items instanceof WireArray) {
            $items->wire($a);
            $a->import($items);
        } else if(is_array($items)) {
            if(ctype_digit(implode('0', array_keys($items)))) {
                $a->import($items);
            } else {
                $a->setArray($items);
            }
        } else if($items !== null) {
            $a->add($items);
        }
        return $a;
    }
}

define('SORT_APPEND_NULLS', 32);