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