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