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'] = ' ('.$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"> </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').': '; 506 $TR .= $fullname.' ('.$_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