1<?php
2/**
3 * Doodle Plugin 2.0: helps to schedule meetings
4 *
5 * @license	GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @url     http://www.dokuwiki.org/plugin:doodle2
7 * @author  Robert Rackl <wiki@doogie.de>
8 * @author	Jonathan Tsai <tryweb@ichiayi.com>
9 * @author  Esther Brunner <wikidesign@gmail.com>
10 * @author  Romain Coltel <aorimn@gmail.com>
11 */
12
13if(!defined('DOKU_INC')) define('DOKU_INC',realpath(dirname(__FILE__).'/../../').'/');
14if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN',DOKU_INC.'lib/plugins/');
15require_once(DOKU_PLUGIN.'syntax.php');
16
17/**
18 * Displays a table where users can vote for some predefined choices
19 * Syntax:
20 *
21 * <pre>
22 * <doodle
23 *   title="What do you like best?"
24 *   auth="none|ip|user"
25 *   adminUsers="user1|user2"
26 *   adminGroups="group1|group2"
27 *   voteType="default|multi"
28 *   closed="true|false" >
29 *     * Option 1
30 *     * Option 2 **some wikimarkup** \\ is __allowed__!
31 *     * Option 3
32 * </doodle>
33 * </pre>
34 *
35 * Only required parameteres are a title and at least one option.
36 *
37 * <h3>Parameters</h3>
38 * auth="none" - everyone can vote with any username, (IPs will be recorded but not checked)
39 * auth="ip"   - everyone can vote with any username, votes will be tracked by IP to prevent duplicate voting
40 * auth="user" - users must login with a valid dokuwiki user. This has the advantage, that users can
41 *               edit their vote ("change their mind") later on.
42 *
43 * <h3>adminUsers and adminGroups</h3>
44 * "|"-separated list of adminUsers or adminGroups, whose members can always edit and delete <b>any</b> entry.
45 *
46 * <h3>Vote Type</h3>
47 * default    - user can vote for exactly one option (round checkboxes will be shown)
48 * multi      - can choose any number of options, including none (square checkboxes will be shown).
49 *
50 * If closed=="true", then no one can vote anymore. The result will still be shown on the page.
51 *
52 * The doodle's data is saved in '<dokuwiki>/data/meta/title_of_vote.doodle'. The filename is the (masked) title.
53 * This has the advantage that you can move your doodle to another page, without loosing the data.
54 */
55class syntax_plugin_doodle extends DokuWiki_Syntax_Plugin
56{
57    const AUTH_NONE = 0;
58    const AUTH_IP   = 1;
59    const AUTH_USER = 2;
60
61    /**
62     * return info about this plugin
63     */
64    function getInfo() {
65        return array(
66            'author' => 'Robert Rackl',
67            'email'  => 'wiki@doogie.de',
68            'date'   => '2010/10/26',
69            'name'   => 'Doodle Plugin 2.0',
70            'desc'   => 'helps to schedule meetings',
71            'url'    => 'http://wiki.splitbrain.org/plugin:doodle2',
72        );
73    }
74
75    function getType()  { return 'substition';}
76    function getPType() { return 'block';}
77    function getSort()  { return 168; }
78
79    /**
80     * Connect pattern to lexer
81     */
82    function connectTo($mode) {
83        $this->Lexer->addSpecialPattern('<doodle\b.*?>.+?</doodle>', $mode, 'plugin_doodle');
84    }
85
86    /**
87     * Handle the match, parse parameters & choices
88     * and prepare everything for the render() method.
89     */
90    function handle($match, $state, $pos, &$handler) {
91        $match = substr($match, 8, -9);              // strip markup (including space after "<doodle ")
92        list($parameterStr, $choiceStr) = preg_split('/>/u', $match, 2);
93
94        //----- default parameter settings
95        $params = array(
96            'title'          => 'Default title',
97            'auth'           => self::AUTH_NONE,
98            'adminUsers'     => '',
99            'adminGroups'    => '',
100            'adminMail'      => null,
101            'voteType'       => 'default',
102            'closed'         => FALSE
103        );
104
105        //----- parse parameteres into name="value" pairs
106        preg_match_all("/(\w+?)=\"(.*?)\"/", $parameterStr, $regexMatches, PREG_SET_ORDER);
107        //debout($parameterStr);
108        //debout($regexMatches);
109        for ($i = 0; $i < count($regexMatches); $i++) {
110            $name  = strtoupper($regexMatches[$i][1]);  // first subpattern: name of attribute in UPPERCASE
111            $value = $regexMatches[$i][2];              // second subpattern is value
112            if (strcmp($name, "TITLE") == 0) {
113                $params['title'] = hsc(trim($value));
114            } else
115            if (strcmp($name, "AUTH") == 0) {
116               if (strcasecmp($value, 'IP') == 0) {
117                   $params['auth'] = self::AUTH_IP;
118               } else
119               if (strcasecmp($value, 'USER') == 0) {
120                   $params['auth'] = self::AUTH_USER;
121               }
122            } else
123            if (strcmp($name, "ADMINUSERS") == 0) {
124                $params['adminUsers'] = $value;
125            } else
126            if (strcmp($name, "ADMINGROUPS") == 0) {
127                $params['adminGroups'] = $value;
128            } else
129            if (strcmp($name, "ADMINMAIL") == 0) {
130                // check for valid email adress
131                if (preg_match('/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,5})$/', $value)) {
132                    $params['adminMail'] = $value;
133                }
134            } else
135            if (strcmp($name, "VOTETYPE") == 0) {
136                if (preg_match('/default|multi/', $value)) {
137                    $params['voteType'] = $value;
138                }
139            } else
140            if ((strcmp($name, "CLOSEON") == 0) &&
141                (($timestamp = strtotime($value)) !== false) &&
142                (time() > $timestamp)   )
143            {
144                $params['closed'] = 1;
145            } else
146            if (strcmp($name, "CLOSED") == 0) {
147                $params['closed'] = strcasecmp($value, "TRUE") == 0;
148            } else
149            if (strcmp($name, "SORT") == 0) {
150                $params['sort'] = $value;  // make it possible to sort by time
151            }
152        }
153
154        // (If there are no choices inside the <doodle> tag, then doodle's data will be reset.)
155        $choices = $this->parseChoices($choiceStr);
156
157        $result = array('params' => $params, 'choices' => $choices);
158        //debout('handle returns', $result);
159        return $result;
160    }
161
162    /**
163     * parse list of choices
164     * explode, trim and encode html entities,
165     * empty choices will be skipped.
166     */
167    function parseChoices($choiceStr) {
168        $choices = array();
169        preg_match_all('/^   \* (.*?)$/m', $choiceStr, $matches, PREG_PATTERN_ORDER);
170        foreach ($matches[1] as $choice) {
171            $choice = hsc(trim($choice));
172            if (!empty($choice)) {
173                $choice = preg_replace('#\\\\\\\\#', '<br />', $choice);       # two(!) backslashes for a newline
174                $choice = preg_replace('#\*\*(.*?)\*\*#', '<b>\1</b>', $choice);   # bold
175                $choice = preg_replace('#__(.*?)__#', '<u>\1</u>', $choice);   # underscore
176                $choice = preg_replace('#//(.*?)//#', '<i>\1</i>', $choice);   # italic
177                $choices []= $choice;
178            }
179        }
180        //debout($choices);
181        return $choices;
182    }
183
184    // ----- these fields will always be initialized at the beginning of the render function
185    //       and can then be used in helper functions below.
186    public $params    = array();
187    public $choices   = array();
188    public $doodle    = array();
189    public $template  = array();   // output values for doodle_template.php
190
191    /**
192     * Read doodle data from file,
193     * add new vote if user just submitted one and
194     * create output xHTML from template
195     */
196    function render($mode, &$renderer, $data) {
197        if ($mode != 'xhtml') return false;
198
199        //debout("render: $mode");
200        global $lang;
201        global $auth;
202        global $conf;
203        global $INFO; // needed for users real name
204        global $ACT;  // action from $_REQUEST['do']
205        global $REV;  // to not allow any action if it's an old page
206        global $ID;   // name of current page
207
208        //debout('data in render', $data);
209
210        $this->params    = $data['params'];
211        $this->choices   = $data['choices'];
212        $this->doodle    = array();
213        $this->template  = array();
214
215        // prevent caching to ensure the poll results are fresh
216        $renderer->info['cache'] = false;
217
218        // ----- read doodle data from file (if there are choices given and there is a file)
219        if (count($this->choices) > 0) {
220            $this->doodle = $this->readDoodleDataFromFile();
221        }
222
223        //FIXME: count($choices) may be different from number of choices in $doodle data!
224
225        // ----- FORM ACTIONS (only allowed when showing the most recent version of the page, not when editing) -----
226        $formId =  'doodle__form__'.cleanID($this->params['title']);
227        if ($ACT == 'show' && $_REQUEST['formId'] == $formId && $REV == false) {
228            // ---- cast new vote
229            if (!empty($_REQUEST['cast__vote'])) {
230                $this->castVote();
231            } else
232            // ---- start editing an entry
233            if (!empty($_REQUEST['edit__entry']) ) {
234                $this->startEditEntry();
235            } else
236            // ---- save changed entry
237            if (!empty($_REQUEST['change__vote']) ) {
238                $this->castVote();
239            } else
240            // ---- delete an entry completely
241            if (!empty($_REQUEST['delete__entry']) ) {
242                $this->deleteEntry();
243            }
244        }
245
246        /******** Format of the $doodle array ***********
247         * The $doodle array maps fullnames (with html special characters masked) to an array of userData for this vote.
248         * Each sub array contains:
249         *   'username' loggin name if use was logged in
250         *   'choices'  is an (variable length!) array of column indexes where user has voted
251         *   'ip'       ip of voting machine
252         *   'time'     unix timestamp when vote was casted
253
254
255        $doodle = array(
256          'Robert' => array(
257            'username'  => 'doogie'
258            'choices'   => array(0, 3),
259            'ip'        => '123.123.123.123',
260            'time'      => 1284970602
261          ),
262          'Peter' => array(
263            'choices'   => array(),
264            'ip'        => '222.122.111.1',
265            'time'      > 12849702333
266          ),
267          'Sabine' => array(
268            'choices'   => array(0, 1, 2, 3, 4),
269            'ip'        => '333.333.333.333',
270            'time'      => 1284970222
271          ),
272        );
273        */
274
275        // ---- fill $this->template variable for doodle_template.php (column by column)
276        $this->template['title']      = hsc($this->params['title']);
277        $this->template['choices']    = $this->choices;
278        $this->template['result']     = $this->params['closed'] ? $this->getLang('final_result') : $this->getLang('count');
279        $this->template['doodleData'] = array();  // this will be filled with some HTML snippets
280        $this->template['formId']     = $formId;
281        if ($this->params['closed']) {
282            $this->template['msg'] = $this->getLang('poll_closed');
283        }
284
285        for($col = 0; $col < count($this->choices); $col++) {
286            $this->template['count'][$col] = 0;
287            foreach ($this->doodle as $fullname => $userData) {
288                if (!empty($userData['username'])) {
289                  $this->template['doodleData']["$fullname"]['username'] = '&nbsp;('.$userData['username'].')';
290                }
291                if (in_array($col, $userData['choices'])) {
292                    $timeLoc = strftime($conf['dformat'], $userData['time']);  // localized time of vote
293                    $this->template['doodleData']["$fullname"]['choice'][$col] =
294                        '<td  class="centeralign" style="background-color:#AFA"><img src="'.DOKU_BASE.'lib/images/success.png" title="'.$timeLoc.'"></td>';
295                    $this->template['count']["$col"]++;
296                } else {
297                    $this->template['doodleData']["$fullname"]['choice'][$col] =
298                        '<td  class="centeralign" style="background-color:#FCC">&nbsp;</td>';
299                }
300            }
301        }
302
303        // ---- add edit link to editable entries
304        foreach($this->doodle as $fullname => $userData) {
305            if ($ACT == 'show' && $REV == false &&
306                $this->isAllowedToEditEntry($fullname))
307            {
308                // the javascript source of these functions is in script.js
309                $this->template['doodleData']["$fullname"]['editLinks'] =
310                   '<a href="javascript:editEntry(\''.$formId.'\',\''.$fullname.'\')">'.
311                   '  <img src="'.DOKU_BASE.'lib/images/pencil.png" alt="edit entry" style="float:left">'.
312                   '</a>'.
313                   '<a href="javascript:deleteEntry(\''.$formId.'\',\''.$fullname.'\')">'.
314                   '  <img src="'.DOKU_BASE.'lib/images/del.png" alt="delete entry" style="float:left">'.
315                   '</a>';
316            }
317        }
318
319        // ---- calculates if user is allowed to vote
320        $this->template['inputTR'] = $this->getInputTR();
321
322        // ----- I am using PHP as a templating engine here.
323        //debout("Template", $this->template);
324        ob_start();
325        include 'doodle_template.php';  // the array $template can be used inside doodle_template.php!
326        $doodle_table = ob_get_contents();
327        ob_end_clean();
328        $renderer->doc .= $doodle_table;
329    }
330
331    // --------------- FORM ACTIONS -----------
332    /**
333     * ACTION: cast a new vote
334     * or save a changed vote
335     * (If user is allowed to.)
336     */
337    function castVote() {
338        $fullname          = hsc(trim($_REQUEST['fullname']));
339        $selected_indexes  = $_REQUEST['selected_indexes'];  // may not be set when all checkboxes are deseleted.
340
341        if (empty($fullname)) {
342            $this->template['msg'] = $this->getLang('dont_have_name');
343            return;
344        }
345        if (empty($selected_indexes)) {
346            if ($this->params['voteType'] == 'multi') {
347                $selected_indexes = array();   //allow empty vote only if voteType is "multi"
348            } else {
349                $this->template['msg'] = $this->getLang('select_one_option');
350                return;
351            }
352        }
353
354        //---- check if user is allowed to vote, according to 'auth' parameter
355
356        //if AUTH_USER, then user must be logged in
357        if ($this->params['auth'] == self::AUTH_USER  && !$this->isLoggedIn()) {
358            $this->template['msg'] = $this->getLang('must_be_logged_in');
359            return;
360        }
361
362        //if AUTH_IP, then prevent duplicate votes by IP.
363        //Exception: If user is logged in he is always allowed to change the vote with his fullname, even if he is on another IP.
364        if ($this->params['auth'] == self::AUTH_IP && !$this->isLoggedIn() && !isset($_REQUEST['change__vote']) ) {
365            foreach($this->doodle as $existintFullname => $userData) {
366              if (strcmp($userData['ip'], $_SERVER['REMOTE_ADDR']) == 0) {
367                $this->template['msg'] = sprintf($this->getLang('ip_has_already_voted'), $_SERVER['REMOTE_ADDR']);
368                return;
369              }
370            }
371        }
372
373        //do not vote twice, unless change__vote is set
374        if (isset($this->doodle["$fullname"]) && !isset($_REQUEST['change__vote']) ) {
375            $this->template['msg'] = $this->getLang('you_voted_already');
376            return;
377        }
378
379        //check if change__vote is allowed
380        if (!empty($_REQUEST['change__vote']) &&
381            !$this->isAllowedToEditEntry($fullname))
382        {
383            $this->template['msg'] = $this->getLang('not_allowed_to_change');
384            return;
385        }
386
387        if (!empty($_SERVER['REMOTE_USER'])) {
388          $this->doodle["$fullname"]['username'] = $_SERVER['REMOTE_USER'];
389        }
390        $this->doodle["$fullname"]['choices'] = $selected_indexes;
391        $this->doodle["$fullname"]['time']    = time();
392        $this->doodle["$fullname"]['ip']      = $_SERVER['REMOTE_ADDR'];
393        $this->writeDoodleDataToFile();
394        $this->template['msg'] = $this->getLang('vote_saved');
395
396        //send mail if  $params['adminMail'] is filled
397        if (!empty($this->params['adminMail'])) {
398            $subj = "[DoodlePlugin] Vote casted by $fullname (".$this->doodle["$fullname"]['username'].')';
399            $body = 'User has casted a vote'."\n\n".print_r($this->doodle["$fullname"], true);
400            mail_send($this->params['adminMail'], $subj, $body, $conf['mailfrom']);
401        }
402    }
403
404    /**
405     * ACTION: start editing an entry
406     * expects fullname of voter in request param edit__entry
407     */
408    function startEditEntry() {
409        $fullname = hsc(trim($_REQUEST['edit__entry']));
410        if (!$this->isAllowedToEditEntry($fullname)) return;
411
412        $this->template['editEntry']['fullname']         = $fullname;
413        $this->template['editEntry']['selected_indexes'] = $this->doodle["$fullname"]['choices'];
414        // $fullname will be shown in the input row
415    }
416
417    /** ACTION: delete an entry completely */
418    function deleteEntry() {
419        $fullname = hsc(trim($_REQUEST['delete__entry']));
420        if (!$this->isAllowedToEditEntry($fullname))   return;
421
422        unset($this->doodle["$fullname"]);
423        $this->writeDoodleDataToFile();
424        $this->template['msg'] = $this->getLang('vote_deleted');
425    }
426
427    // ---------- HELPER METHODS -----------
428
429    /**
430     * check if the currently logged in user is allowed to edit a given entry.
431     * @return true if entryFullname is the entry of the current user, or
432     *         the currently logged in user is in the list of admins
433     */
434    function isAllowedToEditEntry($entryFullname) {
435        global $INFO;
436        global $auth;
437
438        if (empty($entryFullname)) return false;
439        if (!isset($this->doodle["$entryFullname"])) return false;
440        if ($this->params['closed']) return false;
441        if (!$this->isLoggedIn()) return false;
442
443        //check adminGroups
444        if (!empty($this->params['adminGroups'])) {
445            $adminGroups = explode('|', $this->params['adminGroups']); // array of adminGroups
446            $usersGroups = $INFO['userinfo']['grps'];  // array of groups that the user is in
447            if (count(array_intersect($adminGroups, $usersGroups)) > 0) return true;
448        }
449
450        //check adminUsers
451        if (!empty($this->params['adminUsers'])) {
452            $adminUsers = explode('|', $this->params['adminUsers']);
453            return in_array($_SERVER['REMOTE_USER'], $adminUsers);
454        }
455
456        //check own entry
457        return strcasecmp(hsc($INFO['userinfo']['name']), $entryFullname) == 0;  // compare real name
458    }
459
460    /**
461     * return true if the user is currently logged in
462     */
463    function isLoggedIn() {
464        // see http://www.dokuwiki.org/devel:environment
465        global $INFO;
466        return isset($INFO['userinfo']);
467    }
468
469    /**
470     * calculate the input table row:
471     * @return   complete <TR> tags for input row and information message
472     * May return empty string, if user is not allowed to vote
473     *
474     * If user is logged in he is always allowed edit his own entry. ("change his mind")
475     * If user is logged in and has already voted, empty string will be returned.
476     * If user is not logged in but login is required (auth="user"), then also return '';
477     */
478    function getInputTR() {
479        global $ACT;
480        global $INFO;
481        if ($ACT != 'show') return '';
482        if ($this->params['closed']) return '';
483
484        $fullname = '';
485        $editMode = false;
486        if ($this->isLoggedIn()) {
487            $fullname = $INFO['userinfo']['name'];
488            if (isset($this->template['editEntry'])) {
489                $fullname = $this->template['editEntry']['fullname'];
490                $editMode = true;
491            } else {
492                if (isset($this->doodle["$fullname"]) ) return '';
493            }
494        } else {
495            if ($this->params['auth'] == self::AUTH_USER) return '';
496        }
497
498        // build html for tr
499        $c = count($this->choices);
500        $TR  = '';
501        //$TR .= '<tr style="height:3px"><th colspan="'.($c+1).'"></th></tr>';
502        $TR .= '<tr>';
503        $TR .= '<td class="rightalign">';
504        if ($fullname) {
505            if ($editMode) $TR .= $this->getLang('edit').':&nbsp;';
506            $TR .= $fullname.'&nbsp;('.$_SERVER['REMOTE_USER'].')';
507            $TR .= '<input type="hidden" name="fullname" value="'.$fullname.'">';
508        } else {
509            $TR .= '<input type="text" name="fullname" value="">';
510        }
511        $TR .='</td>';
512
513        for($col = 0; $col < $c; $col++) {
514            $selected = '';
515            if ($editMode && in_array($col, $this->template['editEntry']['selected_indexes']) ) {
516                $selected = 'checked="checked"';
517            }
518            $TR .= '<td class="centeralign">';
519
520            if ($this->params['voteType'] == 'multi') {
521                $inputType = "checkbox";
522            } else {
523                $inputType = "radio";
524            }
525            $TR .= '<input type="'.$inputType.'" name="selected_indexes[]" value="'.$col.'"';
526            $TR .= $selected.">";
527            $TR .= '</TD>';
528        }
529
530        $TR .= '</tr>';
531        $TR .= '<tr>';
532        $TR .= '  <td colspan="'.($c+1).'" class="centeralign">';
533
534        if ($editMode) {
535            $TR .= '    <input type="submit" id="voteButton" value=" '.$this->getLang('btn_change').' " name="change__vote" class="button">';
536        } else {
537            $TR .= '    <input type="submit" id="voteButton" value=" '.$this->getLang('btn_vote').' " name="cast__vote" class="button">';
538        }
539        $TR .= '  </td>';
540        $TR .= '</tr>';
541
542        return $TR;
543    }
544
545
546    /**
547     * Loads the serialized doodle data from the file in the metadata directory.
548     * If the file does not exist yet, an empty array is returned.
549     * @return the $doodle array
550     * @see writeDoodleDataToFile()
551     */
552    function readDoodleDataFromFile() {
553        $dfile     = $this->getDoodleFileName();
554        $doodle    = array();
555        if (file_exists($dfile)) {
556            $doodle = unserialize(file_get_contents($dfile));
557        }
558        //sanitize: $doodle[$fullnmae]['choices'] must be at least an array
559        //          This may happen if user deselected all choices
560        foreach($doodle as $fullname => $userData) {
561            if (!is_array($doodle["$fullname"]['choices'])) {
562                $doodle["$fullname"]['choices'] = array();
563            }
564        }
565
566        if (strcmp($this->params['sort'], 'time') == 0) {
567            debout("sorting by time");
568            uasort($doodle, 'cmpEntryByTime');
569        } else {
570            uksort($doodle, "strnatcasecmp"); // case insensitive "natural" sort
571        }
572        //debout("read from $dfile", $doodle);
573        return $doodle;
574    }
575
576    /**
577     * serialize the doodles data to a file
578     */
579    function writeDoodleDataToFile() {
580        if (!is_array($this->doodle)) return;
581        $dfile = $this->getDoodleFileName();
582        uksort($this->doodle, "strnatcasecmp"); // case insensitive "natural" sort
583        io_saveFile($dfile, serialize($this->doodle));
584        //debout("written to $dfile", $doodle);
585        return $dfile;
586    }
587
588    /**
589     * create unique filename for this doodle from its title.
590     * (replaces space with underscore etc.)
591     */
592    function getDoodleFileName() {
593        if (empty($this->params['title'])) {
594          debout('Doodle must have title.');
595          return 'doodle.doodle';
596        }
597        $dID       = hsc(trim($this->params['title']));
598        $dfile     = metaFN($dID, '.doodle');       // serialized doodle data file in meta directory
599        return $dfile;
600    }
601
602
603} // end of class
604
605// ----- static functions
606
607/** compare two doodle entries by the time of vote */
608function cmpEntryByTime($a, $b) {
609    return strcmp($a['time'], $b['time']);
610}
611
612
613function debout() {
614    if (func_num_args() == 1) {
615        msg('<pre>'.hsc(print_r(func_get_arg(0), true)).'</pre>');
616    } else if (func_num_args() == 2) {
617        msg('<h2>'.func_get_arg(0).'</h2><pre>'.hsc(print_r(func_get_arg(1), true)).'</pre>');
618    }
619
620}
621
622?>
623