<?php
/**
 * Akeeba Engine
 * The modular PHP5 site backup engine
 *
 * @copyright Copyright (c)2006-2016 Nicholas K. Dionysopoulos
 * @license   GNU GPL version 3 or, at your option, any later version
 * @package   akeebaengine
 *
 *
 */

namespace Akeeba\Engine\Postproc\Connector;

// Protection against direct access
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\APIError;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\Base as BaseException;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\cURLError;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\InvalidJSON;
use Akeeba\Engine\Postproc\Connector\Dropbox2\Exception\UnexpectedHTTPStatus;

defined('AKEEBAENGINE') or die();

/**
 * Dropbox (API v2) post-processing engine for Akeeba Engine
 *
 * @package Akeeba\Engine\Postproc\Connector
 */
class Dropbox2
{
	/**
	 * The access token for connecting to Dropbox
	 *
	 * @var   string
	 */
	private $accessToken = '';

	/**
	 * The root URL for the Dropbox RPC API, ref https://www.dropbox.com/developers/documentation/http
	 */
	const rootUrl = 'https://api.dropboxapi.com/2/';

	/**
	 * The root URL for the Dropbox Content API, ref https://www.dropbox.com/developers/documentation/http
	 */
	const contentRootUrl = 'https://content.dropboxapi.com/2/';

	/**
	 * The URL of the helper script which is used to authenticate you with Dropbox
	 */
	const helperUrl = 'https://www.akeebabackup.com/oauth2/dropbox.php';

	/**
	 * Default cURL options
	 *
	 * @var array
	 */
	private $defaultOptions = array(
		CURLOPT_SSL_VERIFYPEER => true,
		CURLOPT_SSL_VERIFYHOST => true,
		CURLOPT_VERBOSE        => true,
		CURLOPT_HEADER         => false,
		CURLINFO_HEADER_OUT    => false,
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_CAINFO         => AKEEBA_CACERT_PEM,
	);

	/**
	 * Public constructor
	 *
	 * @param   string   $accessToken   The access token for accessing Dropbox
	 */
	public function __construct($accessToken)
	{
		$this->accessToken = $accessToken;
	}

	/**
	 * Return information about the current user's account
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-users-get_current_account
	 */
	public function getCurrentAccount()
	{
		$relativeUrl = 'users/get_current_account';

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json; charset=utf-8'
		)), 'null');

		return $result;
	}

	/**
	 * Get the raw listing of a folder
	 *
	 * @param   string  $path              The relative path of the folder to list its contents
	 * @param   bool    $recursive         Produce a recursive listing? [false]
	 * @param   bool    $includeMediaInfo  Include media information in the metadata? [false]
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-list_folder
	 */
	public function getRawContents($path = '/', $recursive = false, $includeMediaInfo = false)
	{
		$relativeUrl = 'files/list_folder';

		$path = $this->normalizePath($path);

		$params = array(
			'path'               => $path,
			'recursive'          => $recursive ? true : false,
			'include_media_info' => $includeMediaInfo ? true : false,
		);

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json; charset=utf-8'
		)), $paramsForPost);

		return $result;
	}

	/**
	 * Get the raw listing of a folder
	 *
	 * @param   string  $cursor            The cursor recieved from getRawContents
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-list_folder-continue
	 */
	public function getRawContentsContinue($cursor)
	{
		$relativeUrl = 'files/list_folder/continue';

		$params = array(
			'cursor'             => $cursor,
		);

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json; charset=utf-8'
		)), $paramsForPost);

		return $result;
	}

	/**
	 * Get the processed listing of a folder
	 *
	 * @param   string  $path          The relative path of the folder to list its contents
	 *
	 * @return  array  Two arrays under keys folders and files. Each array's key is the file/folder name, the value is the Dropbox Folder ID (folder) or size in bytes (file)
	 */
	public function listContents($path = '/')
	{
		$result = array();

		$rawContents = $this->getRawContents($path);
		$result = $rawContents['entries'];

		while ($rawContents['has_more'])
		{
			$cursor = $rawContents['cursor'];
			$rawContents = $this->getRawContentsContinue($cursor);
			$result = array_merge($result, $rawContents['entries']);
		}

		$return = array(
			'files' => array(),
			'folders' => array(),
		);

		foreach ($result as $item)
		{
			if ($item['.tag'] == 'folder')
			{
				$return['folders'][$item['name']] = $item['id'];

				continue;
			}

			$return['files'][$item['name']] = $item['size'];
		}

		return $return;
	}

	/**
	 * Return the metadata for a file or folder
	 *
	 * @param   string  $path              The relative path of the file/folder to fetch the metadata for
	 * @param   bool    $includeMediaInfo  Include media information in the metadata? [false]
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-get_metadata
	 */
	public function getMetadata($path, $includeMediaInfo = false)
	{
		$relativeUrl = 'files/get_metadata';

		$path = $this->normalizePath($path);

		$params = array(
			'path'               => $path,
			'include_media_info' => $includeMediaInfo ? true : false,
		);

		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json; charset=utf-8'
		)), $paramsForPost);

		return $result;
	}

	/**
	 * Delete a file. See https://www.dropbox.com/developers/documentation/http#documentation-files-delete
	 *
	 * @param   string  $path         The relative path to the file to delete
	 * @param   bool    $failOnError  Throw exception if the deletion fails? Default true.
	 *
	 * @return  bool  True on success
	 *
	 * @throws  \Exception
	 */
	public function delete($path, $failOnError = true)
	{
		$relativeUrl = 'files/delete';
		$path = $this->normalizePath($path);

		$params = array(
			'path' => $path
		);
		$paramsForPost = json_encode($params);

		try
		{
			$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
				'Content-Type: application/json; charset=utf-8'
			)), $paramsForPost);
		}
		catch (\Exception $e)
		{
			if (!$failOnError)
			{
				return false;
			}

			throw $e;
		}

		return true;
	}

	/**
	 * Download a remote file
	 *
	 * @param   string  $path       The path of the file in OneDrive
	 * @param   string  $localFile  The absolute filesystem path where the file will be downloaded to
	 */
	public function download($path, $localFile)
	{
		$relativeUrl = 'files/download';
		$path = $this->normalizePath($path, 'content');

		$params = array(
			'path' => $path
		);
		$paramsForPost = json_encode($params);

		$this->fetch('POST', self::contentRootUrl, $relativeUrl, array(
			'headers'   => array(
				'Content-Type:', // WARNING: Content-Type MUST be empty!
				'Dropbox-API-Arg: ' . $paramsForPost
			),
			'file'      => $localFile,
			'file_mode' => 'wb',
		));
	}

	/**
	 * Get a shared download URL for the remote file with the specified path to Dropbox root. This kind of URL is
	 * suitable for sharing with third parties. It doesn't the (secret) authentication token.
	 *
	 * @param   string  $path  Relative path to Dropbox root
	 *
	 * @return  string  Shared URL to download the file's contents
	 *
	 * @see     https://www.dropbox.com/developers/documentation/http#documentation-sharing-create_shared_link
	 */
	public function getSharedUrl($path)
	{
		$relativeUrl = 'sharing/create_shared_link';
		$path = $this->normalizePath($path);

		$params = array(
			'path' => $path
		);
		$paramsForPost = json_encode($params);

		$result = $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json; charset=utf-8'
		)), $paramsForPost);

		return $result['url'];
	}

	/**
	 * Returns an fully qualified, authenticated URL from a relative URL. This URL is NOT meant for sharing! It contains
	 * the (secret) authentication token!
	 *
	 * @param   string  $path  The URL to apply
	 *
	 * @return  string
	 */
	public function getAuthenticatedUrl($path)
	{
		$path = $this->normalizePath($path);

		$params = array(
			'path' => $path
		);

		$paramsForURL = json_encode($params);

		$url = self::contentRootUrl . 'files/download';
		$url .= '?authorization=Bearer%20' . urlencode($this->accessToken);
		$url .= '&arg=' .urlencode($paramsForURL);

		return $url;
	}

	/**
	 * Creates a new multipart upload session and returns its upload URL
	 *
	 * @return  string  The upload session ID
	 *
	 * @see     https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-start
	 */
	public function createUploadSession()
	{
		$relativeUrl = 'files/upload_session/start';

		$info = $this->fetch('POST', self::contentRootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/octet-stream'
		)));

		return $info['session_id'];
	}

	/**
	 * Finish an already started upload session and commits the file to a specific location in Dropbox
	 *
	 * @param   string  $sessionId  The upload session ID
	 * @param   string  $path       Relative path of the file in Dropbox
	 * @param   int     $offset     The file size that's been already uploaded
	 * @param   bool    $mute       If true, the Dropbox desktop/mobile app will NOT notify users of the uploaded file
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function finishUploadSession($sessionId, $path, $offset, $mute = false)
	{
		$relativeUrl = 'files/upload_session/finish';
		$path = $this->normalizePath($path);

		$params = array(
			'cursor' => array(
				'session_id' => $sessionId,
				'offset'     => $offset
			),
			'commit' => array(
				'path'       => $path,
				'mode'       => 'overwrite',
				'autorename' => false,
				'mute'       => $mute ? true : false
			)
		);
		$paramsForPost = json_encode($params);

		return $this->fetch('POST', self::contentRootUrl, $relativeUrl, array(
			'headers' => array(
				'Content-Type: application/octet-stream',
				'Dropbox-API-Arg: ' . $paramsForPost
			)
		));
	}

	/**
	 * Upload a part
	 *
	 * @param   string  $sessionId  The upload session URL, see createUploadSession
	 * @param   string  $localFile   Absolute filesystem path of the source file
	 * @param   int     $from        Starting byte to begin uploading, default is 0 (start of file)
	 * @param   int     $length      Chunk size in bytes, default 10Mb, must NOT be over 60Mb!  MUST be a multiple of 320Kb.
	 *
	 * @return  void
	 */
	public function uploadPart($sessionId, $localFile, $from = 0, $length = 10485760)
	{
		$relativeUrl = 'files/upload_session/append';

		clearstatcache();
		$totalSize = filesize($localFile);
		$to = $from + $length - 1;

		if ($to > ($totalSize - 1))
		{
			$to = $totalSize - 1;
		}

		$contentLength = $to - $from + 1;

		$params = array(
			'session_id' => $sessionId,
			'offset'     => $from,
		);
		$paramsForPost = json_encode($params);

		$additional = array(
			'headers' => array(
				'Content-Type: application/octet-stream',
				'Dropbox-API-Arg: ' . $paramsForPost
			),
		    'no-parse' => true
		);

		$fp = @fopen($localFile, 'rb');

		if ($fp === false)
		{
			throw new \RuntimeException("Could not open $localFile for reading", 500);
		}

		fseek($fp, $from);
		$data = fread($fp, $contentLength);
		fclose($fp);

		$this->fetch('POST', self::contentRootUrl, $relativeUrl, $additional, $data);
	}

	/**
	 * Upload a file using multipart uploads. Useful for files over 100Mb and up to 2Gb.
	 *
	 * @param   string  $path       Relative path in Dropbox
	 * @param   string  $localFile  Absolute filesystem path of the source file
	 * @param   int     $partSize   Part size in bytes, default 10Mb.
	 * @param   bool    $mute       If true, the Dropbox desktop/mobile app will NOT notify users of the uploaded file
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function resumableUpload($path, $localFile, $partSize = 10485760, $mute = false)
	{
		clearstatcache();
		$totalSize = filesize($localFile);

		$sessionId = $this->createUploadSession();
		$from = 0;

		while (true)
		{
			$this->uploadPart($sessionId, $localFile, $from, $partSize);

			$from += $partSize;

			if ($from >= $totalSize)
			{
				break;
			}
		}

		return $this->finishUploadSession($sessionId, $path, $totalSize, $mute);
	}

	/**
	 * Automatically decides which upload method to use to upload a file to Dropbox. This method will return when the
	 * entire file has been uploaded. If you want to implement staggered uploads use the createUploadSession and
	 * uploadPart methods.
	 *
	 * @param   string  $path       The remote path relative to Dropbox root
	 * @param   string  $localFile  The absolute local filesystem path
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-upload_session-finish
	 */
	public function upload($path, $localFile)
	{
		clearstatcache();
		$filesize = @filesize($localFile);

		// Use resumable uploads with up to 1Mb parts
		return $this->resumableUpload($path, $localFile, 1048576);
	}

	/**
	 * Make a directory (including all of its parent directories) if the directory doesn't exist. If it already exists
	 * nothing happens. If it doesn't exist and cannot be created an exception is raised.
	 *
	 * @param   string  $path  The path to create
	 *
	 * @return  array  See https://www.dropbox.com/developers/documentation/http#documentation-files-create_folder
	 */
	public function makeDirectory($path)
	{
		$path = $this->normalizePath($path);

		try
		{
			$ownMetaData = $this->getMetadata($path);
		}
		catch (\Exception $e)
		{
			$ownMetaData = null;
		}

		// Empty path means that it already exists (it's the root)
		if (empty($path))
		{
			return array($ownMetaData);
		}

		// Get the parent path and the directory components of the path
		$parentPath = '/';
		$folder = $path;

		if (strpos($path, '/') !== false)
		{
			$pathParts = explode('/', $path);
			$folder = array_pop($pathParts);
			$parentPath = implode('/', $pathParts);
		}

		// Does this path exist in the parent path?
		$mustCreate = false;

		try
		{
			if (is_null($ownMetaData))
			{
				$parentMetaData = $this->getMetadata($parentPath);
			}
		}
		catch (\Exception $e)
		{
			// The parent folder doesn't exist. Create it!
			$this->makeDirectory($parentPath);
		}

		if (!is_null($ownMetaData))
		{
			return $ownMetaData;
		}

		// We have to create a new folder $folder in parent folder $parentPath.
		$relativeUrl = 'files/create_folder';
		$params = array(
			'path' => $path
		);
		$paramsForPost = json_encode($params);

		return $this->fetch('POST', self::rootUrl, $relativeUrl, array('headers' => array(
			'Content-Type: application/json'
		)), $paramsForPost);
	}

	/**
	 * Execute an API call
	 *
	 * @param   string  $method        The HTTP method
	 * @param   string  $baseUrl       The base URL. Use one of self::rootUrl or self::contentRootUrl
	 * @param   string  $relativeUrl   The relative URL to ping
	 * @param   array   $additional    Additional parameters
	 * @param   mixed   $explicitPost  Passed explicitly to POST requests if set, otherwise $additional is passed.
	 *
	 * @throws  \RuntimeException
	 *
	 * @return  array
	 */
	protected function fetch($method, $baseUrl, $relativeUrl, array $additional = array(), $explicitPost = null)
	{
		// Get full URL, if required
		$url = $relativeUrl;

		if (substr($relativeUrl, 0, 6) != 'https:')
		{
			$url = $baseUrl . ltrim($relativeUrl, '/');
		}

		// Should I expect a specific header?
		$expectHttpStatus = false;

		if (isset($additional['expect-status']))
		{
			$expectHttpStatus = $additional['expect-status'];
			unset($additional['expect-status']);
		}

		// Am I told to not parse the result?
		$noParse = false;

		if (isset($additional['no-parse']))
		{
			$noParse = $additional['no-parse'];
			unset ($additional['no-parse']);
		}

		// Am I told not to follow redirections?
		$followRedirect = true;

		if (isset($additional['follow-redirect']))
		{
			$followRedirect = $additional['follow-redirect'];
			unset ($additional['follow-redirect']);
		}

		// Initialise and execute a cURL request
		$ch = curl_init($url);

		// Get the default options array
		$options = $this->defaultOptions;

		// Do I have explicit cURL options to add?
		if (isset($additional['curl-options']) && is_array($additional['curl-options']))
		{
			// We can't use array_merge since we have integer keys and array_merge reassigns them :(
			foreach ($additional['curl-options'] as $k => $v)
			{
				$options[$k] = $v;
			}
		}

		// Set up custom headers
		$headers = array();

		if (isset($additional['headers']))
		{
			$headers = $additional['headers'];
			unset ($additional['headers']);
		}

		// Add the authorization header
		$headers[] = 'Authorization: Bearer ' . $this->accessToken;

		$options[ CURLOPT_HTTPHEADER ] = $headers;

		// Handle files
		$file = null;
		$fp = null;
		$fileMode = null;

		if (isset($additional['file']))
		{
			$file = $additional['file'];
			unset ($additional['file']);
		}

		if (isset($additional['file_mode']))
		{
			$fileMode = $additional['file_mode'];
			unset ($additional['file_mode']);
		}

		if (!isset($additional['fp']) && !empty($file))
		{
			if (is_null($fileMode))
			{
				$fileMode = ($method == 'GET') ? 'wb' : 'rb';
			}

			$fp = @fopen($file, $fileMode);
		}
		elseif (isset($additional['fp']))
		{
			$fp = $additional['fp'];
			unset($additional['fp']);
		}

		// Set up additional options
		if ($method == 'GET' && $fp)
		{
			$options[ CURLOPT_RETURNTRANSFER ] = false;
			$options[ CURLOPT_HEADER ]         = false;
			$options[ CURLOPT_FILE ]           = $fp;
			$options[ CURLOPT_BINARYTRANSFER ] = true;

			if (!$expectHttpStatus)
			{
				$expectHttpStatus = 200;
			}
		}
		elseif ($method == 'POST')
		{
			$options[ CURLOPT_POST ] = true;

			if ($explicitPost)
			{
				$options[ CURLOPT_POSTFIELDS ] = $explicitPost;
			}
			elseif (!empty($additional))
			{
				$options[ CURLOPT_POSTFIELDS ] = $additional;
			}
			// This is required for some broken servers, e.g. SiteGround
			else
			{
				$options[ CURLOPT_POSTFIELDS ] = '';
			}
		}
		elseif ($method == 'PUT' && $fp)
		{
			$options[ CURLOPT_PUT ]    = true;
			$options[ CURLOPT_INFILE ] = $fp;

			if ($file)
			{
				clearstatcache();
				$options[ CURLOPT_INFILESIZE ] = @filesize($file);
			}
			else
			{
				$options[ CURLOPT_INFILESIZE ] = strlen(stream_get_contents($fp));
			}

			fseek($fp, 0);
		}
		else // Any other HTTP method, e.g. DELETE
		{
			$options[ CURLOPT_CUSTOMREQUEST ] = $method;

			if ($explicitPost)
			{
				$options[ CURLOPT_POSTFIELDS ] = $explicitPost;
			}
			elseif (!empty($additional))
			{
				$options[ CURLOPT_POSTFIELDS ] = $additional;
			}
		}

		// Set the cURL options at once
		@curl_setopt_array($ch, $options);

		// Set the follow location flag
		if ($followRedirect)
		{
			@curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
		}

		// Execute and parse the response
		$response = curl_exec($ch);
		$errNo = curl_errno($ch);
		$error = curl_error($ch);
		$lastHttpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

		curl_close($ch);

		// Close open file pointers
		if ($fp)
		{
			@fclose($fp);

			if ($expectHttpStatus && ($expectHttpStatus != $lastHttpCode))
			{
				if ($file && ($method == 'GET'))
				{
					@unlink($file);
				}

				throw new UnexpectedHTTPStatus($lastHttpCode);
			}
		}

		// Did we have a cURL error?
		if ($errNo)
		{
			throw new cURLError($errNo, $error);
		}

		if ($expectHttpStatus)
		{
			if ($expectHttpStatus == $lastHttpCode)
			{
				return array();
			}
		}

		if ($noParse)
		{
			return $response;
		}

		// Parse the response
		$originalResponse = $response;
		$response = json_decode($response, true);

		// Did we get invalid JSON data?
		if (!$response)
		{
			throw new InvalidJSON("Invalid JSON Data: $originalResponse");
		}

		unset($originalResponse);

		// Did we get an error response?
		if (isset($response['error']) && is_array($response['error']))
		{
			$decodedError = $this->decodeError($response['error']);

			throw new APIError($decodedError['code'], $decodedError['description'], 500);
		}

		// Did we get an error response (from the helper script)?
		if (isset($response['error']))
		{
			$error = $response['error'];
			$errorDescription = isset($response['error_description']) ? $response['error_description'] : 'No error description provided';

			throw new APIError($error, $errorDescription, 500);
		}

		return $response;
	}

	/**
	 * Normalize the path of a resource inside the Dropbox account
	 *
	 * @param   string  $relativePath  The relative path to the Dropbox root
	 *
	 * @return string
	 */
	protected function normalizePath($relativePath)
	{
		$relativePath = trim($relativePath, '/');

		if (empty($relativePath))
		{
			$path = '';

			return $path;
		}

		$path = '/' . $relativePath;

		if ($path == '/')
		{
			$path = '';
		}

		return $path;
	}

	/**
	 * Decodes the error messages returned by Dropbox
	 *
	 * @param   array  $error  The error structure returned by Dropbox
	 *
	 * @return  array  Error code and description
	 */
	protected function decodeError($error)
	{
		// Initialise
		$ret = array(
			'code'        => 'unknown',
			'description' => 'No error description provided. Raw error: ' . print_r($error, true)
		);

		// Make sure there's an error tag
		if (!isset($error['.tag']))
		{
			$error['.tag'] = 'other';
		}

		$ret['code'] = $error['.tag'];

		switch ($error['.tag'])
		{
			case 'path':
			case 'path_lookup':
				$tag = $error['.tag'];

				if (!isset($error[$tag]['.tag']))
				{
					$error[$tag]['.tag'] = 'other';
				}

				$ret['code'] = $error[$tag]['.tag'];

				switch ($ret['code'])
				{
					case 'malformed_path':
						$ret['description'] = 'This field is optional.';
						break;

					case 'not_found':
						$ret['description'] = 'There is nothing at the given path.';
						break;

					case 'not_file':
						$ret['description'] = 'Dropbox was expecting a file, but the given path refers to something that isn\'t a file.';
						break;

					case 'not_folder':
						$ret['description'] = 'Dropbox was expecting a folder, but the given path refers to something that isn\'t a folder.';
						break;

					case 'restricted_content':
						$ret['description'] = 'The file cannot be transferred because the content is restricted. For example, sometimes there are legal restrictions due to copyright claims.';
						break;
				}
				break;

			case 'path_write':
				if (!isset($error['path_write']['.tag']))
				{
					$error['path_write']['.tag'] = 'other';
				}

				$ret['code'] = $error['path_write']['.tag'];

				switch ($ret['code'])
				{
					case 'malformed_path':
						$ret['description'] = 'This field is optional.';
						break;

					case 'conflict':
						$ret['description'] = 'Couldn\'t write to the target path because of a conflict.';
						break;

					case 'no_write_permission':
						$ret['description'] = 'You do not have permissions to write to the target location.';
						break;

					case 'insufficient_space':
						$ret['description'] = 'You do not have enough available space (bytes) to write more data.';
						break;

					case 'disallowed_name':
						$ret['description'] = 'Dropbox will not save the file or folder because its name contains characters that are not allowed.';
						break;
				}
				break;

			case 'reset':
				$ret['description'] = 'The folder listing cursor has been invalidated. Try getting a new folder list.';
				break;
		}

		return $ret;
	}

}