<?php
/**
 * Nag_Task handles as single task as well as a list of tasks and implements a
 * recursive iterator to handle a (hierarchical) list of tasks.
 *
 * See the enclosed file COPYING for license information (GPL). If you
 * did not receive this file, see http://www.horde.org/licenses/gpl.
 *
 * @author  Jan Schneider <jan@horde.org>
 * @package Nag
 *
 * @property tags array  An array of tags this task is tagged with.
 */
class Nag_Task
{
    /**
     * The task id.
     *
     * @var string
     */
    public $id;

    /**
     * This task's tasklist id.
     *
     * @var string
     */
    public $tasklist;

    /**
     * This task's tasklist name.
     *
     * Overrides the $tasklist's share name.
     *
     * @var string
     */
    public $tasklist_name;

    /**
     * The task uid.
     *
     * @var string
     */
    public $uid;

    /**
     * The task owner.
     *
     * @var string
     */
    public $owner;

    /**
     * The task assignee.
     *
     * @var string
     */
    public $assignee;

    /**
     * The task title.
     *
     * @var string
     */
    public $name;

    /**
     * The task decription.
     *
     * @var string
     */
    public $desc;

    /**
     * The start date timestamp.
     *
     * @var integer
     */
    public $start;

    /**
     * The due date timestamp.
     *
     * @var integer
     */
    public $due;

    /**
     * Recurrence rules for recurring tasks.
     *
     * @var Horde_Date_Recurrence
     */
    public $recurrence;

    /**
     * The task priority from 1 = highest to 5 = lowest.
     *
     * @var integer
     */
    public $priority;

    /**
     * The estimated task length.
     *
     * @var float
     */
    public $estimate;

    /**
     * Whether the task is completed.
     *
     * @var boolean
     */
    public $completed;

    /**
     * The completion date timestamp.
     *
     * @var integer
     */
    public $completed_date;

    /**
     * The creation time.
     *
     * @var Horde_Date
     */
    public $created;

    /**
     * The creator string.
     *
     * @var string
     */
    public $createdby;

    /**
     * The last modification time.
     *
     * @var Horde_Date
     */
    public $modified;

    /**
     * The last-modifier string.
     *
     * @var string
     */
    public $modifiedby;

    /**
     * The task alarm threshold in minutes.
     *
     * @var integer
     */
    public $alarm;

    /**
     * The particular alarm methods overridden for this task.
     *
     * @var array
     */
    public $methods;

    /**
     * Whether the task is private.
     *
     * @var boolean
     */
    public $private;

    /**
     * URL to view the task.
     *
     * @var string
     */
    public $view_link;

    /**
     * URL to complete the task.
     *
     * @var string
     */
    public $complete_link;

    /**
     * URL to edit the task.
     *
     * @var string
     */
    public $edit_link;

    /**
     * URL to delete the task.
     *
     * @var string
     */
    public $delete_link;

    /**
     * The parent task's id.
     *
     * @var string
     */
    public $parent_id = '';

    /**
     * The parent task.
     *
     * @var Nag_Task
     */
    public $parent;

    /**
     * The sub-tasks.
     *
     * @var array
     */
    public $children = array();

    /**
     * This task's idention (child) level.
     *
     * @var integer
     */
    public $indent = 0;

    /**
     * Whether this is the last sub-task.
     *
     * @var boolean
     */
    public $lastChild;

    /**
     * A storage driver.
     *
     * @var Nag_Driver
     */
    protected $_storage;

    /**
     * Internal flag.
     *
     * @var boolean
     * @see each()
     */
    protected $_inlist = false;

    /**
     * Internal pointer.
     *
     * @var integer
     * @see each()
     */
    protected $_pointer = 0;

    /**
     * Task id => pointer dictionary.
     *
     * @var array
     */
    protected $_dict = array();

    /**
     * Task tags from the storage backend (e.g. Kolab)
     *
     * @var array
     */
    public $internaltags;

    /**
     * Task tags (lazy loaded).
     *
     * @var string
     */
    protected $_tags;

    /**
     * Constructor.
     *
     * Takes a hash and returns a nice wrapper around it.
     *
     * @param Nag_Driver $storage  A storage driver.
     * @param array $task          A task hash.
     */
    public function __construct(Nag_Driver $storage = null, array $task = null)
    {
        if ($storage) {
            $this->_storage = $storage;
        }
        if ($task) {
            $this->merge($task);
        }
    }

    /**
     * Getter.
     *
     * Returns the 'id' and 'creator' properties.
     *
     * @param string $name  Property name.
     *
     * @return mixed  Property value.
     */
    public function __get($name)
    {
        switch ($name) {
        case 'tags':
            if (!isset($this->_tags)) {
                $this->synchronizeTags($GLOBALS['injector']->getInstance('Nag_Tagger')->getTags($this->uid, 'task'));
            }
            return $this->_tags;
        }

        $trace = debug_backtrace();
        trigger_error('Undefined property via __get(): ' . $name
                      . ' in ' . $trace[0]['file']
                      . ' on line ' . $trace[0]['line'],
                      E_USER_NOTICE);
        return null;
    }

    /**
     * Setter.
     *
     * @param string $name  Property name.
     * @param mixed $value  Property value.
     */
    public function __set($name, $value)
    {
        switch ($name) {
        case 'tags':
            $this->_tags = $value;
            return;
        }
        $trace = debug_backtrace();
        trigger_error('Undefined property via __set(): ' . $name
                      . ' in ' . $trace[0]['file']
                      . ' on line ' . $trace[0]['line'],
                      E_USER_NOTICE);
    }

    /**
     * Deep clone so we can clone the child objects too.
     *
     */
    public function __clone()
    {
        foreach ($this->children as $key => $value) {
            $this->children[$key] = clone $value;
        }
    }

    /**
     * Merges a task hash into this task object.
     *
     * @param array $task  A task hash.
     */
    public function merge(array $task)
    {
        foreach ($task as $key => $val) {
            switch ($key) {
            case 'tasklist_id':
                $key = 'tasklist';
                break;
            case 'task_id':
                $key = 'id';
                break;
            case 'parent':
                $key = 'parent_id';
                break;
            }
            $this->$key = $val;
        }
    }

    /**
     * Disconnect this task from any child tasks. Used when building search
     * result sets since child tasks will be re-added if they actually match
     * the result, and there is no guarentee that a tasks's parent will
     * be present in the result set.
     */
    public function orphan()
    {
        $this->children = array();
        $this->_dict = array();
        $this->lastChild = null;
        $this->indent = null;
    }

    /**
     * Saves this task in the storage backend.
     *
     * @throws Nag_Exception
     */
    public function save()
    {
        $this->_storage->modify($this->id, $this->toHash(true));
    }

    /**
     * Returns the parent task of this task, if one exists.
     *
     * @return mixed  The parent task, null if none exists
     */
    public function getParent()
    {
        if (!$this->parent_id) {
            return null;
        }
        return Nag::getTask($this->tasklist, $this->parent_id);
    }

    /**
     * Adds a sub task to this task.
     *
     * @param Nag_Task $task  A sub task.
     */
    public function add(Nag_Task $task, $replace = false)
    {
        if (!isset($this->_dict[$task->id])) {
            $this->_dict[$task->id] = count($this->children);
            $task->parent = $this;
            $this->children[] = $task;
        } elseif ($replace) {
            $this->children[$this->_dict[$task->id]]= $task;
        }
    }

    /**
     * Loads all sub-tasks.
     *
     * @param
     */
    public function loadChildren($include_history = true)
    {
        try {
            $this->children = $this->_storage->getChildren($this->id, $include_history);
        } catch (Nag_Exception $e) {}
    }

    /**
     * Merges an array of tasks into this task's children.
     *
     * @param array $children  A list of Nag_Tasks.
     *
     */
    public function mergeChildren(array $children)
    {
        for ($i = 0, $c = count($children); $i < $c; ++$i) {
            $this->add($children[$i]);
        }
    }

    /**
     * Returns a sub task by its id.
     *
     * The methods goes recursively through all sub tasks until it finds the
     * searched task.
     *
     * @param string $key  A task id.
     *
     * @return Nag_Task  The searched task or null.
     */
    public function get($key)
    {
        return isset($this->_dict[$key]) ?
            $this->children[$this->_dict[$key]] :
            null;
    }

    /**
     * Returns whether this is a task (not a container) or contains any sub
     * tasks.
     *
     * @return boolean  True if this is a task or has sub tasks.
     */
    public function hasTasks()
    {
        return ($this->id) ? true : $this->hasSubTasks();
    }

    /**
     * Returns whether this task contains any sub tasks.
     *
     * @return boolean  True if this task has sub tasks.
     */
    public function hasSubTasks()
    {
        foreach ($this->children as $task) {
            if ($task->hasTasks()) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns whether all sub tasks are completed.
     *
     * @return boolean  True if all sub tasks are completed.
     */
    public function childrenCompleted()
    {
        foreach ($this->children as $task) {
            if (!$task->completed || !$task->childrenCompleted()) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns whether any tasks in the list are overdue.
     *
     * @return boolean  True if any task or sub tasks are overdue.
     */
    public function childrenOverdue()
    {
        if (!empty($this->due)) {
            $due = new Horde_Date($this->due);
            if ($due->compareDate(new Horde_Date(time())) <= 0) {
                return true;
            }
        }
        foreach ($this->children as $task) {
            if ($task->childrenOverdue()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Returns the number of tasks including this and any sub tasks.
     *
     * @return integer  The number of tasks and sub tasks.
     */
    public function count()
    {
        $count = $this->id ? 1 : 0;
        foreach ($this->children as $task) {
            $count += $task->count();
        }
        return $count;
    }

    /**
     * Returns the estimated length for this and any sub tasks.
     *
     * @return integer  The estimated length sum.
     */
    public function estimation()
    {
        $estimate = $this->estimate;
        foreach ($this->children as $task) {
            $estimate += $task->estimation();
        }
        return $estimate;
    }

    /**
     * Returns whether this task is a recurring task.
     *
     * @return boolean  True if this is a recurring task.
     */
    public function recurs()
    {
        return isset($this->recurrence) &&
            !$this->recurrence->hasRecurType(Horde_Date_Recurrence::RECUR_NONE);
    }

    /**
     * Toggles completion status of this task. Moves a recurring task
     * to the next occurence on completion. Enforces the rule that sub
     * tasks must be completed before parent tasks.
     */
    public function toggleComplete($ignore_children = false)
    {
        if ($ignore_children) {
            $this->loadChildren();
            if (!$this->completed && !$this->childrenCompleted()) {
                throw new Nag_Exception(_("Must complete all children tasks."));
            }
        }
        if ($this->completed) {
            $this->completed_date = null;
            $this->completed = false;
            if ($parent = $this->getParent()) {
                if ($parent->completed) {
                    $parent->toggleComplete(true);
                    $parent->save();
                }
            }
            if ($this->recurs()) {
                /* Only delete the latest completion. */
                $completions = $this->recurrence->getCompletions();
                sort($completions);
                list($year, $month, $mday) = sscanf(
                    end($completions),
                    '%04d%02d%02d'
                );
                $this->recurrence->deleteCompletion($year, $month, $mday);
            }
            return;
        }

        if ($this->recurs()) {
            /* Get current occurrence (task due date) */
            $current = $this->recurrence->nextActiveRecurrence(new Horde_Date($this->due));
            if ($current) {
                $this->recurrence->addCompletion($current->year,
                                                 $current->month,
                                                 $current->mday);
                /* Advance this occurence by a day to indicate that we want the
                 * following occurence (Recurrence uses days as minimal time
                 * duration between occurrences). */
                $current->mday++;
                /* Only mark this due date completed if there is another
                 * occurence. */
                if ($next = $this->recurrence->nextActiveRecurrence($current)) {
                    $this->completed = false;
                    return;
                }
            }
        }

        $this->completed_date = time();
        $this->completed = true;
    }

    /**
     * Returns the next start date of this task.
     *
     * Takes recurring tasks into account.
     *
     * @return Horde_Date  The next start date.
     */
    public function getNextStart()
    {
        if (!$this->start) {
            return null;
        }

        if (!$this->recurs() ||
            !($completions = $this->recurrence->getCompletions())) {
            return new Horde_Date($this->start);
        }

        sort($completions);
        list($year, $month, $mday) = sscanf(
            end($completions),
            '%04d%02d%02d'
        );
        $lastCompletion = new Horde_Date($year, $month, $mday);
        $recurrence = clone $this->recurrence;
        $recurrence->start = new Horde_Date($this->start);

        return $recurrence->nextRecurrence($lastCompletion);
    }

    /**
     * Returns the next due date of this task.
     *
     * Takes recurring tasks into account.
     *
     * @return Horde_Date|null  The next due date or null if no due date.
     */
    public function getNextDue()
    {
        if (!$this->due) {
            return null;
        }
        if (!$this->recurs()) {
            return new Horde_Date($this->due);
        }
        if (!($nextActive = $this->recurrence->nextActiveRecurrence($this->due))) {
            return null;
        }
        return $nextActive;
    }

    /**
     * Format the description - link URLs, etc.
     *
     * @return string
     */
    public function getFormattedDescription()
    {
        $desc = $GLOBALS['injector']
            ->getInstance('Horde_Core_Factory_TextFilter')
            ->filter($this->desc,
                     'text2html',
                     array('parselevel' => Horde_Text_Filter_Text2html::MICRO));
        try {
            return Horde::callHook('format_description', array($desc), 'nag');
        } catch (Horde_Exception_HookNotSet $e) {
            return $desc;
        }
    }

    /**
     * Resets the tasks iterator.
     *
     * Call this each time before looping through the tasks.
     *
     * @see each()
     */
    public function reset()
    {
        foreach (array_keys($this->children) as $key) {
            $this->children[$key]->reset();
        }
        $this->_pointer = 0;
        $this->_inlist = false;
    }

    /**
     * Return the task, if present anywhere in this tasklist, regardless of
     * child depth.
     *
     * @param  string $taskId  The task id we are looking for.
     *
     * @return Nag_Task|false  The task object, if found. Otherwise false.
     */
    public function hasTask($taskId)
    {
        $this->reset();
        while ($task = $this->each()) {
            if ($task->id == $taskId) {
                return $task;
            }
        }

        return false;
    }

    /**
     * Returns the next task iterating through all tasks and sub tasks.
     *
     * Call reset() each time before looping through the tasks:
     * <code>
     * $tasks->reset();
     * while ($task = $tasks->each() {
     *     ...
     * }
     *
     * @see reset()
     */
    public function each()
    {
        if ($this->id && !$this->_inlist) {
            $this->_inlist = true;
            return $this;
        }
        if ($this->_pointer >= count($this->children)) {
            return false;
        }
        $next = $this->children[$this->_pointer]->each();
        if ($next) {
            return $next;
        }
        $this->_pointer++;
        return $this->each();
    }

    /**
     * Helper method for getting only a slice of the total tasks in this list.
     *
     * @param integer $page     The starting page.
     * @param integer $perpage  The count of tasks per page.
     *
     * @return Nag_Task  The resulting task list.
     */
    public function getSlice($page = 0, $perpage = null)
    {
        $this->reset();

        // Position at start task
        $start = $page * (empty($perpage) ? 0 : $perpage);
        $count = 0;
        while ($count < $start) {
            if (!$this->each()) {
                return new Nag_Task();
            }
            ++$count;
        }

        $count = 0;
        $results = new Nag_Task();
        $max = (empty($perpage) ? ($this->count() - $start) : $perpage);
        while ($count < $max) {
            if ($next = $this->each()) {
                $results->add($next);
                ++$count;
            } else {
                $count = $max;
            }
        }
        $results->process();
        return $results;
    }

    /**
     * Processes a list of tasks by adding action links, obscuring details of
     * private tasks and calculating indentation.
     *
     * @param integer $indent  The indention level of the tasks.
     */
    public function process($indent = null)
    {
        global $conf;

        /* Link cache. */
        static $view_url_list, $task_url_list;

        /* Set indention. */
        if (is_null($indent)) {
            $indent = 0;
            if ($parent = $this->getParent()) {
                $indent = $parent->indent + 1;
            }
        }
        $this->indent = $indent;
        if ($this->id) {
            $indent++;
        }

        /* Process children. */
        for ($i = 0, $c = count($this->children); $i < $c; ++$i) {
            $this->children[$i]->process($indent);
        }

        /* Mark last child. */
        if (count($this->children)) {
            $this->children[count($this->children) - 1]->lastChild = true;
        }

        /* Only process further if this is really a (parent) task, not only a
         * task list container. */
        if (!$this->id) {
            return;
        }

        if (!isset($view_url_list[$this->tasklist])) {
            $view_url_list[$this->tasklist] = Horde::url('view.php')->add('tasklist', $this->tasklist);
            $task_url_list[$this->tasklist] = Horde::url('task.php')->add('tasklist', $this->tasklist);
        }

        /* Obscure private tasks. */
        if ($this->private && $this->owner != $GLOBALS['registry']->getAuth()) {
            $this->name = _("Private Task");
            $this->desc = '';
        }

        /* Create task links. */
        $this->view_link = $view_url_list[$this->tasklist]->copy()->add('task', $this->id);

        $task_url_task = $task_url_list[$this->tasklist]->copy()->add('task', $this->id);
        $this->complete_link = Horde::url(
            $conf['urls']['pretty'] == 'rewrite'
                ? 't/complete'
                : 'task/complete.php'
            )->add(array(
                'url' => Horde::signUrl(Horde::url('list.php')),
                'task' => $this->id,
                'tasklist' => $this->tasklist
            ));
        $this->edit_link = $task_url_task->copy()->add('actionID', 'modify_task');
        $this->delete_link = $task_url_task->copy()->add('actionID', 'delete_task');
    }

    /**
     * Returns the HTML code for any tree icons, when displaying this task in
     * a tree view.
     *
     * @return string  The HTML code for necessary tree icons.
     */
    public function treeIcons()
    {
        $html = '';

        $parent = $this->parent;
        for ($i = 1; $i < $this->indent; ++$i) {
            if ($parent && $parent->lastChild) {
                $html = Horde::img('tree/blank.png') . $html;
            } else {
                $html = Horde::img('tree/line.png', '|') . $html;
            }
            $parent = $parent->parent;
        }
        if ($this->indent) {
            if ($this->lastChild) {
                $html .= Horde::img($GLOBALS['registry']->nlsconfig->curr_rtl ? 'tree/rev-joinbottom.png' : 'tree/joinbottom.png', '\\');
            } else {
                $html .= Horde::img($GLOBALS['registry']->nlsconfig->curr_rtl ? 'tree/rev-join.png' : 'tree/join.png', '+');
            }
        }

        return $html;
    }

    /**
     * Recursively loads tags for all tasks contained in this object.
     */
    public function loadTags()
    {
        $ids = array();
        if (!isset($this->_tags)) {
            $ids[] = $this->uid;
        }
        foreach ($this->children as $task) {
            $ids[] = $task->uid;
        }
        if (!$ids) {
            return;
        }

        $results = $GLOBALS['injector']->getInstance('Nag_Tagger')->getTags($ids);

        if (isset($results[$this->uid])) {
            $this->synchronizeTags($results[$this->uid]);
        }
        foreach ($this->children as $task) {
            if (isset($results[$task->uid])) {
                $task->synchronizeTags($results[$task->uid]);
                $task->loadTags();
            }
        }
    }

    /**
     * Syncronizes tags from the tagging backend with the task storage backend,
     * if necessary.
     *
     * @param array $tags  Tags from the tagging backend.
     */
    public function synchronizeTags(array $tags)
    {
        if (isset($this->internaltags)) {
            usort($tags, 'strcoll');
            if (array_diff($this->internaltags, $tags)) {
                $GLOBALS['injector']->getInstance('Nag_Tagger')->replaceTags(
                    $this->uid,
                    $this->internaltags,
                    $this->owner,
                    'task'
                );
            }
            $this->_tags = implode(',', $this->internaltags);
        } else {
            $this->_tags = $tags;
        }
    }

    /**
     * Sorts sub tasks by the given criteria.
     *
     * @param string $sortby     The field by which to sort
     *                           (Nag::SORT_PRIORITY, Nag::SORT_NAME
     *                           Nag::SORT_DUE, Nag::SORT_COMPLETION).
     * @param integer $sortdir   The direction by which to sort
     *                           (Nag::SORT_ASCEND, Nag::SORT_DESCEND).
     * @param string $altsortby  The secondary sort field.
     */
    public function sort($sortby, $sortdir, $altsortby)
    {
        /* Sorting criteria for the task list. */
        $sort_functions = array(
            Nag::SORT_PRIORITY => 'ByPriority',
            Nag::SORT_NAME => 'ByName',
            Nag::SORT_DUE => 'ByDue',
            Nag::SORT_START => 'ByStart',
            Nag::SORT_COMPLETION => 'ByCompletion',
            Nag::SORT_ASSIGNEE => 'ByAssignee',
            Nag::SORT_ESTIMATE => 'ByEstimate',
            Nag::SORT_OWNER => 'ByOwner'
        );

        /* Sort the array if we have a sort function defined for this
         * field. */
        if (isset($sort_functions[$sortby])) {
            $prefix = ($sortdir == Nag::SORT_DESCEND) ? '_rsort' : '_sort';
            usort($this->children, array('Nag', $prefix . $sort_functions[$sortby]));
            if (isset($sort_functions[$altsortby]) && $altsortby !== $sortby) {
                $task_buckets = array();
                for ($i = 0, $c = count($this->children); $i < $c; ++$i) {
                    if (!isset($task_buckets[$this->children[$i]->$sortby])) {
                        $task_buckets[$this->children[$i]->$sortby] = array();
                    }
                    $task_buckets[$this->children[$i]->$sortby][] = $this->children[$i];
                }
                $tasks = array();
                foreach ($task_buckets as $task_bucket) {
                    usort($task_bucket, array('Nag', $prefix . $sort_functions[$altsortby]));
                    $tasks = array_merge($tasks, $task_bucket);
                }
                $this->children = $tasks;
            }

            /* Mark last child. */
            for ($i = 0, $c = count($this->children); $i < $c; ++$i) {
                $this->children[$i]->lastChild = false;
            }
            if (count($this->children)) {
                $this->children[count($this->children) - 1]->lastChild = true;
            }

            for ($i = 0, $c = count($this->children); $i < $c; ++$i) {
                $this->_dict[$this->children[$i]->id] = $i;
                $this->children[$i]->sort($sortby, $sortdir, $altsortby);
            }
        }
    }

    /**
     * Returns a hash representation for this task.
     *
     * @return array  A task hash.
     */
    public function toHash()
    {
        $hash = array(
            'tasklist_id' => $this->tasklist,
            'task_id' => $this->id,
            'uid' => $this->uid,
            'parent' => $this->parent_id,
            'owner' => $this->owner,
            'assignee' => $this->assignee,
            'name' => $this->name,
            'desc' => $this->desc,
            'start' => $this->start,
            'due' => $this->due,
            'priority' => $this->priority,
            'estimate' => $this->estimate,
            'completed' => $this->completed,
            'completed_date' => $this->completed_date,
            'alarm' => $this->alarm,
            'methods' => $this->methods,
            'private' => $this->private,
            'recurrence' => $this->recurrence,
            'tags' => $this->tags);

        return $hash;
    }

    /**
     * Returns a simple object suitable for json transport representing this
     * task.
     *
     * @param boolean $full        Whether to return all task details.
     * @param string $time_format  The date() format to use for time formatting.
     *
     * @return object  A simple object.
     */
    public function toJson($full = false, $time_format = 'H:i')
    {
        $json = new stdClass;
        $json->l = $this->tasklist;
        $json->p = $this->parent_id;
        $json->i = $this->indent;
        $json->n = $this->name;
        if ($this->desc) {
            //TODO: Get the proper amount of characters, and cut by last
            //whitespace
            $json->sd = Horde_String::substr($this->desc, 0, 80);
        }
        $json->cp = (boolean)$this->completed;
        if ($this->due && ($due = $this->getNextDue())) {
            $json->du = $due->toJson();
        }
        if ($this->start && ($start = $this->getNextStart())) {
            $json->s = $start->toJson();
        }
        $json->pr = (int)$this->priority;
        if ($this->recurs()) {
            $json->r = $this->recurrence->getRecurType();
        }
        $json->t = array_values($this->tags);

        if ($full) {
            // @todo: do we really need all this?
            $json->id = $this->id;
            $json->de = $this->desc;
            if ($this->due) {
                $date = new Horde_Date($this->due);
                $json->dd = $date->strftime('%x');
                $json->dt = $date->format($time_format);
            }
            $json->as = $this->assignee;
            if ($this->estimate) {
                $json->e = $this->estimate;
            }
            /*
            $json->o = $this->owner;

            if ($this->completed_date) {
                $date = new Horde_Date($this->completed_date);
                $json->cd = $date->toJson();
            }
            */
            $json->a = (int)$this->alarm;
            $json->m = $this->methods;
            //$json->pv = (boolean)$this->private;
            if ($this->recurs()) {
                $json->r = $this->recurrence->toJson();
            }

            if ($this->tasklist == '**EXTERNAL**') {
                $json->vl = (string)$this->view_link;
                $json->cl = (string)$this->complete_link;
                $json->pe = $json->pd = false;
            } else {
                try {
                    $share = $GLOBALS['nag_shares']->getShare($this->tasklist);
                } catch (Horde_Share_Exception $e) {
                    Horde::log($e->getMessage(), 'ERR');
                    throw new Nag_Exception($e);
                }
                $json->pe = $share->hasPermission(
                    $GLOBALS['registry']->getAuth(),
                    Horde_Perms::EDIT
                );
                $json->pd = $share->hasPermission(
                    $GLOBALS['registry']->getAuth(),
                    Horde_Perms::DELETE
                );
            }
        }

        return $json;
    }

    /**
     * Returns an alarm hash of this task suitable for Horde_Alarm.
     *
     * @param string $user  The user to return alarms for.
     * @param Prefs $prefs  A Prefs instance.
     *
     * @return array  Alarm hash or null.
     */
    public function toAlarm($user = null, $prefs = null)
    {
        if (empty($this->alarm) || $this->completed) {
            return;
        }

        if (empty($user)) {
            $user = $GLOBALS['registry']->getAuth();
        }
        if (empty($prefs)) {
            $prefs = $GLOBALS['prefs'];
        }

        $methods = !empty($this->methods) ? $this->methods : @unserialize($prefs->getValue('task_alarms'));
        if (!$methods) {
            $methods = array();
        }

        if (isset($methods['notify'])) {
            $methods['notify']['show'] = array(
                '__app' => $GLOBALS['registry']->getApp(),
                'task' => $this->id,
                'tasklist' => $this->tasklist);
            $methods['notify']['ajax'] = 'task:' . $this->tasklist . ':' . $this->id;
            if (!empty($methods['notify']['sound'])) {
                if ($methods['notify']['sound'] == 'on') {
                    // Handle boolean sound preferences;
                    $methods['notify']['sound'] = (string)Horde_Themes::sound('theetone.wav');
                } else {
                    // Else we know we have a sound name that can be
                    // served from Horde.
                    $methods['notify']['sound'] = (string)Horde_Themes::sound($methods['notify']['sound']);
                }
            }
        }
        if (isset($methods['mail'])) {
            $image = Nag::getImagePart('big_alarm.png');

            $view = new Horde_View(array('templatePath' => NAG_TEMPLATES . '/alarm', 'encoding' => 'UTF-8'));
            new Horde_View_Helper_Text($view);
            $view->task = $this;
            $view->imageId = $image->getContentId();
            $view->due = new Horde_Date($this->due);
            $view->dateFormat = $prefs->getValue('date_format');
            $view->timeFormat = $prefs->getValue('twentyFour') ? 'H:i' : 'h:ia';
            if (!$prefs->isLocked('task_alarms')) {
                $view->prefsUrl = Horde::url($GLOBALS['registry']->getServiceLink('prefs', 'nag'), true)->remove(session_name());
            }

            $methods['mail']['mimepart'] = Nag::buildMimeMessage($view, 'mail', $image);
        }

        if (isset($methods['desktop'])) {
            $methods['desktop']['url'] = Horde::url('view.php', true)->add('tasklist', $this->tasklist)->add('task', $this->id)->toString(true, true);
        }

        return array(
            'id' => $this->uid,
            'user' => $user,
            'start' => new Horde_Date($this->due - $this->alarm * 60),
            'methods' => array_keys($methods),
            'params' => $methods,
            'title' => $this->name,
            'text' => $this->desc);
    }

    /**
     * Exports this task in iCalendar format.
     *
     * @param Horde_Icalendar $calendar  A Horde_Icalendar object that acts as
     *                                   the container.
     *
     * @return Horde_Icalendar_Vtodo  A vtodo component of this task.
     */
    public function toiCalendar(Horde_Icalendar $calendar)
    {
        $vTodo = Horde_Icalendar::newComponent('vtodo', $calendar);
        $v1 = $calendar->getAttribute('VERSION') == '1.0';

        $vTodo->setAttribute('UID', $this->uid);

        if (!empty($this->assignee)) {
            $vTodo->setAttribute('ORGANIZER', $this->assignee);
        }

        if (!empty($this->name)) {
            $vTodo->setAttribute('SUMMARY', $this->name);
        }

        if (!empty($this->desc)) {
            $vTodo->setAttribute('DESCRIPTION', $this->desc);
        }

        if (isset($this->priority)) {
            $priorityMap = array(
                0 => 5,
                1 => 1,
                2 => 3,
                3 => 5,
                4 => 7,
                5 => 9,
            );
            $vTodo->setAttribute('PRIORITY', $priorityMap[$this->priority]);
        }

        if (!empty($this->parent_id) && !empty($this->parent)) {
            $vTodo->setAttribute('RELATED-TO', $this->parent->uid);
        }

        if ($this->private) {
            $vTodo->setAttribute('CLASS', 'PRIVATE');
        }

        if (!empty($this->start)) {
            $vTodo->setAttribute('DTSTART', $this->start);
        }

        if ($this->due) {
            $vTodo->setAttribute('DUE', $this->due);

            if ($this->alarm) {
                if ($v1) {
                    $vTodo->setAttribute('AALARM', $this->due - $this->alarm * 60);
                } else {
                    $vAlarm = Horde_Icalendar::newComponent('valarm', $vTodo);
                    $vAlarm->setAttribute('ACTION', 'DISPLAY');
                    $vAlarm->setAttribute('DESCRIPTION', $this->name);
                    $vAlarm->setAttribute('TRIGGER;VALUE=DURATION', '-PT' . $this->alarm . 'M');
                    $vTodo->addComponent($vAlarm);
                }
                $hordeAlarm = $GLOBALS['injector']->getInstance('Horde_Alarm');
                if ($hordeAlarm->exists($this->uid, $GLOBALS['registry']->getAuth()) &&
                    $hordeAlarm->isSnoozed($this->uid, $GLOBALS['registry']->getAuth())) {
                    $vTodo->setAttribute('X-MOZ-LASTACK', new Horde_Date($_SERVER['REQUEST_TIME']));
                    $alarm = $hordeAlarm->get($this->uid, $GLOBALS['registry']->getAuth());
                    if (!empty($alarm['snooze'])) {
                        $alarm['snooze']->setTimezone(date_default_timezone_get());
                        $vTodo->setAttribute('X-MOZ-SNOOZE-TIME', $alarm['snooze']);
                    }
                }
            }
        }

        if ($this->completed) {
            $vTodo->setAttribute('STATUS', 'COMPLETED');
            $vTodo->setAttribute('COMPLETED', $this->completed_date ? $this->completed_date : $_SERVER['REQUEST_TIME']);
        } else {
            if ($v1) {
                $vTodo->setAttribute('STATUS', 'NEEDS ACTION');
            } else {
                $vTodo->setAttribute('STATUS', 'NEEDS-ACTION');
            }
        }
        if (!empty($this->estimate)) {
            $vTodo->setAttribute('X-HORDE-ESTIMATE', $this->estimate);
        }

        if ($this->tags) {
            $vTodo->setAttribute('CATEGORIES', '', array(), true, array_values($this->tags));
        }

        /* Get the task's history. */
        $created = $modified = null;
        try {
            $log = $GLOBALS['injector']->getInstance('Horde_History')->getHistory('nag:' . $this->tasklist . ':' . $this->uid);
            foreach ($log as $entry) {
                switch ($entry['action']) {
                case 'add':
                    $created = $entry['ts'];
                    break;

                case 'modify':
                    $modified = $entry['ts'];
                    break;
                }
            }
        } catch (Exception $e) {}
        if (!empty($created)) {
            $vTodo->setAttribute($v1 ? 'DCREATED' : 'CREATED', $created);
            if (empty($modified)) {
                $modified = $created;
            }
        }
        if (!empty($modified)) {
            $vTodo->setAttribute('LAST-MODIFIED', $modified);
        }

        return $vTodo;
    }

    /**
     * Create an AS message from this task
     *
     * @param array $options  Options:
     *   - protocolversion: (float)  The EAS version to support
     *                      DEFAULT: 2.5
     *   - bodyprefs: (array)  A BODYPREFERENCE array.
     *                DEFAULT: none (No body prefs enforced).
     *   - truncation: (integer)  Truncate event body to this length
     *                 DEFAULT: none (No truncation).
     *
     * @return Horde_ActiveSync_Message_Task
     */
    public function toASTask(array $options = array())
    {
        $message = new Horde_ActiveSync_Message_Task(array(
            'protocolversion' => $options['protocolversion'])
        );

        /* Notes and Title */
        if ($options['protocolversion'] >= Horde_ActiveSync::VERSION_TWELVE) {
            if (!empty($this->desc)) {
                $bp = $options['bodyprefs'];
                $body = new Horde_ActiveSync_Message_AirSyncBaseBody();
                $body->type = Horde_ActiveSync::BODYPREF_TYPE_PLAIN;
                if (isset($bp[Horde_ActiveSync::BODYPREF_TYPE_PLAIN]['truncationsize'])) {
                    $truncation = $bp[Horde_ActiveSync::BODYPREF_TYPE_PLAIN]['truncationsize'];
                } elseif (isset($bp[Horde_ActiveSync::BODYPREF_TYPE_HTML])) {
                    $truncation = $bp[Horde_ActiveSync::BODYPREF_TYPE_HTML]['truncationsize'];
                    $this->desc = Horde_Text_Filter::filter($this->desc, 'Text2html', array('parselevel' => Horde_Text_Filter_Text2html::MICRO));
                } else {
                    $truncation = false;
                }
                if ($truncation && Horde_String::length($this->desc) > $truncation) {
                    $body->data = Horde_String::substr($this->desc, 0, $truncation);
                    $body->truncated = 1;
                } else {
                    $body->data = $this->desc;
                }
                $body->estimateddatasize = Horde_String::length($this->desc);
                $message->airsyncbasebody = $body;
            }
        } else {
            $message->body = $this->desc;
        }
        $message->subject = $this->name;

        /* Completion */
        if ($this->completed) {
            if ($this->completed_date) {
                $message->datecompleted = new Horde_Date($this->completed_date);
            }
            $message->complete = Horde_ActiveSync_Message_Task::TASK_COMPLETE_TRUE;
        } else {
            $message->complete = Horde_ActiveSync_Message_Task::TASK_COMPLETE_FALSE;
        }

        /* Due Date */
        if (!empty($this->due)) {
            if ($this->due) {
                $message->utcduedate = new Horde_Date($this->getNextDue());
            }
            $message->duedate = clone($message->utcduedate);
        }

        /* Start Date */
        if (!empty($this->start)) {
            if ($this->start) {
                $message->utcstartdate = new Horde_Date($this->start);
            }
            $message->startdate = clone($message->utcstartdate);
        }

        /* Priority */
        switch ($this->priority) {
        case 5:
            $priority = Horde_ActiveSync_Message_Task::IMPORTANCE_LOW;
            break;
        case 4:
        case 3:
        case 2:
            $priority = Horde_ActiveSync_Message_Task::IMPORTANCE_NORMAL;
            break;
        case 1:
            $priority = Horde_ActiveSync_Message_Task::IMPORTANCE_HIGH;
            break;
        default:
            $priority = Horde_ActiveSync_Message_Task::IMPORTANCE_NORMAL;
        }
        $message->setImportance($priority);

        /* Reminders */
        if ($this->due && $this->alarm) {
            $message->setReminder(new Horde_Date($this->due - $this->alarm * 60));
        }

        /* Recurrence */
        if ($this->recurs()) {
            $message->setRecurrence($this->recurrence);
        }

        /* Categories */
        $message->categories = $this->tags;

        return $message;
    }

    /**
     * Creates a task from a Horde_Icalendar_Vtodo object.
     *
     * @param Horde_Icalendar_Vtodo $vTodo  The iCalendar data to update from.
     */
    public function fromiCalendar(Horde_Icalendar_Vtodo $vTodo)
    {
        /* Owner is always current user. */
        $this->owner = $GLOBALS['registry']->getAuth();

        try {
            $name = $vTodo->getAttribute('SUMMARY');
            if (!is_array($name)) {
                $this->name = $name;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $assignee = $vTodo->getAttribute('ORGANIZER');
            if (!is_array($assignee)) { $this->assignee = $assignee; }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $uid = $vTodo->getAttribute('UID');
            if (!is_array($uid)) { $this->uid = $uid; }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $relations = $vTodo->getAttribute('RELATED-TO');
            if (!is_array($relations)) {
                $relations = array($relations);
            }
            $params = $vTodo->getAttribute('RELATED-TO', true);
            foreach ($relations as $id => $relation) {
                if (empty($params[$id]['RELTYPE']) ||
                    Horde_String::upper($params[$id]['RELTYPE']) == 'PARENT') {
                    try {
                        $parent = $this->_storage->getByUID($relation);
                        $this->parent_id = $parent->id;
                    } catch (Horde_Exception_NotFound $e) {
                    }
                    break;
                }
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $start = $vTodo->getAttribute('DTSTART');
            if (!is_array($start)) {
                // Date-Time field
                $this->start = $start;
            } else {
                // Date field
                $this->start = mktime(0, 0, 0, (int)$start['month'], (int)$start['mday'], (int)$start['year']);
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $due = $vTodo->getAttribute('DUE');
            if (is_array($due)) {
                $this->due = mktime(0, 0, 0, (int)$due['month'], (int)$due['mday'], (int)$due['year']);
            } elseif (!empty($due)) {
                $this->due = $due;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        // vCalendar 1.0 alarms
        try {
            $alarm = $vTodo->getAttribute('AALARM');
            if (!is_array($alarm) && !empty($alarm) && !empty($this->due)) {
                $this->alarm = intval(($this->due - $alarm) / 60);
                if ($this->alarm === 0) {
                    // We don't support alarms exactly at due date.
                    $this->alarm = 1;
                }
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        // @TODO: vCalendar 2.0 alarms

        try {
            $desc = $vTodo->getAttribute('DESCRIPTION');
            if (!is_array($desc)) {
                $this->desc = $desc;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $priority = $vTodo->getAttribute('PRIORITY');
            if (!is_array($priority)) {
                $priorityMap = array(
                    0 => 3,
                    1 => 1,
                    2 => 1,
                    3 => 2,
                    4 => 2,
                    5 => 3,
                    6 => 4,
                    7 => 4,
                    8 => 5,
                    9 => 5,
                );
                $this->priority = isset($priorityMap[$priority])
                    ? $priorityMap[$priority]
                    : 3;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $cat = $vTodo->getAttribute('CATEGORIES');
            if (!is_array($cat)) {
                $this->tags = $cat;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $status = $vTodo->getAttribute('STATUS');
            if (!is_array($status)) {
                $this->completed = !strcasecmp($status, 'COMPLETED');
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $class = $vTodo->getAttribute('CLASS');
            if (!is_array($class)) {
                $class = Horde_String::upper($class);
                $this->private = $class == 'PRIVATE' || $class == 'CONFIDENTIAL';
            }
        } catch (Horde_Icalendar_Exception $e) {
        }

        try {
            $estimate = $vTodo->getAttribute('X-HORDE-ESTIMATE');
            if (!is_array($estimate)) {
                $this->estimate = $estimate;
            }
        } catch (Horde_Icalendar_Exception $e) {
        }
    }

    /**
     * Create a nag Task object from an activesync message
     *
     * @param Horde_ActiveSync_Message_Task $message  The task object
     */
    public function fromASTask(Horde_ActiveSync_Message_Task $message)
    {
        /* Owner is always current user. */
        $this->owner = $GLOBALS['registry']->getAuth();

        /* Must set _tags so we don't lazy load tags from the backend in the
         * case that this is an edit. For edits, all current tags will be passed
         * from the client.
         */
        $this->_tags = array();

        /* Notes and Title */
        if ($message->getProtocolVersion() >= Horde_ActiveSync::VERSION_TWELVE) {
            if ($message->airsyncbasebody->type == Horde_ActiveSync::BODYPREF_TYPE_HTML) {
                $this->desc = Horde_Text_Filter::filter($message->airsyncbasebody->data, 'Html2text');
            } else {
                $this->desc = $message->airsyncbasebody->data;
            }
        } else {
            $this->desc = $message->body;
        }

        $this->name = $message->subject;
        $tz = date_default_timezone_get();

        /* Completion: Note we don't use self::toggleCompletion() becuase of
         * the way that EAS hanldes recurring tasks (see below). */
        if ($this->completed = $message->complete) {
            if ($message->datecompleted) {
                $message->datecompleted->setTimezone($tz);
                $this->completed_date = $message->datecompleted->timestamp();
            } else {
                $this->completed_date = null;
            }
        }

        /* Due Date */
        if ($due = $message->utcduedate) {
            $due->setTimezone($tz);
            $this->due = $due->timestamp();
        } elseif ($due = $message->duedate) {
            // "Local" date, sent as a UTC datetime string,
            // but must be interpreted as a local time. Since
            // we have no timezone information we have to assume it's the
            // same as $tz.
            $due = new Horde_Date(
                array(
                    'year' => $due->year,
                    'month' => $due->month,
                    'mday' => $due->mday,
                    'hour' => $due->hour,
                    'min' => $due->min
                ),
                $tz
            );
            $this->due = $due->timestamp();
        }

        /* Start Date */
        if ($start = $message->utcstartdate) {
            $start->setTimezone($tz);
            $this->start = $start->timestamp();
        } elseif ($start = $message->startdate) {
            // See note above regarding utc vs local times.
            $start = new Horde_Date(
                array(
                    'year' => $start->year,
                    'month' => $start->month,
                    'mday' => $start->mday,
                    'hour' => $start->hour,
                    'min' => $start->min
                ),
                $tz
            );
            $this->start = $start->timestamp();
        }

        /* Priority */
        switch ($message->getImportance()) {
        case Horde_ActiveSync_Message_Task::IMPORTANCE_LOW:
            $this->priority = 5;
            break;
        case Horde_ActiveSync_Message_Task::IMPORTANCE_NORMAL:
            $this->priority = 3;
            break;
        case Horde_ActiveSync_Message_Task::IMPORTANCE_HIGH:
            $this->priority = 1;
            break;
        default:
            $this->priority = 3;
        }

        if (($alarm = $message->getReminder()) && $this->due) {
            $alarm->setTimezone($tz);
            $this->alarm = ($this->due - $alarm->timestamp()) / 60;
        }

        $this->tasklist = $GLOBALS['prefs']->getValue('default_tasklist');

        /* Categories */
        if (is_array($message->categories) && count($message->categories)) {
            $this->tags = implode(',', $message->categories);
        }

        // Recurrence is handled by the client deleting the original event
        // and recreating a "dead" completed event and an active recurring
        // event with the first due date being the next due date in the
        // series. So, if deadoccur is set, we have to ignore the recurrence
        // properties. Otherwise, editing the "dead" occurance will recreate
        // a completely new recurring series on the client.
        if (!($message->recurrence && $message->recurrence->deadoccur) &&
            !$message->deadoccur) {

            if ($rrule = $message->getRecurrence()) {
                $this->recurrence = $rrule;
            }
        }
    }

}
