<?php
/**
 * eGroupWare - iCalendar based on Horde 3
 *
 * Class representing iCalendar files.
 *
 *
 * Using the PEAR Log class (which need to be installed!)
 *
 * @link http://www.egroupware.org
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @package api
 * @subpackage horde
 * @author Mike Cochrane <mike@graftonhall.co.nz>
 * @author Joerg Lehrke <jlehrke@noc.de>
 * @copyright (c) The Horde Project (http://www.horde.org/)
 * @version $Id$
 */

/**
 * Class representing iCalendar files.
 */
class Horde_iCalendar {

    /**
     * The parent (containing) iCalendar object.
     *
     * @var Horde_iCalendar
     */
    var $_container = false;

    /**
     * The name/value pairs of attributes for this object (UID,
     * DTSTART, etc.). Which are present depends on the object and on
     * what kind of component it is.
     *
     * @var array
     */
    var $_attributes = array();

    /**
     * Any children (contained) iCalendar components of this object.
     *
     * @var array
     */
    var $_components = array();

    /**
     * According to RFC 2425, we should always use CRLF-terminated lines.
     *
     * @var string
     */
    var $_newline = "\r\n";

    /**
     * iCalendar format version (different behavior for 1.0 and 2.0
     * especially with recurring events).
     *
     * @var string
     */
    var $_version;

    function Horde_iCalendar($version = '2.0')
    {
        $this->_version = $version;
        $this->setAttribute('VERSION', $version);
    }

    /**
     * Return a reference to a new component.
     *
     * @param string          $type       The type of component to return
     * @param Horde_iCalendar $container  A container that this component
     *                                    will be associated with.
     *
     * @return object  Reference to a Horde_iCalendar_* object as specified.
     *
     * @static
     */
    function &newComponent($type, &$container)
    {
        $type = String::lower($type);
        $class = 'Horde_iCalendar_' . $type;
        if (!class_exists($class)) {
            include 'Horde/iCalendar/' . $type . '.php';
        }
        if (class_exists($class)) {
            $component = new $class();
            if ($container !== false) {
                $component->_container = &$container;
                // Use version of container, not default set by component
                // constructor.
                $component->_version = $container->_version;
            }
        } else {
            // Should return an dummy x-unknown type class here.
            $component = false;
        }

        return $component;
    }

    /**
     * Sets the value of an attribute.
     *
     * @param string $name     The name of the attribute.
     * @param string $value    The value of the attribute.
     * @param array $params    Array containing any addition parameters for
     *                         this attribute.
     * @param boolean $append  True to append the attribute, False to replace
     *                         the first matching attribute found.
     * @param array $values    Array representation of $value.  For
     *                         comma/semicolon seperated lists of values.  If
     *                         not set use $value as single array element.
     */
    function setAttribute($name, $value, $params = array(), $append = true,
                          $values = false)
    {
        // Make sure we update the internal format version if
        // setAttribute('VERSION', ...) is called.
        if ($name == 'VERSION') {
            $this->_version = $value;
            if ($this->_container !== false) {
                $this->_container->_version = $value;
            }
        }

        if (!$values) {
            $values = array($value);
        }
        $found = false;
        if (!$append) {
            foreach (array_keys($this->_attributes) as $key) {
                if ($this->_attributes[$key]['name'] == String::upper($name)) {
                    $this->_attributes[$key]['params'] = $params;
                    $this->_attributes[$key]['value'] = $value;
                    $this->_attributes[$key]['values'] = $values;
                    $found = true;
                    break;
                }
            }
        }

        if ($append || !$found) {
            $this->_attributes[] = array(
                'name'      => String::upper($name),
                'params'    => $params,
                'value'     => $value,
                'values'    => $values
            );
        }
    }

    /**
     * Sets parameter(s) for an (already existing) attribute.  The
     * parameter set is merged into the existing set.
     *
     * @param string $name   The name of the attribute.
     * @param array $params  Array containing any additional parameters for
     *                       this attribute.
     * @return boolean  True on success, false if no attribute $name exists.
     */
    function setParameter($name, $params = array())
    {
        $keys = array_keys($this->_attributes);
        foreach ($keys as $key) {
            if ($this->_attributes[$key]['name'] == $name) {
                $this->_attributes[$key]['params'] =
                    array_merge($this->_attributes[$key]['params'], $params);
                return true;
            }
        }

        return false;
    }

    /**
     * Get the value of an attribute.
     *
     * @param string $name     The name of the attribute.
     * @param boolean $params  Return the parameters for this attribute instead
     *                         of its value.
     *
     * @return mixed (object)  PEAR_Error if the attribute does not exist.
     *               (string)  The value of the attribute.
     *               (array)   The parameters for the attribute or
     *                         multiple values for an attribute.
     */
    function getAttribute($name, $params = false)
    {
        $result = array();
        foreach ($this->_attributes as $attribute) {
            if ($attribute['name'] == $name) {
                if ($params) {
                    $result[] = $attribute['params'];
                } else {
                    $result[] = $attribute['value'];
                }
            }
        }
        if (!count($result)) {
            require_once 'PEAR.php';
            return PEAR::raiseError('Attribute "' . $name . '" Not Found');
        } if (count($result) == 1 && !$params) {
            return $result[0];
        } else {
            return $result;
        }
    }

    /**
     * Gets the values of an attribute as an array.  Multiple values
     * are possible due to:
     *
     *  a) multiplce occurences of 'name'
     *  b) (unsecapd) comma seperated lists.
     *
     * So for a vcard like "KEY:a,b\nKEY:c" getAttributesValues('KEY')
     * will return array('a', 'b', 'c').
     *
     * @param string  $name    The name of the attribute.
     * @return mixed (object)  PEAR_Error if the attribute does not exist.
     *               (array)   Multiple values for an attribute.
     */
    function getAttributeValues($name)
    {
        $result = array();
        foreach ($this->_attributes as $attribute) {
            if ($attribute['name'] == $name) {
                $result = array_merge($attribute['values'], $result);
            }
        }
        if (!count($result)) {
            return PEAR::raiseError('Attribute "' . $name . '" Not Found');
        }
        return $result;
    }

    /**
     * Returns the value of an attribute, or a specified default value
     * if the attribute does not exist.
     *
     * @param string $name    The name of the attribute.
     * @param mixed $default  What to return if the attribute specified by
     *                        $name does not exist.
     *
     * @return mixed (string) The value of $name.
     *               (mixed)  $default if $name does not exist.
     */
    function getAttributeDefault($name, $default = '')
    {
        $value = $this->getAttribute($name);
        return is_a($value, 'PEAR_Error') ? $default : $value;
    }

    /**
     * Remove all occurences of an attribute.
     *
     * @param string $name  The name of the attribute.
     */
    function removeAttribute($name)
    {
        $keys = array_keys($this->_attributes);
        foreach ($keys as $key) {
            if ($this->_attributes[$key]['name'] == $name) {
                unset($this->_attributes[$key]);
            }
        }
    }

    /**
     * Get attributes for all tags or for a given tag.
     *
     * @param string $tag  Return attributes for this tag, or all attributes if
     *                     not given.
     *
     * @return array  An array containing all the attributes and their types.
     */
    function getAllAttributes($tag = false)
    {
        if ($tag === false) {
            return $this->_attributes;
        }
        $result = array();
        foreach ($this->_attributes as $attribute) {
            if ($attribute['name'] == $tag) {
                $result[] = $attribute;
            }
        }
        return $result;
    }

    /**
     * Add a vCalendar component (eg vEvent, vTimezone, etc.).
     *
     * @param Horde_iCalendar $component  Component (subclass) to add.
     */
    function addComponent($component)
    {
        if (is_a($component, 'Horde_iCalendar')) {
            $component->_container = &$this;
            $this->_components[] = &$component;
        }
    }

    /**
     * Retrieve all the components.
     *
     * @return array  Array of Horde_iCalendar objects.
     */
    function getComponents()
    {
        return $this->_components;
    }

    function getType()
    {
        return 'vcalendar';
    }

    /**
     * Return the classes (entry types) we have.
     *
     * @return array  Hash with class names Horde_iCalendar_xxx as keys
     *                and number of components of this class as value.
     */
    function getComponentClasses()
    {
        $r = array();
        foreach ($this->_components as $c) {
            $cn = strtolower(get_class($c));
            if (empty($r[$cn])) {
                $r[$cn] = 1;
            } else {
                $r[$cn]++;
            }
        }

        return $r;
    }

    /**
     * Number of components in this container.
     *
     * @return integer  Number of components in this container.
     */
    function getComponentCount()
    {
        return count($this->_components);
    }

    /**
     * Retrieve a specific component.
     *
     * @param integer $idx  The index of the object to retrieve.
     *
     * @return mixed    (boolean) False if the index does not exist.
     *                  (Horde_iCalendar_*) The requested component.
     */
    function getComponent($idx)
    {
        if (isset($this->_components[$idx])) {
            return $this->_components[$idx];
        } else {
            return false;
        }
    }

    /**
     * Locates the first child component of the specified class, and returns a
     * reference to it.
     *
     * @param string $type  The type of component to find.
     *
     * @return boolean|Horde_iCalendar_*  False if no subcomponent of the
     *                                    specified class exists or a reference
     *                                    to the requested component.
     */
    function &findComponent($childclass)
    {
        $childclass = 'Horde_iCalendar_' . String::lower($childclass);
        $keys = array_keys($this->_components);
        foreach ($keys as $key) {
            if (is_a($this->_components[$key], $childclass)) {
                return $this->_components[$key];
            }
        }

        $component = false;
        return $component;
    }

    /**
     * Locates the first matching child component of the specified class, and
     * returns a reference to it.
     *
     * @param string $childclass  The type of component to find.
     * @param string $attribute   This attribute must be set in the component
     *                            for it to match.
     * @param string $value       Optional value that $attribute must match.
     *
     * @return boolean|Horde_iCalendar_*  False if no matching subcomponent of
     *                                    the specified class exists, or a
     *                                    reference to the requested component.
     */
    function &findComponentByAttribute($childclass, $attribute, $value = null)
    {
        $childclass = 'Horde_iCalendar_' . String::lower($childclass);
        $keys = array_keys($this->_components);
        foreach ($keys as $key) {
            if (is_a($this->_components[$key], $childclass)) {
                $attr = $this->_components[$key]->getAttribute($attribute);
                if (is_a($attr, 'PEAR_Error')) {
                    continue;
                }
                if ($value !== null && $value != $attr) {
                    continue;
                }
                return $this->_components[$key];
            }
        }

        $component = false;
        return $component;
    }

    /**
     * Clears the iCalendar object (resets the components and attributes
     * arrays).
     */
    function clear()
    {
        $this->_components = array();
        $this->_attributes = array();
    }

    /**
     * Checks if entry is vcalendar 1.0, vcard 2.1 or vnote 1.1.
     *
     * These 'old' formats are defined by www.imc.org. The 'new' (non-old)
     * formats icalendar 2.0 and vcard 3.0 are defined in rfc2426 and rfc2445
     * respectively.
     *
     * @since Horde 3.1.2
     */
    function isOldFormat()
    {
	$retval = true;
	switch ($this->getType()) {
		case 'vcard':
            		$retval = ($this->_version < 3);
			break;
		case 'vNote':
            		$retval = ($this->_version < 2);
			break;
		default:
			$retval = ($this->_version < 2);
			break;
	}
        return $retval;
    }

    /**
     * Export as vCalendar format.
     *
     * @param string $charset  The encoding charset for $text. Defaults to
     *                         utf-8 for new format, standard character set for old format.
     */
    function exportvCalendar($charset = null)
    {
        // Default values.
        $requiredAttributes['PRODID'] = '-//The Horde Project//Horde_iCalendar Library' . (defined('HORDE_VERSION') ? ', Horde ' . constant('HORDE_VERSION') : '') . '//EN';
        $requiredAttributes['METHOD'] = 'PUBLISH';

        foreach ($requiredAttributes as $name => $default_value) {
            if (is_a($this->getattribute($name), 'PEAR_Error')) {
                $this->setAttribute($name, $default_value);
            }
        }

        return $this->_exportvData('VCALENDAR', $charset);
    }

    /**
     * Export this entry as a hash array with tag names as keys.
     *
     * @param boolean $paramsInKeys
     *                If false, the operation can be quite lossy as the
     *                parameters are ignored when building the array keys.
     *                So if you export a vcard with
     *                LABEL;TYPE=WORK:foo
     *                LABEL;TYPE=HOME:bar
     *                the resulting hash contains only one label field!
     *                If set to true, array keys look like 'LABEL;TYPE=WORK'
     * @return array  A hash array with tag names as keys.
     */
    function toHash($paramsInKeys = false)
    {
        $hash = array();
        foreach ($this->_attributes as $a)  {
            $k = $a['name'];
            if ($paramsInKeys && is_array($a['params'])) {
                foreach ($a['params'] as $p => $v) {
                    $k .= ";$p=$v";
                }
            }
            $hash[$k] = $a['value'];
        }

        return $hash;
    }

    /**
     * Parses a string containing vCalendar data.
     *
     * @todo This method doesn't work well at all, if $base is VCARD.
     *
     * @param string $text     The data to parse.
     * @param string $base     The type of the base object.
     * @param string $charset  The encoding charset for $text. Defaults to
     *                         utf-8 for new format, iso-8859-1 for old format.
     * @param boolean $clear   If true clears the iCal object before parsing.
     *
     * @return boolean  True on successful import, false otherwise.
     */
    function parsevCalendar($text, $base = 'VCALENDAR', $charset = null, $clear = true)
    {
        if ($clear) {
            $this->clear();
        }

        if ($base == 'VTODO' &&
        	preg_match('/^BEGIN:VTODO(.*)^END:VEVENT/ism', $text, $matches)) {
        	// Workaround for Funambol VTODO bug in Mozilla Sync Plugins
        	Horde::logMessage('iCalendar: Funambol VTODO-bug detected, workaround activated...',
        		__FILE__, __LINE__, PEAR_LOG_WARNING);
            $container = true;
            $vCal = $matches[1];
        } elseif (preg_match('/^BEGIN:' . $base . '(.*)^END:' . $base . '/ism', $text, $matches)) {
            $container = true;
            $vCal = $matches[1];
        } else {
            // Text isn't enclosed in BEGIN:VCALENDAR
            // .. END:VCALENDAR. We'll try to parse it anyway.
            $container = false;
            $vCal = $text;
        }

        if (preg_match('/^VERSION:(\d\.\d)\s*$/ism', $vCal, $matches)) {
            // define the version asap
            #Horde::logMessage("iCalendar VERSION:" . $matches[1], __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $this->setAttribute('VERSION', $matches[1]);
        }

		// Preserve a trailing CR
        $vCal = trim($vCal) . "\n";

        // All subcomponents.
        $matches = null;
        // Workaround for Funambol VTODO bug in Mozilla Sync Plugins
        if (preg_match_all('/^BEGIN:(VTODO)(\r\n|\r|\n)(.*)^END:VEVENT/Uims', $vCal, $matches) ||
        	preg_match_all('/^BEGIN:(.*)(\r\n|\r|\n)(.*)^END:\1/Uims', $vCal, $matches)) {
            // vTimezone components are processed first. They are
            // needed to process vEvents that may use a TZID.
            foreach ($matches[0] as $key => $data) {
                $type = trim($matches[1][$key]);
                if ($type != 'VTIMEZONE') {
                    continue;
                }
                $component = &Horde_iCalendar::newComponent($type, $this);
                if ($component === false) {
                    return PEAR::raiseError("Unable to create object for type $type");
                }
                $component->parsevCalendar($data, $type, $charset);

                $this->addComponent($component);

                // Remove from the vCalendar data.
                $vCal = str_replace($data, '', $vCal);
            }

            // Now process the non-vTimezone components.
            foreach ($matches[0] as $key => $data) {
                $type = trim($matches[1][$key]);
                if ($type == 'VTIMEZONE') {
                    continue;
                }
                $component = &Horde_iCalendar::newComponent($type, $this);
                if ($component === false) {
                    return PEAR::raiseError("Unable to create object for type $type");
                }
                $component->parsevCalendar($data, $type, $charset);

                $this->addComponent($component);

                // Remove from the vCalendar data.
                $vCal = str_replace($data, '', $vCal);
            }
        } elseif (!$container) {
            return false;
        }

        // Unfold "quoted printable" folded lines like:
        //  BODY;ENCODING=QUOTED-PRINTABLE:=
        //  another=20line=
        //  last=20line
        while (preg_match_all('/^([^:]+;\s*((ENCODING=)?QUOTED-PRINTABLE|ENCODING=[Q|q])(.*=\r?\n)+(.*[^=])?\r?\n)/mU', $vCal, $matches)) {
            foreach ($matches[1] as $s) {
            	if ($this->isOldFormat()) {
                	$r = preg_replace('/=\r?\n([ \t])?/', '\1', $s);
            	} else {
            		$r = preg_replace('/=\r?\n[ \t]*/', '', $s);
            	}
                $vCal = str_replace($s, $r, $vCal);
            }
        }

        // Unfold any folded lines.
        if ($this->isOldFormat()) {
        	// old formats force folding at whitespace which must therefore be preserved
        	$vCal = preg_replace('/[\r\n]+([ \t])/', '\1', $vCal);
        } else {
        	$vCal = preg_replace('/[\r\n]+[ \t]+/', '', $vCal);
        }

		$isDate = false;

        // Parse the remaining attributes.
        if (preg_match_all('/^((?:[^":]+|(?:"[^"]*")+)*):([^\r\n]*)\r?$/m', $vCal, $matches)) {
            foreach ($matches[0] as $attribute) {
                preg_match('/([^:;]*)((;(?:(?:[^":\\\]*(?:\\\.)?)+|(?:"[^"]*")+)*)?):([^\r\n]*)[\r\n]*/', $attribute, $parts);
                $tag = trim(String::upper($parts[1]));
                $value = $parts[4];
                $params = array();

                // Parse parameters.
                if (!empty($parts[2])) {
                    preg_match_all('/;(([^;=]*)(=("[^"]*"|(?:[^;\\\]*(?:\\\.)?)*))?)/', $parts[2], $param_parts);
                    foreach ($param_parts[2] as $key => $paramName) {
                        $paramName = String::upper($paramName);
                        $paramValue = $param_parts[4][$key];
                        if ($paramName == 'TYPE') {
                            $paramValue = preg_split('/(?<!\\\\),/', $paramValue);
                            if (count($paramValue) == 1) {
                                $paramValue = $paramValue[0];
                            }
                        }
                        $params[$paramName] = $paramValue;
                    }
                }

                // Charset and encoding handling.
		if (isset($params['QUOTED-PRINTABLE'])) {
			$params['ENCODING'] = 'QUOTED-PRINTABLE';
		}
		if (isset($params['BASE64'])) {
			$params['ENCODING'] = 'BASE64';
		}
		if (isset($params['ENCODING'])) {
			switch (String::upper($params['ENCODING'])) {
				case 'Q':
				case 'QUOTED-PRINTABLE':
					$value = quoted_printable_decode($value);
					if (isset($params['CHARSET'])) {
						$value = $GLOBALS['egw']->translation->convert($value, $params['CHARSET']);
					} else {
						$value = $GLOBALS['egw']->translation->convert($value,
							empty($charset) ? ($this->isOldFormat() ? 'iso-8859-1' : 'utf-8') : $charset);
					}
					// Funambol hack :-(
					$value = str_replace('\\\\n', "\n", $value);
					break;
				case 'B':
				case 'BASE64':
					$value = base64_decode($value);
					break;
			}
		} elseif (isset($params['CHARSET'])) {
			$value = $GLOBALS['egw']->translation->convert($value, $params['CHARSET']);
		} else {
			// As per RFC 2279, assume UTF8 if we don't have an
			// explicit charset parameter.
			$value = $GLOBALS['egw']->translation->convert($value,
				empty($charset) ? ($this->isOldFormat() ? 'iso-8859-1' : 'utf-8') : $charset);
		}

		// Get timezone info for date fields from $params.
		$tzid = isset($params['TZID']) ? trim($params['TZID'], '\"') : false;

		switch ($tag) {
			case 'VERSION': // already processed
				break;
				// Date fields.
			case 'COMPLETED':
			case 'CREATED':
			case 'LAST-MODIFIED':
				$this->setAttribute($tag, $this->_parseDateTime($value, $tzid), $params);
				break;

			case 'BDAY':
			case 'X-SYNCJE-ANNIVERSARY':
				$this->setAttribute($tag, $value, $params, true, $this->_parseDate($value));
				break;

			case 'DTEND':
			case 'DTSTART':
			case 'DTSTAMP':
			case 'DUE':
			case 'AALARM':
			case 'DALARM':
			case 'RECURRENCE-ID':
			case 'X-RECURRENCE-ID':
				// types like AALARM may contain additional data after a ;
				// ignore these.
				$ts = explode(';', $value);
				if (isset($params['VALUE']) && $params['VALUE'] == 'DATE') {
					$isDate = true;
					$this->setAttribute($tag, $this->_parseDateTime($ts[0], $tzid), $params, true, $this->_parseDate($ts[0]));
				} else {
					$this->setAttribute($tag, $this->_parseDateTime($ts[0], $tzid), $params);
				}
				break;

			case 'TRIGGER':
				if (isset($params['VALUE'])) {
					if ($params['VALUE'] == 'DATE-TIME') {
						$this->setAttribute($tag, $this->_parseDateTime($value, $tzid), $params);
					} else {
						$this->setAttribute($tag, $this->_parseDuration($value), $params);
					}
				} else {
					$this->setAttribute($tag, $this->_parseDuration($value), $params);
				}
				break;

				// Comma or semicolon seperated dates.
			case 'EXDATE':
			case 'RDATE':
				$dates = array();
				preg_match_all('/[;,]([^;,]*)/', ';' . $value, $values);

				foreach ($values[1] as $value) {
					if ((isset($params['VALUE'])
							&& $params['VALUE'] == 'DATE') || (!isset($params['VALUE']) && $isDate)) {
						$dates[] = $this->_parseDate(trim($value));
					} else {
						$dates[] = $this->_parseDateTime(trim($value), $tzid);
					}
				}
				$this->setAttribute($tag, isset($dates[0]) ? $dates[0] : null, $params, true, $dates);
				break;

				// Duration fields.
			case 'DURATION':
				$this->setAttribute($tag, $this->_parseDuration($value), $params);
				break;

				// Period of time fields.
			case 'FREEBUSY':
				$periods = array();
				preg_match_all('/,([^,]*)/', ',' . $value, $values);
				foreach ($values[1] as $value) {
					$periods[] = $this->_parsePeriod($value);
				}

				$this->setAttribute($tag, isset($periods[0]) ? $periods[0] : null, $params, true, $periods);
				break;

				// UTC offset fields.
			case 'TZOFFSETFROM':
			case 'TZOFFSETTO':
				$this->setAttribute($tag, $this->_parseUtcOffset($value), $params);
				break;

				// Integer fields.
			case 'PERCENT-COMPLETE':
			case 'PRIORITY':
			case 'REPEAT':
			case 'SEQUENCE':
				$this->setAttribute($tag, intval($value), $params);
				break;

				// Geo fields.
			case 'GEO':
				if ($this->isOldFormat()) {
					$floats = explode(',', $value);
					$value = array('latitude' => floatval($floats[1]),
						'longitude' => floatval($floats[0]));
				} else {
					$floats = explode(';', $value);
					$value = array('latitude' => floatval($floats[0]),
						'longitude' => floatval($floats[1]));
				}
				$this->setAttribute($tag, $value, $params);
				break;

				// Recursion fields. # add more flexibility
				#case 'EXRULE':
				#case 'RRULE':
				#    $this->setAttribute($tag, trim($value), $params);
				#    break;

				// Binary fields.
			case 'PHOTO':
				$this->setAttribute($tag, $value, $params);
				break;

				// ADR, ORG and N are lists seperated by unescaped semicolons
				// with a specific number of slots.
			case 'ADR':
			case 'N':
			case 'ORG':
				$value = trim($value);
				// As of rfc 2426 2.4.2 semicolon, comma, and colon must
				// be escaped (comma is unescaped after splitting below).
				$value = str_replace(array('\\n', '\\N', '\\;', '\\:'),
					array("\n", "\n", ';', ':'),
					$value);

				// Split by unescaped semicolons:
				$values = preg_split('/(?<!\\\\);/', $value);
				$value = str_replace('\\;', ';', $value);
				$values = str_replace('\\;', ';', $values);
				$value = str_replace('\\,', ',', $value);
				$values = str_replace('\\,', ',', $values);
				$this->setAttribute($tag, trim($value), $params, true, $values);
				break;

				// CATEGORIES is a lists seperated by unescaped commas
				// with a unspecific number of slots.
			case 'CATEGORIES':
				$value = trim($value);
				// As of rfc 2426 2.4.2 semicolon, comma, and colon must
				// be escaped (semicolon is unescaped after splitting below).
				$value = str_replace(array('\\n', '\\N', '\\,', '\\:'),
					array("\n", "\n", ',', ':'),
					$value);

				// Split by unescaped commas:
				$values = preg_split('/(?<!\\\\),/', $value);
				$value = str_replace('\\;', ';', $value);
				$values = str_replace('\\;', ';', $values);
				$value = str_replace('\\,', ',', $value);
				$values = str_replace('\\,', ',', $values);
				$this->setAttribute($tag, trim($value), $params, true, $values);
				break;

				// String fields.
			default:
				if ($this->isOldFormat()) {
					// vCalendar 1.0 and vCard 2.1 only escape semicolons
					// and use unescaped semicolons to create lists.
					$value = trim($value);
					// Split by unescaped semicolons:
					$values = preg_split('/(?<!\\\\);/', $value);
					$value = str_replace('\\;', ';', $value);
					$values = str_replace('\\;', ';', $values);
					$this->setAttribute($tag, trim($value), $params, true, $values);
				} else {
					$value = trim($value);
					// As of rfc 2426 2.4.2 semicolon, comma, and colon
					// must be escaped (comma is unescaped after splitting
					// below).
					$value = str_replace(array('\\n', '\\N', '\\;', '\\:', '\\\\'),
						array("\n", "\n", ';', ':', '\\'),
						$value);

					// Split by unescaped commas.
					$values = preg_split('/(?<!\\\\),/', $value);
					$value = str_replace('\\,', ',', $value);
					$values = str_replace('\\,', ',', $values);

					$this->setAttribute($tag, trim($value), $params, true, $values);
				}
			break;
		}
            }
        }

        return true;
    }

    /**
     * Export this component in vCal format.
     *
     * @param string $base  The type of the base object.
     * @param string $charset  The encoding charset for $text. Defaults to
     *                         utf-8 for new format, standard character set for old format.
     *
     * @return string  vCal format data.
     */
    function _exportvData($base = 'VCALENDAR', $charset = null)
    {
    	$base = String::upper($base);

        $result = 'BEGIN:' . $base . $this->_newline;

        // VERSION is not allowed for entries enclosed in VCALENDAR/ICALENDAR,
        // as it is part of the enclosing VCALENDAR/ICALENDAR. See rfc2445
        if ($base !== 'VEVENT' && $base !== 'VTODO' && $base !== 'VALARM' &&
            $base !== 'VJOURNAL' && $base !== 'VFREEBUSY' &&
            $base !== 'VTIMEZONE' && $base !== 'STANDARD' && $base != 'DAYLIGHT') {
            // Ensure that version is the first attribute.
            $result .= 'VERSION:' . $this->_version . $this->_newline;
        }

        if (empty($charset)) {
        	if ($this->isOldFormat()) {
        		$charset = NLS::getCharset();
        	} else {
        		$charset = 'utf-8';
        	}
        }

        foreach ($this->_attributes as $attribute) {
            $name = $attribute['name'];
            if ($name == 'VERSION') {
                // Already done.
                continue;
            }

            $params_str = '';
            $params = $attribute['params'];
            if ($params) {
                foreach ($params as $param_name => $param_value) {
                    /* Skip CHARSET for iCalendar 2.0 data, not allowed. */
                    if ($param_name == 'CHARSET') {
                    	if (!$this->isOldFormat() || empty($param_value)) {
                        	continue;
                    	} else {
                    		$param_value = String::Upper($param_value);
                    	}
                    }
                    if ($param_name == 'ENCODING') {
                    	continue;
                    }
                    /* Skip VALUE=DATE for vCalendar 1.0 data, not allowed. */
                    if ($this->isOldFormat() &&
                        $param_name == 'VALUE' && $param_value == 'DATE') {
                        continue;
                    }
                    /* Skip TZID for iCalendar 1.0 data, not supported. */
					if ($this->isOldFormat() && $param_name == 'TZID') {
                        continue;
                    }
                    // Skip CN in ATTENDEE adn ORGANIZER for vCalendar 1.0
                    if ($this->isOldFormat() && $param_name == 'CN' &&
                    	($name == 'ATTENDEE' || $name == 'ORGANIZER')) {
                    	continue;
                    }
                    if ($param_value === null) {
                        $params_str .= ";$param_name";
                    } else {
                        $params_str .= ";$param_name=$param_value";
                    }
                }
            }

            $value = $attribute['value'];

            switch ($name) {
            // Date fields.
            case 'COMPLETED':
            case 'CREATED':
            case 'DCREATED':
            case 'LAST-MODIFIED':
                $value = $this->_exportDateTime($value);
                break;

			// Support additional fields after date.
 			case 'AALARM':
            case 'DALARM':
            	if (isset($params['VALUE'])) {
                    if ($params['VALUE'] == 'DATE') {
                        // VCALENDAR 1.0 uses T000000 - T235959 for all day events:
                        if ($this->isOldFormat() && $name == 'DTEND') {
                            $d = new Horde_Date($value);
                            $value = new Horde_Date(array(
                                'year' => $d->year,
                                'month' => $d->month,
                                'mday' => $d->mday - 1));
                            $value->correct();
                            $value = $this->_exportDate($value, '235959');
                        } else {
                            $value = $this->_exportDate($value, '000000');
                        }
                    } else {
                        $value = $this->_exportDateTime($value);
                    }
                } else {
                    $value = $this->_exportDateTime($value);
                }

            	if (is_array($attribute['values']) &&
		            	count($attribute['values']) > 0) {
		            $values = $attribute['values'];
	            	if ($this->isOldFormat()) {
		            	$values = str_replace(';', '\\;', $values);
	            	} else {
                        // As of rfc 2426 2.5 semicolon and comma must be
                        // escaped.
                        $values = str_replace(array('\\', ';', ','),
                                              array('\\\\', '\\;', '\\,'),
                                              $values);
	            	}
	            	$value .= ';' . implode(';', $values);
		         }

                break;

            case 'DTEND':
            case 'DTSTART':
            case 'DTSTAMP':
            case 'DUE':
            case 'RECURRENCE-ID':
            case 'X-RECURRENCE-ID':
                if (isset($params['VALUE'])) {
                    if ($params['VALUE'] == 'DATE') {
                        // VCALENDAR 1.0 uses T000000 - T235959 for all day events:
                        if ($this->isOldFormat() && $name == 'DTEND') {
                            $d = new Horde_Date($value);
                            $value = new Horde_Date(array(
                                'year' => $d->year,
                                'month' => $d->month,
                                'mday' => $d->mday - 1));
                            $value->correct();
                            $value = $this->_exportDate($value, '235959');
                        } else {
                            $value = $this->_exportDate($value, '000000');
                        }
                    } else {
                        $value = $this->_exportDateTime($value);
                    }
                } else {
                    $value = $this->_exportDateTime($value);
                }
                break;

            // Comma or semicolon seperated dates.
            case 'EXDATE':
            case 'RDATE':
	            if (is_array($attribute['values'])) {
		            $values = $attribute['values'];
	            } elseif (!empty($value)) {
	            	if ($this->isOldFormat()) {
		            	$values = explode(';', $value);
	            	} else {
	            		$values = explode(',', $value);
	            	}
	            } else {
		            break;
	            }
                $dates = array();
                foreach ($values as $date) {
                    if (isset($params['VALUE'])) {
                        if ($params['VALUE'] == 'DATE') {
                            $dates[] = $this->_exportDate($date, '000000');
                        } elseif ($params['VALUE'] == 'PERIOD') {
                            $dates[] = $this->_exportPeriod($date);
                        } else {
                            $dates[] = $this->_exportDateTime($date);
                        }
                    } else {
                        $dates[] = $this->_exportDateTime($date);
                    }
                }
                if ($this->isOldFormat()) {
	                $value = implode(';', $dates);
                } else {
	                $value = implode(',', $dates);
                }
                break;

            case 'TRIGGER':
                if (isset($params['VALUE'])) {
                    if ($params['VALUE'] == 'DATE-TIME') {
                        $value = $this->_exportDateTime($value);
                    } elseif ($params['VALUE'] == 'DURATION') {
                        $value = $this->_exportDuration($value);
                    }
                } else {
                    $value = $this->_exportDuration($value);
                }
                break;

            // Duration fields.
            case 'DURATION':
                $value = $this->_exportDuration($value);
                break;

            // Period of time fields.
            case 'FREEBUSY':
                $value_str = '';
                foreach ($value as $period) {
                    $value_str .= empty($value_str) ? '' : ',';
                    $value_str .= $this->_exportPeriod($period);
                }
                $value = $value_str;
                break;

            // UTC offset fields.
            case 'TZOFFSETFROM':
            case 'TZOFFSETTO':
                $value = $this->_exportUtcOffset($value);
                break;

            // Integer fields.
            case 'PERCENT-COMPLETE':
            case 'PRIORITY':
            case 'REPEAT':
            case 'SEQUENCE':
                $value = "$value";
                break;

            // Geo fields.
            case 'GEO':
                if ($this->isOldFormat()) {
                    $value = $value['longitude'] . ',' . $value['latitude'];
                } else {
                    $value = $value['latitude'] . ';' . $value['longitude'];
                }
                break;

            // Recurrence fields.
            case 'EXRULE':
            	break;

            case 'RRULE':
                if (!empty($params_str) &&  $params_str[0] == ';')
                {
                	// The standard requires a double colon RRULE:...
                	 $params_str[0] = ':';
                }
                break;

            case 'PHOTO':
                break;

            default:
                if ($this->isOldFormat()) {
                    if (is_array($attribute['values']) &&
                        count($attribute['values']) > 1) {
                        $values = $attribute['values'];
                        if ($name == 'N' || $name == 'ADR' || $name == 'ORG') {
                            $glue = ';';
                        } else {
                            $glue = ',';
                        }
                        $values = str_replace(';', '\\;', $values);
                        $value = implode($glue, $values);
                    } else {
                        /* vcard 2.1 and vcalendar 1.0 escape only
                         * semicolons */
                        $value = str_replace(';', '\\;', $value);
                    }
                    // Text containing newlines or ASCII >= 127 must be BASE64
                    // or QUOTED-PRINTABLE encoded. Currently we use
                    // QUOTED-PRINTABLE as default.
                    if (preg_match('/[^\x20-\x7F]/', $value) &&
		                    !isset($params['ENCODING']))  {
	                    $params['ENCODING'] = 'QUOTED-PRINTABLE';
                    }
                    if (preg_match('/([\177-\377])/', $value) &&
		                    !isset($params['CHARSET'])) {
	                    // Add CHARSET as well. At least the synthesis client
	                    // gets confused otherwise
	                    $params['CHARSET'] = String::upper($charset);
	                    $params_str .= ';CHARSET=' . $params['CHARSET'];
                    }
                } else {
                    if (is_array($attribute['values']) &&
                        count($attribute['values']) > 1) {
                        $values = $attribute['values'];
                        if ($name == 'N' || $name == 'ADR' || $name == 'ORG') {
                            $glue = ';';
                        } else {
                            $glue = ',';
                        }
                        // As of rfc 2426 2.5 semicolon and comma must be
                        // escaped.
                        $values = str_replace(array('\\', ';', ','),
                                              array('\\\\', '\\;', '\\,'),
                                              $values);
                        $value = implode($glue, $values);
                    } else {
                        // As of rfc 2426 2.5 semicolon and comma must be
                        // escaped.
                        $value = str_replace(array('\\', ';', ','),
                                             array('\\\\', '\\;', '\\,'),
                                             $value);
                    }
                    $value = preg_replace('/\r?\n/', "\n", $value);
                }
            }

			$encoding = (!empty($params['ENCODING']) && strlen(trim($value)) > 0);

            if ($encoding) {
	            switch($params['ENCODING']) {
		            case 'Q':
		            case 'QUOTED-PRINTABLE':
		            	if (!$this->isOldFormat())
		            	{
		            		$encoding = false;
		            		break;
		            	}
			            $params_str .= ';ENCODING=' . $params['ENCODING'];
			            $value = str_replace("\r", '', $value);
			            $result .= $name . $params_str . ':'
				            . str_replace('=0A', '=0D=0A',
					            $this->_quotedPrintableEncode($value))
								. $this->_newline;
			            break;
		            case 'FUNAMBOL-QP':
		            	// Funambol needs some special quoting
			            $value = str_replace(array('<', "\r"), array('&lt;', ''), $value);
			            if (!$this->isOldFormat())
			            {
			            	$encoding = false;
			            	break;
			            }
			            $params_str .= ';ENCODING=QUOTED-PRINTABLE';
			            $result .= $name . $params_str . ':'
				            . str_replace('=0A', '=0D=0A',
					            $this->_quotedPrintableEncode($value, false))
								. $this->_newline;
			            break;
		            case 'B':
		            case 'BASE64':
			            $params_str .= ';ENCODING=' . $params['ENCODING'];
			            $attr_string = $name . $params_str . ':' . $this->_newline . ' ' . $this->_base64Encode($value);
			            $attr_string = String::wordwrap($attr_string, 75, $this->_newline . ' ',
				            true, 'utf-8', true); // charset does not matter
			            $result .= $attr_string . $this->_newline;
			            if ($this->isOldFormat()) {
				            $result .= $this->_newline; // Append an empty line
			            }
	            }
            }

            if (!$encoding) {
                $value = str_replace(array("\r", "\n"), array('', '\\n'), $value);
                $attr_string = $name . $params_str;
                if (strlen($value) > 0) {
                	$attr_string .= ':' . $value;
                } elseif ($name != 'RRULE') {
                	$attr_string .= ':';
                }
                if (!$this->isOldFormat()) {
                    $attr_string = String::wordwrap($attr_string, 75, $this->_newline . ' ',
                                                    true, $charset, true);
                }
                $result .= $attr_string . $this->_newline;
            }
        }

        foreach ($this->_components as $component) {
            if ($this->isOldFormat() &&  $component->getType() == 'vTimeZone') {
    			// Not supported
				continue;
        	}
            $result .= $component->exportvCalendar($charset);
        }

        return $result . 'END:' . $base . $this->_newline;
    }

    /**
     * Parse a UTC Offset field.
     */
    function _parseUtcOffset($text)
    {
        $offset = array();
        if (preg_match('/(\+|-)([0-9]{2})([0-9]{2})([0-9]{2})?/', $text, $timeParts)) {
            $offset['ahead']  = (bool)($timeParts[1] == '+');
            $offset['hour']   = intval($timeParts[2]);
            $offset['minute'] = intval($timeParts[3]);
            if (isset($timeParts[4])) {
                $offset['second'] = intval($timeParts[4]);
            }
            return $offset;
        } else {
            return false;
        }
    }

    /**
     * Export a UTC Offset field.
     */
    function _exportUtcOffset($value)
    {
        $offset = $value['ahead'] ? '+' : '-';
        $offset .= sprintf('%02d%02d',
                           $value['hour'], $value['minute']);
        if (isset($value['second'])) {
            $offset .= sprintf('%02d', $value['second']);
        }

        return $offset;
    }

    /**
     * Parse a Time Period field.
     */
    function _parsePeriod($text)
    {
        $periodParts = explode('/', $text);

        $start = $this->_parseDateTime($periodParts[0]);

        if ($duration = $this->_parseDuration($periodParts[1])) {
            return array('start' => $start, 'duration' => $duration);
        } elseif ($end = $this->_parseDateTime($periodParts[1])) {
            return array('start' => $start, 'end' => $end);
        }
    }

    /**
     * Export a Time Period field.
     */
    function _exportPeriod($value)
    {
        $period = $this->_exportDateTime($value['start']);
        $period .= '/';
        if (isset($value['duration'])) {
            $period .= $this->_exportDuration($value['duration']);
        } else {
            $period .= $this->_exportDateTime($value['end']);
        }
        return $period;
    }

    /**
     * Grok the TZID and return an offset in seconds from UTC for this
     * date and time.
     */
    function _parseTZID($date, $time, $tzid)
    {
        $vtimezone = $this->_container->findComponentByAttribute('vtimezone', 'TZID', $tzid);
        if (!$vtimezone) {
            return false;
        }

        $change_times = array();
        foreach ($vtimezone->getComponents() as $o) {
            $t = $vtimezone->parseChild($o, $date['year']);
            if ($t !== false) {
                $change_times[] = $t;
            }
        }

        if (!$change_times) {
            return false;
        }

        sort($change_times);

        // Time is arbitrarily based on UTC for comparison.
        $t = @gmmktime($time['hour'], $time['minute'], $time['second'],
                       $date['month'], $date['mday'], $date['year']);

        if ($t < $change_times[0]['time']) {
            return $change_times[0]['from'];
        }

        for ($i = 0, $n = count($change_times); $i < $n - 1; $i++) {
            if (($t >= $change_times[$i]['time']) &&
                ($t < $change_times[$i + 1]['time'])) {
                return $change_times[$i]['to'];
            }
        }

        if ($t >= $change_times[$n - 1]['time']) {
            return $change_times[$n - 1]['to'];
        }

        return false;
    }

    /**
     * Parses a DateTime field and returns a unix timestamp. If the
     * field cannot be parsed then the original text is returned
     * unmodified.
     *
     * @todo This function should be moved to Horde_Date and made public.
     */
    function _parseDateTime($text, $tzid = false)
    {
        $dateParts = explode('T', $text);
        if (count($dateParts) != 2 && !empty($text)) {
            // Not a datetime field but may be just a date field.
            if (!preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $text, $match)) {
                // Or not
                return $text;
            }
            $newtext = $text.'T000000';
            $dateParts = explode('T', $newtext);
        }

        if (!$date = Horde_iCalendar::_parseDate($dateParts[0])) {
            return $text;
        }
        if (!$time = Horde_iCalendar::_parseTime($dateParts[1])) {
            return $text;
        }

        // Get timezone info for date fields from $tzid and container.
        $tzoffset = ($time['zone'] == 'Local' && $tzid && is_a($this->_container, 'Horde_iCalendar'))
            ? $this->_parseTZID($date, $time, $tzid) : false;
        if ($time['zone'] == 'UTC' || $tzoffset !== false) {
            $result = @gmmktime($time['hour'], $time['minute'], $time['second'],
                                $date['month'], $date['mday'], $date['year']);
            if ($tzoffset) {
                $result -= $tzoffset;
            }
        } else {
            // We don't know the timezone so assume local timezone.
            // FIXME: shouldn't this be based on the user's timezone
            // preference rather than the server's timezone?
            $result = @mktime($time['hour'], $time['minute'], $time['second'],
                              $date['month'], $date['mday'], $date['year']);
        }

        return ($result !== false) ? $result : $text;
    }

    /**
     * Export a DateTime field.
     */
    function _exportDateTime($value)
    {
        if (is_numeric($value)) {
            $temp = array();
            $tz = date('O', $value);
            $TZOffset = (3600 * substr($tz, 0, 3)) + (60 * substr(date('O', $value), 3, 2));
            $value -= $TZOffset;

            $temp['zone']   = 'UTC';
            $temp['year']   = date('Y', $value);
            $temp['month']  = date('n', $value);
            $temp['mday']   = date('j', $value);
            $temp['hour']   = date('G', $value);
            $temp['minute'] = date('i', $value);
            $temp['second'] = date('s', $value);
            return Horde_iCalendar::_exportDate($temp) . 'T' . Horde_iCalendar::_exportTime($temp);
        } else if (is_object($value) || is_array($value)) {
            $dateOb = new Horde_Date($value);
            return Horde_iCalendar::_exportDateTime($dateOb->timestamp());
        }
        return $value; // nothing to do with us, let's not touch it
    }

    /**
     * Parses a Time field.
     *
     * @static
     */
    function _parseTime($text)
    {
        if (preg_match('/([0-9]{2})([0-9]{2})([0-9]{2})(Z)?/', $text, $timeParts)) {
            $time['hour'] = intval($timeParts[1]);
            $time['minute'] = intval($timeParts[2]);
            $time['second'] = intval($timeParts[3]);
            if (isset($timeParts[4])) {
                $time['zone'] = 'UTC';
            } else {
                $time['zone'] = 'Local';
            }
            return $time;
        } else {
            return false;
        }
    }

    /**
     * Exports a Time field.
     */
    function _exportTime($value)
    {
        $time = sprintf('%02d%02d%02d',
                        $value['hour'], $value['minute'], $value['second']);
        if ($value['zone'] == 'UTC') {
            $time .= 'Z';
        }
        return $time;
    }

    /**
     * Parses a Date field.
     *
     * @static
     */
    function _parseDate($text)
    {
        $parts = explode('T', $text);
        if (count($parts) == 2) {
            $text = $parts[0];
        }

        if (!preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $text, $match)) {
            return false;
        }

        return array('year' => $match[1],
                     'month' => $match[2],
                     'mday' => $match[3]);
    }

    /**
     * Exports a date field.
     *
     * @param object|array $value  Date object or hash.
     * @param string $autoconvert  If set, use this as time part to export the
     *                             date as datetime when exporting to Vcalendar
     *                             1.0. Examples: '000000' or '235959'
     */
    function _exportDate($value, $autoconvert = false)
    {
        if (is_object($value)) {
            $value = array('year' => $value->year, 'month' => $value->month, 'mday' => $value->mday);
        }
        if ($autoconvert !== false && $this->isOldFormat()) {
            return sprintf('%04d%02d%02dT%s', $value['year'], $value['month'], $value['mday'], $autoconvert);
        } else {
            return sprintf('%04d%02d%02d', $value['year'], $value['month'], $value['mday']);
        }
    }

    /**
     * Parse a Duration Value field.
     */
    function _parseDuration($text)
    {
        if (preg_match('/([+]?|[-])P(([0-9]+W)|([0-9]+D)|)(T(([0-9]+H)|([0-9]+M)|([0-9]+S))+)?/', trim($text), $durvalue)) {
            // Weeks.
            $duration = 7 * 86400 * intval($durvalue[3]);

            if (count($durvalue) > 4) {
                // Days.
                $duration += 86400 * intval($durvalue[4]);
            }
            if (count($durvalue) > 5) {
                // Hours.
                $duration += 3600 * intval($durvalue[7]);

                // Mins.
                if (isset($durvalue[8])) {
                    $duration += 60 * intval($durvalue[8]);
                }

                // Secs.
                if (isset($durvalue[9])) {
                    $duration += intval($durvalue[9]);
                }
            }

            // Sign.
            if ($durvalue[1] == "-") {
                $duration *= -1;
            }

            return $duration;
        } else {
            return false;
        }
    }

    /**
     * Export a duration value.
     */
    function _exportDuration($value)
    {
        $duration = '';
        if ($value < 0) {
            $value *= -1;
            $duration .= '-';
        }
        $duration .= 'P';

        $weeks = floor($value / (7 * 86400));
        $value = $value % (7 * 86400);
        if ($weeks) {
            $duration .= $weeks . 'W';
        }

        $days = floor($value / (86400));
        $value = $value % (86400);
        if ($days) {
            $duration .= $days . 'D';
        }

        if ($value) {
            $duration .= 'T';

            $hours = floor($value / 3600);
            $value = $value % 3600;
            if ($hours) {
                $duration .= $hours . 'H';
            }

            $mins = floor($value / 60);
            $value = $value % 60;
            if ($mins) {
                $duration .= $mins . 'M';
            }

            if ($value) {
                $duration .= $value . 'S';
            }
        }

        return $duration;
    }


   /**
     * Convert an 8bit string to a base64 string
     * to RFC2045, section 6.7.
     *
     * @param  string $input  The string to be encoded.
     *
     * @return string         The base64 encoded string.
     */
    function _base64Encode($input = '')
    {
        return base64_encode($input);
    }


    /**
     * Converts an 8bit string to a quoted-printable string according to RFC
     * 2045, section 6.7.
     *
     * imap_8bit() does not apply all necessary rules.
     *
     * @param string $input  The string to be encoded.
     *
     * @return string  The quoted-printable encoded string.
     */
    function _quotedPrintableEncode($input = '', $withFolding=true)
    {
        $output = $line = '';
        $len = strlen($input);

        for ($i = 0; $i < $len; ++$i) {
            $ord = ord($input[$i]);
            // Encode non-printable characters (rule 2).
            if ($ord == 9 ||
                ($ord >= 32 && $ord <= 60) ||
                ($ord >= 62 && $ord <= 126)) {
                $chunk = $input[$i];
            } else {
                // Quoted printable encoding (rule 1).
                $chunk = '=' . String::upper(sprintf('%02X', $ord));
            }
            $line .= $chunk;
            // Wrap long lines (rule 5)
            if ($withFolding && strlen($line) + 1 > 76) {
                $line = String::wordwrap($line, 75, "=\r\n", true, 'us-ascii', true);
                $newline = strrchr($line, "\r\n");
                if ($newline !== false) {
                    $output .= substr($line, 0, -strlen($newline) + 2);
                    $line = substr($newline, 2);
                } else {
                    $output .= $line;
                }
                continue;
            }
            // Wrap at line breaks for better readability (rule 4).
            if ($withFolding && substr($line, -3) == '=0A') {
                $output .= $line . "=\r\n";
                $line = '';
            }
        }
        $output .= $line;

        // Trailing whitespace must be encoded (rule 3).
        $lastpos = strlen($output) - 1;
        if ($output[$lastpos] == chr(9) ||
            $output[$lastpos] == chr(32)) {
            $output[$lastpos] = '=';
            $output .= String::upper(sprintf('%02X', ord($output[$lastpos])));
        }

        return $output;
    }

}