4 Classes for managesieve operations (using PEAR::Net_Sieve)
6 Author: Aleksander Machniak <alec@alec.pl>
8 $Id: rcube_sieve.php 4241 2010-11-20 17:59:50Z alec $
12 // Sieve Language Basics: http://www.ietf.org/rfc/rfc5228.txt
14 define('SIEVE_ERROR_CONNECTION', 1);
15 define('SIEVE_ERROR_LOGIN', 2);
16 define('SIEVE_ERROR_NOT_EXISTS', 3); // script not exists
17 define('SIEVE_ERROR_INSTALL', 4); // script installation
18 define('SIEVE_ERROR_ACTIVATE', 5); // script activation
19 define('SIEVE_ERROR_DELETE', 6); // script deletion
20 define('SIEVE_ERROR_INTERNAL', 7); // internal error
21 define('SIEVE_ERROR_DEACTIVATE', 8); // script activation
22 define('SIEVE_ERROR_OTHER', 255); // other/unknown error
27 private $sieve; // Net_Sieve object
28 private $error = false; // error flag
29 private $list = array(); // scripts list
31 public $script; // rcube_sieve_script object
32 public $current; // name of currently loaded script
33 private $disabled; // array of disabled extensions
39 * @param string Username (for managesieve login)
40 * @param string Password (for managesieve login)
41 * @param string Managesieve server hostname/address
42 * @param string Managesieve server port number
43 * @param string Managesieve authentication method
44 * @param boolean Enable/disable TLS use
45 * @param array Disabled extensions
46 * @param boolean Enable/disable debugging
47 * @param string Proxy authentication identifier
48 * @param string Proxy authentication password
50 public function __construct($username, $password='', $host='localhost', $port=2000,
51 $auth_type=null, $usetls=true, $disabled=array(), $debug=false,
52 $auth_cid=null, $auth_pw=null)
54 $this->sieve = new Net_Sieve();
57 $this->sieve->setDebug(true, array($this, 'debug_handler'));
60 if (PEAR::isError($this->sieve->connect($host, $port, NULL, $usetls))) {
61 return $this->_set_error(SIEVE_ERROR_CONNECTION);
64 if (!empty($auth_cid)) {
66 $username = $auth_cid;
70 if (PEAR::isError($this->sieve->login($username, $password,
71 $auth_type ? strtoupper($auth_type) : null, $authz))
73 return $this->_set_error(SIEVE_ERROR_LOGIN);
76 $this->disabled = $disabled;
79 public function __destruct() {
80 $this->sieve->disconnect();
84 * Getter for error code
86 public function error()
88 return $this->error ? $this->error : false;
92 * Saves current script into server
94 public function save($name = null)
97 return $this->_set_error(SIEVE_ERROR_INTERNAL);
100 return $this->_set_error(SIEVE_ERROR_INTERNAL);
103 $name = $this->current;
105 $script = $this->script->as_text();
108 $script = '/* empty script */';
110 if (PEAR::isError($this->sieve->installScript($name, $script)))
111 return $this->_set_error(SIEVE_ERROR_INSTALL);
117 * Saves text script into server
119 public function save_script($name, $content = null)
122 return $this->_set_error(SIEVE_ERROR_INTERNAL);
125 $content = '/* empty script */';
127 if (PEAR::isError($this->sieve->installScript($name, $content)))
128 return $this->_set_error(SIEVE_ERROR_INSTALL);
134 * Activates specified script
136 public function activate($name = null)
139 return $this->_set_error(SIEVE_ERROR_INTERNAL);
142 $name = $this->current;
144 if (PEAR::isError($this->sieve->setActive($name)))
145 return $this->_set_error(SIEVE_ERROR_ACTIVATE);
151 * De-activates specified script
153 public function deactivate()
156 return $this->_set_error(SIEVE_ERROR_INTERNAL);
158 if (PEAR::isError($this->sieve->setActive('')))
159 return $this->_set_error(SIEVE_ERROR_DEACTIVATE);
165 * Removes specified script
167 public function remove($name = null)
170 return $this->_set_error(SIEVE_ERROR_INTERNAL);
173 $name = $this->current;
175 // script must be deactivated first
176 if ($name == $this->sieve->getActive())
177 if (PEAR::isError($this->sieve->setActive('')))
178 return $this->_set_error(SIEVE_ERROR_DELETE);
180 if (PEAR::isError($this->sieve->removeScript($name)))
181 return $this->_set_error(SIEVE_ERROR_DELETE);
183 if ($name == $this->current)
184 $this->current = null;
190 * Gets list of supported by server Sieve extensions
192 public function get_extensions()
195 return $this->_set_error(SIEVE_ERROR_INTERNAL);
197 $ext = $this->sieve->getExtensions();
198 // we're working on lower-cased names
199 $ext = array_map('strtolower', (array) $ext);
202 $supported = $this->script->get_extensions();
203 foreach ($ext as $idx => $ext_name)
204 if (!in_array($ext_name, $supported))
208 return array_values($ext);
212 * Gets list of scripts from server
214 public function get_scripts()
219 return $this->_set_error(SIEVE_ERROR_INTERNAL);
221 $this->list = $this->sieve->listScripts();
223 if (PEAR::isError($this->list))
224 return $this->_set_error(SIEVE_ERROR_OTHER);
231 * Returns active script name
233 public function get_active()
236 return $this->_set_error(SIEVE_ERROR_INTERNAL);
238 return $this->sieve->getActive();
242 * Loads script by name
244 public function load($name)
247 return $this->_set_error(SIEVE_ERROR_INTERNAL);
249 if ($this->current == $name)
252 $script = $this->sieve->getScript($name);
254 if (PEAR::isError($script))
255 return $this->_set_error(SIEVE_ERROR_OTHER);
257 // try to parse from Roundcube format
258 $this->script = $this->_parse($script);
260 $this->current = $name;
266 * Loads script from text content
268 public function load_script($script)
271 return $this->_set_error(SIEVE_ERROR_INTERNAL);
273 // try to parse from Roundcube format
274 $this->script = $this->_parse($script);
278 * Creates rcube_sieve_script object from text script
280 private function _parse($txt)
282 // try to parse from Roundcube format
283 $script = new rcube_sieve_script($txt, $this->disabled);
285 // ... else try to import from different formats
286 if (empty($script->content)) {
287 $script = $this->_import_rules($txt);
288 $script = new rcube_sieve_script($script, $this->disabled);
291 // replace all elsif with if+stop, we support only ifs
292 foreach ($script->content as $idx => $rule) {
293 if (!isset($script->content[$idx+1])
294 || preg_match('/^else|elsif$/', $script->content[$idx+1]['type'])) {
296 if (!preg_match('/^(stop|vacation)$/', $rule['actions'][count($rule['actions'])-1]['type'])) {
297 $script->content[$idx]['actions'][] = array(
308 * Gets specified script as text
310 public function get_script($name)
313 return $this->_set_error(SIEVE_ERROR_INTERNAL);
315 $content = $this->sieve->getScript($name);
317 if (PEAR::isError($content))
318 return $this->_set_error(SIEVE_ERROR_OTHER);
324 * Creates empty script or copy of other script
326 public function copy($name, $copy)
329 return $this->_set_error(SIEVE_ERROR_INTERNAL);
332 $content = $this->sieve->getScript($copy);
334 if (PEAR::isError($content))
335 return $this->_set_error(SIEVE_ERROR_OTHER);
338 return $this->save_script($name, $content);
341 private function _import_rules($script)
346 // Squirrelmail (Avelsieve)
347 if ($tokens = preg_split('/(#START_SIEVE_RULE.*END_SIEVE_RULE)\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
348 foreach($tokens as $token) {
349 if (preg_match('/^#START_SIEVE_RULE.*/', $token, $matches)) {
350 $name[$i] = "unnamed rule ".($i+1);
351 $content .= "# rule:[".$name[$i]."]\n";
353 elseif (isset($name[$i])) {
354 // This preg_replace is added because I've found some Avelsieve scripts
355 // with rules containing "if" here. I'm not sure it was working
356 // before without this or not.
357 $token = preg_replace('/^if\s+/', '', trim($token));
358 $content .= "if $token\n";
364 else if ($tokens = preg_split('/(# .+)\r?\n/i', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
365 foreach($tokens as $token) {
366 if (preg_match('/^# (.+)/i', $token, $matches)) {
367 $name[$i] = $matches[1];
368 $content .= "# rule:[" . $name[$i] . "]\n";
370 elseif (isset($name[$i])) {
371 $token = str_replace(":comparator \"i;ascii-casemap\" ", "", $token);
372 $content .= $token . "\n";
381 private function _set_error($error)
383 $this->error = $error;
388 * This is our own debug handler for connection
390 public function debug_handler(&$sieve, $message)
392 write_log('sieve', preg_replace('/\r\n$/', '', $message));
397 class rcube_sieve_script
399 public $content = array(); // script rules array
401 private $supported = array( // extensions supported by class
406 'vacation', // RFC5230
407 'relational', // RFC3431
408 // TODO: (most wanted first) body, imapflags, notify, regex
414 * @param string Script's text content
415 * @param array Disabled extensions
417 public function __construct($script, $disabled=NULL)
419 if (!empty($disabled))
420 foreach ($disabled as $ext)
421 if (($idx = array_search($ext, $this->supported)) !== false)
422 unset($this->supported[$idx]);
424 $this->content = $this->_parse_text($script);
428 * Adds script contents as text to the script array (at the end)
430 * @param string Text script contents
432 public function add_text($script)
434 $content = $this->_parse_text($script);
437 // check existsing script rules names
438 foreach ($this->content as $idx => $elem) {
439 $names[$elem['name']] = $idx;
442 foreach ($content as $elem) {
443 if (!isset($names[$elem['name']])) {
444 array_push($this->content, $elem);
453 * Adds rule to the script (at the end)
455 * @param string Rule name
456 * @param array Rule content (as array)
458 public function add_rule($content)
460 // TODO: check this->supported
461 array_push($this->content, $content);
462 return sizeof($this->content)-1;
465 public function delete_rule($index)
467 if(isset($this->content[$index])) {
468 unset($this->content[$index]);
474 public function size()
476 return sizeof($this->content);
479 public function update_rule($index, $content)
481 // TODO: check this->supported
482 if ($this->content[$index]) {
483 $this->content[$index] = $content;
490 * Returns script as text
492 public function as_text()
499 foreach ($this->content as $rule) {
505 $script .= '# rule:[' . $rule['name'] . "]\n";
507 // constraints expressions
508 foreach ($rule['tests'] as $test) {
510 switch ($test['test']) {
512 $tests[$i] .= ($test['not'] ? 'not ' : '');
513 $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
516 $tests[$i] .= ($test['not'] ? 'not true' : 'true');
519 $tests[$i] .= ($test['not'] ? 'not ' : '');
520 if (is_array($test['arg']))
521 $tests[$i] .= 'exists ["' . implode('", "', $this->_escape_string($test['arg'])) . '"]';
523 $tests[$i] .= 'exists "' . $this->_escape_string($test['arg']) . '"';
526 $tests[$i] .= ($test['not'] ? 'not ' : '');
528 // relational operator + comparator
529 if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
530 array_push($exts, 'relational');
531 array_push($exts, 'comparator-i;ascii-numeric');
532 $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
535 $tests[$i] .= 'header :' . $test['type'];
537 if (is_array($test['arg1']))
538 $tests[$i] .= ' ["' . implode('", "', $this->_escape_string($test['arg1'])) . '"]';
540 $tests[$i] .= ' "' . $this->_escape_string($test['arg1']) . '"';
542 if (is_array($test['arg2']))
543 $tests[$i] .= ' ["' . implode('", "', $this->_escape_string($test['arg2'])) . '"]';
545 $tests[$i] .= ' "' . $this->_escape_string($test['arg2']) . '"';
552 // $script .= ($idx>0 ? 'els' : '').($rule['join'] ? 'if allof (' : 'if anyof (');
553 // disabled rule: if false #....
554 $script .= 'if' . ($rule['disabled'] ? ' false #' : '');
555 $script .= $rule['join'] ? ' allof (' : ' anyof (';
556 if (sizeof($tests) > 1)
557 $script .= implode(", ", $tests);
558 else if (sizeof($tests))
559 $script .= $tests[0];
565 foreach ($rule['actions'] as $action) {
566 switch ($action['type']) {
568 array_push($exts, 'fileinto');
569 $script .= "\tfileinto ";
570 if ($action['copy']) {
572 array_push($exts, 'copy');
574 $script .= "\"" . $this->_escape_string($action['target']) . "\";\n";
577 $script .= "\tredirect ";
578 if ($action['copy']) {
580 array_push($exts, 'copy');
582 $script .= "\"" . $this->_escape_string($action['target']) . "\";\n";
586 array_push($exts, $action['type']);
587 if (strpos($action['target'], "\n")!==false)
588 $script .= "\t".$action['type']." text:\n" . $action['target'] . "\n.\n;\n";
590 $script .= "\t".$action['type']." \"" . $this->_escape_string($action['target']) . "\";\n";
595 $script .= "\t" . $action['type'] .";\n";
598 array_push($exts, 'vacation');
599 $script .= "\tvacation";
601 $script .= " :days " . $action['days'];
602 if ($action['addresses'])
603 $script .= " :addresses " . $this->_print_list($action['addresses']);
604 if ($action['subject'])
605 $script .= " :subject \"" . $this->_escape_string($action['subject']) . "\"";
606 if ($action['handle'])
607 $script .= " :handle \"" . $this->_escape_string($action['handle']) . "\"";
609 $script .= " :from \"" . $this->_escape_string($action['from']) . "\"";
612 if (strpos($action['reason'], "\n")!==false)
613 $script .= " text:\n" . $action['reason'] . "\n.\n;\n";
615 $script .= " \"" . $this->_escape_string($action['reason']) . "\";\n";
626 $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
632 * Returns script object
635 public function as_array()
637 return $this->content;
641 * Returns array of supported extensions
644 public function get_extensions()
646 return array_values($this->supported);
650 * Converts text script to rules array
652 * @param string Text script
654 private function _parse_text($script)
660 $script = preg_replace('|/\*.*?\*/|sm', '', $script);
663 if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
664 foreach($tokens as $token) {
665 if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
666 $content[$i]['name'] = $matches[1];
668 else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
669 if ($rule = $this->_tokenize_rule($token)) {
670 $content[$i] = array_merge($content[$i], $rule);
673 else // unknown rule format
683 * Convert text script fragment to rule object
685 * @param string Text rule
687 private function _tokenize_rule($content)
691 if (preg_match('/^(if|elsif|else)\s+((true|false|not\s+true|allof|anyof|exists|header|not|size)(.*))\s+\{(.*)\}$/sm',
692 trim($content), $matches)) {
694 $tests = trim($matches[2]);
696 // disabled rule (false + comment): if false #.....
697 if ($matches[3] == 'false') {
698 $tests = preg_replace('/^false\s+#\s+/', '', $tests);
704 list($tests, $join) = $this->_parse_tests($tests);
705 $actions = $this->_parse_actions(trim($matches[5]));
707 if ($tests && $actions)
709 'type' => $matches[1],
711 'actions' => $actions,
713 'disabled' => $disabled,
721 * Parse body of actions section
723 * @param string Text body
724 * @return array Array of parsed action type/target pairs
726 private function _parse_actions($content)
731 $patterns[] = '^\s*discard;';
732 $patterns[] = '^\s*keep;';
733 $patterns[] = '^\s*stop;';
734 $patterns[] = '^\s*redirect\s+(.*?[^\\\]);';
735 if (in_array('fileinto', $this->supported))
736 $patterns[] = '^\s*fileinto\s+(.*?[^\\\]);';
737 if (in_array('reject', $this->supported)) {
738 $patterns[] = '^\s*reject\s+text:(.*)\n\.\n;';
739 $patterns[] = '^\s*reject\s+(.*?[^\\\]);';
740 $patterns[] = '^\s*ereject\s+text:(.*)\n\.\n;';
741 $patterns[] = '^\s*ereject\s+(.*?[^\\\]);';
743 if (in_array('vacation', $this->supported))
744 $patterns[] = '^\s*vacation\s+(.*?[^\\\]);';
746 $pattern = '/(' . implode('\s*$)|(', $patterns) . '$\s*)/ms';
748 // parse actions body
749 if (preg_match_all($pattern, $content, $mm, PREG_SET_ORDER)) {
750 foreach ($mm as $m) {
751 $content = trim($m[0]);
753 if(preg_match('/^(discard|keep|stop)/', $content, $matches)) {
754 $result[] = array('type' => $matches[1]);
756 else if(preg_match('/^fileinto/', $content)) {
757 $target = $m[sizeof($m)-1];
759 if (preg_match('/^:copy\s+/', $target)) {
760 $target = preg_replace('/^:copy\s+/', '', $target);
763 $result[] = array('type' => 'fileinto', 'copy' => $copy,
764 'target' => $this->_parse_string($target));
766 else if(preg_match('/^redirect/', $content)) {
767 $target = $m[sizeof($m)-1];
769 if (preg_match('/^:copy\s+/', $target)) {
770 $target = preg_replace('/^:copy\s+/', '', $target);
773 $result[] = array('type' => 'redirect', 'copy' => $copy,
774 'target' => $this->_parse_string($target));
776 else if(preg_match('/^(reject|ereject)\s+(.*);$/sm', $content, $matches)) {
777 $result[] = array('type' => $matches[1], 'target' => $this->_parse_string($matches[2]));
779 else if(preg_match('/^vacation\s+(.*);$/sm', $content, $matches)) {
780 $vacation = array('type' => 'vacation');
782 if (preg_match('/:days\s+([0-9]+)/', $content, $vm)) {
783 $vacation['days'] = $vm[1];
784 $content = preg_replace('/:days\s+([0-9]+)/', '', $content);
786 if (preg_match('/:subject\s+"(.*?[^\\\])"/', $content, $vm)) {
787 $vacation['subject'] = $vm[1];
788 $content = preg_replace('/:subject\s+"(.*?[^\\\])"/', '', $content);
790 if (preg_match('/:addresses\s+\[(.*?[^\\\])\]/', $content, $vm)) {
791 $vacation['addresses'] = $this->_parse_list($vm[1]);
792 $content = preg_replace('/:addresses\s+\[(.*?[^\\\])\]/', '', $content);
794 if (preg_match('/:handle\s+"(.*?[^\\\])"/', $content, $vm)) {
795 $vacation['handle'] = $vm[1];
796 $content = preg_replace('/:handle\s+"(.*?[^\\\])"/', '', $content);
798 if (preg_match('/:from\s+"(.*?[^\\\])"/', $content, $vm)) {
799 $vacation['from'] = $vm[1];
800 $content = preg_replace('/:from\s+"(.*?[^\\\])"/', '', $content);
803 $content = preg_replace('/^vacation/', '', $content);
804 $content = preg_replace('/;$/', '', $content);
805 $content = trim($content);
807 if (preg_match('/^:mime/', $content, $vm)) {
808 $vacation['mime'] = true;
809 $content = preg_replace('/^:mime/', '', $content);
812 $vacation['reason'] = $this->_parse_string($content);
814 $result[] = $vacation;
823 * Parse test/conditions section
827 private function _parse_tests($content)
832 if (preg_match('/^(allof|anyof)\s+\((.*)\)$/sm', $content, $matches)) {
833 $content = $matches[2];
834 $join = $matches[1]=='allof' ? true : false;
839 // supported tests regular expressions
840 // TODO: comparators, envelope
841 $patterns[] = '(not\s+)?(exists)\s+\[(.*?[^\\\])\]';
842 $patterns[] = '(not\s+)?(exists)\s+(".*?[^\\\]")';
843 $patterns[] = '(not\s+)?(true)';
844 $patterns[] = '(not\s+)?(size)\s+:(under|over)\s+([0-9]+[KGM]{0,1})';
845 $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))\[(.*?[^\\\]")\]\s+\[(.*?[^\\\]")\]';
846 $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))(".*?[^\\\]")\s+(".*?[^\\\]")';
847 $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))\[(.*?[^\\\]")\]\s+(".*?[^\\\]")';
848 $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))(".*?[^\\\]")\s+\[(.*?[^\\\]")\]';
849 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+\[(.*?[^\\\]")\]\s+\[(.*?[^\\\]")\]';
850 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+(".*?[^\\\]")\s+(".*?[^\\\]")';
851 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+\[(.*?[^\\\]")\]\s+(".*?[^\\\]")';
852 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+(".*?[^\\\]")\s+\[(.*?[^\\\]")\]';
855 $pattern = '/(' . implode(')|(', $patterns) . ')/';
857 // ...and parse tests list
858 if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
859 foreach ($matches as $match) {
860 $size = sizeof($match);
862 if (preg_match('/^(not\s+)?size/', $match[0])) {
865 'not' => $match[$size-4] ? true : false,
866 'type' => $match[$size-2], // under/over
867 'arg' => $match[$size-1], // value
870 else if (preg_match('/^(not\s+)?header/', $match[0])) {
871 $type = $match[$size-5];
872 if (preg_match('/^(count|value)\s+"([gtleqn]{2})"/', $type, $m))
873 $type = $m[1] . '-' . $m[2];
877 'type' => $type, // is/contains/matches
878 'not' => $match[$size-7] ? true : false,
879 'arg1' => $this->_parse_list($match[$size-2]), // header(s)
880 'arg2' => $this->_parse_list($match[$size-1]), // string(s)
883 else if (preg_match('/^(not\s+)?exists/', $match[0])) {
886 'not' => $match[$size-3] ? true : false,
887 'arg' => $this->_parse_list($match[$size-1]), // header(s)
890 else if (preg_match('/^(not\s+)?true/', $match[0])) {
893 'not' => $match[$size-2] ? true : false,
899 return array($result, $join);
907 private function _parse_string($content)
910 $content = trim($content);
912 if (preg_match('/^text:(.*)\.$/sm', $content, $matches))
913 $text = trim($matches[1]);
914 else if (preg_match('/^"(.*)"$/', $content, $matches))
915 $text = str_replace('\"', '"', $matches[1]);
921 * Escape special chars in string value
925 private function _escape_string($content)
927 $replace['/"/'] = '\\"';
929 if (is_array($content)) {
930 for ($x=0, $y=sizeof($content); $x<$y; $x++)
931 $content[$x] = preg_replace(array_keys($replace),
932 array_values($replace), $content[$x]);
937 return preg_replace(array_keys($replace), array_values($replace), $content);
941 * Parse string or list of strings to string or array of strings
945 private function _parse_list($content)
949 for ($x=0, $len=strlen($content); $x<$len; $x++) {
950 switch ($content[$x]) {
952 $str .= $content[++$x];
964 $str .= $content[$x];
969 if (sizeof($result)>1)
971 else if (sizeof($result) == 1)
978 * Convert array of elements to list of strings
982 private function _print_list($list)
984 $list = (array) $list;
985 foreach($list as $idx => $val)
986 $list[$idx] = $this->_escape_string($val);
988 return '["' . implode('","', $list) . '"]';