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>&darr;</span> ';
428                    $ckey = '^' . $ckey;
429                } else {
430                    $text .= '<span>&uarr;</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 .= '&nbsp;';
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 .= '&nbsp;';
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