<?php

require_once 'Horde/DataTree.php';

/**
 * The History:: class provides a method of tracking changes in Horde
 * objects using the Horde DataTree backend.
 *
 * $Horde: framework/History/History.php,v 1.28.2.3 2005/07/03 05:48:03 selsky Exp $
 *
 * Copyright 2003-2005 Chuck Hagenbuch <chuck@horde.org>
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html.
 *
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @since   Horde 2.1
 * @package Horde_History
 */
class Horde_History {

    /**
     * Pointer to a DataTree instance to manage the history.
     *
     * @var DataTree
     */
    var $_datatree;

    /**
     * Constructor.
     */
    function Horde_History()
    {
        global $conf;

        if (empty($conf['datatree']['driver'])) {
            Horde::fatal('You must configure a DataTree backend to use History.', __FILE__, __LINE__);
        }
        $driver = $conf['datatree']['driver'];
        $this->_datatree = &DataTree::singleton($driver,
                                                array_merge(Horde::getDriverConfig('datatree', $driver),
                                                            array('group' => 'horde.history')));
    }

    /**
     * Logs an event to an item's history log. The item must be uniquely
     * identified by $guid. Any other details about the event are passed in
     * $attributes. Standard suggested attributes are:
     *
     *   'who' => The id of the user that performed the action (will be added
     *            automatically if not present).
     *
     *   'ts' => Timestamp of the action (this will be added automatically if
     *           it is not present).
     *
     * @param string $guid            The unique identifier of the entry to
     *                                add to.
     * @param array $attributes       The hash of name => value entries that
     *                                describe this event.
     * @param boolean $replaceAction  If $attributes['action'] is already
     *                                present in the item's history log,
     *                                update that entry instead of creating a
     *                                new one.
     *
     * @return boolean|PEAR_Error  True on success, PEAR_Error on failure.
     */
    function log($guid, $attributes = array(), $replaceAction = false)
    {
        $history = &$this->getHistory($guid, true);
        if (is_a($history, 'PEAR_Error')) {
            return $history;
        }

        $history->log($attributes, $replaceAction);

        return $this->_updateHistory($history);
    }

    /**
     * Returns a DataTreeObject_History object corresponding to the named
     * history entry, with the data retrieved appropriately. If $autocreate
     * is true, and $guid does not already exist, create, save, and return a
     * new History object with this $id.
     *
     * @param string $guid         The name of the history entry to retrieve.
     * @param boolean $autocreate  Automatically create the history entry?
     */
    function &getHistory($guid, $autocreate = false)
    {
        if ($this->_datatree->exists($guid)) {
            $history = &$this->_getHistory($guid);
        } elseif ($autocreate) {
            $history = &$this->_newHistory($guid);
            $result = $this->_addHistory($history);
            if (is_a($result, 'PEAR_Error')) {
                return $result;
            }
        } else {
            /* Return an empty history object for ease of use. */
            $history = &new DataTreeObject_History($guid);
            $history->setHistoryOb($this);
        }

        return $history;
    }

    /**
     * Finds history objects by timestamp, and optionally filter on other
     * fields as well.
     *
     * @param string $cmp     The comparison operator (<, >, <=, >=, or =) to
     *                        check the timestamps with.
     * @param integer $ts     The timestamp to compare against.
     * @param array $filters  An array of additional (ANDed) criteria.
     *                        Each array value should be an array with 3
     *                        entries:
     * <pre>
     *                         'op'    - the operator to compare this field
     *                                   with.
     *                         'field' - the history field being compared
     *                                   (i.e. 'action').
     *                         'value' - the value to check for (i.e. 'add').
     * </pre>
     * @param string $parent  The parent history to start searching at.
     *
     * @return array  An array of history object ids, or an empty array if
     *                none matched the criteria.
     */
    function &getByTimestamp($cmp, $ts, $filters = array(), $parent = DATATREE_ROOT)
    {
        /* Build the timestamp test. */
        $criteria = array(
            array('field' => 'key', 'op' => '=', 'test' => 'ts'),
            array('field' => 'value', 'op' => $cmp, 'test' => $ts));

        /* Add additional filters, if there are any. */
        if (count($filters)) {
            foreach ($filters as $filter) {
                $criteria[] = array('JOIN' => array(
                                  array('field' => 'key', 'op' => '=', 'test' => $filter['field']),
                                  array('field' => 'value', 'op' => $filter['op'], 'test' => $filter['value'])));
            }
        }

        /* Everything is ANDed together. */
        $criteria = array('AND' => $criteria);

        $histories = $this->_datatree->getByAttributes($criteria, $parent);
        if (is_a($histories, 'PEAR_Error') || !count($histories)) {
            /* If we got back an error or an empty array, just return it. */
            return $histories;
        }

        return $this->_getHistories(array_keys($histories));
    }

    /**
     * Gets the timestamp of the most recent change to $guid.
     *
     * @param string $guid     The name of the history entry to retrieve.
     * @params string $action  An action: 'add', 'modify' or 'delete'
     *
     * @return int  The timestamp or 0 if no history entry is found.
     */
    function getTSforAction($guid, $action)
    {
        $history = &$this->getHistory($guid);
        if (is_a($history, 'PEAR_Error')) {
            return 0;
        }

        $ts = 0;
        $a = $history->getData();
        if (is_array($a)) {
            foreach ($a as $entry) {
                if ($entry['action'] == $action && $entry['ts'] > $ts) {
                    $ts = $entry['ts'];
                }
            }
        }

        return $ts;
    }

    /**
     * Returns a DataTreeObject_History object corresponding to the named
     * history entry, with the data retrieved appropriately.
     *
     * @param string $guid  The name of the history entry to retrieve.
     */
    function &_getHistory($guid)
    {
        /* Cache of previous retrieved history entries. */
        static $historyCache;

        if (!is_array($historyCache)) {
            $historyCache = array();
        }

        if (!isset($historyCache[$guid])) {
            $historyCache[$guid] = $this->_datatree->getObject($guid, 'DataTreeObject_History');
            if (!is_a($historyCache[$guid], 'PEAR_Error')) {
                $historyCache[$guid]->setHistoryOb($this);
            }
        }

        return $historyCache[$guid];
    }

    /**
     * Returns an array of DataTreeObject_History objects corresponding to
     * the given set of unique IDs, with the details retrieved appropriately.
     *
     * @param array $guids  The array of ids to retrieve.
     */
    function &_getHistories($guids)
    {
        $histories = &$this->_datatree->getObjects($guids, 'DataTreeObject_History');
        if (is_a($histories, 'PEAR_Error')) {
            return $histories;
        }

        $keys = array_keys($histories);
        foreach ($keys as $key) {
            if (!is_a($histories[$key], 'PEAR_Error')) {
                $histories[$key]->setHistoryOb($this);
            }
        }

        return $histories;
    }

    /**
     * Changes the name of a history entry without changing its contents.
     *
     * @param DataTreeObject_History $history  The history entry to rename.
     * @param string $newName                  The entry's new name.
     */
    function rename($history, $newName)
    {
        if (!is_a($history, 'DataTreeObject_History')) {
            return PEAR::raiseError('History entries must be DataTreeObject_History objects or extend that class.');
        }
        return $this->_datatree->rename($history, $newName);
    }

    /**
     * Copies a history entry's data to a new name, keeping the old entry as
     * well.
     *
     * @param DataTreeObject_History $history  The history entry to copy.
     * @param string $newName                  The copy's new name.
     */
    function copy($history, $newName)
    {
        if (!is_a($history, 'DataTreeObject_History')) {
            return PEAR::raiseError('History entries must be DataTreeObject_History objects or extend that class.');
        }
        $new = &$this->_newHistory($newName);
        $new->data = $history->data;
        return $this->_addHistory($new);
    }

    /**
     * Removes a history entry from the history system permanently.
     *
     * @param DataTreeObject_History $history  The history entry to remove.
     */
    function removeHistory($history)
    {
        if (!is_a($history, 'DataTreeObject_History')) {
            return PEAR::raiseError('History entries must be DataTreeObject_History objects or extend that class.');
        }
        return $this->_datatree->remove($history, false);
    }

    /**
     * Remove one or more history entries by name. This function does
     * *not* do the validation, reordering, etc. that remove()
     * does. If you need to check for children, re-do ordering, etc.,
     * then you must remove() objects one-by-one. This is for code
     * that knows it's dealing with single (non-parented) objects and
     * needs to delete a batch of them quickly.
     *
     * @param array $names  The history entries to remove.
     */
    function removeByNames($names)
    {
        return $this->_datatree->removeByNames($names);
    }

    /**
     * Gets a list of every history entry, in the format cid => historyname.
     *
     * @return array  CID => historyname hash.
     */
    function listHistories()
    {
        static $entries;

        if (is_null($entries)) {
            $entries = $this->_datatree->get(DATATREE_FORMAT_FLAT, DATATREE_ROOT, true);
            unset($entries[DATATREE_ROOT]);
        }

        return $entries;
    }

    /**
     * Returns a new history entry object.
     *
     * @access private
     *
     * @param string $guid  The entry's name.
     *
     * @return DataTreeObject_History  A new history entry object.
     */
    function &_newHistory($guid)
    {
        if (empty($guid)) {
            return PEAR::raiseError(_("History entry names must be non-empty"));
        }
        $history = &new DataTreeObject_History($guid);
        $history->setHistoryOb($this);
        return $history;
    }

    /**
     * Adds an entry to the history system. The entry must first be created
     * with &History::_newHistory() before this function is called.
     *
     * @access private
     *
     * @param DataTreeObject_History $history  The new history entry object.
     */
    function _addHistory($history)
    {
        if (!is_a($history, 'DataTreeObject_History')) {
            return PEAR::raiseError('History entries must be DataTreeObject_History objects or extend that class.');
        }
        return $this->_datatree->add($history);
    }

    /**
     * Stores updated data of a history to the backend system.
     *
     * @access private
     *
     * @param DataTreeObject_History $history  The history entry to update.
     */
    function _updateHistory($history)
    {
        if (!is_a($history, 'DataTreeObject_History')) {
            return PEAR::raiseError('History entries must be DataTreeObject_History objects or extend that class.');
        }
        return $this->_datatree->updateData($history);
    }

    /**
     * Attempts to return a reference to a concrete History instance.
     * It will only create a new instance if no History instance
     * currently exists.
     *
     * This method must be invoked as: $var = &History::singleton()
     *
     * @return Horde_History  The concrete History reference, or false on an
     *                        error.
     */
    function &singleton()
    {
        static $history;

        if (!isset($history)) {
            $history = &new Horde_History();
        }

        return $history;
    }

}

/**
 * Extension of the DataTreeObject class for storing History information in
 * the DataTree backend. If you want to store specialized History
 * information, you should extend this class instead of extending
 * DataTreeObject directly.
 *
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @since   Horde 2.1
 * @package Horde_History
 */
class DataTreeObject_History extends DataTreeObject {

    /**
     * The History object which this history came from - needed for updating
     * data in the backend to make changes stick, etc.
     *
     * @var History
     */
    var $_historyOb;

    /**
     * Associates a History object with this history.
     *
     * @param History $historyOb  The History object.
     */
    function setHistoryOb(&$historyOb)
    {
        $this->_historyOb = &$historyOb;
    }

    /**
     * Logs an event to this item's history log. Details about the event are
     * passed in $attributes. Standard suggested attributes are:
     *
     *   'who' => The id of the user that performed the action (will be added
     *            automatically if not present).
     *
     *   'ts' => Timestamp of the action (this will be added automatically if
     *           it is not present).
     *
     * @param array $attributes       The hash of name => value entries that
     *                                describe this event.
     * @param boolean $replaceAction  If $attributes['action'] is already
     *                                present in the item's history log,
     *                                update that entry instead of creating a
     *                                new one.
     *
     * @return boolean|PEAR_Error  True on success, PEAR_Error on failure.
     */
    function log($attributes = array(), $replaceAction = false)
    {
        if (empty($attributes['who'])) {
            $attributes['who'] = Auth::getAuth();
        }
        if (empty($attributes['ts'])) {
            $attributes['ts'] = time();
        }

        /* If we want to replace an entry with the same action, try and find
         * one. Track whether or not we succeed in $done, so we know whether
         * or not to add the entry later. */
        $done = false;
        if ($replaceAction && !empty($attributes['action'])) {
            $count = count($this->data);
            for ($i = 0; $i < $count; $i++) {
                if (!empty($this->data[$i]['action']) &&
                    $this->data[$i]['action'] == $attributes['action']) {
                    $this->data[$i] = $attributes;
                    $done = true;
                    break;
                }
            }
        }

        /* If we're not replacing by action, or if we didn't find an entry to
         * replace, tack $attributes onto the end of the $data array. */
        if (!$done) {
            $this->data[] = $attributes;
        }
    }

    /**
     * Saves any changes to this object to the backend permanently.
     */
    function save()
    {
        $this->_historyOb->_updateHistory($this);
    }

    /**
     * Maps this object's attributes from the data array into a format that
     * we can store in the attributes storage backend.
     *
     * @return array  The attributes array.
     */
    function _toAttributes()
    {
        /* Default to no attributes. */
        $attributes = array();

        /* Loop through all users, if any. */
        foreach ($this->data as $index => $entry) {
            foreach ($entry as $key => $value) {
                $attributes[] = array('name' => (string)$index,
                                      'key' => (string)$key,
                                      'value' => (string)$value);
            }
        }

        return $attributes;
    }

    /**
     * Takes in a list of attributes from the backend and map it to our
     * internal data array.
     *
     * @param array $attributes  The list of attributes from the backend
     *                           (attribute name, key, and value).
     */
    function _fromAttributes($attributes)
    {
        /* Initialize data array. */
        $this->data = array();

        foreach ($attributes as $attr) {
            if (!isset($this->data[$attr['name']])) {
                $this->data[$attr['name']] = array();
            }
            $this->data[$attr['name']][$attr['key']] = $attr['value'];
        }
    }

}
