1<?php
2
3namespace Sabre\VObject;
4
5use
6    InvalidArgumentException;
7
8/**
9 * This is the CLI interface for sabre-vobject.
10 *
11 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
12 * @author Evert Pot (http://evertpot.com/)
13 * @license http://sabre.io/license/ Modified BSD License
14 */
15class Cli
16{
17    /**
18     * No output.
19     *
20     * @var bool
21     */
22    protected $quiet = false;
23
24    /**
25     * Help display.
26     *
27     * @var bool
28     */
29    protected $showHelp = false;
30
31    /**
32     * Whether to spit out 'mimedir' or 'json' format.
33     *
34     * @var string
35     */
36    protected $format;
37
38    /**
39     * JSON pretty print.
40     *
41     * @var bool
42     */
43    protected $pretty;
44
45    /**
46     * Source file.
47     *
48     * @var string
49     */
50    protected $inputPath;
51
52    /**
53     * Destination file.
54     *
55     * @var string
56     */
57    protected $outputPath;
58
59    /**
60     * output stream.
61     *
62     * @var resource
63     */
64    protected $stdout;
65
66    /**
67     * stdin.
68     *
69     * @var resource
70     */
71    protected $stdin;
72
73    /**
74     * stderr.
75     *
76     * @var resource
77     */
78    protected $stderr;
79
80    /**
81     * Input format (one of json or mimedir).
82     *
83     * @var string
84     */
85    protected $inputFormat;
86
87    /**
88     * Makes the parser less strict.
89     *
90     * @var bool
91     */
92    protected $forgiving = false;
93
94    /**
95     * Main function.
96     *
97     * @return int
98     */
99    public function main(array $argv)
100    {
101        // @codeCoverageIgnoreStart
102        // We cannot easily test this, so we'll skip it. Pretty basic anyway.
103
104        if (!$this->stderr) {
105            $this->stderr = fopen('php://stderr', 'w');
106        }
107        if (!$this->stdout) {
108            $this->stdout = fopen('php://stdout', 'w');
109        }
110        if (!$this->stdin) {
111            $this->stdin = fopen('php://stdin', 'r');
112        }
113
114        // @codeCoverageIgnoreEnd
115
116        try {
117            list($options, $positional) = $this->parseArguments($argv);
118
119            if (isset($options['q'])) {
120                $this->quiet = true;
121            }
122            $this->log($this->colorize('green', 'sabre/vobject ').$this->colorize('yellow', Version::VERSION));
123
124            foreach ($options as $name => $value) {
125                switch ($name) {
126                    case 'q':
127                        // Already handled earlier.
128                        break;
129                    case 'h':
130                    case 'help':
131                        $this->showHelp();
132
133                        return 0;
134                        break;
135                    case 'format':
136                        switch ($value) {
137                            // jcard/jcal documents
138                            case 'jcard':
139                            case 'jcal':
140
141                            // specific document versions
142                            case 'vcard21':
143                            case 'vcard30':
144                            case 'vcard40':
145                            case 'icalendar20':
146
147                            // specific formats
148                            case 'json':
149                            case 'mimedir':
150
151                            // icalendar/vcad
152                            case 'icalendar':
153                            case 'vcard':
154                                $this->format = $value;
155                                break;
156
157                            default:
158                                throw new InvalidArgumentException('Unknown format: '.$value);
159                        }
160                        break;
161                    case 'pretty':
162                        if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
163                            $this->pretty = true;
164                        }
165                        break;
166                    case 'forgiving':
167                        $this->forgiving = true;
168                        break;
169                    case 'inputformat':
170                        switch ($value) {
171                            // json formats
172                            case 'jcard':
173                            case 'jcal':
174                            case 'json':
175                                $this->inputFormat = 'json';
176                                break;
177
178                            // mimedir formats
179                            case 'mimedir':
180                            case 'icalendar':
181                            case 'vcard':
182                            case 'vcard21':
183                            case 'vcard30':
184                            case 'vcard40':
185                            case 'icalendar20':
186
187                                $this->inputFormat = 'mimedir';
188                                break;
189
190                            default:
191                                throw new InvalidArgumentException('Unknown format: '.$value);
192                        }
193                        break;
194                    default:
195                        throw new InvalidArgumentException('Unknown option: '.$name);
196                }
197            }
198
199            if (0 === count($positional)) {
200                $this->showHelp();
201
202                return 1;
203            }
204
205            if (1 === count($positional)) {
206                throw new InvalidArgumentException('Inputfile is a required argument');
207            }
208
209            if (count($positional) > 3) {
210                throw new InvalidArgumentException('Too many arguments');
211            }
212
213            if (!in_array($positional[0], ['validate', 'repair', 'convert', 'color'])) {
214                throw new InvalidArgumentException('Uknown command: '.$positional[0]);
215            }
216        } catch (InvalidArgumentException $e) {
217            $this->showHelp();
218            $this->log('Error: '.$e->getMessage(), 'red');
219
220            return 1;
221        }
222
223        $command = $positional[0];
224
225        $this->inputPath = $positional[1];
226        $this->outputPath = isset($positional[2]) ? $positional[2] : '-';
227
228        if ('-' !== $this->outputPath) {
229            $this->stdout = fopen($this->outputPath, 'w');
230        }
231
232        if (!$this->inputFormat) {
233            if ('.json' === substr($this->inputPath, -5)) {
234                $this->inputFormat = 'json';
235            } else {
236                $this->inputFormat = 'mimedir';
237            }
238        }
239        if (!$this->format) {
240            if ('.json' === substr($this->outputPath, -5)) {
241                $this->format = 'json';
242            } else {
243                $this->format = 'mimedir';
244            }
245        }
246
247        $realCode = 0;
248
249        try {
250            while ($input = $this->readInput()) {
251                $returnCode = $this->$command($input);
252                if (0 !== $returnCode) {
253                    $realCode = $returnCode;
254                }
255            }
256        } catch (EofException $e) {
257            // end of file
258        } catch (\Exception $e) {
259            $this->log('Error: '.$e->getMessage(), 'red');
260
261            return 2;
262        }
263
264        return $realCode;
265    }
266
267    /**
268     * Shows the help message.
269     */
270    protected function showHelp()
271    {
272        $this->log('Usage:', 'yellow');
273        $this->log('  vobject [options] command [arguments]');
274        $this->log('');
275        $this->log('Options:', 'yellow');
276        $this->log($this->colorize('green', '  -q            ')."Don't output anything.");
277        $this->log($this->colorize('green', '  -help -h      ').'Display this help message.');
278        $this->log($this->colorize('green', '  --format      ').'Convert to a specific format. Must be one of: vcard, vcard21,');
279        $this->log($this->colorize('green', '  --forgiving   ').'Makes the parser less strict.');
280        $this->log('                vcard30, vcard40, icalendar20, jcal, jcard, json, mimedir.');
281        $this->log($this->colorize('green', '  --inputformat ').'If the input format cannot be guessed from the extension, it');
282        $this->log('                must be specified here.');
283        // Only PHP 5.4 and up
284        if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
285            $this->log($this->colorize('green', '  --pretty      ').'json pretty-print.');
286        }
287        $this->log('');
288        $this->log('Commands:', 'yellow');
289        $this->log($this->colorize('green', '  validate').' source_file              Validates a file for correctness.');
290        $this->log($this->colorize('green', '  repair').' source_file [output_file]  Repairs a file.');
291        $this->log($this->colorize('green', '  convert').' source_file [output_file] Converts a file.');
292        $this->log($this->colorize('green', '  color').' source_file                 Colorize a file, useful for debugging.');
293        $this->log(
294        <<<HELP
295
296If source_file is set as '-', STDIN will be used.
297If output_file is omitted, STDOUT will be used.
298All other output is sent to STDERR.
299
300HELP
301        );
302
303        $this->log('Examples:', 'yellow');
304        $this->log('   vobject convert contact.vcf contact.json');
305        $this->log('   vobject convert --format=vcard40 old.vcf new.vcf');
306        $this->log('   vobject convert --inputformat=json --format=mimedir - -');
307        $this->log('   vobject color calendar.ics');
308        $this->log('');
309        $this->log('https://github.com/fruux/sabre-vobject', 'purple');
310    }
311
312    /**
313     * Validates a VObject file.
314     *
315     * @param Component $vObj
316     *
317     * @return int
318     */
319    protected function validate(Component $vObj)
320    {
321        $returnCode = 0;
322
323        switch ($vObj->name) {
324            case 'VCALENDAR':
325                $this->log('iCalendar: '.(string) $vObj->VERSION);
326                break;
327            case 'VCARD':
328                $this->log('vCard: '.(string) $vObj->VERSION);
329                break;
330        }
331
332        $warnings = $vObj->validate();
333        if (!count($warnings)) {
334            $this->log('  No warnings!');
335        } else {
336            $levels = [
337                1 => 'REPAIRED',
338                2 => 'WARNING',
339                3 => 'ERROR',
340            ];
341            $returnCode = 2;
342            foreach ($warnings as $warn) {
343                $extra = '';
344                if ($warn['node'] instanceof Property) {
345                    $extra = ' (property: "'.$warn['node']->name.'")';
346                }
347                $this->log('  ['.$levels[$warn['level']].'] '.$warn['message'].$extra);
348            }
349        }
350
351        return $returnCode;
352    }
353
354    /**
355     * Repairs a VObject file.
356     *
357     * @param Component $vObj
358     *
359     * @return int
360     */
361    protected function repair(Component $vObj)
362    {
363        $returnCode = 0;
364
365        switch ($vObj->name) {
366            case 'VCALENDAR':
367                $this->log('iCalendar: '.(string) $vObj->VERSION);
368                break;
369            case 'VCARD':
370                $this->log('vCard: '.(string) $vObj->VERSION);
371                break;
372        }
373
374        $warnings = $vObj->validate(Node::REPAIR);
375        if (!count($warnings)) {
376            $this->log('  No warnings!');
377        } else {
378            $levels = [
379                1 => 'REPAIRED',
380                2 => 'WARNING',
381                3 => 'ERROR',
382            ];
383            $returnCode = 2;
384            foreach ($warnings as $warn) {
385                $extra = '';
386                if ($warn['node'] instanceof Property) {
387                    $extra = ' (property: "'.$warn['node']->name.'")';
388                }
389                $this->log('  ['.$levels[$warn['level']].'] '.$warn['message'].$extra);
390            }
391        }
392        fwrite($this->stdout, $vObj->serialize());
393
394        return $returnCode;
395    }
396
397    /**
398     * Converts a vObject file to a new format.
399     *
400     * @param Component $vObj
401     *
402     * @return int
403     */
404    protected function convert($vObj)
405    {
406        $json = false;
407        $convertVersion = null;
408        $forceInput = null;
409
410        switch ($this->format) {
411            case 'json':
412                $json = true;
413                if ('VCARD' === $vObj->name) {
414                    $convertVersion = Document::VCARD40;
415                }
416                break;
417            case 'jcard':
418                $json = true;
419                $forceInput = 'VCARD';
420                $convertVersion = Document::VCARD40;
421                break;
422            case 'jcal':
423                $json = true;
424                $forceInput = 'VCALENDAR';
425                break;
426            case 'mimedir':
427            case 'icalendar':
428            case 'icalendar20':
429            case 'vcard':
430                break;
431            case 'vcard21':
432                $convertVersion = Document::VCARD21;
433                break;
434            case 'vcard30':
435                $convertVersion = Document::VCARD30;
436                break;
437            case 'vcard40':
438                $convertVersion = Document::VCARD40;
439                break;
440        }
441
442        if ($forceInput && $vObj->name !== $forceInput) {
443            throw new \Exception('You cannot convert a '.strtolower($vObj->name).' to '.$this->format);
444        }
445        if ($convertVersion) {
446            $vObj = $vObj->convert($convertVersion);
447        }
448        if ($json) {
449            $jsonOptions = 0;
450            if ($this->pretty) {
451                $jsonOptions = JSON_PRETTY_PRINT;
452            }
453            fwrite($this->stdout, json_encode($vObj->jsonSerialize(), $jsonOptions));
454        } else {
455            fwrite($this->stdout, $vObj->serialize());
456        }
457
458        return 0;
459    }
460
461    /**
462     * Colorizes a file.
463     *
464     * @param Component $vObj
465     *
466     * @return int
467     */
468    protected function color($vObj)
469    {
470        fwrite($this->stdout, $this->serializeComponent($vObj));
471    }
472
473    /**
474     * Returns an ansi color string for a color name.
475     *
476     * @param string $color
477     *
478     * @return string
479     */
480    protected function colorize($color, $str, $resetTo = 'default')
481    {
482        $colors = [
483            'cyan' => '1;36',
484            'red' => '1;31',
485            'yellow' => '1;33',
486            'blue' => '0;34',
487            'green' => '0;32',
488            'default' => '0',
489            'purple' => '0;35',
490        ];
491
492        return "\033[".$colors[$color].'m'.$str."\033[".$colors[$resetTo].'m';
493    }
494
495    /**
496     * Writes out a string in specific color.
497     *
498     * @param string $color
499     * @param string $str
500     */
501    protected function cWrite($color, $str)
502    {
503        fwrite($this->stdout, $this->colorize($color, $str));
504    }
505
506    protected function serializeComponent(Component $vObj)
507    {
508        $this->cWrite('cyan', 'BEGIN');
509        $this->cWrite('red', ':');
510        $this->cWrite('yellow', $vObj->name."\n");
511
512        /**
513         * Gives a component a 'score' for sorting purposes.
514         *
515         * This is solely used by the childrenSort method.
516         *
517         * A higher score means the item will be lower in the list.
518         * To avoid score collisions, each "score category" has a reasonable
519         * space to accommodate elements. The $key is added to the $score to
520         * preserve the original relative order of elements.
521         *
522         * @param int   $key
523         * @param array $array
524         *
525         * @return int
526         */
527        $sortScore = function ($key, $array) {
528            if ($array[$key] instanceof Component) {
529                // We want to encode VTIMEZONE first, this is a personal
530                // preference.
531                if ('VTIMEZONE' === $array[$key]->name) {
532                    $score = 300000000;
533
534                    return $score + $key;
535                } else {
536                    $score = 400000000;
537
538                    return $score + $key;
539                }
540            } else {
541                // Properties get encoded first
542                // VCARD version 4.0 wants the VERSION property to appear first
543                if ($array[$key] instanceof Property) {
544                    if ('VERSION' === $array[$key]->name) {
545                        $score = 100000000;
546
547                        return $score + $key;
548                    } else {
549                        // All other properties
550                        $score = 200000000;
551
552                        return $score + $key;
553                    }
554                }
555            }
556        };
557
558        $children = $vObj->children();
559        $tmp = $children;
560        uksort(
561            $children,
562            function ($a, $b) use ($sortScore, $tmp) {
563                $sA = $sortScore($a, $tmp);
564                $sB = $sortScore($b, $tmp);
565
566                return $sA - $sB;
567            }
568        );
569
570        foreach ($children as $child) {
571            if ($child instanceof Component) {
572                $this->serializeComponent($child);
573            } else {
574                $this->serializeProperty($child);
575            }
576        }
577
578        $this->cWrite('cyan', 'END');
579        $this->cWrite('red', ':');
580        $this->cWrite('yellow', $vObj->name."\n");
581    }
582
583    /**
584     * Colorizes a property.
585     *
586     * @param Property $property
587     */
588    protected function serializeProperty(Property $property)
589    {
590        if ($property->group) {
591            $this->cWrite('default', $property->group);
592            $this->cWrite('red', '.');
593        }
594
595        $this->cWrite('yellow', $property->name);
596
597        foreach ($property->parameters as $param) {
598            $this->cWrite('red', ';');
599            $this->cWrite('blue', $param->serialize());
600        }
601        $this->cWrite('red', ':');
602
603        if ($property instanceof Property\Binary) {
604            $this->cWrite('default', 'embedded binary stripped. ('.strlen($property->getValue()).' bytes)');
605        } else {
606            $parts = $property->getParts();
607            $first1 = true;
608            // Looping through property values
609            foreach ($parts as $part) {
610                if ($first1) {
611                    $first1 = false;
612                } else {
613                    $this->cWrite('red', $property->delimiter);
614                }
615                $first2 = true;
616                // Looping through property sub-values
617                foreach ((array) $part as $subPart) {
618                    if ($first2) {
619                        $first2 = false;
620                    } else {
621                        // The sub-value delimiter is always comma
622                        $this->cWrite('red', ',');
623                    }
624
625                    $subPart = strtr(
626                        $subPart,
627                        [
628                            '\\' => $this->colorize('purple', '\\\\', 'green'),
629                            ';' => $this->colorize('purple', '\;', 'green'),
630                            ',' => $this->colorize('purple', '\,', 'green'),
631                            "\n" => $this->colorize('purple', "\\n\n\t", 'green'),
632                            "\r" => '',
633                        ]
634                    );
635
636                    $this->cWrite('green', $subPart);
637                }
638            }
639        }
640        $this->cWrite('default', "\n");
641    }
642
643    /**
644     * Parses the list of arguments.
645     *
646     * @param array $argv
647     */
648    protected function parseArguments(array $argv)
649    {
650        $positional = [];
651        $options = [];
652
653        for ($ii = 0; $ii < count($argv); ++$ii) {
654            // Skipping the first argument.
655            if (0 === $ii) {
656                continue;
657            }
658
659            $v = $argv[$ii];
660
661            if ('--' === substr($v, 0, 2)) {
662                // This is a long-form option.
663                $optionName = substr($v, 2);
664                $optionValue = true;
665                if (strpos($optionName, '=')) {
666                    list($optionName, $optionValue) = explode('=', $optionName);
667                }
668                $options[$optionName] = $optionValue;
669            } elseif ('-' === substr($v, 0, 1) && strlen($v) > 1) {
670                // This is a short-form option.
671                foreach (str_split(substr($v, 1)) as $option) {
672                    $options[$option] = true;
673                }
674            } else {
675                $positional[] = $v;
676            }
677        }
678
679        return [$options, $positional];
680    }
681
682    protected $parser;
683
684    /**
685     * Reads the input file.
686     *
687     * @return Component
688     */
689    protected function readInput()
690    {
691        if (!$this->parser) {
692            if ('-' !== $this->inputPath) {
693                $this->stdin = fopen($this->inputPath, 'r');
694            }
695
696            if ('mimedir' === $this->inputFormat) {
697                $this->parser = new Parser\MimeDir($this->stdin, ($this->forgiving ? Reader::OPTION_FORGIVING : 0));
698            } else {
699                $this->parser = new Parser\Json($this->stdin, ($this->forgiving ? Reader::OPTION_FORGIVING : 0));
700            }
701        }
702
703        return $this->parser->parse();
704    }
705
706    /**
707     * Sends a message to STDERR.
708     *
709     * @param string $msg
710     */
711    protected function log($msg, $color = 'default')
712    {
713        if (!$this->quiet) {
714            if ('default' !== $color) {
715                $msg = $this->colorize($color, $msg);
716            }
717            fwrite($this->stderr, $msg."\n");
718        }
719    }
720}
721