<?php
/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2014-2016 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
 *
 */

use qtism\common\datatypes\QtiFile;
use qtism\common\datatypes\QtiIdentifier;
use qtism\common\datatypes\QtiPair;
use qtism\common\datatypes\QtiDirectedPair;
use qtism\common\datatypes\QtiBoolean;
use qtism\common\enums\BaseType;
use qtism\common\enums\Cardinality;
use qtism\runtime\common\Variable;
use qtism\runtime\common\MultipleContainer;
use qtism\runtime\common\OrderedContainer;

/**
 * A class aiming at providing utility methods for the taoQtiCommon
 * extension.
 *
 * @author Jérôme Bogaerts <jerome@taotesting.com>
 *
 */
class taoQtiCommon_helpers_Utils {

    /**
     * Amount of bytes to read for each
     * read instructions while reading a
     * JSON payload. The current value is
     * 262,144 bytes -> 256 kbytes
     *
     * @var integer
     */
    const JSON_PAYLOAD_CHUNK_SIZE = 262144;

    /**
     * Reads a JSON Payload from 'php://input' and decodes it
     * as an associative array
     *
     * @return boolean|array An associative array representing the JSON payload or false if an error occurs.
     */
    static public function readJsonPayload() {
        $fp = fopen('php://input', 'rb');

        if ($fp === false) {
            return false;
        }

        $payload = '';

        while (feof($fp) !== true) {
            $payload .= fread($fp, self::JSON_PAYLOAD_CHUNK_SIZE);
        }

        @fclose($fp);

        return @json_decode($payload, true);
    }

    /**
     * Marshall a QTI File datatype into a binary string. The resulting binary string constitution
     * is the following:
     *
     * * The length of the file name, unsigned short.
     * * The file name, string.
     * * The length of the mime type, unsigned short.
     * * The mime type, string.
     * * The binary content of the file, string.
     *
     * @param File $file
     * @return string
     */
    static public function qtiFileToString(QtiFile $file) {
        $string = '';
        $filename = $file->getFilename();
        $mimetype = $file->getMimeType();
        $filenameLen = strlen($filename);
        $mimetypeLen = strlen($mimetype);

        $string .= pack('S', $filenameLen);
        $string .= $filename;

        $string .= pack('S', $mimetypeLen);
        $string .= $mimetype;

        // Avoid invalid data for serialize
        // (This could make me cry...)
        $string .= $file->getData();
        return $string;
    }

    /**
     * Whether or not a given QTI Variable contains the QTI Placeholder File.
     *
     * @param Variable $variable
     * @return boolean
     */
    static public function isQtiFilePlaceHolder(Variable $variable) {

        $correctBaseType = $variable->getBaseType() === BaseType::FILE;
        $correctCardinality = $variable->getCardinality() === Cardinality::SINGLE;

        if ($correctBaseType === true && $correctCardinality === true) {

            $value = $variable->getValue();
            $notNull = $value !== null;
            $mime = taoQtiCommon_helpers_PciJsonMarshaller::FILE_PLACEHOLDER_MIMETYPE;

            if ($notNull === true && $value->getMimeType() === $mime) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether or not a given QTI Variable contains a QTI File value.
     *
     * @param Variable $variable
     * @param boolean $considerNull Whether or not to consider File variables containing NULL variables.
     * @return boolean
     */
    static public function isQtiFile(Variable $variable, $considerNull = true) {

        $correctBaseType = $variable->getBaseType() === BaseType::FILE;
        $correctCardinality = $variable->getCardinality() === Cardinality::SINGLE;
        $nullConsideration = ($considerNull === true) ? true : $variable->getValue() !== null;

        return $correctBaseType === true && $correctCardinality === true && $nullConsideration === true;
    }

    /**
     * Transform a TAO serialized result value to its QtiDatatype equivalent.
     *
     * This method transforms a TAO serialized result $value to a the equivalent QtiDatatype depending
     * on result's $cardinality and $baseType.
     *
     * Note: at the present time, this implementation only supports 'single', 'multiple', and 'ordered' cardinality in conjunction
     * with the 'identifier', 'boolean', 'pair' or 'directedPair' baseType.
     *
     * @param string $cardinality i.e. 'ordered', 'multiple' or 'single'.
     * @param string $basetype i.e. 'identifier'
     * @param string $value A TAO serialized result value e.g. '<choice1 choice2 choice3>'
     * @return mixed A QtiDatatype object or null in case of no possibility to transform $value in the appropriate target $cardinality and $basetype.
     */
    static public function toQtiDatatype($cardinality, $basetype, $value)
    {
        // @todo support all baseTypes
        $datatype = null;

        if (is_string($value) && empty($value) === false && $cardinality !== 'record' && ($basetype === 'identifier' || $basetype === 'pair' || $basetype === 'directedPair' || $basetype === 'boolean')) {
            if ($cardinality !== 'simple') {
                $value = trim($value, "<>[]");
                $value = explode(';', $value);
            } else {
                $value = array($value);
            }

            if (count($value) === 1 && empty($value[0]) === true) {
                $value = array();
            }

            $value = array_map(
                function($val) {
                    return trim($val);
                },
                $value
            );

            $qtiBasetype = BaseType::getConstantByName($basetype);
            $datatype = ($cardinality === 'ordered') ? new OrderedContainer($qtiBasetype) : new MultipleContainer($qtiBasetype);
            foreach ($value as $val) {
                try {
                    switch ($basetype) {
                        case 'identifier':
                            $datatype[] = new QtiIdentifier($val);
                            break;

                        case 'pair':
                            $pair = explode("\x20", $val);
                            if (count($pair) === 2) {
                                $datatype[] = new QtiPair($pair[0], $pair[1]);
                            }
                            break;

                        case 'directedPair':
                            $pair = explode("\x20", $val);
                            if (count($pair) === 2) {
                                $datatype[] = new QtiDirectedPair($pair[0], $pair[1]);
                            }
                            break;

                        case 'boolean':
                            if ($val === 'true') {
                                $datatype[] = new QtiBoolean(true);
                            } elseif ($val === 'false') {
                                $datatype[] = new QtiBoolean(false);
                            } else {
                                $datatype[] = new QtiBoolean($val);
                            }
                            break;
                    }
                } catch (InvalidArgumentException $e) {
                    $datatype = null;
                    break;
                }
            }

            $datatype = ($cardinality === 'single') ? (isset($datatype[0]) ? $datatype[0] : null) : $datatype;
        }

        return $datatype;
    }
}


<?php

namespace Charcoal\Object;

use InvalidArgumentException;
use DateTime;
use DateTimeInterface;

// From Pimple
use Pimple\Container;

// From 'charcoal-factory'
use Charcoal\Factory\FactoryInterface;

// From 'charcoal-core'
use Charcoal\Model\AbstractModel;

// From 'charcoal-object'
use Charcoal\Object\ObjectRevisionInterface;
use Charcoal\Object\RevisionableInterface;

/**
 * Represents the changeset of an object.
 *
 * A revision is a record of modifications to an object.
 *
 * Intended to be used to collect all routes related to models
 * under a single source (e.g., database table).
 *
 * {@see Charcoal\Object\ObjectRoute} for a similar model that aggregates data
 * under a common source.
 */
class ObjectRevision extends AbstractModel implements ObjectRevisionInterface
{
    /**
     * Object type of this revision (required)
     * @var string $targetType
     */
    private $targetType;

    /**
     * Object ID of this revision (required)
     * @var mixed $objectId
     */
    private $targetId;

    /**
     * Revision number. Sequential integer for each object's ID. (required)
     * @var integer $revNum
     */
    private $revNum;

    /**
     * Timestamp; when this revision was created
     * @var DateTimeInterface|null $revTs
     */
    private $revTs;

    /**
     * The (admin) user that was
     * @var string|null $revUser
     */
    private $revUser;

    /**
     * @var array $dataPrev
     */
    private $dataPrev;

    /**
     * @var array $dataObj
     */
    private $dataObj;

    /**
     * @var array $dataDiff
     */
    private $dataDiff;

    /**
     * @var FactoryInterface $modelFactory
     */
    private $modelFactory;

    /**
     * Dependencies
     * @param Container $container DI Container.
     * @return void
     */
    protected function setDependencies(Container $container)
    {
        parent::setDependencies($container);

        $this->setModelFactory($container['model/factory']);
    }

    /**
     * @param FactoryInterface $factory The factory used to create models.
     * @return void
     */
    protected function setModelFactory(FactoryInterface $factory)
    {
        $this->modelFactory = $factory;
    }

    /**
     * @return FactoryInterface The model factory.
     */
    protected function modelFactory()
    {
        return $this->modelFactory;
    }

    /**
     * @param string $targetType The object type (type-ident).
     * @throws InvalidArgumentException If the obj type parameter is not a string.
     * @return ObjectRevision Chainable
     */
    public function setTargetType($targetType)
    {
        if (!is_string($targetType)) {
            throw new InvalidArgumentException(
                'Revisions obj type must be a string.'
            );
        }
        $this->targetType = $targetType;
        return $this;
    }

    /**
     * @return string
     */
    public function targetType()
    {
        return $this->targetType;
    }

    /**
     * @param mixed $targetId The object ID.
     * @return ObjectRevision Chainable
     */
    public function setTargetId($targetId)
    {
        $this->targetId = $targetId;
        return $this;
    }

    /**
     * @return mixed
     */
    public function targetId()
    {
        return $this->targetId;
    }

    /**
     * @param integer $revNum The revision number.
     * @throws InvalidArgumentException If the revision number argument is not numerical.
     * @return ObjectRevision Chainable
     */
    public function setRevNum($revNum)
    {
        if (!is_numeric($revNum)) {
            throw new InvalidArgumentException(
                'Revision number must be an integer (numeric).'
            );
        }
        $this->revNum = intval($revNum);
        return $this;
    }

    /**
     * @return integer
     */
    public function revNum()
    {
        return $this->revNum;
    }

    /**
     * @param mixed $revTs The revision's timestamp.
     * @throws InvalidArgumentException If the timestamp is invalid.
     * @return ObjectRevision Chainable
     */
    public function setRevTs($revTs)
    {
        if ($revTs === null) {
            $this->revTs = null;
            return $this;
        }
        if (is_string($revTs)) {
            $revTs = new DateTime($revTs);
        }
        if (!($revTs instanceof DateTimeInterface)) {
            throw new InvalidArgumentException(
                'Invalid "Revision Date" value. Must be a date/time string or a DateTimeInterface object.'
            );
        }
        $this->revTs = $revTs;
        return $this;
    }

    /**
     * @return DateTimeInterface|null
     */
    public function revTs()
    {
        return $this->revTs;
    }

    /**
     * @param string $revUser The revision user ident.
     * @throws InvalidArgumentException If the revision user parameter is not a string.
     * @return ObjectRevision Chainable
     */
    public function setRevUser($revUser)
    {
        if ($revUser === null) {
            $this->revUser = null;
            return $this;
        }
        if (!is_string($revUser)) {
            throw new InvalidArgumentException(
                'Revision user must be a string.'
            );
        }
        $this->revUser = $revUser;
        return $this;
    }

    /**
     * @return string
     */
    public function revUser()
    {
        return $this->revUser;
    }

    /**
     * @param string|array|null $data The previous revision data.
     * @return ObjectRevision Chainable
     */
    public function setDataPrev($data)
    {
        if (!is_array($data)) {
            $data = json_decode($data, true);
        }
        if ($data === null) {
            $data = [];
        }
        $this->dataPrev = $data;
        return $this;
    }

    /**
     * @return array
     */
    public function dataPrev()
    {
        return $this->dataPrev;
    }

    /**
     * @param array|string|null $data The current revision (object) data.
     * @return ObjectRevision Chainable
     */
    public function setDataObj($data)
    {
        if (!is_array($data)) {
            $data = json_decode($data, true);
        }
        if ($data === null) {
            $data = [];
        }
        $this->dataObj = $data;
        return $this;
    }

    /**
     * @return array
     */
    public function dataObj()
    {
        return $this->dataObj;
    }

    /**
     * @param array|string $data The data diff.
     * @return ObjectRevision
     */
    public function setDataDiff($data)
    {
        if (!is_array($data)) {
            $data = json_decode($data, true);
        }
        if ($data === null) {
            $data = [];
        }
        $this->dataDiff = $data;
        return $this;
    }

    /**
     * @return array
     */
    public function dataDiff()
    {
        return $this->dataDiff;
    }

    /**
     * Create a new revision from an object
     *
     * 1. Load the last revision
     * 2. Load the current item from DB
     * 3. Create diff from (1) and (2).
     *
     * @param RevisionableInterface $obj The object to create the revision from.
     * @return ObjectRevision Chainable
     */
    public function createFromObject(RevisionableInterface $obj)
    {
        $prevRev = $this->lastObjectRevision($obj);

        $this->setTargetType($obj->objType());
        $this->setTargetId($obj->id());
        $this->setRevNum($prevRev->revNum() + 1);
        $this->setRevTs('now');

        if (is_callable([$obj, 'lastModifiedBy'])) {
            $this->setRevUser($obj->lastModifiedBy());
        }

        $this->setDataObj($obj->data());
        $this->setDataPrev($prevRev->dataObj());

        $diff = $this->createDiff();
        $this->setDataDiff($diff);

        return $this;
    }

    /**
     * @param array $dataPrev Optional. Previous revision data.
     * @param array $dataObj  Optional. Current revision (object) data.
     * @return array The diff data
     */
    public function createDiff(array $dataPrev = null, array $dataObj = null)
    {
        if ($dataPrev === null) {
            $dataPrev = $this->dataPrev();
        }
        if ($dataObj === null) {
            $dataObj = $this->dataObj();
        }
        $dataDiff = $this->recursiveDiff($dataPrev, $dataObj);
        return $dataDiff;
    }

    /**
     * Recursive arrayDiff.
     *
     * @param array $array1 First array.
     * @param array $array2 Second Array.
     * @return array The array diff.
     */
    public function recursiveDiff(array $array1, array $array2)
    {
        $diff = [];

        // Compare array1
        foreach ($array1 as $key => $value) {
            if (!array_key_exists($key, $array2)) {
                $diff[0][$key] = $value;
            } elseif (is_array($value)) {
                if (!is_array($array2[$key])) {
                    $diff[0][$key] = $value;
                    $diff[1][$key] = $array2[$key];
                } else {
                    $new = $this->recursiveDiff($value, $array2[$key]);
                    if ($new !== false) {
                        if (isset($new[0])) {
                            $diff[0][$key] = $new[0];
                        }
                        if (isset($new[1])) {
                            $diff[1][$key] = $new[1];
                        }
                    }
                }
            } elseif ($array2[$key] !== $value) {
                $diff[0][$key] = $value;
                $diff[1][$key] = $array2[$key];
            }
        }

        // Compare array2
        foreach ($array2 as $key => $value) {
            if (!array_key_exists($key, $array1)) {
                $diff[1][$key] = $value;
            }
        }

        return $diff;
    }

    /**
     * @param RevisionableInterface $obj The object  to load the last revision of.
     * @return ObjectRevision The last revision for the give object.
     */
    public function lastObjectRevision(RevisionableInterface $obj)
    {
        if ($this->source()->tableExists() === false) {
            $this->source()->createTable();
        }

        $rev = $this->modelFactory()->create(self::class);

        $sql = sprintf('
            SELECT
                *
            FROM
                `%s`
            WHERE
                `target_type` = :target_type
            AND
                `target_id` = :target_id
            ORDER BY
                `rev_ts` desc
            LIMIT 1', $this->source()->table());
        $rev->loadFromQuery($sql, [
            'target_type' => $obj->objType(),
            'target_id'   => $obj->id()
        ]);

        return $rev;
    }

    /**
     * Retrieve a specific object revision, by revision number.
     *
     * @param RevisionableInterface $obj    Target object.
     * @param integer               $revNum The revision number to load.
     * @return ObjectRevision
     */
    public function objectRevisionNum(RevisionableInterface $obj, $revNum)
    {
        if ($this->source()->tableExists() === false) {
            $this->source()->createTable();
        }

        $rev = $this->modelFactory()->create(self::class);

        $sql = sprintf('
            SELECT
                *
            FROM
                `%s`
            WHERE
                `target_type` = :target_type
            AND
                `target_id` = :target_id
            AND
                `rev_num` = :rev_num
            LIMIT 1', $this->source()->table());
        $rev->loadFromQuery($sql, [
            'target_type' => $obj->objType(),
            'target_id'   => $obj->id(),
            'rev_num'     => intval($revNum)
        ]);

        return $rev;
    }
}


<?php

namespace Charcoal\Object;

use DateTime;
use DateTimeInterface;
use Exception;
use InvalidArgumentException;

// From Pimple
use Pimple\Container;

// From 'charcoal-core'
use Charcoal\Model\AbstractModel;

// From 'charcoal-translator'
use Charcoal\Translator\TranslatorAwareTrait;

// From 'charcoal-object'
use Charcoal\Object\UserDataInterface;

/**
 * User Data is a base model for objects typically submitted by the end-user of the application.
 *
 * Although it is not abstract, it is typically used by extending into a subclass.
 */
class UserData extends AbstractModel implements
    UserDataInterface
{
    use TranslatorAwareTrait;

    /**
     * Client IP address of the end-user.
     *
     * @var integer|null
     */
    private $ip;

    /**
     * Language of the end-user or source URI.
     *
     * @var string|null
     */
    private $lang;

    /**
     * Source URL or identifier of end-user submission.
     *
     * @var string|null
     */
    private $origin;

    /**
     * Creation timestamp of submission.
     *
     * @var DateTimeInterface|null
     */
    private $ts;

    /**
     * Dependencies
     * @param Container $container DI Container.
     * @return void
     */
    protected function setDependencies(Container $container)
    {
        parent::setDependencies($container);

        $this->setTranslator($container['translator']);
    }

    /**
     * Set the client IP address.
     *
     * @param  integer|null $ip The remote IP at object creation.
     * @return UserDataInterface Chainable
     */
    public function setIp($ip)
    {
        if ($ip === null) {
            $this->ip = null;
            return $this;
        }

        if (is_string($ip)) {
            $ip = ip2long($ip);
        } elseif (is_numeric($ip)) {
            $ip = intval($ip);
        } else {
            $ip = 0;
        }

        $this->ip = $ip;

        return $this;
    }

    /**
     * Retrieve the client IP address.
     *
     * @return integer|null
     */
    public function ip()
    {
        return $this->ip;
    }

    /**
     * Set the origin language.
     *
     * @param  string $lang The language code.
     * @throws InvalidArgumentException If the argument is not a string.
     * @return UserDataInterface Chainable
     */
    public function setLang($lang)
    {
        if ($lang !== null) {
            if (!is_string($lang)) {
                throw new InvalidArgumentException(
                    'Language must be a string'
                );
            }
        }

        $this->lang = $lang;

        return $this;
    }

    /**
     * Retrieve the language.
     *
     * @return string
     */
    public function lang()
    {
        return $this->lang;
    }

    /**
     * Set the origin of the object submission.
     *
     * @param  string $origin The source URL or identifier of the submission.
     * @throws InvalidArgumentException If the argument is not a string.
     * @return UserDataInterface Chainable
     */
    public function setOrigin($origin)
    {
        if ($origin !== null) {
            if (!is_string($origin)) {
                throw new InvalidArgumentException(
                    'Origin must be a string.'
                );
            }
        }

        $this->origin = $origin;

        return $this;
    }

    /**
     * Resolve the origin of the user data.
     *
     * @return string
     */
    public function resolveOrigin()
    {
        $host = getenv('HTTP_HOST');
        $uri  = '';
        if ($host) {
            $uri = 'http';

            if (getenv('HTTPS') === 'on') {
                $uri .= 's';
            }

            $uri .= '://'.$host;
        }
        $uri .= getenv('REQUEST_URI');

        return $uri;
    }

    /**
     * Retrieve the origin of the object submission.
     *
     * @return string
     */
    public function origin()
    {
        return $this->origin;
    }

    /**
     * Set when the object was created.
     *
     * @param  DateTimeInterface|string|null $timestamp The timestamp at object's creation.
     *     NULL is accepted and instances of DateTimeInterface are recommended;
     *     any other value will be converted (if possible) into one.
     * @throws InvalidArgumentException If the timestamp is invalid.
     * @return UserDataInterface Chainable
     */
    public function setTs($timestamp)
    {
        if ($timestamp === null) {
            $this->ts = null;
            return $this;
        }

        if (is_string($timestamp)) {
            try {
                $timestamp = new DateTime($timestamp);
            } catch (Exception $e) {
                throw new InvalidArgumentException(sprintf(
                    'Invalid timestamp: %s',
                    $e->getMessage()
                ), 0, $e);
            }
        }

        if (!$timestamp instanceof DateTimeInterface) {
            throw new InvalidArgumentException(
                'Invalid timestamp value. Must be a date/time string or a DateTime object.'
            );
        }

        $this->ts = $timestamp;

        return $this;
    }

    /**
     * Retrieve the creation timestamp.
     *
     * @return DateTimeInterface|null
     */
    public function ts()
    {
        return $this->ts;
    }

    /**
     * Event called before _creating_ the object.
     *
     * @see    Charcoal\Source\StorableTrait::preSave() For the "create" Event.
     * @return boolean
     */
    protected function preSave()
    {
        $result = parent::preSave();

        $this->setTs('now');

        if (getenv('REMOTE_ADDR')) {
            $this->setIp(getenv('REMOTE_ADDR'));
        }

        if (!isset($this->origin)) {
            $this->setOrigin($this->resolveOrigin());
        }

        return $result;
    }
}


<?php

namespace Charcoal\Object;

use DateTime;
use DateTimeInterface;
use Exception;
use RuntimeException;
use InvalidArgumentException;

// From 'charcoal-factory'
use Charcoal\Factory\FactoryInterface;

// From 'charcoal-core'
use Charcoal\Model\AbstractModel;

// From 'charcoal-object'
use Charcoal\Object\ObjectScheduleInterface;

/**
 * The object schedule class allows object properties to be changed at a scheduled time.
 *
 * ## Required Services
 *
 * - "model/factory" — {@see \Charcoal\Model\ModelFactory}
 */
class ObjectSchedule extends AbstractModel implements ObjectScheduleInterface
{
    /**
     * Store the factory instance for the current class.
     *
     * @var FactoryInterface
     */
    private $modelFactory;

    /**
     * The object type of the scheduled object (required).
     *
     * @var string
     */
    private $targetType;

    /**
     * The object ID of the scheduled object (required).
     *
     * @var mixed
     */
    private $targetId;

    /**
     * When the item should be processed.
     *
     * The date/time at which this queue item job should be ran.
     * If NULL, 0, or a past date/time, then it should be performed immediately.
     *
     * @var DateTimeInterface $scheduledDate
     */
    private $scheduledDate;

    /**
     * The property identifier of the scheduled object (required).
     *
     * @var array
     */
    private $dataDiff = [];

    /**
     * Whether the item has been processed.
     *
     * @var boolean $processed
     */
    private $processed = false;

    /**
     * When the item was processed.
     *
     * @var DateTimeInterface $processedDate
     */
    private $processedDate;

    /**
     * Set an object model factory.
     *
     * @param FactoryInterface $factory The model factory, to create objects.
     * @return ObjectScheduleInterface Chainable
     */
    public function setModelFactory(FactoryInterface $factory)
    {
        $this->modelFactory = $factory;

        return $this;
    }

    /**
     * Retrieve the object model factory.
     *
     * @throws RuntimeException If the model factory was not previously set.
     * @return FactoryInterface
     */
    protected function modelFactory()
    {
        if (!isset($this->modelFactory)) {
            throw new RuntimeException(sprintf(
                'Model Factory is not defined for "%s"',
                get_class($this)
            ));
        }

        return $this->modelFactory;
    }

    /**
     * Set the scheduled object's type.
     *
     * @param string $targetType The object type (model).
     * @throws InvalidArgumentException If the object type parameter is not a string.
     * @return ObjectScheduleInterface Chainable
     */
    public function setTargetType($targetType)
    {
        if (!is_string($targetType)) {
            throw new InvalidArgumentException(
                'Scheduled object type must be a string.'
            );
        }

        $this->targetType = $targetType;

        return $this;
    }

    /**
     * Retrieve the scheduled object's type.
     *
     * @return string
     */
    public function targetType()
    {
        return $this->targetType;
    }

    /**
     * Set the scheduled object's ID.
     *
     * @param mixed $targetId The object ID.
     * @return ObjectScheduleInterface Chainable
     */
    public function setTargetId($targetId)
    {
        $this->targetId = $targetId;

        return $this;
    }

    /**
     * Retrieve the scheduled object's ID.
     *
     * @return mixed
     */
    public function targetId()
    {
        return $this->targetId;
    }

    /**
     * @param array|string $data The data diff.
     * @return ObjectRevision
     */
    public function setDataDiff($data)
    {
        if (!is_array($data)) {
            $data = json_decode($data, true);
        }
        if ($data === null) {
            $data = [];
        }
        $this->dataDiff = $data;
        return $this;
    }

    /**
     * @return array
     */
    public function dataDiff()
    {
        return $this->dataDiff;
    }

    /**
     * Set the schedule's processed status.
     *
     * @param boolean $processed Whether the schedule has been processed.
     * @return ObjectScheduleInterface Chainable
     */
    public function setProcessed($processed)
    {
        $this->processed = !!$processed;

        return $this;
    }

    /**
     * Determine if the schedule has been processed.
     *
     * @return boolean
     */
    public function processed()
    {
        return $this->processed;
    }

    /**
     * Set the date/time the item should be processed at.
     *
     * @param  null|string|DateTimeInterface $ts A date/time string or object.
     * @throws InvalidArgumentException If the date/time is invalid.
     * @return ObjectScheduleInterface Chainable
     */
    public function setScheduledDate($ts)
    {
        if ($ts === null) {
            $this->scheduledDate = null;
            return $this;
        }

        if (is_string($ts)) {
            try {
                $ts = new DateTime($ts);
            } catch (Exception $e) {
                throw new InvalidArgumentException(sprintf(
                    '%s (%s)',
                    $e->getMessage(),
                    $ts
                ), 0, $e);
            }
        }

        if (!($ts instanceof DateTimeInterface)) {
            throw new InvalidArgumentException(
                'Invalid "Processing Date" value. Must be a date/time string or a DateTime object.'
            );
        }

        $this->scheduledDate = $ts;

        return $this;
    }

    /**
     * Retrieve the date/time the item should be processed at.
     *
     * @return null|DateTimeInterface
     */
    public function scheduledDate()
    {
        return $this->scheduledDate;
    }

    /**
     * Set the date/time the item was processed at.
     *
     * @param  null|string|DateTimeInterface $ts A date/time string or object.
     * @throws InvalidArgumentException If the date/time is invalid.
     * @return ObjectScheduleInterface Chainable
     */
    public function setProcessedDate($ts)
    {
        if ($ts === null) {
            $this->processedDate = null;
            return $this;
        }

        if (is_string($ts)) {
            try {
                $ts = new DateTime($ts);
            } catch (Exception $e) {
                throw new InvalidArgumentException(sprintf(
                    '%s (%s)',
                    $e->getMessage(),
                    $ts
                ), 0, $e);
            }
        }

        if (!($ts instanceof DateTimeInterface)) {
            throw new InvalidArgumentException(
                'Invalid "Processed Date" value. Must be a date/time string or a DateTime object.'
            );
        }

        $this->processedDate = $ts;

        return $this;
    }

    /**
     * Retrieve the date/time the item was processed at.
     *
     * @return null|DateTimeInterface
     */
    public function processedDate()
    {
        return $this->processedDate;
    }

    /**
     * Hook called before saving the item.
     *
     * Presets the item as _to-be_ processed and queued now.
     *
     * @return boolean
     */
    protected function preSave()
    {
        parent::preSave();

        $this->setProcessed(false);

        return true;
    }

    /**
     * Process the item.
     *
     * @param  callable $callback        An optional callback routine executed after the item is processed.
     * @param  callable $successCallback An optional callback routine executed when the item is resolved.
     * @param  callable $failureCallback An optional callback routine executed when the item is rejected.
     * @return boolean|null  Success / Failure, or null in case of a skipped item.
     */
    public function process(
        callable $callback = null,
        callable $successCallback = null,
        callable $failureCallback = null
    ) {

        if ($this->processed() === true) {
            // Do not process twice, ever.
            return null;
        }

        if ($this->targetType() === null) {
            $this->logger->error('Can not process object schedule: no object type defined.');
            return false;
        }

        if ($this->targetId() === null) {
            $this->logger->error(sprintf(
                'Can not process object schedule: no object "%s" ID defined.',
                $this->targetType()
            ));
            return false;
        }

        if (empty($this->dataDiff())) {
            $this->logger->error('Can not process object schedule: no changes (diff) defined.');
            return false;
        }

        $obj = $this->modelFactory()->create($this->targetType());
        $obj->load($this->targetId());
        if (!$obj->id()) {
            $this->logger->error(sprintf(
                'Can not load "%s" object %s',
                $this->targetType(),
                $this->targetId()
            ));
        }
        $obj->setData($this->dataDiff());
        $update = $obj->update(array_keys($this->dataDiff()));

        if ($update) {
            $this->setProcessed(true);
            $this->setProcessedDate('now');
            $this->update(['processed', 'processed_date']);

            if ($successCallback !== null) {
                $successCallback($this);
            }
        } else {
            if ($failureCallback !== null) {
                $failureCallback($this);
            }
        }

        if ($callback !== null) {
            $callback($this);
        }

        return $update;
    }
}


<?php
namespace Charcoal\Object;

use DateTime;
use DateTimeInterface;
use InvalidArgumentException;
use RuntimeException;
use Exception;

// From Pimple
use Pimple\Container;

// From 'charcoal-core'
use Charcoal\Model\AbstractModel;
use Charcoal\Loader\CollectionLoader;

// From 'charcoal-factory'
use Charcoal\Factory\FactoryInterface;

// From 'charcoal-object'
use Charcoal\Object\ObjectRouteInterface;

/**
 * Represents a route to an object (i.e., a permalink).
 *
 * Intended to be used to collect all routes related to models
 * under a single source (e.g., database table).
 *
 * {@see Charcoal\Object\ObjectRevision} for a similar model that aggregates data
 * under a common source.
 *
 * Requirements:
 *
 * - 'model/factory'
 * - 'model/collection/loader'
 */
class ObjectRoute extends AbstractModel implements
    ObjectRouteInterface
{
    /**
     * A route is active by default.
     *
     * @var boolean
     */
    protected $active = true;

    /**
     * The route's URI.
     *
     * @var string
     */
    protected $slug;

    /**
     * The route's locale.
     *
     * @var string
     */
    protected $lang;

    /**
     * The creation timestamp.
     *
     * @var DateTime
     */
    protected $creationDate;

    /**
     * The last modification timestamp.
     *
     * @var DateTime
     */
    protected $lastModificationDate;

    /**
     * The foreign object type related to this route.
     *
     * @var string
     */
    protected $routeObjType;

    /**
     * The foreign object ID related to this route.
     *
     * @var mixed
     */
    protected $routeObjId;

    /**
     * The foreign object's template identifier.
     *
     * @var string
     */
    protected $routeTemplate;

    /**
     * Retrieve the foreign object's routes options.
     *
     * @var array
     */
    protected $routeOptions;

    /**
     * Retrieve the foreign object's routes options ident.
     *
     * @var string
     */
    protected $routeOptionsIdent;

    /**
     * Store a copy of the original—_preferred_—slug before alterations are made.
     *
     * @var string
     */
    private $originalSlug;

    /**
     * Store the increment used to create a unique slug.
     *
     * @var integer
     */
    private $slugInc = 0;

    /**
     * Store the factory instance for the current class.
     *
     * @var FactoryInterface
     */
    private $modelFactory;

    /**
     * Store the collection loader for the current class.
     *
     * @var CollectionLoader
     */
    private $collectionLoader;

    /**
     * Inject dependencies from a DI Container.
     *
     * @param  Container $container A dependencies container instance.
     * @return void
     */
    protected function setDependencies(Container $container)
    {
        parent::setDependencies($container);

        $this->setModelFactory($container['model/factory']);
        $this->setCollectionLoader($container['model/collection/loader']);
    }

    /**
     * Event called before _creating_ the object.
     *
     * @see    Charcoal\Source\StorableTrait::preSave() For the "create" Event.
     * @return boolean
     */
    protected function preSave()
    {
        $this->generateUniqueSlug();
        $this->setCreationDate('now');
        $this->setLastModificationDate('now');

        return parent::preSave();
    }

    /**
     * Event called before _updating_ the object.
     *
     * @see    Charcoal\Source\StorableTrait::preUpdate() For the "update" Event.
     * @param  array $properties Optional. The list of properties to update.
     * @return boolean
     */
    protected function preUpdate(array $properties = null)
    {
        $this->setCreationDate('now');
        $this->setLastModificationDate('now');

        return parent::preUpdate($properties);
    }

    /**
     * Determine if the current slug is unique.
     *
     * @return boolean
     */
    public function isSlugUnique()
    {
        $proto = $this->modelFactory()->get(self::class);
        $loader = $this->collectionLoader();
        $loader
            ->reset()
            ->setModel($proto)
            ->addFilter('active', true)
            ->addFilter('slug', $this->slug())
            ->addFilter('lang', $this->lang())
            ->addOrder('creation_date', 'desc')
            ->setPage(1)
            ->setNumPerPage(1);

        $routes = $loader->load()->objects();
        if (!$routes) {
            return true;
        }
        $obj = reset($routes);
        if (!$obj->id()) {
            return true;
        }
        if ($obj->id() === $this->id()) {
            return true;
        }
        if ($obj->routeObjId() === $this->routeObjId() &&
            $obj->routeObjType() === $this->routeObjType() &&
            $obj->lang() === $this->lang()
        ) {
            $this->setId($obj->id());

            return true;
        }

        return false;
    }

    /**
     * Generate a unique URL slug for routable object.
     *
     * @return self
     */
    public function generateUniqueSlug()
    {
        if (!$this->isSlugUnique()) {
            if (!$this->originalSlug) {
                $this->originalSlug = $this->slug();
            }
            $this->slugInc++;
            $this->setSlug($this->originalSlug.'-'.$this->slugInc);

            return $this->generateUniqueSlug();
        }

        return $this;
    }

    /**
     * Set an object model factory.
     *
     * @param  FactoryInterface $factory The model factory, to create objects.
     * @return self
     */
    protected function setModelFactory(FactoryInterface $factory)
    {
        $this->modelFactory = $factory;

        return $this;
    }

    /**
     * Set a model collection loader.
     *
     * @param  CollectionLoader $loader The collection loader.
     * @return self
     */
    protected function setCollectionLoader(CollectionLoader $loader)
    {
        $this->collectionLoader = $loader;

        return $this;
    }

    /**
     * Set the object route URI.
     *
     * @param  string|null $slug The route.
     * @throws InvalidArgumentException If the slug argument is not a string.
     * @return self
     */
    public function setSlug($slug)
    {
        if ($slug === null) {
            $this->slug = null;

            return $this;
        }
        if (!is_string($slug)) {
            throw new InvalidArgumentException(
                'Slug is not a string'
            );
        }
        $this->slug = $slug;

        return $this;
    }

    /**
     * Set the locale of the object route.
     *
     * @param  string $lang The route's locale.
     * @return self
     */
    public function setLang($lang)
    {
        $this->lang = $lang;

        return $this;
    }

    /**
     * Set the route's last creation date.
     *
     * @param  string|DateTimeInterface|null $time The date/time value.
     * @throws InvalidArgumentException If the date/time value is invalid.
     * @return self
     */
    public function setCreationDate($time)
    {
        if (empty($time) && !is_numeric($time)) {
            $this->creationDate = null;

            return $this;
        }

        if (is_string($time)) {
            try {
                $time = new DateTime($time);
            } catch (Exception $e) {
                throw new InvalidArgumentException(sprintf(
                    'Invalid Creation Date: %s',
                    $e->getMessage()
                ), $e->getCode(), $e);
            }
        }

        if (!$time instanceof DateTimeInterface) {
            throw new InvalidArgumentException(
                'Creation Date must be a date/time string or an instance of DateTimeInterface'
            );
        }

        $this->creationDate = $time;

        return $this;
    }

    /**
     * Set the route's last modification date.
     *
     * @param  string|DateTimeInterface|null $time The date/time value.
     * @throws InvalidArgumentException If the date/time value is invalid.
     * @return self
     */
    public function setLastModificationDate($time)
    {
        if (empty($time) && !is_numeric($time)) {
            $this->lastModificationDate = null;

            return $this;
        }

        if (is_string($time)) {
            try {
                $time = new DateTime($time);
            } catch (Exception $e) {
                throw new InvalidArgumentException(sprintf(
                    'Invalid Updated Date: %s',
                    $e->getMessage()
                ), $e->getCode(), $e);
            }
        }

        if (!$time instanceof DateTimeInterface) {
            throw new InvalidArgumentException(
                'Updated Date must be a date/time string or an instance of DateTimeInterface'
            );
        }

        $this->lastModificationDate = $time;

        return $this;
    }

    /**
     * Set the foreign object type related to this route.
     *
     * @param  string $type The object type.
     * @return self
     */
    public function setRouteObjType($type)
    {
        $this->routeObjType = $type;

        return $this;
    }

    /**
     * Set the foreign object ID related to this route.
     *
     * @param  string $id The object ID.
     * @return self
     */
    public function setRouteObjId($id)
    {
        $this->routeObjId = $id;

        return $this;
    }

    /**
     * Set the foreign object's template identifier.
     *
     * @param  string $template The template identifier.
     * @return self
     */
    public function setRouteTemplate($template)
    {
        $this->routeTemplate = $template;

        return $this;
    }

    /**
     * Customize the template's options.
     *
     * @param  mixed $options Template options.
     * @return self
     */
    public function setRouteOptions($options)
    {
        if (is_string($options)) {
            $options = json_decode($options, true);
        }

        $this->routeOptions = $options;

        return $this;
    }

    /**
     * @param string $routeOptionsIdent Template options ident.
     * @return self
     */
    public function setRouteOptionsIdent($routeOptionsIdent)
    {
        $this->routeOptionsIdent = $routeOptionsIdent;

        return $this;
    }

    /**
     * Retrieve the object model factory.
     *
     * @throws RuntimeException If the model factory was not previously set.
     * @return FactoryInterface
     */
    public function modelFactory()
    {
        if (!isset($this->modelFactory)) {
            throw new RuntimeException(sprintf(
                'Model Factory is not defined for "%s"',
                get_class($this)
            ));
        }

        return $this->modelFactory;
    }

    /**
     * Retrieve the model collection loader.
     *
     * @throws RuntimeException If the collection loader was not previously set.
     * @return CollectionLoader
     */
    public function collectionLoader()
    {
        if (!isset($this->collectionLoader)) {
            throw new RuntimeException(sprintf(
                'Collection Loader is not defined for "%s"',
                get_class($this)
            ));
        }

        return $this->collectionLoader;
    }

    /**
     * Retrieve the object route.
     *
     * @return string
     */
    public function slug()
    {
        return $this->slug;
    }

    /**
     * Retrieve the locale of the object route.
     *
     * @return string
     */
    public function lang()
    {
        return $this->lang;
    }

    /**
     * Retrieve the route's creation date.
     *
     * @return DateTimeInterface|null
     */
    public function creationDate()
    {
        return $this->creationDate;
    }

    /**
     * Retrieve the route's last modification date.
     *
     * @return DateTimeInterface|null
     */
    public function lastModificationDate()
    {
        return $this->lastModificationDate;
    }

    /**
     * Retrieve the foreign object type related to this route.
     *
     * @return string
     */
    public function routeObjType()
    {
        return $this->routeObjType;
    }

    /**
     * Retrieve the foreign object ID related to this route.
     *
     * @return string
     */
    public function routeObjId()
    {
        return $this->routeObjId;
    }

    /**
     * Retrieve the foreign object's template identifier.
     *
     * @return string
     */
    public function routeTemplate()
    {
        return $this->routeTemplate;
    }

    /**
     * Retrieve the template's customized options.
     *
     * @return array
     */
    public function routeOptions()
    {
        return $this->routeOptions;
    }

    /**
     * @return string
     */
    public function routeOptionsIdent()
    {
        return $this->routeOptionsIdent;
    }

    /**
     * Alias of {@see self::slug()}.
     *
     * @return string
     */
    public function __toString()
    {
        return (string)$this->slug();
    }
}


<?php

namespace Charcoal\Object;

use InvalidArgumentException;
use UnexpectedValueException;

// From 'charcoal-core'
use Charcoal\Model\ModelInterface;

/**
 * Full implementation, as a trait, of the `HierarchicalInterface`
 */
trait HierarchicalTrait
{
    /**
     * The object's parent, if any, in the hierarchy.
     *
     * @var HierarchicalInterface|null
     */
    protected $master;

    /**
     * Store a copy of the object's ancestry.
     *
     * @var HierarchicalInterface[]|null
     */
    private $hierarchy = null;

    /**
     * Store a copy of the object's descendants.
     *
     * @var HierarchicalInterface[]|null
     */
    private $children;

    /**
     * Store a copy of the object's siblings.
     *
     * @var HierarchicalInterface[]|null
     */
    private $siblings;

    /**
     * A store of cached objects.
     *
     * @var ModelInterface[] $objectCache
     */
    public static $objectCache = [];

    /**
     * Reset this object's hierarchy.
     *
     * The object's hierarchy can be rebuilt with {@see self::hierarchy()}.
     *
     * @return HierarchicalInterface Chainable
     */
    public function resetHierarchy()
    {
        $this->hierarchy = null;

        return $this;
    }

    /**
     * Set this object's immediate parent.
     *
     * @param  mixed $master The object's parent (or master).
     * @throws UnexpectedValueException The current object cannot be its own parent.
     * @return HierarchicalInterface Chainable
     */
    public function setMaster($master)
    {
        $master = $this->objFromIdent($master);

        if ($master instanceof ModelInterface) {
            if ($master->id() === $this->id()) {
                throw new UnexpectedValueException(sprintf(
                    'Can not be ones own parent: %s',
                    $master->id()
                ));
            }
        }

        $this->master = $master;

        $this->resetHierarchy();

        return $this;
    }

    /**
     * Retrieve this object's immediate parent.
     *
     * @return HierarchicalInterface|null
     */
    public function master()
    {
        return $this->master;
    }

    /**
     * Determine if this object has a direct parent.
     *
     * @return boolean
     */
    public function hasMaster()
    {
        return ($this->master() !== null);
    }

    /**
     * Determine if this object is the head (top-level) of its hierarchy.
     *
     * Top-level objects do not have a parent (master).
     *
     * @return boolean
     */
    public function isTopLevel()
    {
        return ($this->master() === null);
    }

    /**
     * Determine if this object is the tail (last-level) of its hierarchy.
     *
     * Last-level objects do not have a children.
     *
     * @return boolean
     */
    public function isLastLevel()
    {
        return !$this->hasChildren();
    }

    /**
     * Retrieve this object's position (level) in its hierarchy.
     *
     * Starts at "1" (top-level).
     *
     * The level is calculated by loading all ancestors with {@see self::hierarchy()}.
     *
     * @return integer
     */
    public function hierarchyLevel()
    {
        $hierarchy = $this->hierarchy();
        $level = (count($hierarchy) + 1);

        return $level;
    }

    /**
     * Retrieve the top-level ancestor of this object.
     *
     * @return HierarchicalInterface|null
     */
    public function toplevelMaster()
    {
        $hierarchy = $this->invertedHierarchy();
        if (isset($hierarchy[0])) {
            return $hierarchy[0];
        } else {
            return null;
        }
    }

    /**
     * Determine if this object has any ancestors.
     *
     * @return boolean
     */
    public function hasParents()
    {
        return !!count($this->hierarchy());
    }

    /**
     * Retrieve this object's ancestors (from immediate parent to top-level).
     *
     * @return array
     */
    public function hierarchy()
    {
        if (!isset($this->hierarchy)) {
            $hierarchy = [];
            $master = $this->master();
            while ($master) {
                $hierarchy[] = $master;
                $master = $master->master();
            }

            $this->hierarchy = $hierarchy;
        }

        return $this->hierarchy;
    }

    /**
     * Retrieve this object's ancestors, inverted from top-level to immediate.
     *
     * @return array
     */
    public function invertedHierarchy()
    {
        $hierarchy = $this->hierarchy();
        return array_reverse($hierarchy);
    }

    /**
     * Determine if the object is the parent of the given object.
     *
     * @param mixed $child The child (or ID) to match against.
     * @return boolean
     */
    public function isMasterOf($child)
    {
        $child = $this->objFromIdent($child);
        return ($child->master() == $this);
    }

    /**
     * Determine if the object is a parent/ancestor of the given object.
     *
     * @param mixed $child The child (or ID) to match against.
     * @return boolean
     * @todo Implementation needed.
     */
    public function recursiveIsMasterOf($child)
    {
        $child = $this->objFromIdent($child);

        return false;
    }

    /**
     * Get wether the object has any children at all
     * @return boolean
     */
    public function hasChildren()
    {
        $numChildren = $this->numChildren();
        return ($numChildren > 0);
    }

    /**
     * Get the number of children directly under this object.
     * @return integer
     */
    public function numChildren()
    {
        $children = $this->children();
        return count($children);
    }

    /**
     * Get the total number of children in the entire hierarchy.
     * This method counts all children and sub-children, unlike `numChildren()` which only count 1 level.
     * @return integer
     */
    public function recursiveNumChildren()
    {
        // TODO
        return 0;
    }

    /**
     * @param array $children The children to set.
     * @return HierarchicalInterface Chainable
     */
    public function setChildren(array $children)
    {
        $this->children = [];
        foreach ($children as $c) {
            $this->addChild($c);
        }
        return $this;
    }

    /**
     * @param mixed $child The child object (or ident) to add.
     * @throws UnexpectedValueException The current object cannot be its own child.
     * @return HierarchicalInterface Chainable
     */
    public function addChild($child)
    {
        $child = $this->objFromIdent($child);

        if ($child instanceof ModelInterface) {
            if ($child->id() === $this->id()) {
                throw new UnexpectedValueException(sprintf(
                    'Can not be ones own child: %s',
                    $child->id()
                ));
            }
        }

        $this->children[] = $child;

        return $this;
    }

    /**
     * Get the children directly under this object.
     * @return array
     */
    public function children()
    {
        if ($this->children !== null) {
            return $this->children;
        }

        $this->children = $this->loadChildren();
        return $this->children;
    }

    /**
     * @return array
     */
    abstract public function loadChildren();

    /**
     * @param mixed $master The master object (or ident) to check against.
     * @return boolean
     */
    public function isChildOf($master)
    {
        $master = $this->objFromIdent($master);
        if ($master === null) {
            return false;
        }
        return ($master == $this->master());
    }

    /**
     * @param mixed $master The master object (or ident) to check against.
     * @return boolean
     */
    public function recursiveIsChildOf($master)
    {
        $master = $this->objFromIdent($master);
        if ($master === null) {
            return false;
        }
        // TODO
    }

    /**
     * @return boolean
     */
    public function hasSiblings()
    {
        $numSiblings = $this->numSiblings();
        return ($numSiblings > 1);
    }

    /**
     * @return integer
     */
    public function numSiblings()
    {
        $siblings = $this->siblings();
        return count($siblings);
    }

    /**
     * Get all the objects on the same level as this one.
     * @return array
     */
    public function siblings()
    {
        if ($this->siblings !== null) {
            return $this->siblings;
        }
        $master = $this->master();
        if ($master === null) {
            // Todo: return all top-level objects.
            $siblings = [];
        } else {
            // Todo: Remove "current" object from siblings
            $siblings = $master->children();
        }
        $this->siblings = $siblings;
        return $this->siblings;
    }

    /**
     * @param mixed $sibling The sibling to check.
     * @return boolean
     */
    public function isSiblingOf($sibling)
    {
        $sibling = $this->objFromIdent($sibling);
        return ($sibling->master() == $this->master());
    }

    /**
     * @param mixed $ident The ident.
     * @throws InvalidArgumentException If the identifier is not a scalar value.
     * @return HierarchicalInterface|null
     */
    private function objFromIdent($ident)
    {
        if ($ident === null) {
            return null;
        }

        $class = get_called_class();

        if (is_object($ident) && ($ident instanceof $class)) {
            return $ident;
        }

        if (is_array($ident) && isset($ident[$this->key()])) {
            $ident = $ident[$this->key()];
        }

        if (!is_scalar($ident)) {
            throw new InvalidArgumentException(sprintf(
                'Can not load object (not a scalar or a "%s")',
                $class
            ));
        }

        $cached = $this->loadObjectFromCache($ident);
        if ($cached !== null) {
            return $cached;
        }

        $obj = $this->loadObjectFromSource($ident);

        if ($obj !== null) {
            $this->addObjectToCache($obj);
        }

        return $obj;
    }

    /**
     * Retrieve an object from the storage source by its ID.
     *
     * @param mixed $id The object id.
     * @return null|HierarchicalInterface
     */
    private function loadObjectFromSource($id)
    {
        $obj = $this->modelFactory()->create($this->objType());
        $obj->load($id);

        if ($obj->id()) {
            return $obj;
        } else {
            return null;
        }
    }

    /**
     * Retrieve an object from the cache store by its ID.
     *
     * @param mixed $id The object id.
     * @return null|HierarchicalInterface
     */
    private function loadObjectFromCache($id)
    {
        $objType = $this->objType();
        if (isset(static::$objectCache[$objType][$id])) {
            return static::$objectCache[$objType][$id];
        } else {
            return null;
        }
    }

    /**
     * Add an object to the cache store.
     *
     * @param ModelInterface $obj The object to store.
     * @return HierarchicalInterface Chainable
     */
    private function addObjectToCache(ModelInterface $obj)
    {
        static::$objectCache[$this->objType()][$obj->id()] = $obj;

        return $this;
    }

    /**
     * Retrieve the object model factory.
     *
     * @return \Charcoal\Factory\FactoryInterface
     */
    abstract public function modelFactory();

    /**
     * @return string
     */
    abstract public function id();

    /**
     * @return string
     */
    abstract public function objType();
}


<?php

namespace Charcoal\Object;

use Exception;
use InvalidArgumentException;

/**
 *
 */
trait CategoryTrait
{
    /**
     * @var string
     */
    private $categoryItemType;

    /**
     * @var \Charcoal\Object\CategorizableInterface[]|array
     */
    private $categoryItems;

    /**
     * @param string $type The category item type.
     * @throws InvalidArgumentException If the type argument is not a string.
     * @return self
     */
    public function setCategoryItemType($type)
    {
        if (!is_string($type)) {
            throw new InvalidArgumentException(
                'Item type must be a string.'
            );
        }
        $this->categoryItemType = $type;
        return $this;
    }

    /**
     * @throws Exception If no item type was previously set.
     * @return string
     */
    public function categoryItemType()
    {
        if ($this->categoryItemType === null) {
            throw new Exception(
                'Item type is unset. Set item type before calling getter.'
            );
        }
        return $this->categoryItemType;
    }

    /**
     * Gets the number of items, directly within this category.
     *
     * @return integer
     */
    public function numCategoryItems()
    {
        $items = $this->categoryItems();
        return count($items);
    }

    /**
     * Gets wether the category has any items, directly within it.
     *
     * @return boolean
     */
    public function hasCategoryItems()
    {
        $numItems = $this->numCategoryItems();
        return ($numItems > 0);
    }

    /**
     * Retrieves the category items, directly within it.
     *
     * @return \Charcoal\Object\CategorizableInterface[]|array A list of `CategorizableInterface` objects
     */
    public function categoryItems()
    {
        if ($this->categoryItems === null) {
            $this->categoryItems = $this->loadCategoryItems();
        }
        return $this->categoryItems;
    }

    /**
     * Loads the category items (directly within it).
     *
     * This method is abstract so must be reimplemented.
     * Typically, a class would use a CollectionLoader to load the category items.
     *
     * @return \Charcoal\Object\CategorizableInterface[]|array
     */
    abstract protected function loadCategoryItems();
}


<?php

namespace Charcoal\Object;

use Exception;
use InvalidArgumentException;
use UnexpectedValueException;

// From 'charcoal-core'
use Charcoal\Loader\CollectionLoader;

// From 'charcoal-translator'
use Charcoal\Translator\Translation;

// From 'charcoal-view'
use Charcoal\View\ViewableInterface;

// From 'charcoal-object'
use Charcoal\Object\ObjectRoute;
use Charcoal\Object\ObjectRouteInterface;

/**
 * Full implementation, as Trait, of the {@see \Charcoal\Object\RoutableInterface}.
 *
 * This implementation uses a secondary model, {@see \Charcoal\Object\ObjectRoute},
 * to collect all routes of routable models under a single source.
 */
trait RoutableTrait
{
    /**
     * The object's route.
     *
     * @var \Charcoal\Translator\Translation|null
     */
    protected $slug;

    /**
     * Whether the slug is editable.
     *
     * If FALSE, the slug is always auto-generated from its pattern.
     * If TRUE, the slug is auto-generated only if the slug is empty.
     *
     * @var boolean|null
     */
    private $isSlugEditable;

    /**
     * The object's route pattern.
     *
     * @var \Charcoal\Translator\Translation|null
     */
    private $slugPattern = '';

    /**
     * A prefix for the object's route.
     *
     * @var \Charcoal\Translator\Translation|null
     */
    private $slugPrefix = '';

    /**
     * A suffix for the object's route.
     *
     * @var \Charcoal\Translator\Translation|null
     */
    private $slugSuffix = '';

    /**
     * The class name of the object route model.
     *
     * Must be a fully-qualified PHP namespace and an implementation of
     * {@see \Charcoal\Object\ObjectRouteInterface}. Used by the model factory.
     *
     * @var string
     */
    private $objectRouteClass = ObjectRoute::class;

    /**
     * The object's route options.
     *
     * @var array|null
     */
    protected $routeOptions;

    /**
     * Retrieve the foreign object's routes options ident.
     *
     * @var string
     */
    protected $routeOptionsIdent;

    /**
     * Set the object's URL slug pattern.
     *
     * @param  mixed $pattern The slug pattern.
     * @return RoutableInterface Chainable
     */
    public function setSlugPattern($pattern)
    {
        $this->slugPattern = $this->translator()->translation($pattern);

        return $this;
    }

    /**
     * Retrieve the object's URL slug pattern.
     *
     * @throws Exception If a slug pattern is not defined.
     * @return \Charcoal\Translator\Translation|null
     */
    public function slugPattern()
    {
        if (!$this->slugPattern) {
            $metadata = $this->metadata();

            if (isset($metadata['routable']['pattern'])) {
                $this->setSlugPattern($metadata['routable']['pattern']);
            } elseif (isset($metadata['slug_pattern'])) {
                $this->setSlugPattern($metadata['slug_pattern']);
            } else {
                throw new Exception(sprintf(
                    'Undefined route pattern (slug) for %s',
                    get_called_class()
                ));
            }
        }

        return $this->slugPattern;
    }

    /**
     * Retrieve route prefix for the object's URL slug pattern.
     *
     * @return \Charcoal\Translator\Translation|null
     */
    public function slugPrefix()
    {
        if (!$this->slugPrefix) {
            $metadata = $this->metadata();

            if (isset($metadata['routable']['prefix'])) {
                $this->slugPrefix = $this->translator()->translation($metadata['routable']['prefix']);
            }
        }

        return $this->slugPrefix;
    }

    /**
     * Retrieve route suffix for the object's URL slug pattern.
     *
     * @return \Charcoal\Translator\Translation|null
     */
    public function slugSuffix()
    {
        if (!$this->slugSuffix) {
            $metadata = $this->metadata();

            if (isset($metadata['routable']['suffix'])) {
                $this->slugSuffix = $this->translator()->translation($metadata['routable']['suffix']);
            }
        }

        return $this->slugSuffix;
    }

    /**
     * Determine if the slug is editable.
     *
     * @return boolean
     */
    public function isSlugEditable()
    {
        if ($this->isSlugEditable === null) {
            $metadata = $this->metadata();

            if (isset($metadata['routable']['editable'])) {
                $this->isSlugEditable = !!$metadata['routable']['editable'];
            } else {
                $this->isSlugEditable = false;
            }
        }

        return $this->isSlugEditable;
    }

    /**
     * Set the object's URL slug.
     *
     * @param  mixed $slug The slug.
     * @return RoutableInterface Chainable
     */
    public function setSlug($slug)
    {
        $slug = $this->translator()->translation($slug);
        if ($slug !== null) {
            $this->slug = $slug;

            $values = $this->slug->data();
            foreach ($values as $lang => $val) {
                $this->slug[$lang] = $this->slugify($val);
            }
        } else {
            /** @todo Hack used for regenerating route */
            if (isset($_POST['slug'])) {
                $this->slug = [];
            } else {
                $this->slug = null;
            }
        }

        return $this;
    }

    /**
     * Retrieve the object's URL slug.
     *
     * @return \Charcoal\Translator\Translation|null
     */
    public function slug()
    {
        return $this->slug;
    }

    /**
     * Generate a URL slug from the object's URL slug pattern.
     *
     * @throws UnexpectedValueException If the slug is empty.
     * @return \Charcoal\Translator\Translation
     */
    public function generateSlug()
    {
        $languages = $this->translator()->availableLocales();
        $patterns  = $this->slugPattern();
        $curSlug   = $this->slug();
        $newSlug   = [];

        $origLang = $this->translator()->getLocale();
        foreach ($languages as $lang) {
            $pattern = $patterns[$lang];

            $this->translator()->setLocale($lang);
            if ($this->isSlugEditable() && isset($curSlug[$lang]) && strlen($curSlug[$lang])) {
                $newSlug[$lang] = $curSlug[$lang];
            } else {
                $newSlug[$lang] = $this->generateRoutePattern($pattern);
                if (!strlen($newSlug[$lang])) {
                    throw new UnexpectedValueException(sprintf(
                        'The slug is empty. The pattern is "%s"',
                        $pattern
                    ));
                }
            }
            $newSlug[$lang] = $this->finalizeSlug($newSlug[$lang]);

            $newRoute = $this->createRouteObject();
            $newRoute->setData([
                'lang'           => $lang,
                'slug'           => $newSlug[$lang],
                'route_obj_type' => $this->objType(),
                'route_obj_id'   => $this->id(),
            ]);

            if (!$newRoute->isSlugUnique()) {
                $newRoute->generateUniqueSlug();
                $newSlug[$lang] = $newRoute->slug();
            }
        }
        $this->translator()->setLocale($origLang);

        return $this->translator()->translation($newSlug);
    }

    /**
     * Generate a route from the given pattern.
     *
     * @uses   self::parseRouteToken() If a view renderer is unavailable.
     * @param  string $pattern The slug pattern.
     * @return string Returns the generated route.
     */
    protected function generateRoutePattern($pattern)
    {
        if ($this instanceof ViewableInterface && $this->view() !== null) {
            $route = $this->view()->render($pattern, $this->viewController());
        } else {
            $route = preg_replace_callback('~\{\{\s*(.*?)\s*\}\}~i', [ $this, 'parseRouteToken' ], $pattern);
        }

        return $this->slugify($route);
    }

    /**
     * Parse the given slug (URI token) for the current object.
     *
     * @used-by self::generateRoutePattern() If a view renderer is unavailable.
     * @uses    self::filterRouteToken() For customize the route value filtering,
     * @param   string|array $token The token to parse relative to the model entry.
     * @throws  InvalidArgumentException If a route token is not a string.
     * @return  string
     */
    protected function parseRouteToken($token)
    {
        // Processes matches from a regular expression operation
        if (is_array($token) && isset($token[1])) {
            $token = $token[1];
        }

        $token = trim($token);
        $method = [ $this, $token ];

        if (is_callable($method)) {
            $value = call_user_func($method);
            /** @see \Charcoal\Config\AbstractEntity::offsetGet() */
        } elseif (isset($this[$token])) {
            $value = $this[$token];
        } else {
            return '';
        }

        $value = $this->filterRouteToken($value, $token);
        if (!is_string($value) && !is_numeric($value)) {
            throw new InvalidArgumentException(sprintf(
                'Route token "%1$s" must be a string with %2$s; received %3$s',
                $token,
                get_called_class(),
                (is_object($value) ? get_class($value) : gettype($value))
            ));
        }

        return $value;
    }

    /**
     * Filter the given value for a URI.
     *
     * @used-by self::parseRouteToken() To resolve the token's value.
     * @param   mixed  $value A value to filter.
     * @param   string $token The parsed token.
     * @return  string The filtered $value.
     */
    protected function filterRouteToken($value, $token = null)
    {
        unset($token);

        if ($value instanceof \Closure) {
            $value = $value();
        }

        if ($value instanceof \DateTime) {
            $value = $value->format('Y-m-d-H:i');
        }

        if (method_exists($value, '__toString')) {
            $value = strval($value);
        }

        return $value;
    }

    /**
     * Route generation.
     *
     * Saves all routes to {@see \Charcoal\Object\ObjectRoute}.
     *
     * @param  mixed $slug Slug by langs.
     * @param  array $data Object route custom data.
     * @throws InvalidArgumentException If the slug is invalid.
     * @return void
     */
    protected function generateObjectRoute($slug = null, array $data = [])
    {
        if (!$slug) {
            $slug = $this->generateSlug();
        }

        if ($slug instanceof Translation) {
            $slugs = $slug->data();
        } else {
            throw new InvalidArgumentException(sprintf(
                '[%s] slug parameter must be an instance of %s, received %s',
                get_called_class().'::'.__FUNCTION__,
                Translation::class,
                is_object($slug) ? get_class($slug) : gettype($slug)
            ));
        }

        if (!is_array($data)) {
            $data = [];
        }

        $origLang = $this->translator()->getLocale();
        foreach ($slugs as $lang => $slug) {
            if (!in_array($lang, $this->translator()->availableLocales())) {
                continue;
            }
            $this->translator()->setLocale($lang);

            $newRoute = $this->createRouteObject();
            $oldRoute = $this->getLatestObjectRoute();

            $defaultData = [
                // Not used, might be too much.
                'route_template'      => $this->templateIdent(),
                'route_options'       => $this->routeOptions(),
                'route_options_ident' => $this->routeOptionsIdent(),
            ];

            $immutableData = [
                'lang'                => $lang,
                'slug'                => $slug,
                'route_obj_type'      => $this->objType(),
                'route_obj_id'        => $this->id(),
                'active'              => true,
            ];

            $newData = array_merge($defaultData, $data, $immutableData);

            // Unchanged but sync extra properties
            if ($slug === $oldRoute->slug()) {
                $oldRoute->setData([
                    'route_template'      => $newData['route_template'],
                    'route_options'       => $newData['route_options'],
                    'route_options_ident' => $newData['route_options_ident'],
                ]);
                $oldRoute->update([ 'route_template', 'route_options' ]);

                continue;
            }

            $newRoute->setData($newData);

            if (!$newRoute->isSlugUnique()) {
                $newRoute->generateUniqueSlug();
            }

            if ($newRoute->id()) {
                $newRoute->update();
            } else {
                $newRoute->save();
            }
        }

        $this->translator()->setLocale($origLang);
    }

    /**
     * Retrieve the latest object route.
     *
     * @param  string|null $lang If object is multilingual, return the object route for the specified locale.
     * @throws InvalidArgumentException If the given language is invalid.
     * @return ObjectRouteInterface Latest object route.
     */
    protected function getLatestObjectRoute($lang = null)
    {
        if ($lang === null) {
            $lang = $this->translator()->getLocale();
        } elseif (!in_array($lang, $this->translator()->availableLocales())) {
            throw new InvalidArgumentException(sprintf(
                'Invalid language, received %s',
                (is_object($lang) ? get_class($lang) : gettype($lang))
            ));
        }

        if (!$this->objType() || !$this->id()) {
            return $this->createRouteObject();
        }

        $loader = $this->createRouteObjectCollectionLoader();
        $loader
            ->setNumPerPage(1)
            ->setPage(1)
            ->addOrder('creation_date', 'desc')
            ->addFilters([
                [
                    'property' => 'route_obj_type',
                    'value'    => $this->objType(),
                ],
                [
                    'property' => 'route_obj_id',
                    'value'    => $this->id(),
                ],
                [
                    'property' => 'route_options_ident',
                    'operator' => 'IS NULL'
                ],
                [
                    'property' => 'lang',
                    'value'    => $lang,
                ],
                [
                    'property' => 'active',
                    'value'    => true,
                ],
            ]);

        $collection = $loader->load()->objects();

        if (!count($collection)) {
            return $this->createRouteObject();
        }

        return $collection[0];
    }

    /**
     * Retrieve the object's URI.
     *
     * @param  string|null $lang If object is multilingual, return the object route for the specified locale.
     * @return string
     */
    public function url($lang = null)
    {
        $slug = $this->slug();

        if ($slug instanceof Translation && $lang) {
            return $slug[$lang];
        }

        if ($slug) {
            return $slug;
        }

        $url = (string)$this->getLatestObjectRoute($lang)->slug();
        return $url;
    }

    /**
     * Convert a string into a slug.
     *
     * @param  string $str The string to slugify.
     * @return string The slugified string.
     */
    public function slugify($str)
    {
        static $sluggedArray;

        if (isset($sluggedArray[$str])) {
            return $sluggedArray[$str];
        }

        $metadata    = $this->metadata();
        $separator   = isset($metadata['routable']['separator']) ? $metadata['routable']['separator'] : '-';
        $delimiters  = '-_|';
        $pregDelim   = preg_quote($delimiters);
        $directories = '\\/';
        $pregDir     = preg_quote($directories);

        // Do NOT remove forward slashes.
        $slug = preg_replace('![^(\p{L}|\p{N})(\s|\/)]!u', $separator, $str);

        if (!isset($metadata['routable']['lowercase']) || $metadata['routable']['lowercase'] === false) {
            $slug = mb_strtolower($slug, 'UTF-8');
        }

        // Strip HTML
        $slug = strip_tags($slug);

        // Remove diacritics
        $slug = htmlentities($slug, ENT_COMPAT, 'UTF-8');
        $slug = preg_replace('!&([a-zA-Z])(uml|acute|grave|circ|tilde|cedil|ring);!', '$1', $slug);

        // Simplify ligatures
        $slug = preg_replace('!&([a-zA-Z]{2})(lig);!', '$1', $slug);

        // Remove unescaped HTML characters
        $unescaped = '!&(raquo|laquo|rsaquo|lsaquo|rdquo|ldquo|rsquo|lsquo|hellip|amp|nbsp|quot|ordf|ordm);!';
        $slug = preg_replace($unescaped, '', $slug);

        // Unify all dashes/underscores as one separator character
        $flip = ($separator === '-') ? '_' : '-';
        $slug = preg_replace('!['.preg_quote($flip).']+!u', $separator, $slug);

        // Remove all whitespace and normalize delimiters
        $slug = preg_replace('![_\|\s|\(\)]+!', $separator, $slug);

        // Squeeze multiple delimiters and whitespace with a single separator
        $slug = preg_replace('!['.$pregDelim.'\s]{2,}!', $separator, $slug);

        // Squeeze multiple URI path delimiters
        $slug = preg_replace('!['.$pregDir.']{2,}!', $separator, $slug);

        // Remove delimiters surrouding URI path delimiters
        $slug = preg_replace('!(?<=['.$pregDir.'])['.$pregDelim.']|['.$pregDelim.'](?=['.$pregDir.'])!', '', $slug);

        // Strip leading and trailing dashes or underscores
        $slug = trim($slug, $delimiters);

        // Cache the slugified string
        $sluggedArray[$str] = $slug;

        return $slug;
    }

    /**
     * Finalize slug.
     *
     * Adds any prefix and suffix defined in the routable configuration set.
     *
     * @param  string $slug A slug.
     * @throws UnexpectedValueException If the slug affixes are invalid.
     * @return string
     */
    protected function finalizeSlug($slug)
    {
        $prefix = $this->slugPrefix();
        if ($prefix) {
            $prefix = $this->generateRoutePattern((string)$prefix);
            if ($slug === $prefix) {
                throw new UnexpectedValueException('The slug is the same as the prefix.');
            }
            $slug = $prefix.preg_replace('!^'.preg_quote($prefix).'\b!', '', $slug);
        }

        $suffix = $this->slugSuffix();
        if ($suffix) {
            $suffix = $this->generateRoutePattern((string)$suffix);
            if ($slug === $suffix) {
                throw new UnexpectedValueException('The slug is the same as the suffix.');
            }
            $slug = preg_replace('!\b'.preg_quote($suffix).'$!', '', $slug).$suffix;
        }

        $slug = rtrim($slug, '/');

        return $slug;
    }

    /**
     * Delete all object routes.
     *
     * Should be called on object deletion {@see \Charcoal\Model\AbstractModel::preDelete()}.
     *
     * @return boolean Success or failure.
     */
    protected function deleteObjectRoutes()
    {
        if (!$this->objType()) {
            return false;
        }

        if (!$this->id()) {
            return false;
        }

        $loader = $this->createRouteObjectCollectionLoader();
        $loader
            ->addFilters([
                [
                    'property' => 'route_obj_type',
                    'value'    => $this->objType(),
                ],
                [
                    'property' => 'route_obj_id',
                    'value'    => $this->id(),
                ],
            ]);

        $collection = $loader->load();
        foreach ($collection as $route) {
            $route->delete();
        }

        return true;
    }

    /**
     * Create a route collection loader.
     *
     * @return CollectionLoader
     */
    public function createRouteObjectCollectionLoader()
    {
        $loader = new CollectionLoader([
            'logger'  => $this->logger,
            'factory' => $this->modelFactory(),
            'model'   => $this->getRouteObjectPrototype(),
        ]);

        return $loader;
    }

    /**
     * Create a route object.
     *
     * @return ObjectRouteInterface
     */
    public function createRouteObject()
    {
        $route = $this->modelFactory()->create($this->objectRouteClass());

        return $route;
    }

    /**
     * Retrieve the route object prototype.
     *
     * @return ObjectRouteInterface
     */
    public function getRouteObjectPrototype()
    {
        $proto = $this->modelFactory()->get($this->objectRouteClass());

        return $proto;
    }

    /**
     * Set the class name of the object route model.
     *
     * @param  string $className The class name of the object route model.
     * @throws InvalidArgumentException If the class name is not a string.
     * @return AbstractPropertyDisplay Chainable
     */
    protected function setObjectRouteClass($className)
    {
        if (!is_string($className)) {
            throw new InvalidArgumentException(
                'Route class name must be a string.'
            );
        }

        $this->objectRouteClass = $className;

        return $this;
    }

    /**
     * Retrieve the class name of the object route model.
     *
     * @return string
     */
    public function objectRouteClass()
    {
        return $this->objectRouteClass;
    }

    /**
     * Set the object's route options
     *
     * @param  mixed $options The object routes's options.
     * @return self
     */
    public function setRouteOptions($options)
    {
        if (is_string($options)) {
            $options = json_decode($options, true);
        }

        $this->routeOptions = $options;

        return $this;
    }

    /**
     * Retrieve the object's route options
     *
     * @return array|null
     */
    public function routeOptions()
    {
        return $this->routeOptions;
    }

    /**
     * @param string $routeOptionsIdent Template options ident.
     * @return self
     */
    public function setRouteOptionsIdent($routeOptionsIdent)
    {
        $this->routeOptionsIdent = $routeOptionsIdent;

        return $this;
    }

    /**
     * @return string
     */
    public function routeOptionsIdent()
    {
        return $this->routeOptionsIdent;
    }

    /**
     * Determine if the routable object is active.
     *
     * The route controller will validate the object via this method. If the routable object
     * is NOT active, the route controller will usually default to _404 Not Found_.
     *
     * By default — if the object has an "active" property, that value is checked, else —
     * the route is always _active_.
     *
     * @return boolean
     */
    public function isActiveRoute()
    {
        if (isset($this['active'])) {
            return !!$this['active'];
        } else {
            return true;
        }
    }

    /**
     * Retrieve the object model factory.
     *
     * @return \Charcoal\Factory\FactoryInterface
     */
    abstract public function modelFactory();

    /**
     * Retrieve the routable object's template identifier.
     *
     * @return mixed
     */
    abstract public function templateIdent();

    /**
     * @return \Charcoal\Translator\Translator
     */
    abstract protected function translator();
}


<?php

namespace Charcoal\Object;

use InvalidArgumentException;

// From 'charcoal-core'
use Charcoal\Loader\CollectionLoader;

// From 'charcoal-object'
use Charcoal\Object\ObjectRevision;
use Charcoal\Object\ObjectRevisionInterface;

/**
 *
 */
trait RevisionableTrait
{
    /**
     * @var boolean $revisionEnabled
     */
    protected $revisionEnabled = true;

    /**
     * The class name of the object revision model.
     *
     * Must be a fully-qualified PHP namespace and an implementation of
     * {@see \Charcoal\Object\ObjectRevisionInterface}. Used by the model factory.
     *
     * @var string
     */
    private $objectRevisionClass = ObjectRevision::class;

    /**
     * @param boolean $enabled The (revision) enabled flag.
     * @return RevisionableInterface Chainable
     */
    public function setRevisionEnabled($enabled)
    {
        $this->revisionEnabled = !!$enabled;
        return $this;
    }

    /**
     * @return boolean
     */
    public function revisionEnabled()
    {
        return $this->revisionEnabled;
    }

    /**
     * Create a revision object.
     *
     * @return ObjectRevisionInterface
     */
    public function createRevisionObject()
    {
        $rev = $this->modelFactory()->create($this->objectRevisionClass());

        return $rev;
    }

    /**
     * Set the class name of the object revision model.
     *
     * @param  string $className The class name of the object revision model.
     * @throws InvalidArgumentException If the class name is not a string.
     * @return AbstractPropertyDisplay Chainable
     */
    protected function setObjectRevisionClass($className)
    {
        if (!is_string($className)) {
            throw new InvalidArgumentException(
                'Route class name must be a string.'
            );
        }

        $this->objectRevisionClass = $className;
        return $this;
    }

    /**
     * Retrieve the class name of the object revision model.
     *
     * @return string
     */
    public function objectRevisionClass()
    {
        return $this->objectRevisionClass;
    }

    /**
     * @return ObjectRevision
     * @see \Charcoal\Object\ObjectRevision::create_fromObject()
     */
    public function generateRevision()
    {
        $rev = $this->createRevisionObject();

        $rev->createFromObject($this);
        if (!empty($rev->dataDiff())) {
            $rev->save();
        }

        return $rev;
    }

    /**
     * @return ObjectRevision
     * @see \Charcoal\Object\ObejctRevision::lastObjectRevision
     */
    public function latestRevision()
    {
        $rev = $this->createRevisionObject();
        $rev = $rev->lastObjectRevision($this);

        return $rev;
    }

    /**
     * @param integer $revNum The revision number.
     * @return ObjectRevision
     * @see \Charcoal\Object\ObejctRevision::objectRevisionNum
     */
    public function revisionNum($revNum)
    {
        $rev = $this->createRevisionObject();
        $rev = $rev->objectRevisionNum($this, intval($revNum));

        return $rev;
    }

    /**
     * Retrieves all revisions for the current objet
     *
     * @param callable $callback Optional object callback.
     * @return array
     */
    public function allRevisions(callable $callback = null)
    {
        $loader = new CollectionLoader([
            'logger'    => $this->logger,
            'factory'   => $this->modelFactory()
        ]);
        $loader->setModel($this->createRevisionObject());
        $loader->addFilter('target_type', $this->objType());
        $loader->addFilter('target_id', $this->id());
        $loader->addOrder('rev_ts', 'desc');
        if ($callback !== null) {
            $loader->setCallback($callback);
        }
        $revisions = $loader->load();

        return $revisions->objects();
    }

    /**
     * @param integer $revNum The revision number to revert to.
     * @throws InvalidArgumentException If revision number is invalid.
     * @return boolean Success / Failure.
     */
    public function revertToRevision($revNum)
    {
        if (!$revNum) {
            throw new InvalidArgumentException(
                'Invalid revision number'
            );
        }

        $rev = $this->revisionNum(intval($revNum));

        if (!$rev->id()) {
            return false;
        }
        if (is_callable([$this, 'setLastModifiedBy'])) {
            $this->setLastModifiedBy($rev->revUser());
        }
        $this->setData($rev->dataObj());
        $this->update();

        return true;
    }

    /**
     * Retrieve the object model factory.
     *
     * @return \Charcoal\Factory\FactoryInterface
     */
    abstract public function modelFactory();

    /**
     * @return \Charcoal\Model\MetadataInterface
     */
    abstract public function metadata();
}


<?php

namespace Charcoal\Object;

use DateTime;
use DateTimeInterface;
use InvalidArgumentException;
use UnexpectedValueException;
use Exception;

/**
 * Publishable Object
 *
 * A full implementation, as trait, of the {@see \Charcoal\Object\PublishableInterface}.
 */
trait PublishableTrait
{
    /**
     * The publication date.
     *
     * @var DateTimeInterface $publishDate
     */
    protected $publishDate;

    /**
     * The expiration date.
     *
     * @var DateTimeInterface $expiryDate
     */
    protected $expiryDate;

    /**
     * The publication status.
     *
     * @var string|null
     */
    protected $publishStatus;

    /**
     * Set the object's publication date.
     *
     * @param  string|DateTimeInterface|null $time The date/time value.
     * @throws UnexpectedValueException If the date/time value is invalid.
     * @throws InvalidArgumentException If the value is not a date/time instance.
     * @return PublishableInterface Chainable
     */
    public function setPublishDate($time)
    {
        if ($time === null || $time === '') {
            $this->publishDate = null;
            return $this;
        }

        if (is_string($time)) {
            try {
                $time = new DateTime($time);
            } catch (Exception $e) {
                throw new UnexpectedValueException(sprintf(
                    'Invalid Publication Date: %s',
                    $e->getMessage()
                ), $e->getCode(), $e);
            }
        }

        if (!$time instanceof DateTimeInterface) {
            throw new InvalidArgumentException(
                'Publication Date must be a date/time string or an instance of DateTimeInterface'
            );
        }

        $this->publishDate = $time;

        return $this;
    }

    /**
     * Retrieve the object's publication date.
     *
     * @return DateTimeInterface|null
     */
    public function publishDate()
    {
        return $this->publishDate;
    }

    /**
     * Set the object's expiration date.
     *
     * @param  string|DateTimeInterface|null $time The date/time value.
     * @throws UnexpectedValueException If the date/time value is invalid.
     * @throws InvalidArgumentException If the value is not a date/time instance.
     * @return PublishableInterface Chainable
     */
    public function setExpiryDate($time)
    {
        if ($time === null || $time === '') {
            $this->expiryDate = null;
            return $this;
        }

        if (is_string($time)) {
            try {
                $time = new DateTime($time);
            } catch (Exception $e) {
                throw new UnexpectedValueException(sprintf(
                    'Invalid Expiration Date: %s',
                    $e->getMessage()
                ), $e->getCode(), $e);
            }
        }

        if (!$time instanceof DateTimeInterface) {
            throw new InvalidArgumentException(
                'Expiration Date must be a date/time string or an instance of DateTimeInterface'
            );
        }

        $this->expiryDate = $time;

        return $this;
    }

    /**
     * Retrieve the object's expiration date.
     *
     * @return DateTimeInterface|null
     */
    public function expiryDate()
    {
        return $this->expiryDate;
    }

    /**
     * Set the object's publication status.
     *
     * @param  string $status A publication status.
     * @throws InvalidArgumentException If the status is invalid.
     * @return PublishableInterface Chainable
     */
    public function setPublishStatus($status)
    {
        if ($status === null || $status === '') {
            $this->publishStatus = null;
            return $this;
        }

        $specialStatus = [
            static::STATUS_EXPIRED  => static::STATUS_PUBLISHED,
            static::STATUS_UPCOMING => static::STATUS_PUBLISHED
        ];

        /** Resolve any special statuses */
        if (isset($specialStatus[$status])) {
            $status = $specialStatus[$status];
        }

        $validStatus = [
            static::STATUS_DRAFT,
            static::STATUS_PENDING,
            static::STATUS_PUBLISHED
        ];

        if (!in_array($status, $validStatus)) {
            throw new InvalidArgumentException(sprintf(
                'Status "%s" is not a valid publish status.',
                $status
            ));
        }

        $this->publishStatus = $status;

        return $this;
    }

    /**
     * Retrieve the object's publication status.
     *
     * @return string|null
     */
    public function publishStatus()
    {
        return $this->publishStatus;
    }

    /**
     * Retrieve the object's publication status based on publication and expiration dates.
     *
     * - If the publication status is not "published", that status is returned (e.g., "draft", "pending", NULL).
     * - If no publication date is set, then it's assumed to be always published (or "expired").
     * - If no expiration date is set, then it's assumed to never expire.
     * - If a publication date is set to a future date, then it's assumed to be scheduled to be published ("upcoming").
     *
     * @return string|null
     */
    public function publishDateStatus()
    {
        $now = new DateTime();
        $publish = $this->publishDate();
        $expiry  = $this->expiryDate();
        $status  = $this->publishStatus();

        if ($status !== static::STATUS_PUBLISHED) {
            return $status;
        }

        if (!$publish) {
            if (!$expiry || $now < $expiry) {
                return static::STATUS_PUBLISHED;
            } else {
                return static::STATUS_EXPIRED;
            }
        } else {
            if ($now < $publish) {
                return static::STATUS_UPCOMING;
            } else {
                if (!$expiry || $now < $expiry) {
                    return static::STATUS_PUBLISHED;
                } else {
                    return static::STATUS_EXPIRED;
                }
            }
        }
    }

    /**
     * Determine if the object is published.
     *
     * @return boolean
     */
    public function isPublished()
    {
        return ($this->publishDateStatus() === static::STATUS_PUBLISHED);
    }
}