1<?php 2 3use dokuwiki\Extension\SyntaxPlugin; 4 5/** 6 * 7 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 8 * @author Andreas Gohr <andi@splitbrain.org> 9 */ 10/** 11 * Class syntax_plugin_data_table 12 */ 13class syntax_plugin_data_table extends SyntaxPlugin 14{ 15 /** 16 * will hold the data helper plugin 17 * 18 * @var $dthlp helper_plugin_data 19 */ 20 public $dthlp; 21 22 public $sums = []; 23 24 /** 25 * Constructor. Load helper plugin 26 */ 27 public function __construct() 28 { 29 $this->dthlp = plugin_load('helper', 'data'); 30 } 31 32 /** 33 * What kind of syntax are we? 34 */ 35 public function getType() 36 { 37 return 'substition'; 38 } 39 40 /** 41 * What about paragraphs? 42 */ 43 public function getPType() 44 { 45 return 'block'; 46 } 47 48 /** 49 * Where to sort in? 50 */ 51 public function getSort() 52 { 53 return 155; 54 } 55 56 /** 57 * Connect pattern to lexer 58 */ 59 public function connectTo($mode) 60 { 61 $this->Lexer->addSpecialPattern( 62 '----+ *datatable(?: [ a-zA-Z0-9_]*)?-+\n.*?\n----+', 63 $mode, 64 'plugin_data_table' 65 ); 66 } 67 68 /** 69 * Handle the match - parse the data 70 * 71 * This parsing is shared between the multiple different output/control 72 * syntaxes 73 * 74 * @param string $match The text matched by the patterns 75 * @param int $state The lexer state for the match 76 * @param int $pos The character position of the matched text 77 * @param Doku_Handler $handler The Doku_Handler object 78 * @return bool|array Return an array with all data you want to use in render, false don't add an instruction 79 */ 80 public function handle($match, $state, $pos, Doku_Handler $handler) 81 { 82 if (!$this->dthlp->ready()) return null; 83 84 // get lines and additional class 85 $lines = explode("\n", $match); 86 array_pop($lines); 87 $class = array_shift($lines); 88 $class = preg_replace('/^----+ *data[a-z]+/', '', $class); 89 $class = trim($class, '- '); 90 91 $data = [ 92 'classes' => $class, 93 'limit' => 0, 94 'dynfilters' => false, 95 'summarize' => false, 96 'rownumbers' => (bool)$this->getConf('rownumbers'), 97 'sepbyheaders' => false, 98 'headers' => [], 99 'widths' => [], 100 'filter' => [] 101 ]; 102 103 // parse info 104 foreach ($lines as $line) { 105 // ignore comments 106 $line = preg_replace('/(?<![&\\\\])#.*$/', '', $line); 107 $line = str_replace('\\#', '#', $line); 108 $line = trim($line); 109 if (empty($line)) continue; 110 $line = preg_split('/\s*:\s*/', $line, 2); 111 $line[0] = strtolower($line[0]); 112 113 $logic = 'OR'; 114 // handle line commands (we allow various aliases here) 115 switch ($line[0]) { 116 case 'select': 117 case 'cols': 118 case 'field': 119 case 'col': 120 $cols = explode(',', $line[1]); 121 foreach ($cols as $col) { 122 $col = trim($col); 123 if (!$col) continue; 124 $column = $this->dthlp->column($col); 125 $data['cols'][$column['key']] = $column; 126 } 127 break; 128 case 'title': 129 $data['title'] = $line[1]; 130 break; 131 case 'head': 132 case 'header': 133 case 'headers': 134 $cols = $this->parseValues($line[1]); 135 $data['headers'] = array_merge($data['headers'], $cols); 136 break; 137 case 'align': 138 $cols = explode(',', $line[1]); 139 foreach ($cols as $col) { 140 $col = trim(strtolower($col)); 141 if ($col[0] == 'c') { 142 $col = 'center'; 143 } elseif ($col[0] == 'r') { 144 $col = 'right'; 145 } else { 146 $col = 'left'; 147 } 148 $data['align'][] = $col; 149 } 150 break; 151 case 'widths': 152 $cols = explode(',', $line[1]); 153 foreach ($cols as $col) { 154 $col = trim($col); 155 $data['widths'][] = $col; 156 } 157 break; 158 case 'min': 159 $data['min'] = abs((int)$line[1]); 160 break; 161 case 'limit': 162 case 'max': 163 $data['limit'] = abs((int)$line[1]); 164 break; 165 case 'order': 166 case 'sort': 167 $column = $this->dthlp->column($line[1]); 168 $sort = $column['key']; 169 if (substr($sort, 0, 1) == '^') { 170 $data['sort'] = [substr($sort, 1), 'DESC']; 171 } else { 172 $data['sort'] = [$sort, 'ASC']; 173 } 174 break; 175 case 'where': 176 case 'filter': 177 case 'filterand': 178 case 'and': // phpcs:ignore PSR2.ControlStructures.SwitchDeclaration.TerminatingComment 179 $logic = 'AND'; 180 case 'filteror': 181 case 'or': 182 if (!$logic) { 183 $logic = 'OR'; 184 } 185 $flt = $this->dthlp->parseFilter($line[1]); 186 if (is_array($flt)) { 187 $flt['logic'] = $logic; 188 $data['filter'][] = $flt; 189 } 190 break; 191 case 'page': 192 case 'target': 193 $data['page'] = cleanID($line[1]); 194 break; 195 case 'dynfilters': 196 $data['dynfilters'] = (bool)$line[1]; 197 break; 198 case 'rownumbers': 199 $data['rownumbers'] = (bool)$line[1]; 200 break; 201 case 'summarize': 202 $data['summarize'] = (bool)$line[1]; 203 break; 204 case 'sepbyheaders': 205 $data['sepbyheaders'] = (bool)$line[1]; 206 break; 207 default: 208 msg("data plugin: unknown option '" . hsc($line[0]) . "'", -1); 209 } 210 } 211 212 // we need at least one column to display 213 if (!is_array($data['cols']) || $data['cols'] === []) { 214 msg('data plugin: no columns selected', -1); 215 return null; 216 } 217 218 // fill up headers with field names if necessary 219 $data['headers'] = (array)$data['headers']; 220 $cnth = count($data['headers']); 221 $cntf = count($data['cols']); 222 for ($i = $cnth; $i < $cntf; $i++) { 223 $column = array_slice($data['cols'], $i, 1); 224 $columnprops = array_pop($column); 225 $data['headers'][] = $columnprops['title']; 226 } 227 228 $data['sql'] = $this->buildSQL($data); 229 230 // Save current request params for comparison in updateSQL 231 $data['cur_param'] = $this->dthlp->getPurrentParam(false); 232 return $data; 233 } 234 235 protected $before_item = '<tr>'; 236 protected $after_item = '</tr>'; 237 protected $before_val = '<td %s>'; 238 protected $after_val = '</td>'; 239 240 /** 241 * Handles the actual output creation. 242 * 243 * @param string $format output format being rendered 244 * @param Doku_Renderer $renderer the current renderer object 245 * @param array $data data created by handler() 246 * @return boolean rendered correctly? (however, returned value is not used at the moment) 247 */ 248 public function render($format, Doku_Renderer $renderer, $data) 249 { 250 if ($format != 'xhtml') return false; 251 if (is_null($data)) return false; 252 if (!$this->dthlp->ready()) return false; 253 $sqlite = $this->dthlp->getDB(); 254 if (!$sqlite) return false; 255 256 $renderer->info['cache'] = false; 257 258 //reset counters 259 $this->sums = []; 260 261 if ($this->hasRequestFilter() || isset($_REQUEST['dataofs'])) { 262 $this->updateSQLwithQuery($data); // handles request params 263 } 264 $this->dthlp->replacePlaceholdersInSQL($data); 265 266 // run query 267 $clist = array_keys($data['cols']); 268 $rows = $sqlite->queryAll($data['sql']); 269 $cnt = count($rows); 270 271 if ($cnt === 0) { 272 $this->nullList($data, $clist, $renderer); 273 return true; 274 } 275 276 if ($data['limit'] && $cnt > $data['limit']) { 277 $rows = array_slice($rows, 0, $data['limit']); 278 } 279 280 //build classnames per column 281 $classes = []; 282 $class_names_cache = []; 283 $offset = 0; 284 if ($data['rownumbers']) { 285 $offset = 1; //rownumbers are in first column 286 $classes[] = $data['align'][0] . 'align rownumbers'; 287 } 288 foreach ($clist as $index => $col) { 289 $class = ''; 290 if (isset($data['align'])) { 291 $class .= $data['align'][$index + $offset]; 292 } 293 $class .= 'align ' . hsc(sectionID($col, $class_names_cache)); 294 $classes[] = $class; 295 } 296 297 //start table/list 298 $renderer->doc .= $this->preList($clist, $data); 299 300 foreach ($rows as $rownum => $row) { 301 // build data rows 302 $renderer->doc .= $this->before_item; 303 304 if ($data['rownumbers']) { 305 $renderer->doc .= sprintf($this->before_val, 'class="' . $classes[0] . '"'); 306 $renderer->doc .= $rownum + 1; 307 $renderer->doc .= $this->after_val; 308 } 309 310 foreach (array_values($row) as $num => $cval) { 311 $num_rn = $num + $offset; 312 313 $renderer->doc .= sprintf($this->beforeVal($data, $num_rn), 'class="' . $classes[$num_rn] . '"'); 314 $renderer->doc .= $this->dthlp->formatData( 315 $data['cols'][$clist[$num]], 316 $cval, 317 $renderer 318 ); 319 $renderer->doc .= $this->afterVal($data, $num_rn); 320 321 // clean currency symbols 322 $nval = str_replace('$€₤', '', $cval); 323 $nval = str_replace('/ [A-Z]{0,3}$/', '', $nval); 324 $nval = str_replace(',', '.', $nval); 325 $nval = trim($nval); 326 327 // summarize 328 if ($data['summarize'] && is_numeric($nval)) { 329 if (!isset($this->sums[$num])) { 330 $this->sums[$num] = 0; 331 } 332 $this->sums[$num] += $nval; 333 } 334 } 335 $renderer->doc .= $this->after_item; 336 } 337 $renderer->doc .= $this->postList($data, $cnt); 338 339 return true; 340 } 341 342 /** 343 * Before value in table cell 344 * 345 * @param array $data instructions by handler 346 * @param int $colno column number 347 * @return string 348 */ 349 protected function beforeVal(&$data, $colno) 350 { 351 return $this->before_val; 352 } 353 354 /** 355 * After value in table cell 356 * 357 * @param array $data 358 * @param int $colno 359 * @return string 360 */ 361 protected function afterVal(&$data, $colno) 362 { 363 return $this->after_val; 364 } 365 366 /** 367 * Create table header 368 * 369 * @param array $clist keys of the columns 370 * @param array $data instruction by handler 371 * @return string html of table header 372 */ 373 public function preList($clist, $data) 374 { 375 global $ID; 376 global $conf; 377 378 // Save current request params to not loose them 379 $cur_params = $this->dthlp->getPurrentParam(); 380 381 //show active filters 382 $text = '<div class="table dataaggregation">'; 383 if (isset($_REQUEST['dataflt'])) { 384 $filters = $this->dthlp->getFilters(); 385 $fltrs = []; 386 foreach ($filters as $filter) { 387 if (strpos($filter['compare'], 'LIKE') !== false) { 388 if (strpos($filter['compare'], 'NOT') !== false) { 389 $comparator_value = '!~' . str_replace('%', '*', $filter['value']); 390 } else { 391 $comparator_value = '*~' . str_replace('%', '', $filter['value']); 392 } 393 $fltrs[] = $filter['key'] . $comparator_value; 394 } else { 395 $fltrs[] = $filter['key'] . $filter['compare'] . $filter['value']; 396 } 397 } 398 399 $text .= '<div class="filter">'; 400 $text .= '<h4>' . sprintf($this->getLang('tablefilteredby'), hsc(implode(' & ', $fltrs))) . '</h4>'; 401 $text .= '<div class="resetfilter">' . 402 '<a href="' . wl($ID) . '">' . $this->getLang('tableresetfilter') . '</a>' . 403 '</div>'; 404 $text .= '</div>'; 405 } 406 // build table 407 $text .= '<table class="inline dataplugin_table ' . $data['classes'] . '">'; 408 // build column headers 409 $text .= '<tr>'; 410 411 if ($data['rownumbers']) { 412 $text .= '<th>#</th>'; 413 } 414 415 foreach ($data['headers'] as $num => $head) { 416 $ckey = $clist[$num]; 417 418 $width = ''; 419 if (isset($data['widths'][$num]) && $data['widths'][$num] != '-') { 420 $width = ' style="width: ' . $data['widths'][$num] . ';"'; 421 } 422 $text .= '<th' . $width . '>'; 423 424 // add sort arrow 425 if (isset($data['sort']) && $ckey == $data['sort'][0]) { 426 if ($data['sort'][1] == 'ASC') { 427 $text .= '<span>↓</span> '; 428 $ckey = '^' . $ckey; 429 } else { 430 $text .= '<span>↑</span> '; 431 } 432 } 433 434 // Clickable header for dynamic sorting 435 $text .= '<a href="' . wl($ID, ['datasrt' => $ckey] + $cur_params) . 436 '" title="' . $this->getLang('sort') . '">' . hsc($head) . '</a>'; 437 $text .= '</th>'; 438 } 439 $text .= '</tr>'; 440 441 // Dynamic filters 442 if ($data['dynfilters']) { 443 $text .= '<tr class="dataflt">'; 444 445 if ($data['rownumbers']) { 446 $text .= '<th></th>'; 447 } 448 449 foreach ($data['headers'] as $num => $head) { 450 $text .= '<th>'; 451 $form = new Doku_Form(['method' => 'GET']); 452 $form->_hidden = []; 453 if (!$conf['userewrite']) { 454 $form->addHidden('id', $ID); 455 } 456 457 $key = 'dataflt[' . $data['cols'][$clist[$num]]['colname'] . '*~' . ']'; 458 $val = $cur_params[$key] ?? ''; 459 460 // Add current request params 461 foreach ($cur_params as $c_key => $c_val) { 462 if ($c_val !== '' && $c_key !== $key) { 463 $form->addHidden($c_key, $c_val); 464 } 465 } 466 467 $form->addElement(form_makeField('text', $key, $val, '')); 468 $text .= $form->getForm(); 469 $text .= '</th>'; 470 } 471 $text .= '</tr>'; 472 } 473 474 return $text; 475 } 476 477 /** 478 * Create an empty table 479 * 480 * @param array $data instruction by handler() 481 * @param array $clist keys of the columns 482 * @param Doku_Renderer $R 483 */ 484 public function nullList($data, $clist, $R) 485 { 486 $R->doc .= $this->preList($clist, $data); 487 $R->tablerow_open(); 488 $R->tablecell_open(count($clist), 'center'); 489 $R->cdata($this->getLang('none')); 490 $R->tablecell_close(); 491 $R->tablerow_close(); 492 $R->doc .= '</table></div>'; 493 } 494 495 /** 496 * Create table footer 497 * 498 * @param array $data instruction by handler() 499 * @param int $rowcnt number of rows 500 * @return string html of table footer 501 */ 502 public function postList($data, $rowcnt) 503 { 504 global $ID; 505 $text = ''; 506 // if summarize was set, add sums 507 if ($data['summarize']) { 508 $text .= '<tr>'; 509 $len = count($data['cols']); 510 511 if ($data['rownumbers']) $text .= '<td></td>'; 512 513 for ($i = 0; $i < $len; $i++) { 514 $text .= '<td class="' . $data['align'][$i] . 'align">'; 515 if (!empty($this->sums[$i])) { 516 $text .= '∑ ' . $this->sums[$i]; 517 } else { 518 $text .= ' '; 519 } 520 $text .= '</td>'; 521 } 522 $text .= '<tr>'; 523 } 524 525 // if limit was set, add control 526 if ($data['limit']) { 527 $text .= '<tr><th colspan="' . (count($data['cols']) + ($data['rownumbers'] ? 1 : 0)) . '">'; 528 $offset = (int)$_REQUEST['dataofs']; 529 if ($offset) { 530 $prev = $offset - $data['limit']; 531 if ($prev < 0) { 532 $prev = 0; 533 } 534 535 // keep url params 536 $params = $this->dthlp->a2ua('dataflt', $_REQUEST['dataflt']); 537 if (isset($_REQUEST['datasrt'])) { 538 $params['datasrt'] = $_REQUEST['datasrt']; 539 } 540 $params['dataofs'] = $prev; 541 542 $text .= '<a href="' . wl($ID, $params) . 543 '" title="' . $this->getLang('prev') . 544 '" class="prev">' . $this->getLang('prev') . '</a>'; 545 } 546 547 $text .= ' '; 548 549 if ($rowcnt > $data['limit']) { 550 $next = $offset + $data['limit']; 551 552 // keep url params 553 $params = $this->dthlp->a2ua('dataflt', $_REQUEST['dataflt']); 554 if (isset($_REQUEST['datasrt'])) { 555 $params['datasrt'] = $_REQUEST['datasrt']; 556 } 557 $params['dataofs'] = $next; 558 559 $text .= '<a href="' . wl($ID, $params) . 560 '" title="' . $this->getLang('next') . 561 '" class="next">' . $this->getLang('next') . '</a>'; 562 } 563 $text .= '</th></tr>'; 564 } 565 566 $text .= '</table></div>'; 567 return $text; 568 } 569 570 /** 571 * Builds the SQL query from the given data 572 * 573 * @param array &$data instruction by handler 574 * @return bool|string SQL query or false 575 */ 576 public function buildSQL(&$data) 577 { 578 $cnt = 0; 579 $tables = []; 580 $select = []; 581 $from = ''; 582 583 $from2 = ''; 584 $where2 = '1 = 1'; 585 586 $sqlite = $this->dthlp->getDB(); 587 if (!$sqlite) return false; 588 589 // prepare the columns to show 590 foreach ($data['cols'] as &$col) { 591 $key = $col['key']; 592 if ($key == '%pageid%') { 593 // Prevent stripping of trailing zeros by forcing a CAST 594 $select[] = '" " || pages.page'; 595 } elseif ($key == '%class%') { 596 // Prevent stripping of trailing zeros by forcing a CAST 597 $select[] = '" " || pages.class'; 598 } elseif ($key == '%lastmod%') { 599 $select[] = 'pages.lastmod'; 600 } elseif ($key == '%title%') { 601 $select[] = "pages.page || '|' || pages.title"; 602 } else { 603 if (!isset($tables[$key])) { 604 $tables[$key] = 'T' . (++$cnt); 605 $from .= ' LEFT JOIN data AS ' . $tables[$key] . ' ON ' . $tables[$key] . '.pid = W1.pid'; 606 $from .= ' AND ' . $tables[$key] . ".key = " . $sqlite->getPdo()->quote($key); 607 } 608 $type = $col['type']; 609 if (is_array($type)) { 610 $type = $type['type']; 611 } 612 switch ($type) { 613 case 'pageid': 614 case 'wiki': 615 //note in multivalued case: adds pageid only to first value 616 $select[] = "pages.page || '|' || GROUP_CONCAT_DISTINCT(" . $tables[$key] . ".value,'\n')"; 617 break; 618 default: 619 // Prevent stripping of trailing zeros by forcing a CAST 620 $select[] = 'GROUP_CONCAT_DISTINCT(" " || ' . $tables[$key] . ".value,'\n')"; 621 } 622 } 623 } 624 unset($col); 625 626 // prepare sorting 627 if (isset($data['sort'])) { 628 $col = $data['sort'][0]; 629 630 if ($col == '%pageid%') { 631 $order = 'ORDER BY pages.page ' . $data['sort'][1]; 632 } elseif ($col == '%class%') { 633 $order = 'ORDER BY pages.class ' . $data['sort'][1]; 634 } elseif ($col == '%title%') { 635 $order = 'ORDER BY pages.title ' . $data['sort'][1]; 636 } elseif ($col == '%lastmod%') { 637 $order = 'ORDER BY pages.lastmod ' . $data['sort'][1]; 638 } else { 639 // sort by hidden column? 640 if (!$tables[$col]) { 641 $tables[$col] = 'T' . (++$cnt); 642 $from .= ' LEFT JOIN data AS ' . $tables[$col] . ' ON ' . $tables[$col] . '.pid = W1.pid'; 643 $from .= ' AND ' . $tables[$col] . ".key = " . $sqlite->getPdo()->quote($col); 644 } 645 646 $order = 'ORDER BY ' . $tables[$col] . '.value ' . $data['sort'][1]; 647 } 648 } else { 649 $order = 'ORDER BY 1 ASC'; 650 } 651 652 // may be disabled from config. as it decreases performance a lot 653 $use_dataresolve = $this->getConf('use_dataresolve'); 654 655 // prepare filters 656 $cnt = 0; 657 if (is_array($data['filter']) && count($data['filter'])) { 658 foreach ($data['filter'] as $filter) { 659 $col = $filter['key']; 660 $closecompare = ($filter['compare'] == 'IN(' ? ')' : ''); 661 662 if ($col == '%pageid%') { 663 $where2 .= " " . $filter['logic'] . " pages.page " . 664 $filter['compare'] . " " . $filter['value'] . $closecompare; 665 } elseif ($col == '%class%') { 666 $where2 .= " " . $filter['logic'] . " pages.class " . 667 $filter['compare'] . " " . $filter['value'] . $closecompare; 668 } elseif ($col == '%title%') { 669 $where2 .= " " . $filter['logic'] . " pages.title " . 670 $filter['compare'] . " " . $filter['value'] . $closecompare; 671 } elseif ($col == '%lastmod%') { 672 # parse value to int? 673 $filter['value'] = (int)strtotime($filter['value']); 674 $where2 .= " " . $filter['logic'] . " pages.lastmod " . 675 $filter['compare'] . " " . $filter['value'] . $closecompare; 676 } else { 677 // filter by hidden column? 678 $table = 'T' . (++$cnt); 679 $from2 .= ' LEFT JOIN data AS ' . $table . ' ON ' . $table . '.pid = pages.pid'; 680 $from2 .= ' AND ' . $table . ".key = " . $sqlite->getPdo()->quote($col); 681 682 // apply data resolving? 683 if ($use_dataresolve && $filter['colname'] && (substr($filter['compare'], -4) == 'LIKE')) { 684 $where2 .= ' ' . $filter['logic'] . 685 ' DATARESOLVE(' . $table . '.value,' . $sqlite->getPdo()->quote($filter['colname']) . ') ' . 686 $filter['compare'] . 687 " " . $filter['value']; //value is already escaped 688 } else { 689 $where2 .= ' ' . $filter['logic'] . ' ' . $table . '.value ' . $filter['compare'] . 690 " " . $filter['value'] . $closecompare; //value is already escaped 691 } 692 } 693 } 694 } 695 696 // build the query 697 $sql = "SELECT " . implode(', ', $select) . " 698 FROM ( 699 SELECT DISTINCT pages.pid AS pid 700 FROM pages $from2 701 WHERE $where2 702 ) AS W1 703 $from 704 LEFT JOIN pages ON W1.pid=pages.pid 705 GROUP BY W1.pid 706 $order"; 707 708 // offset and limit 709 if ($data['limit']) { 710 $sql .= ' LIMIT ' . ($data['limit'] + 1); 711 // offset is added from REQUEST params in updateSQLwithQuery 712 } 713 714 return $sql; 715 } 716 717 /** 718 * Handle request paramaters, rebuild sql when needed 719 * 720 * @param array $data instruction by handler() 721 */ 722 public function updateSQLwithQuery(&$data) 723 { 724 if ($this->hasRequestFilter()) { 725 if (isset($_REQUEST['datasrt'])) { 726 if ($_REQUEST['datasrt'][0] == '^') { 727 $data['sort'] = [substr($_REQUEST['datasrt'], 1), 'DESC']; 728 } else { 729 $data['sort'] = [$_REQUEST['datasrt'], 'ASC']; 730 } 731 } 732 733 // add request filters 734 $data['filter'] = array_merge($data['filter'], $this->dthlp->getFilters()); 735 736 // Rebuild SQL FIXME do this smarter & faster 737 $data['sql'] = $this->buildSQL($data); 738 } 739 740 if ($data['limit'] && (int)$_REQUEST['dataofs']) { 741 $data['sql'] .= ' OFFSET ' . ((int)$_REQUEST['dataofs']); 742 } 743 } 744 745 /** 746 * Check whether a sort or filter request parameters are available 747 * 748 * @return bool 749 */ 750 public function hasRequestFilter() 751 { 752 return isset($_REQUEST['datasrt']) || isset($_REQUEST['dataflt']); 753 } 754 755 /** 756 * Split values at the commas, 757 * - Wrap with quotes to escape comma, quotes escaped by two quotes 758 * - Within quotes spaces are stored. 759 * 760 * @param string $line 761 * @return array 762 */ 763 protected function parseValues($line) 764 { 765 $values = []; 766 $inQuote = false; 767 $escapedQuote = false; 768 $value = ''; 769 770 $len = strlen($line); 771 for ($i = 0; $i < $len; $i++) { 772 if ($line[$i] == '"') { 773 if ($inQuote) { 774 if ($escapedQuote) { 775 $value .= '"'; 776 $escapedQuote = false; 777 continue; 778 } 779 if ($line[$i + 1] == '"') { 780 $escapedQuote = true; 781 continue; 782 } 783 $values[] = $value; 784 $inQuote = false; 785 $value = ''; 786 continue; 787 } else { 788 $inQuote = true; 789 $value = ''; //don't store stuff before the opening quote 790 continue; 791 } 792 } elseif ($line[$i] == ',') { 793 if ($inQuote) { 794 $value .= ','; 795 continue; 796 } else { 797 if (strlen($value) < 1) { 798 continue; 799 } 800 $values[] = trim($value); 801 $value = ''; 802 continue; 803 } 804 } 805 806 $value .= $line[$i]; 807 } 808 if (strlen($value) > 0) { 809 $values[] = trim($value); 810 } 811 return $values; 812 } 813} 814