1<?php
2
3/**
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Andreas Gohr <andi@splitbrain.org>
7 */
8
9use dokuwiki\Extension\SyntaxPlugin;
10use dokuwiki\plugin\data\Form\DropdownElement;
11use dokuwiki\Form\InputElement;
12use dokuwiki\Form\CheckableElement;
13use dokuwiki\Form\Element;
14use dokuwiki\Utf8\PhpString;
15
16/**
17 * Class syntax_plugin_data_entry
18 */
19class syntax_plugin_data_entry extends SyntaxPlugin
20{
21    /**
22     * @var helper_plugin_data will hold the data helper plugin
23     */
24    public $dthlp;
25
26    /**
27     * Constructor. Load helper plugin
28     */
29    public function __construct()
30    {
31        $this->dthlp = plugin_load('helper', 'data');
32        if (!$this->dthlp) msg('Loading the data helper failed. Make sure the data plugin is installed.', -1);
33    }
34
35    /**
36     * What kind of syntax are we?
37     */
38    public function getType()
39    {
40        return 'substition';
41    }
42
43    /**
44     * What about paragraphs?
45     */
46    public function getPType()
47    {
48        return 'block';
49    }
50
51    /**
52     * Where to sort in?
53     */
54    public function getSort()
55    {
56        return 155;
57    }
58
59    /**
60     * Connect pattern to lexer
61     */
62    public function connectTo($mode)
63    {
64        $this->Lexer->addSpecialPattern(
65            '----+ *dataentry(?: [ a-zA-Z0-9_]*)?-+\n.*?\n----+',
66            $mode,
67            'plugin_data_entry'
68        );
69    }
70
71    /**
72     * Handle the match - parse the data
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
85        $lines = explode("\n", $match);
86        array_pop($lines);
87        $class = array_shift($lines);
88        $class = str_replace('dataentry', '', $class);
89        $class = trim($class, '- ');
90
91        // parse info
92        $data = [];
93        $columns = [];
94        foreach ($lines as $line) {
95            // ignore comments
96            preg_match('/^(.*?(?<![&\\\\]))(?:#(.*))?$/', $line, $matches);
97            $line = $matches[1];
98            $line = str_replace('\\#', '#', $line);
99            $line = trim($line);
100            if (empty($line)) continue;
101            $line = preg_split('/\s*:\s*/', $line, 2);
102
103            $column = $this->dthlp->column($line[0]);
104            if (isset($matches[2])) {
105                $column['comment'] = $matches[2];
106            }
107            if ($column['multi']) {
108                if (!isset($data[$column['key']])) {
109                    // init with empty array
110                    // Note that multiple occurrences of the field are
111                    // practically merged
112                    $data[$column['key']] = [];
113                }
114                $vals = explode(',', $line[1]);
115                foreach ($vals as $val) {
116                    $val = trim($this->dthlp->cleanData($val, $column['type']));
117                    if ($val == '') continue;
118                    if (!in_array($val, $data[$column['key']])) {
119                        $data[$column['key']][] = $val;
120                    }
121                }
122            } else {
123                $data[$column['key']] = $this->dthlp->cleanData($line[1] ?? '', $column['type']);
124            }
125            $columns[$column['key']] = $column;
126        }
127        return [
128            'data' => $data,
129            'cols' => $columns,
130            'classes' => $class,
131            'pos' => $pos,
132            'len' => strlen($match) // not utf8_strlen
133        ];
134    }
135
136    /**
137     * Create output or save the data
138     *
139     * @param   $format   string        output format being rendered
140     * @param   $renderer Doku_Renderer the current renderer object
141     * @param   $data     array         data created by handler()
142     * @return  boolean                 rendered correctly?
143     */
144    public function render($format, Doku_Renderer $renderer, $data)
145    {
146        if (is_null($data)) return false;
147        if (!$this->dthlp->ready()) return false;
148
149        global $ID;
150        switch ($format) {
151            case 'xhtml':
152                /** @var $renderer Doku_Renderer_xhtml */
153                $this->showData($data, $renderer);
154                return true;
155            case 'metadata':
156                /** @var $renderer Doku_Renderer_metadata */
157                $this->saveData($data, $ID, $renderer->meta['title'] ?? '');
158                return true;
159            case 'plugin_data_edit':
160                /** @var $renderer Doku_Renderer_plugin_data_edit */
161                if (is_a($renderer->form, 'Doku_Form')) {
162                    $this->editDataLegacy($data, $renderer);
163                } else {
164                    $this->editData($data, $renderer);
165                }
166                return true;
167            default:
168                return false;
169        }
170    }
171
172    /**
173     * Output the data in a table
174     *
175     * @param array $data
176     * @param Doku_Renderer_xhtml $R
177     */
178    public function showData($data, $R)
179    {
180        global $ID;
181        $ret = '';
182
183        $sectionEditData = ['target' => 'plugin_data', 'hid' => 'data_entry'];
184        $data['classes'] .= ' ' . $R->startSectionEdit($data['pos'], $sectionEditData);
185
186        $ret .= '<div class="inline dataplugin_entry ' . $data['classes'] . '"><dl>';
187        $class_names = [];
188        foreach ($data['data'] as $key => $val) {
189            if ($val == '' || is_null($val) || (is_array($val) && count($val) == 0)) continue;
190            $type = $data['cols'][$key]['type'];
191            if (is_array($type)) {
192                $type = $type['type'];
193            }
194            if ($type === 'hidden') continue;
195
196            $class_name = hsc(sectionID($key, $class_names));
197            $ret .= '<dt class="' . $class_name . '">' .
198                hsc($data['cols'][$key]['title']) .
199                '<span class="sep">: </span></dt>';
200            $ret .= '<dd class="' . $class_name . '">';
201            if (is_array($val)) {
202                $cnt = count($val);
203                for ($i = 0; $i < $cnt; $i++) {
204                    if ($type === 'wiki') {
205                        $val[$i] = $ID . '|' . $val[$i];
206                    }
207                    $ret .= $this->dthlp->formatData($data['cols'][$key], $val[$i], $R);
208                    if ($i < $cnt - 1) {
209                        $ret .= '<span class="sep">, </span>';
210                    }
211                }
212            } else {
213                if ($type === 'wiki') {
214                    $val = $ID . '|' . $val;
215                }
216                $ret .= $this->dthlp->formatData($data['cols'][$key], $val, $R);
217            }
218            $ret .= '</dd>';
219        }
220        $ret .= '</dl></div>';
221        $R->doc .= $ret;
222        $R->finishSectionEdit($data['len'] + $data['pos']);
223    }
224
225    /**
226     * Save date to the database
227     */
228    public function saveData($data, $id, $title)
229    {
230        $sqlite = $this->dthlp->getDB();
231        if (!$sqlite) return false;
232
233        if (!$title) {
234            $title = $id;
235        }
236
237        $class = $data['classes'];
238
239        $sqlite->getPdo()->beginTransaction();
240        try {
241            // store page info
242            $this->replaceQuery(
243                "INSERT OR IGNORE INTO pages (page,title,class) VALUES (?,?,?)",
244                $id,
245                $title,
246                $class
247            );
248
249            // Update title if insert failed (record already saved before)
250            $revision = filemtime(wikiFN($id));
251            $this->replaceQuery(
252                "UPDATE pages SET title = ?, class = ?, lastmod = ? WHERE page = ?",
253                $title,
254                $class,
255                $revision,
256                $id
257            );
258
259            // fetch page id
260            /** @var PDOStatement $res */
261            $res = $this->replaceQuery("SELECT pid FROM pages WHERE page = ?", $id);
262            $all = $res->fetchAll(\PDO::FETCH_ASSOC);
263            $res->closeCursor();
264            $pid = (int)$all[0]['pid'];
265            if (!$pid) {
266                throw new Exception("data plugin: failed saving data");
267            }
268
269            // remove old data
270            $sqlite->query("DELETE FROM DATA WHERE pid = ?", $pid);
271
272            // insert new data
273            foreach ($data['data'] as $key => $val) {
274                if (is_array($val)) foreach ($val as $v) {
275                    $this->replaceQuery(
276                        "INSERT INTO DATA (pid, KEY, VALUE) VALUES (?, ?, ?)",
277                        $pid,
278                        $key,
279                        $v
280                    );
281                } else {
282                    $this->replaceQuery(
283                        "INSERT INTO DATA (pid, KEY, VALUE) VALUES (?, ?, ?)",
284                        $pid,
285                        $key,
286                        $val
287                    );
288                }
289            }
290
291            // finish transaction
292            $sqlite->getPdo()->commit();
293        } catch (\Exception $exception) {
294            $sqlite->getPdo()->rollBack();
295            msg(hsc($exception->getMessage()), -1);
296        }
297
298        return true;
299    }
300
301    /**
302     *
303     * @fixme replace this madness
304     * @return bool|mixed
305     */
306    public function replaceQuery()
307    {
308        $args = func_get_args();
309        $argc = func_num_args();
310
311        if ($argc > 1) {
312            for ($i = 1; $i < $argc; $i++) {
313                $data = [];
314                $data['sql'] = $args[$i];
315                $this->dthlp->replacePlaceholdersInSQL($data);
316                $args[$i] = $data['sql'];
317            }
318        }
319
320        $sqlite = $this->dthlp->getDB();
321        if (!$sqlite) return false;
322
323        return call_user_func_array(array(&$sqlite, 'query'), $args);
324    }
325
326
327    /**
328     * The custom editor for editing data entries
329     *
330     * Gets called from action_plugin_data::_editform() where also the form member is attached
331     *
332     * @param array $data
333     * @param Doku_Renderer_plugin_data_edit $renderer
334     * @deprecated                          _editData() is used since Igor
335     */
336    protected function editDataLegacy($data, &$renderer)
337    {
338        $renderer->form->startFieldset($this->getLang('dataentry'));
339        $renderer->form->_content[count($renderer->form->_content) - 1]['class'] = 'plugin__data';
340        $renderer->form->addHidden('range', '0-0'); // Adora Belle bugfix
341
342        if ($this->getConf('edit_content_only')) {
343            $renderer->form->addHidden('data_edit[classes]', $data['classes']);
344
345            $columns = ['title', 'value', 'comment'];
346            $class = 'edit_content_only';
347        } else {
348            $renderer->form->addElement(
349                form_makeField(
350                    'text',
351                    'data_edit[classes]',
352                    $data['classes'],
353                    $this->getLang('class'),
354                    'data__classes'
355                )
356            );
357
358            $columns = ['title', 'type', 'multi', 'value', 'comment'];
359            $class = 'edit_all_content';
360
361            // New line
362            $data['data'][''] = '';
363            $data['cols'][''] = ['type' => '', 'multi' => false];
364        }
365
366        $renderer->form->addElement("<table class=\"$class\">");
367
368        //header
369        $header = '<tr>';
370        foreach ($columns as $column) {
371            $header .= '<th class="' . $column . '">' . $this->getLang($column) . '</th>';
372        }
373        $header .= '</tr>';
374        $renderer->form->addElement($header);
375
376        //rows
377        $n = 0;
378        foreach ($data['cols'] as $key => $vals) {
379            $fieldid = 'data_edit[data][' . $n++ . ']';
380            $content = $vals['multi'] ? implode(', ', $data['data'][$key]) : $data['data'][$key];
381            if (is_array($vals['type'])) {
382                $vals['basetype'] = $vals['type']['type'];
383                if (isset($vals['type']['enum'])) {
384                    $vals['enum'] = $vals['type']['enum'];
385                }
386                $vals['type'] = $vals['origtype'];
387            } else {
388                $vals['basetype'] = $vals['type'];
389            }
390
391            if ($vals['type'] === 'hidden') {
392                $renderer->form->addElement('<tr class="hidden">');
393            } else {
394                $renderer->form->addElement('<tr>');
395            }
396            if ($this->getConf('edit_content_only')) {
397                if (isset($vals['enum'])) {
398                    $values = preg_split('/\s*,\s*/', $vals['enum']);
399                    if (!$vals['multi']) {
400                        array_unshift($values, '');
401                    }
402                    $content = form_makeListboxField(
403                        $fieldid . '[value][]',
404                        $values,
405                        $data['data'][$key],
406                        $vals['title'],
407                        '',
408                        '',
409                        ($vals['multi'] ? ['multiple' => 'multiple'] : [])
410                    );
411                } else {
412                    $classes = 'data_type_' . $vals['type'] . ($vals['multi'] ? 's' : '') . ' '
413                        . 'data_type_' . $vals['basetype'] . ($vals['multi'] ? 's' : '');
414
415                    $attr = [];
416                    if ($vals['basetype'] == 'date' && !$vals['multi']) {
417                        $attr['class'] = 'datepicker';
418                    }
419
420                    $content = form_makeField(
421                        'text',
422                        $fieldid . '[value]',
423                        $content,
424                        $vals['title'],
425                        '',
426                        $classes,
427                        $attr
428                    );
429                }
430                $cells = [
431                    hsc($vals['title']) . ':',
432                    $content,
433                    '<span title="' . hsc($vals['comment']) . '">' . hsc($vals['comment']) . '</span>'
434                ];
435                foreach (['multi', 'comment', 'type'] as $field) {
436                    $renderer->form->addHidden($fieldid . "[$field]", $vals[$field]);
437                }
438                //keep key as key, even if title is translated
439                $renderer->form->addHidden($fieldid . "[title]", $vals['origkey']);
440            } else {
441                $check_data = $vals['multi'] ? ['checked' => 'checked'] : [];
442                $cells = [
443                    form_makeField('text', $fieldid . '[title]', $vals['origkey'], $this->getLang('title')),
444                    // when editable, always use the pure key, not a title
445                    form_makeMenuField(
446                        $fieldid . '[type]',
447                        array_merge(
448                            ['', 'page', 'nspage', 'title', 'img', 'mail', 'url', 'tag', 'wiki', 'dt', 'hidden'],
449                            array_keys($this->dthlp->aliases())
450                        ),
451                        $vals['type'],
452                        $this->getLang('type')
453                    ),
454                    form_makeCheckboxField(
455                        $fieldid . '[multi]',
456                        ['1', ''],
457                        $this->getLang('multi'),
458                        '',
459                        '',
460                        $check_data
461                    ),
462                    form_makeField(
463                        'text',
464                        $fieldid . '[value]',
465                        $content,
466                        $this->getLang('value')
467                    ),
468                    form_makeField(
469                        'text',
470                        $fieldid . '[comment]',
471                        $vals['comment'],
472                        $this->getLang('comment'),
473                        '',
474                        'data_comment',
475                        ['readonly' => 1, 'title' => $vals['comment']]
476                    ),
477                ];
478            }
479
480            foreach ($cells as $index => $cell) {
481                $renderer->form->addElement("<td class=\"{$columns[$index]}\">");
482                $renderer->form->addElement($cell);
483                $renderer->form->addElement('</td>');
484            }
485            $renderer->form->addElement('</tr>');
486        }
487
488        $renderer->form->addElement('</table>');
489        $renderer->form->endFieldset();
490    }
491
492    /**
493     * The custom editor for editing data entries
494     *
495     * Gets called from action_plugin_data::_editform() where also the form member is attached
496     *
497     * @param array $data
498     * @param Doku_Renderer_plugin_data_edit $renderer
499     */
500    protected function editData($data, &$renderer)
501    {
502        $renderer->form->addFieldsetOpen($this->getLang('dataentry'))->attr('class', 'plugin__data');
503
504        if ($this->getConf('edit_content_only')) {
505            $renderer->form->setHiddenField('data_edit[classes]', $data['classes']);
506
507            $columns = ['title', 'value', 'comment'];
508            $class = 'edit_content_only';
509        } else {
510            $renderer->form->addTextInput('data_edit[classes]', $this->getLang('class'))
511                ->id('data__classes')
512                ->val($data['classes']);
513
514            $columns = ['title', 'type', 'multi', 'value', 'comment'];
515            $class = 'edit_all_content';
516
517            // New line
518            $data['data'][''] = '';
519            $data['cols'][''] = ['type' => '', 'multi' => false];
520        }
521
522        $renderer->form->addHTML("<table class=\"$class\">");
523
524        //header
525        $header = '<tr>';
526        foreach ($columns as $column) {
527            $header .= '<th class="' . $column . '">' . $this->getLang($column) . '</th>';
528        }
529        $header .= '</tr>';
530        $renderer->form->addHTML($header);
531
532        //rows
533        $n = 0;
534        foreach ($data['cols'] as $key => $vals) {
535            $fieldid = 'data_edit[data][' . $n++ . ']';
536            $content = $vals['multi'] ? implode(', ', $data['data'][$key]) : $data['data'][$key];
537            if (is_array($vals['type'])) {
538                $vals['basetype'] = $vals['type']['type'];
539                if (isset($vals['type']['enum'])) {
540                    $vals['enum'] = $vals['type']['enum'];
541                }
542                $vals['type'] = $vals['origtype'];
543            } else {
544                $vals['basetype'] = $vals['type'];
545            }
546
547            if ($vals['type'] === 'hidden') {
548                $renderer->form->addHTML('<tr class="hidden">');
549            } else {
550                $renderer->form->addHTML('<tr>');
551            }
552            if ($this->getConf('edit_content_only')) {
553                if (isset($vals['enum'])) {
554                    $values = preg_split('/\s*,\s*/', $vals['enum']);
555                    if (!$vals['multi']) {
556                        array_unshift($values, '');
557                    }
558
559                    $el = new DropdownElement(
560                        $fieldid . '[value]',
561                        $values,
562                        $vals['title']
563                    );
564                    $el->useInput(false);
565                    $el->attrs(($vals['multi'] ? ['multiple' => 'multiple'] : []));
566                    $el->attr('selected', $data['data'][$key]);
567                    $el->val($data['data'][$key]);
568                } else {
569                    $classes = 'data_type_' . $vals['type'] . ($vals['multi'] ? 's' : '') . ' '
570                        . 'data_type_' . $vals['basetype'] . ($vals['multi'] ? 's' : '');
571
572                    $attr = [];
573                    if ($vals['basetype'] == 'date' && !$vals['multi']) {
574                        $attr['class'] = 'datepicker';
575                    }
576
577                    $el = new InputElement('text', $fieldid . '[value]', $vals['title']);
578                    $el->useInput(false);
579                    $el->val($content);
580                    $el->addClass($classes);
581                    $el->attrs($attr);
582                }
583                $cells = [
584                    hsc($vals['title']) . ':', $el,
585                    '<span title="' . hsc($vals['comment'] ?? '') . '">' . hsc($vals['comment'] ?? '') . '</span>'
586                ];
587                foreach (['multi', 'comment', 'type'] as $field) {
588                    $renderer->form->setHiddenField($fieldid . "[$field]", $vals[$field] ?? '');
589                }
590                //keep key as key, even if title is translated
591                $renderer->form->setHiddenField($fieldid . "[title]", $vals['origkey'] ?? '');
592            } else {
593                $check_data = $vals['multi'] ? ['checked' => 'checked'] : [];
594                $cells = [];
595
596                $el = new InputElement('text', $fieldid . '[title]', $this->getLang('title'));
597                $el->val($vals['origkey'] ?? '');
598                $cells[] = $el;
599
600                $el = new \dokuwiki\Form\DropdownElement(
601                    $fieldid . '[type]',
602                    array_merge(
603                        ['', 'page', 'nspage', 'title', 'img', 'mail', 'url', 'tag', 'wiki', 'dt', 'hidden'],
604                        array_keys($this->dthlp->aliases())
605                    ),
606                    $this->getLang('type')
607                );
608                $el->val($vals['type']);
609                $cells[] = $el;
610
611                $el = new CheckableElement('checkbox', $fieldid . '[multi]', $this->getLang('multi'));
612                $el->attrs($check_data);
613                $cells[] = $el;
614
615                $el = new InputElement('text', $fieldid . '[value]', $this->getLang('value'));
616                $el->val($content);
617                $cells[] = $el;
618
619                $el = new InputElement('text', $fieldid . '[comment]', $this->getLang('comment'));
620                $el->addClass('data_comment');
621                $el->attrs(['readonly' => '1', 'title' => $vals['comment'] ?? '']);
622                $el->val($vals['comment'] ?? '');
623                $cells[] = $el;
624            }
625
626            foreach ($cells as $index => $cell) {
627                $renderer->form->addHTML("<td class=\"{$columns[$index]}\">");
628                if (is_a($cell, Element::class)) {
629                    $renderer->form->addElement($cell);
630                } else {
631                    $renderer->form->addHTML($cell);
632                }
633                $renderer->form->addHTML('</td>');
634            }
635            $renderer->form->addHTML('</tr>');
636        }
637
638        $renderer->form->addHTML('</table>');
639        $renderer->form->addFieldsetClose();
640    }
641
642    /**
643     * Escapes the given value against being handled as comment
644     *
645     * @param $txt
646     * @return mixed
647     * @todo bad naming
648     */
649    public static function normalize($txt)
650    {
651        return str_replace('#', '\#', trim($txt));
652    }
653
654    /**
655     * Handles the data posted from the editor to recreate the entry syntax
656     *
657     * @param array $data data given via POST
658     * @return string
659     */
660    public static function editToWiki($data)
661    {
662        $nudata = [];
663
664        $len = 0; // we check the maximum lenght for nice alignment later
665        foreach ($data['data'] as $field) {
666            if (is_array($field['value'])) {
667                $field['value'] = implode(', ', $field['value']);
668            }
669            $field = array_map('trim', $field);
670            if ($field['title'] === '') continue;
671
672            $name = syntax_plugin_data_entry::normalize($field['title']);
673
674            if ($field['type'] !== '') {
675                $name .= '_' . syntax_plugin_data_entry::normalize($field['type']);
676            } elseif (substr($name, -1, 1) === 's') {
677                $name .= '_'; // when the field name ends in 's' we need to secure it against being assumed as multi
678            }
679            // 's' is added to either type or name for multi
680            if ($field['multi'] === '1') {
681                $name .= 's';
682            }
683
684            $nudata[] = [$name, syntax_plugin_data_entry::normalize($field['value']), $field['comment']];
685            $len = max($len, PhpString::strlen($nudata[count($nudata) - 1][0]));
686        }
687
688        $ret = '---- dataentry ' . trim($data['classes']) . ' ----' . DOKU_LF;
689        foreach ($nudata as $field) {
690            $ret .= $field[0] . str_repeat(' ', $len + 1 - PhpString::strlen($field[0])) . ': ';
691            $ret .= $field[1];
692            if ($field[2] !== '') {
693                $ret .= ' # ' . $field[2];
694            }
695            $ret .= DOKU_LF;
696        }
697        $ret .= "----\n";
698        return $ret;
699    }
700}
701