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