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