1<?php
2/**
3 * Bureaucracy Plugin: Allows flexible creation of forms
4 *
5 * This plugin allows definition of forms in wiki pages. The forms can be
6 * submitted via email or used to create new pages from templates.
7 *
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Andreas Gohr <andi@splitbrain.org>
10 * @author     Adrian Lang <dokuwiki@cosmocode.de>
11 */
12// must be run within Dokuwiki
13if(!defined('DOKU_INC')) die();
14
15/**
16 * All DokuWiki plugins to extend the parser/rendering mechanism
17 * need to inherit from this class
18 */
19class syntax_plugin_bureaucracy extends DokuWiki_Syntax_Plugin {
20
21    private $form_id = 0;
22    var $patterns = array();
23    var $values = array();
24    var $noreplace = null;
25    var $functions = array();
26
27    /**
28     * Prepare some replacements
29     */
30    public function __construct() {
31        $this->prepareDateTimereplacements();
32        $this->prepareNamespacetemplateReplacements();
33        $this->prepareFunctions();
34    }
35
36    /**
37     * What kind of syntax are we?
38     */
39    public function getType() {
40        return 'substition';
41    }
42
43    /**
44     * What about paragraphs?
45     */
46    public function getPType() {
47        return 'block';
48    }
49
50    /**
51     * Where to sort in?
52     */
53    public function getSort() {
54        return 155;
55    }
56
57    /**
58     * Connect pattern to lexer
59     *
60     * @param string $mode
61     */
62    public function connectTo($mode) {
63        $this->Lexer->addSpecialPattern('<form>.*?</form>', $mode, 'plugin_bureaucracy');
64    }
65
66    /**
67     * Handler to prepare matched data for the rendering process
68     *
69     * @param   string       $match   The text matched by the patterns
70     * @param   int          $state   The lexer state for the match
71     * @param   int          $pos     The character position of the matched text
72     * @param   Doku_Handler $handler The Doku_Handler object
73     * @return  bool|array Return an array with all data you want to use in render, false don't add an instruction
74     */
75    public function handle($match, $state, $pos, Doku_Handler $handler) {
76        $match = substr($match, 6, -7); // remove form wrap
77        $lines = explode("\n", $match);
78        $actions = $rawactions = array();
79        $thanks = '';
80        $labels = '';
81
82        // parse the lines into an command/argument array
83        $cmds = array();
84        while(count($lines) > 0) {
85            $line = trim(array_shift($lines));
86            if(!$line) continue;
87            $args = $this->_parse_line($line, $lines);
88            $args[0] = $this->_sanitizeClassName($args[0]);
89
90            if(in_array($args[0], array('action', 'thanks', 'labels'))) {
91                if(count($args) < 2) {
92                    msg(sprintf($this->getLang('e_missingargs'), hsc($args[0]), hsc($args[1])), -1);
93                    continue;
94                }
95
96                // is action element?
97                if($args[0] == 'action') {
98                    array_shift($args);
99                    $rawactions[] = array('type' => array_shift($args), 'argv' => $args);
100                    continue;
101                }
102
103                // is thank you text?
104                if($args[0] == 'thanks') {
105                    $thanks = $args[1];
106                    continue;
107                }
108
109                // is labels?
110                if($args[0] == 'labels') {
111                    $labels = $args[1];
112                    continue;
113                }
114            }
115
116            if(strpos($args[0], '_') === false) {
117                $name = 'bureaucracy_field' . $args[0];
118            } else {
119                //name convention: plugin_componentname
120                $name = $args[0];
121            }
122
123            /** @var helper_plugin_bureaucracy_field $field */
124            $field = $this->loadHelper($name, false);
125            if($field && is_a($field, 'helper_plugin_bureaucracy_field')) {
126                $field->initialize($args);
127                $cmds[] = $field;
128            } else {
129                $evdata = array('fields' => &$cmds, 'args' => $args);
130                $event = new Doku_Event('PLUGIN_BUREAUCRACY_FIELD_UNKNOWN', $evdata);
131                if($event->advise_before()) {
132                    msg(sprintf($this->getLang('e_unknowntype'), hsc($name)), -1);
133                }
134            }
135
136        }
137
138        // check if action is available
139        foreach($rawactions as $action) {
140            $action['type'] = $this->_sanitizeClassName($action['type']);
141
142            if(strpos($action['type'], '_') === false) {
143                $action['actionname'] = 'bureaucracy_action' . $action['type'];
144            } else {
145                //name convention for other plugins: plugin_componentname
146                $action['actionname'] = $action['type'];
147            }
148
149            list($plugin, $component) = explode('_', $action['actionname']);
150            $alternativename = $action['type'] . '_'. $action['type'];
151
152            // bureaucracy_action<name> or <plugin>_<componentname>
153            if(!plugin_isdisabled($action['actionname']) || @file_exists(DOKU_PLUGIN . $plugin . '/helper/'  . $component . '.php')) {
154                $actions[] = $action;
155
156            // shortcut for other plugins with component name <name>_<name>
157            } elseif(plugin_isdisabled($alternativename) || !@file_exists(DOKU_PLUGIN . $action['type'] . '/helper/'  . $action['type'] . '.php')) {
158                $action['actionname'] = $alternativename;
159                $actions[] = $action;
160
161            // not found
162            } else {
163                $evdata = array('actions' => &$actions, 'action' => $action);
164                $event = new Doku_Event('PLUGIN_BUREAUCRACY_ACTION_UNKNOWN', $evdata);
165                if($event->advise_before()) {
166                    msg(sprintf($this->getLang('e_unknownaction'), hsc($action['actionname'])), -1);
167                }
168            }
169        }
170
171        // action(s) found?
172        if(count($actions) < 1) {
173            msg($this->getLang('e_noaction'), -1);
174        }
175
176        // set thank you message
177        if(!$thanks) {
178            $thanks = "";
179            foreach($actions as $action) {
180                $thanks .= $this->getLang($action['type'] . '_thanks');
181            }
182        } else {
183            $thanks = hsc($thanks);
184        }
185        return array(
186            'fields'  => $cmds,
187            'actions' => $actions,
188            'thanks'  => $thanks,
189            'labels'  => $labels
190        );
191    }
192
193    /**
194     * Handles the actual output creation.
195     *
196     * @param string          $format   output format being rendered
197     * @param Doku_Renderer   $R        the current renderer object
198     * @param array           $data     data created by handler()
199     * @return  boolean                 rendered correctly? (however, returned value is not used at the moment)
200     */
201    public function render($format, Doku_Renderer $R, $data) {
202        if($format != 'xhtml') return false;
203        $R->info['cache'] = false; // don't cache
204
205        /**
206         * replace some time and name placeholders in the default values
207         * @var $field helper_plugin_bureaucracy_field
208         */
209        foreach($data['fields'] as &$field) {
210            if(isset($field->opt['value'])) {
211                $field->opt['value'] = $this->replace($field->opt['value']);
212            }
213        }
214
215        if($data['labels']) $this->loadlabels($data);
216
217        $this->form_id++;
218        if(isset($_POST['bureaucracy']) && checkSecurityToken() && $_POST['bureaucracy']['$$id'] == $this->form_id) {
219            $success = $this->_handlepost($data);
220            if($success !== false) {
221                $R->doc .= '<div class="bureaucracy__plugin" id="scroll__here">' . $success . '</div>';
222                return true;
223            }
224        }
225
226        $R->doc .= $this->_htmlform($data['fields']);
227
228        return true;
229    }
230
231    /**
232     * Initializes the labels, loaded from a defined labelpage
233     *
234     * @param array $data all data passed to render()
235     */
236    protected function loadlabels(&$data) {
237        global $INFO;
238        $labelpage = $data['labels'];
239        $exists = false;
240        resolve_pageid($INFO['namespace'], $labelpage, $exists);
241        if(!$exists) {
242            msg(sprintf($this->getLang('e_labelpage'), html_wikilink($labelpage)), -1);
243            return;
244        }
245
246        // parse simple list (first level cdata only)
247        $labels = array();
248        $instructions = p_cached_instructions(wikiFN($labelpage));
249        $inli = 0;
250        $item = '';
251        foreach($instructions as $instruction) {
252            if($instruction[0] == 'listitem_open') {
253                $inli++;
254                continue;
255            }
256            if($inli === 1 && $instruction[0] == 'cdata') {
257                $item .= $instruction[1][0];
258            }
259            if($instruction[0] == 'listitem_close') {
260                $inli--;
261                if($inli === 0) {
262                    list($k, $v) = explode('=', $item, 2);
263                    $k = trim($k);
264                    $v = trim($v);
265                    if($k && $v) $labels[$k] = $v;
266                    $item = '';
267                }
268            }
269        }
270
271        // apply labels to all fields
272        $len = count($data['fields']);
273        for($i = 0; $i < $len; $i++) {
274            if(isset($data['fields'][$i]->depends_on)) {
275                // translate dependency on fieldsets
276                $label = $data['fields'][$i]->depends_on[0];
277                if(isset($labels[$label])) {
278                    $data['fields'][$i]->depends_on[0] = $labels[$label];
279                }
280
281            } else if(isset($data['fields'][$i]->opt['label'])) {
282                // translate field labels
283                $label = $data['fields'][$i]->opt['label'];
284                if(isset($labels[$label])) {
285                    $data['fields'][$i]->opt['display'] = $labels[$label];
286                }
287            }
288        }
289
290        if(isset($data['thanks'])) {
291            if(isset($labels[$data['thanks']])) {
292                $data['thanks'] = $labels[$data['thanks']];
293            }
294        }
295
296    }
297
298    /**
299     * Validate posted data, perform action(s)
300     *
301     * @param array $data all data passed to render()
302     * @return bool|string
303     *      returns thanks message when fields validated and performed the action(s) succesfully;
304     *      otherwise returns false.
305     */
306    private function _handlepost($data) {
307        $success = true;
308        foreach($data['fields'] as $index => $field) {
309            /** @var $field helper_plugin_bureaucracy_field */
310
311            $isValid = true;
312            if($field->getFieldType() === 'file') {
313                $file = array();
314                foreach($_FILES['bureaucracy'] as $key => $value) {
315                    $file[$key] = $value[$index];
316                }
317                $isValid = $field->handle_post($file, $data['fields'], $index, $this->form_id);
318
319            } elseif($field->getFieldType() === 'fieldset' || !$field->hidden) {
320                $isValid = $field->handle_post($_POST['bureaucracy'][$index], $data['fields'], $index, $this->form_id);
321            }
322
323            if(!$isValid) {
324                // Do not return instantly to allow validation of all fields.
325                $success = false;
326            }
327        }
328        if(!$success) {
329            return false;
330        }
331
332        $thanks_array = array();
333
334        foreach($data['actions'] as $actionData) {
335            /** @var helper_plugin_bureaucracy_action $action */
336            $action = $this->loadHelper($actionData['actionname'], false);
337
338            // action helper found?
339            if(!$action) {
340                msg(sprintf($this->getLang('e_unknownaction'), hsc($actionData['actionname'])), -1);
341                return false;
342            }
343
344            try {
345                $thanks_array[] = $action->run(
346                    $data['fields'],
347                    $data['thanks'],
348                    $actionData['argv']
349                );
350            } catch(Exception $e) {
351                msg($e->getMessage(), -1);
352                return false;
353            }
354        }
355
356        // Perform after_action hooks
357        foreach($data['fields'] as $field) {
358            $field->after_action();
359        }
360
361		// create thanks string
362		$thanks = implode('', array_unique($thanks_array));
363
364        return $thanks;
365    }
366
367    /**
368     * Create the form
369     *
370     * @param helper_plugin_bureaucracy_field[] $fields array with form fields
371     * @return string html of the form
372     */
373    private function _htmlform($fields) {
374        global $INFO;
375
376        $form = new Doku_Form(array('class'   => 'bureaucracy__plugin',
377                                    'id'      => 'bureaucracy__plugin' . $this->form_id,
378                                    'enctype' => 'multipart/form-data'));
379        $form->addHidden('id', $INFO['id']);
380        $form->addHidden('bureaucracy[$$id]', $this->form_id);
381
382        foreach($fields as $id => $field) {
383            $field->renderfield(array('name' => 'bureaucracy[' . $id . ']'), $form, $this->form_id);
384        }
385
386        return $form->getForm();
387    }
388
389    /**
390     * Parse a line into (quoted) arguments
391     * Splits line at spaces, except when quoted
392     *
393     * @author William Fletcher <wfletcher@applestone.co.za>
394     *
395     * @param string $line line to parse
396     * @param array  $lines all remaining lines
397     * @return array with all the arguments
398     */
399    private function _parse_line($line, &$lines) {
400        $args = array();
401        $inQuote = false;
402        $escapedQuote = false;
403        $arg = '';
404        do {
405            $len = strlen($line);
406            for($i = 0; $i < $len; $i++) {
407                if($line[$i] == '"') {
408                    if($inQuote) {
409                        if($escapedQuote) {
410                            $arg .= '"';
411                            $escapedQuote = false;
412                            continue;
413                        }
414                        if($i + 1 < $len && $line[$i + 1] == '"') {
415                            $escapedQuote = true;
416                            continue;
417                        }
418                        array_push($args, $arg);
419                        $inQuote = false;
420                        $arg = '';
421                        continue;
422                    } else {
423                        $inQuote = true;
424                        continue;
425                    }
426                } else if($line[$i] == ' ') {
427                    if($inQuote) {
428                        $arg .= ' ';
429                        continue;
430                    } else {
431                        if(strlen($arg) < 1) continue;
432                        array_push($args, $arg);
433                        $arg = '';
434                        continue;
435                    }
436                }
437                $arg .= $line[$i];
438            }
439            if(!$inQuote || count($lines) === 0) break;
440            $line = array_shift($lines);
441            $arg .= "\n";
442        } while(true);
443        if(strlen($arg) > 0) array_push($args, $arg);
444        return $args;
445    }
446
447    /**
448     * Clean class name
449     *
450     * @param string $classname
451     * @return string cleaned name
452     */
453    private function _sanitizeClassName($classname) {
454        return preg_replace('/[^\w\x7f-\xff]/', '', strtolower($classname));
455    }
456
457    /**
458     * Save content in <noreplace> tags into $this->noreplace
459     *
460     * @param string $input    The text to work on
461     */
462    protected function noreplace_save($input) {
463        $pattern = '/<noreplace>(.*?)<\/noreplace>/is';
464        //save content of <noreplace> tags
465        preg_match_all($pattern, $input, $matches);
466        $this->noreplace = $matches[1];
467    }
468
469    /**
470     * Apply replacement patterns and values as prepared earlier
471     * (disable $strftime to prevent double replacements with default strftime() replacements in nstemplate)
472     *
473     * @param string $input    The text to work on
474     * @param bool   $strftime Apply strftime() replacements
475     * @return string processed text
476     */
477    function replace($input, $strftime = true) {
478        //in helper_plugin_struct_field::setVal $input can be an array
479        //just return $input in that case
480        if (!is_string($input)) return $input;
481        if (is_null($this->noreplace)) $this->noreplace_save($input);
482
483        foreach ($this->values as $label => $value) {
484            $pattern = $this->patterns[$label];
485            if (is_callable($value)) {
486                $input = preg_replace_callback(
487                    $pattern,
488                    $value,
489                    $input
490                );
491            } else {
492                $input = preg_replace($pattern, $value, $input);
493            }
494
495        }
496
497        if($strftime) {
498            $input = preg_replace_callback(
499                '/%./',
500                function($m){return strftime($m[0]);},
501                $input
502            );
503        }
504        // user syntax: %%.(.*?)
505        // strftime() is already applied once, so syntax is at this point: %.(.*?)
506        $input = preg_replace_callback(
507            '/@DATE\((.*?)(?:,\s*(.*?))?\)@/',
508            array($this, 'replacedate'),
509            $input
510        );
511
512        //run functions
513        foreach ($this->functions as $name => $callback) {
514            $pattern = '/@' . preg_quote($name) . '\((.*?)\)@/';
515            if (is_callable($callback)) {
516                $input = preg_replace_callback($pattern, function ($matches) use ($callback) {
517                    return call_user_func($callback, $matches[1]);
518                }, $input);
519            }
520        }
521
522        //replace <noreplace> tags with their original content
523        $pattern = '/<noreplace>.*?<\/noreplace>/is';
524        if (is_array($this->noreplace)) foreach ($this->noreplace as $nr) {
525            $input = preg_replace($pattern, $nr, $input, 1);
526        }
527
528        return $input;
529    }
530
531    /**
532     * (callback) Replace date by request datestring
533     * e.g. '%m(30-11-1975)' is replaced by '11'
534     *
535     * @param array $match with [0]=>whole match, [1]=> first subpattern, [2] => second subpattern
536     * @return string
537     */
538    function replacedate($match) {
539        global $conf;
540
541        //no 2nd argument for default date format
542        $match[2] = $match[2] ?? $conf['dformat'];
543
544        return strftime($match[2], strtotime($match[1]));
545    }
546
547    /**
548     * Same replacements as applied at template namespaces
549     *
550     * @see parsePageTemplate()
551     */
552    function prepareNamespacetemplateReplacements() {
553        /* @var Input $INPUT */
554        global $INPUT;
555        global $INFO;
556        global $USERINFO;
557        global $conf;
558        global $ID;
559
560        $this->patterns['__formpage_id__'] = '/@FORMPAGE_ID@/';
561        $this->patterns['__formpage_ns__'] = '/@FORMPAGE_NS@/';
562        $this->patterns['__formpage_curns__'] = '/@FORMPAGE_CURNS@/';
563        $this->patterns['__formpage_file__'] = '/@FORMPAGE_FILE@/';
564        $this->patterns['__formpage_!file__'] = '/@FORMPAGE_!FILE@/';
565        $this->patterns['__formpage_!file!__'] = '/@FORMPAGE_!FILE!@/';
566        $this->patterns['__formpage_page__'] = '/@FORMPAGE_PAGE@/';
567        $this->patterns['__formpage_!page__'] = '/@FORMPAGE_!PAGE@/';
568        $this->patterns['__formpage_!!page__'] = '/@FORMPAGE_!!PAGE@/';
569        $this->patterns['__formpage_!page!__'] = '/@FORMPAGE_!PAGE!@/';
570        $this->patterns['__user__'] = '/@USER@/';
571        $this->patterns['__name__'] = '/@NAME@/';
572        $this->patterns['__mail__'] = '/@MAIL@/';
573        $this->patterns['__date__'] = '/@DATE@/';
574
575        // replace placeholders
576        $localid = isset($INFO['id']) ? $INFO['id'] : $ID;
577        $file = noNS($localid);
578        $page = strtr($file, $conf['sepchar'], ' ');
579        $this->values['__formpage_id__'] = $localid;
580        $this->values['__formpage_ns__'] = getNS($localid);
581        $this->values['__formpage_curns__'] = curNS($localid);
582        $this->values['__formpage_file__'] = $file;
583        $this->values['__formpage_!file__'] = utf8_ucfirst($file);
584        $this->values['__formpage_!file!__'] = utf8_strtoupper($file);
585        $this->values['__formpage_page__'] = $page;
586        $this->values['__formpage_!page__'] = utf8_ucfirst($page);
587        $this->values['__formpage_!!page__'] = utf8_ucwords($page);
588        $this->values['__formpage_!page!__'] = utf8_strtoupper($page);
589        $this->values['__user__'] = $INPUT->server->str('REMOTE_USER');
590        $this->values['__name__'] = $USERINFO['name'] ?? '';
591        $this->values['__mail__'] = $USERINFO['mail'] ?? '';
592        $this->values['__date__'] = strftime($conf['dformat']);
593    }
594
595    /**
596     * Date time replacements
597     */
598    function prepareDateTimereplacements() {
599        $this->patterns['__year__'] = '/@YEAR@/';
600        $this->patterns['__month__'] = '/@MONTH@/';
601        $this->patterns['__monthname__'] = '/@MONTHNAME@/';
602        $this->patterns['__day__'] = '/@DAY@/';
603        $this->patterns['__time__'] = '/@TIME@/';
604        $this->patterns['__timesec__'] = '/@TIMESEC@/';
605        $this->values['__year__'] = date('Y');
606        $this->values['__month__'] = date('m');
607        $this->values['__monthname__'] = date('B');
608        $this->values['__day__'] = date('d');
609        $this->values['__time__'] = date('H:i');
610        $this->values['__timesec__'] = date('H:i:s');
611
612    }
613
614    /**
615     * Functions that can be used after replacements
616     */
617    function prepareFunctions() {
618        $this->functions['curNS'] = 'curNS';
619        $this->functions['getNS'] = 'getNS';
620        $this->functions['noNS'] = 'noNS';
621        $this->functions['p_get_first_heading'] = 'p_get_first_heading';
622    }
623}
624